refactor(api-server): 23 个超 80 行函数拆分为 ≤80 行(纯重构,行为不变)
拆分范围(14 个文件):migrate / users / operation_logs / energy_stats / charge_records / batteries / device_logs / auth / captcha / main / routes::mod / h5 / cabinet_tree / report_worker 手法:长 SQL 抽模块级 const、拼装与映射与校验抽私有 fn、路由按业务域拆 Router 再 merge、 Excel 导出按"取数/表头/逐行/收尾"分阶段、连续代码块原样搬移+传参。 行为不变的证据: - 路由 79 → 79 条逐条比对一致(路径+handler+方法链;中间件/CORS/with_state 作用域保持) - 字符串字面量多重集比对:11 个文件唯一集合完全一致;report_worker 零差异(435 条字面量全等) - 关键契约计数不变:CAST 14→14、AS SIGNED 4→4、COALESCE 6→6;SQL 文本与 Redis key 零增删 - 未新增 #[allow] / unwrap / expect / panic;captcha 的 RNG 消耗顺序保持(验证码分布不变) 验收:cargo check 0 error / 0 warning;cargo test --bins 39 passed / 0 failed;全仓已无 >80 行函数。 端到端回归:先部署测试环境跑 TCP 协议联调 13/13,且数据库终态与重构前逐字段一致 (orders.energy=22、start_soc=850、end_soc=1000、records.energy=22.00、max_temp=29800、占用状态正确复位)。
This commit is contained in:
parent
999885c38f
commit
8bc7812733
BIN
9b0d3b8abab88e25f36e6a793a6e1eec.jpg
Normal file
BIN
9b0d3b8abab88e25f36e6a793a6e1eec.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
15
AGENTS.md
15
AGENTS.md
@ -63,13 +63,14 @@ D:\charging-cabinet\
|
|||||||
> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx
|
> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx
|
||||||
> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env`
|
> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env`
|
||||||
> (**新二进制强制要求环境变量,缺它起不来**)。
|
> (**新二进制强制要求环境变量,缺它起不来**)。
|
||||||
> - **本服务器不部署测试环境**(用户确认:这台就是生产)—— `pms-api-test` / `pms-device-test`
|
> - **测试环境按本表规划独立部署在同一台服务器上**(测试就是测试,与生产完全隔离):
|
||||||
> 已停止并卸载,`:3000` / `:3001` 已释放。
|
> `pms-api-test :3000` + `pms-device-test :3001`,路径 `/data/pms_dev`,连 `pms_dev` 库 + Redis **db6**;
|
||||||
> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为
|
> `test.anzhizhichong.com` 的 nginx `/api/pms/` 指向 `:3000`。
|
||||||
> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API,
|
> - ⚠️ **绝对不要把测试域名的 API 指到生产端口 `:9000`** —— 历史事故:测试 nginx 曾指向
|
||||||
> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。**
|
> `:3000`,而当时 `:3000` 正是生产 API,等于**测试后台在操作生产数据**。
|
||||||
> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**,
|
> 判断方法:`test` 域名 `/api/pms/` 返回 **401**(有测试后端)是对的;若返回 **502** 说明测试后端没起。
|
||||||
> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。
|
> - 两个环境必须同时满足:生产 `:9000/:9001`(db5/pms)、测试 `:3000/:3001`(db6/pms_dev),
|
||||||
|
> 端口或库号任一串了就是事故。
|
||||||
|
|
||||||
**部署路径:**
|
**部署路径:**
|
||||||
- 产品介绍页:/data/landing
|
- 产品介绍页:/data/landing
|
||||||
|
|||||||
BIN
d6e6add1c7a1e8c1fc2b820ea206e000.png
Normal file
BIN
d6e6add1c7a1e8c1fc2b820ea206e000.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@ -92,9 +92,11 @@ pub(crate) async fn verify_offset(redis_url: &str, challenge_id: &str, user_x: i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
/// 生成背景:渐变底 + 随机纹理线条 + 随机纹理圆点
|
||||||
let mut rng = rand::thread_rng();
|
///
|
||||||
|
/// 注意:按固定顺序消耗随机数(渐变 → 线条 → 圆点),
|
||||||
|
/// 调用方随后取拼图位置,保持与拆分前一致的随机序列。
|
||||||
|
fn generate_background(rng: &mut impl Rng) -> RgbaImage {
|
||||||
// 1. 渐变背景
|
// 1. 渐变背景
|
||||||
let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
||||||
let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
|
||||||
@ -127,11 +129,12 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
|||||||
fill_circle(&mut bg, cx, cy, radius, color);
|
fill_circle(&mut bg, cx, cy, radius, color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 拼图块位置
|
bg
|
||||||
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2;
|
}
|
||||||
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
|
|
||||||
|
|
||||||
// 5. 提取拼图块像素
|
/// 按拼图轮廓从背景中提取原始像素块
|
||||||
|
fn extract_piece_pixels(bg: &RgbaImage, piece_x: u32, piece_y: i32) -> RgbaImage {
|
||||||
|
// 提取拼图块像素
|
||||||
let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32);
|
let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32);
|
||||||
for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
|
for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
|
||||||
for py in 0..PIECE_SIZE {
|
for py in 0..PIECE_SIZE {
|
||||||
@ -141,8 +144,11 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
|
||||||
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
|
/// 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
|
||||||
|
fn build_piece_with_shadow(raw: &RgbaImage) -> RgbaImage {
|
||||||
let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32;
|
let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32;
|
||||||
let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32;
|
let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32;
|
||||||
let mut piece = RgbaImage::new(sw, sh);
|
let mut piece = RgbaImage::new(sw, sh);
|
||||||
@ -170,6 +176,25 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
piece
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_images() -> (RgbaImage, RgbaImage, i32) {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
// 1-3. 渐变背景 + 纹理线条 + 纹理圆点
|
||||||
|
let mut bg = generate_background(&mut rng);
|
||||||
|
|
||||||
|
// 4. 拼图块位置
|
||||||
|
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2;
|
||||||
|
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
|
||||||
|
|
||||||
|
// 5. 提取拼图块像素
|
||||||
|
let raw = extract_piece_pixels(&bg, piece_x, piece_y);
|
||||||
|
|
||||||
|
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
|
||||||
|
let piece = build_piece_with_shadow(&raw);
|
||||||
|
|
||||||
// 7. 背景画缺口
|
// 7. 背景画缺口
|
||||||
draw_hole(&mut bg, piece_x as i32, piece_y);
|
draw_hole(&mut bg, piece_x as i32, piece_y);
|
||||||
|
|
||||||
|
|||||||
@ -510,8 +510,67 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 插入默认角色并分配权限
|
/// 创建默认角色并分配权限
|
||||||
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
|
insert_default_roles(pool).await?;
|
||||||
|
|
||||||
|
// 为总管理员分配所有权限
|
||||||
|
let super_admin_id: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM roles WHERE name = 'super_admin'"
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
if let Some((role_id,)) = super_admin_id {
|
||||||
|
grant_all_permissions(pool, role_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为企业管理员分配非系统管理权限
|
||||||
|
let org_admin_id: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM roles WHERE name = 'org_admin'"
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let org_perm_codes = [
|
||||||
|
"device:view", "device:operate", "device:create", "device:edit",
|
||||||
|
"charge:start", "charge:stop", "charge:open_door",
|
||||||
|
"charge_record:view", "charge_record:export",
|
||||||
|
"device_log:view", "energy:view", "energy:export",
|
||||||
|
"user:view", "user:create", "user:edit",
|
||||||
|
"operation_log:view",
|
||||||
|
// 组织管理
|
||||||
|
"org:view", "org:create", "org:edit", "org:delete",
|
||||||
|
// 项目管理
|
||||||
|
"project:view", "project:create", "project:edit", "project:delete",
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some((role_id,)) = org_admin_id {
|
||||||
|
grant_permissions_by_codes(pool, role_id, &org_perm_codes).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为操作员分配查看和操作权限
|
||||||
|
let normal_id: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM roles WHERE name = 'normal_user'"
|
||||||
|
)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let normal_perm_codes = [
|
||||||
|
"device:view", "device:operate",
|
||||||
|
"charge:start", "charge:stop", "charge:open_door",
|
||||||
|
"charge_record:view", "device_log:view", "energy:view",
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Some((role_id,)) = normal_id {
|
||||||
|
grant_permissions_by_codes(pool, role_id, &normal_perm_codes).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("默认角色及权限分配完成");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建三个默认角色(INSERT IGNORE,已存在则跳过)
|
||||||
|
async fn insert_default_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||||
// 创建总管理员角色
|
// 创建总管理员角色
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
|
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
|
||||||
@ -539,22 +598,46 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 为总管理员分配所有权限
|
Ok(())
|
||||||
let super_admin_id: Option<(i64,)> = sqlx::query_as(
|
}
|
||||||
"SELECT id FROM roles WHERE name = 'super_admin'"
|
|
||||||
|
/// 为指定角色分配全部权限(授权总管理员)
|
||||||
|
async fn grant_all_permissions(pool: &MySqlPool, role_id: i64) -> Result<(), sqlx::Error> {
|
||||||
|
// 查询所有权限ID
|
||||||
|
let perm_ids: Vec<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM permissions"
|
||||||
)
|
)
|
||||||
.fetch_optional(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((role_id,)) = super_admin_id {
|
for (perm_id,) in perm_ids {
|
||||||
// 查询所有权限ID
|
sqlx::query(
|
||||||
let perm_ids: Vec<(i64,)> = sqlx::query_as(
|
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
|
||||||
"SELECT id FROM permissions"
|
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.bind(role_id)
|
||||||
|
.bind(perm_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按权限码逐个为指定角色授权(权限码不存在则跳过)
|
||||||
|
async fn grant_permissions_by_codes(
|
||||||
|
pool: &MySqlPool,
|
||||||
|
role_id: i64,
|
||||||
|
codes: &[&str],
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
for code in codes {
|
||||||
|
let perm: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM permissions WHERE code = ?"
|
||||||
|
)
|
||||||
|
.bind(code)
|
||||||
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for (perm_id,) in perm_ids {
|
if let Some((perm_id,)) = perm {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
|
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
|
||||||
)
|
)
|
||||||
@ -565,81 +648,5 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为企业管理员分配非系统管理权限
|
|
||||||
let org_admin_id: Option<(i64,)> = sqlx::query_as(
|
|
||||||
"SELECT id FROM roles WHERE name = 'org_admin'"
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let org_perm_codes = [
|
|
||||||
"device:view", "device:operate", "device:create", "device:edit",
|
|
||||||
"charge:start", "charge:stop", "charge:open_door",
|
|
||||||
"charge_record:view", "charge_record:export",
|
|
||||||
"device_log:view", "energy:view", "energy:export",
|
|
||||||
"user:view", "user:create", "user:edit",
|
|
||||||
"operation_log:view",
|
|
||||||
// 组织管理
|
|
||||||
"org:view", "org:create", "org:edit", "org:delete",
|
|
||||||
// 项目管理
|
|
||||||
"project:view", "project:create", "project:edit", "project:delete",
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Some((role_id,)) = org_admin_id {
|
|
||||||
for code in &org_perm_codes {
|
|
||||||
let perm: Option<(i64,)> = sqlx::query_as(
|
|
||||||
"SELECT id FROM permissions WHERE code = ?"
|
|
||||||
)
|
|
||||||
.bind(code)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((perm_id,)) = perm {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
|
|
||||||
)
|
|
||||||
.bind(role_id)
|
|
||||||
.bind(perm_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 为操作员分配查看和操作权限
|
|
||||||
let normal_id: Option<(i64,)> = sqlx::query_as(
|
|
||||||
"SELECT id FROM roles WHERE name = 'normal_user'"
|
|
||||||
)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let normal_perm_codes = [
|
|
||||||
"device:view", "device:operate",
|
|
||||||
"charge:start", "charge:stop", "charge:open_door",
|
|
||||||
"charge_record:view", "device_log:view", "energy:view",
|
|
||||||
];
|
|
||||||
|
|
||||||
if let Some((role_id,)) = normal_id {
|
|
||||||
for code in &normal_perm_codes {
|
|
||||||
let perm: Option<(i64,)> = sqlx::query_as(
|
|
||||||
"SELECT id FROM permissions WHERE code = ?"
|
|
||||||
)
|
|
||||||
.bind(code)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((perm_id,)) = perm {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
|
|
||||||
)
|
|
||||||
.bind(role_id)
|
|
||||||
.bind(perm_id)
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("默认角色及权限分配完成");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,18 +21,15 @@ use tracing_subscriber::EnvFilter;
|
|||||||
|
|
||||||
use commands::{AppState, NodeRegistry, PendingCommands};
|
use commands::{AppState, NodeRegistry, PendingCommands};
|
||||||
|
|
||||||
#[tokio::main]
|
/// 初始化日志订阅器(默认级别 info,可通过 RUST_LOG 覆盖)
|
||||||
async fn main() {
|
fn init_tracing() {
|
||||||
// 初始化日志
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||||
.init();
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
// 加载配置
|
/// 初始化 MySQL 连接池并执行自动迁移(失败即退出,启动阶段不降级)
|
||||||
let cfg = config::Config::from_env();
|
async fn init_database(cfg: &config::Config) -> sqlx::MySqlPool {
|
||||||
tracing::info!("配置加载完成");
|
|
||||||
|
|
||||||
// 初始化 MySQL 连接池
|
|
||||||
let db_pool = db::init_pool(&cfg.database_url)
|
let db_pool = db::init_pool(&cfg.database_url)
|
||||||
.await
|
.await
|
||||||
.expect("MySQL 连接失败");
|
.expect("MySQL 连接失败");
|
||||||
@ -44,62 +41,64 @@ async fn main() {
|
|||||||
.expect("数据库迁移失败");
|
.expect("数据库迁移失败");
|
||||||
tracing::info!("数据库迁移完成");
|
tracing::info!("数据库迁移完成");
|
||||||
|
|
||||||
// 初始化 Redis 连接
|
db_pool
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 Redis 连接管理器(失败即退出)
|
||||||
|
async fn init_redis(cfg: &config::Config) -> ::redis::aio::ConnectionManager {
|
||||||
let redis_conn = redis::init_connection(&cfg.redis_url)
|
let redis_conn = redis::init_connection(&cfg.redis_url)
|
||||||
.await
|
.await
|
||||||
.expect("Redis 连接失败");
|
.expect("Redis 连接失败");
|
||||||
tracing::info!("Redis 连接成功");
|
tracing::info!("Redis 连接成功");
|
||||||
|
redis_conn
|
||||||
|
}
|
||||||
|
|
||||||
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
|
/// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
|
||||||
let jwt_secret: Arc<str> = std::env::var("JWT_SECRET")
|
fn load_jwt_secret() -> Arc<str> {
|
||||||
|
std::env::var("JWT_SECRET")
|
||||||
.expect("JWT_SECRET environment variable is required")
|
.expect("JWT_SECRET environment variable is required")
|
||||||
.into();
|
.into()
|
||||||
|
}
|
||||||
// 初始化共享状态
|
|
||||||
let pending_commands = PendingCommands::new();
|
|
||||||
let nodes = NodeRegistry::new(redis_conn.clone());
|
|
||||||
let state = AppState {
|
|
||||||
redis: redis_conn.clone(),
|
|
||||||
mysql: db_pool.clone(),
|
|
||||||
pending_commands: pending_commands.clone(),
|
|
||||||
nodes: nodes.clone(),
|
|
||||||
redis_url: cfg.redis_url.clone(),
|
|
||||||
jwt_secret,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/// 启动全部后台协程:设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留
|
||||||
|
fn spawn_workers(cfg: &config::Config, state: &AppState) {
|
||||||
// 启动后台协程 — 处理设备上报
|
// 启动后台协程 — 处理设备上报
|
||||||
let report_redis = redis_conn.clone();
|
let report_redis = state.redis.clone();
|
||||||
let report_mysql = db_pool.clone();
|
let report_mysql = state.mysql.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::report_worker::start(report_redis, report_mysql).await;
|
workers::report_worker::start(report_redis, report_mysql).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 处理指令响应
|
// 启动后台协程 — 处理指令响应
|
||||||
let reply_redis = redis_conn.clone();
|
let reply_redis = state.redis.clone();
|
||||||
|
let reply_pending = state.pending_commands.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::reply_worker::start(reply_redis, pending_commands).await;
|
workers::reply_worker::start(reply_redis, reply_pending).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 节点心跳超时检测
|
// 启动后台协程 — 节点心跳超时检测
|
||||||
let checker_nodes = nodes.clone();
|
let checker_nodes = state.nodes.clone();
|
||||||
let heartbeat_timeout = cfg.node_heartbeat_timeout;
|
let heartbeat_timeout = cfg.node_heartbeat_timeout;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
|
workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
|
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
|
||||||
let timeout_mysql = db_pool.clone();
|
let timeout_mysql = state.mysql.clone();
|
||||||
let timeout_redis = redis_conn.clone();
|
let timeout_redis = state.redis.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::order_timeout::start(timeout_mysql, timeout_redis).await;
|
workers::order_timeout::start(timeout_mysql, timeout_redis).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
|
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
|
||||||
let retention_mysql = db_pool.clone();
|
let retention_mysql = state.mysql.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
workers::log_retention::start(retention_mysql).await;
|
workers::log_retention::start(retention_mysql).await;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建路由并启动 HTTP 服务(阻塞至进程退出)
|
||||||
|
async fn serve_http(cfg: &config::Config, state: AppState) {
|
||||||
// 构建 HTTP 路由
|
// 构建 HTTP 路由
|
||||||
let app = routes::build(state);
|
let app = routes::build(state);
|
||||||
|
|
||||||
@ -116,3 +115,38 @@ async fn main() {
|
|||||||
.await
|
.await
|
||||||
.expect("服务器运行失败");
|
.expect("服务器运行失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// 初始化日志
|
||||||
|
init_tracing();
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
let cfg = config::Config::from_env();
|
||||||
|
tracing::info!("配置加载完成");
|
||||||
|
|
||||||
|
// 初始化 MySQL 连接池 + 自动迁移
|
||||||
|
let db_pool = init_database(&cfg).await;
|
||||||
|
|
||||||
|
// 初始化 Redis 连接
|
||||||
|
let redis_conn = init_redis(&cfg).await;
|
||||||
|
|
||||||
|
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
|
||||||
|
let jwt_secret = load_jwt_secret();
|
||||||
|
|
||||||
|
// 初始化共享状态
|
||||||
|
let state = AppState {
|
||||||
|
redis: redis_conn.clone(),
|
||||||
|
mysql: db_pool.clone(),
|
||||||
|
pending_commands: PendingCommands::new(),
|
||||||
|
nodes: NodeRegistry::new(redis_conn),
|
||||||
|
redis_url: cfg.redis_url.clone(),
|
||||||
|
jwt_secret,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动后台协程(设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留)
|
||||||
|
spawn_workers(&cfg, &state);
|
||||||
|
|
||||||
|
// 构建路由并启动 HTTP 服务
|
||||||
|
serve_http(&cfg, state).await;
|
||||||
|
}
|
||||||
|
|||||||
@ -244,37 +244,34 @@ struct PhoneInfo {
|
|||||||
country_code: String,
|
country_code: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
|
/// 读取微信小程序凭证(环境变量缺失则报内部错误)
|
||||||
///
|
fn wechat_credentials() -> Result<(String, String), AppError> {
|
||||||
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
|
|
||||||
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
|
|
||||||
pub async fn wx_login(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Json(input): Json<WxLoginInput>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let appid = std::env::var("WECHAT_APPID")
|
let appid = std::env::var("WECHAT_APPID")
|
||||||
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
|
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
|
||||||
let secret = std::env::var("WECHAT_APPSECRET")
|
let secret = std::env::var("WECHAT_APPSECRET")
|
||||||
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
|
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
|
||||||
|
Ok((appid, secret))
|
||||||
|
}
|
||||||
|
|
||||||
let client = Client::new();
|
/// 用 wx.login() 的 code 换取 openid
|
||||||
|
async fn fetch_openid(client: &Client, appid: &str, secret: &str, code: &str) -> Result<String, AppError> {
|
||||||
// 1. 用 code 换取 openid
|
|
||||||
let jscode_url = format!(
|
let jscode_url = format!(
|
||||||
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
|
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
|
||||||
appid, secret, input.code
|
appid, secret, code
|
||||||
);
|
);
|
||||||
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
|
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
|
||||||
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
|
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
|
||||||
.json().await
|
.json().await
|
||||||
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
|
||||||
|
|
||||||
let openid = js_resp.openid.ok_or_else(|| {
|
js_resp.openid.ok_or_else(|| {
|
||||||
let err = js_resp.errmsg.unwrap_or_default();
|
let err = js_resp.errmsg.unwrap_or_default();
|
||||||
AppError::Internal(format!("jscode2session 失败: {}", err))
|
AppError::Internal(format!("jscode2session 失败: {}", err))
|
||||||
})?;
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 2. 获取 access_token
|
/// 获取微信接口调用凭证 access_token
|
||||||
|
async fn fetch_access_token(client: &Client, appid: &str, secret: &str) -> Result<String, AppError> {
|
||||||
let token_url = format!(
|
let token_url = format!(
|
||||||
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
|
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
|
||||||
appid, secret
|
appid, secret
|
||||||
@ -284,18 +281,24 @@ pub async fn wx_login(
|
|||||||
.json().await
|
.json().await
|
||||||
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
|
||||||
|
|
||||||
let access_token = token_resp.access_token.ok_or_else(|| {
|
token_resp.access_token.ok_or_else(|| {
|
||||||
let err = token_resp.errmsg.unwrap_or_default();
|
let err = token_resp.errmsg.unwrap_or_default();
|
||||||
AppError::Internal(format!("获取 access_token 失败: {}", err))
|
AppError::Internal(format!("获取 access_token 失败: {}", err))
|
||||||
})?;
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 用 phone_code 换取手机号
|
/// 用 phone_code 换取微信绑定的手机号
|
||||||
|
async fn fetch_phone_number(
|
||||||
|
client: &Client,
|
||||||
|
access_token: &str,
|
||||||
|
phone_code: &str,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
let phone_url = format!(
|
let phone_url = format!(
|
||||||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
|
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
|
||||||
access_token
|
access_token
|
||||||
);
|
);
|
||||||
let phone_resp: PhoneNumberResp = client.post(&phone_url)
|
let phone_resp: PhoneNumberResp = client.post(&phone_url)
|
||||||
.json(&json!({ "code": input.phone_code }))
|
.json(&json!({ "code": phone_code }))
|
||||||
.send().await
|
.send().await
|
||||||
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
|
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
|
||||||
.json().await
|
.json().await
|
||||||
@ -306,22 +309,51 @@ pub async fn wx_login(
|
|||||||
let err = phone_resp.errmsg.unwrap_or_default();
|
let err = phone_resp.errmsg.unwrap_or_default();
|
||||||
AppError::Internal(format!("获取手机号失败: {}", err))
|
AppError::Internal(format!("获取手机号失败: {}", err))
|
||||||
})?;
|
})?;
|
||||||
let phone_number = phone.pure_phone_number;
|
Ok(phone.pure_phone_number)
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 按手机号查找用户(手机号是唯一标识)
|
/// 按手机号查找用户(手机号是唯一标识)
|
||||||
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
|
async fn find_user_by_phone(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
phone_number: &str,
|
||||||
|
) -> Result<(i64, Option<String>, String, i32, i32, Option<i64>), AppError> {
|
||||||
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
|
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
|
||||||
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
|
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
|
||||||
)
|
)
|
||||||
.bind(&phone_number)
|
.bind(phone_number)
|
||||||
.fetch_optional(&state.mysql)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
user_row.ok_or_else(|| {
|
||||||
|
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
|
||||||
|
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
|
||||||
|
///
|
||||||
|
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
|
||||||
|
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
|
||||||
|
pub async fn wx_login(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(input): Json<WxLoginInput>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let (appid, secret) = wechat_credentials()?;
|
||||||
|
let client = Client::new();
|
||||||
|
|
||||||
|
// 1. 用 code 换取 openid
|
||||||
|
let openid = fetch_openid(&client, &appid, &secret, &input.code).await?;
|
||||||
|
|
||||||
|
// 2. 获取 access_token
|
||||||
|
let access_token = fetch_access_token(&client, &appid, &secret).await?;
|
||||||
|
|
||||||
|
// 3. 用 phone_code 换取手机号
|
||||||
|
let phone_number = fetch_phone_number(&client, &access_token, &input.phone_code).await?;
|
||||||
|
|
||||||
|
// 4. 按手机号查找用户(手机号是唯一标识)
|
||||||
|
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
|
||||||
let (id, name, _phone, status, role_level, organization_id) =
|
let (id, name, _phone, status, role_level, organization_id) =
|
||||||
user_row.ok_or_else(|| {
|
find_user_by_phone(&state.mysql, &phone_number).await?;
|
||||||
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
|
|
||||||
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
|
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
|
||||||
sqlx::query("UPDATE users SET openid = ? WHERE id = ?")
|
sqlx::query("UPDATE users SET openid = ? WHERE id = ?")
|
||||||
|
|||||||
@ -52,6 +52,117 @@ struct BatteryRow {
|
|||||||
created_at: Option<NaiveDateTime>,
|
created_at: Option<NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 构建列表 / 计数 SQL
|
||||||
|
///
|
||||||
|
/// 仅判断筛选条件是否存在以拼接 SQL,具体取值由调用方绑定。
|
||||||
|
fn build_battery_sql(params: &BatteryQuery) -> (String, String) {
|
||||||
|
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
|
||||||
|
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
|
||||||
|
|
||||||
|
if params.status.is_some() {
|
||||||
|
query.push_str(" AND status = ?");
|
||||||
|
count_query.push_str(" AND status = ?");
|
||||||
|
}
|
||||||
|
if params.battery_id.is_some() {
|
||||||
|
query.push_str(" AND battery_id LIKE ?");
|
||||||
|
count_query.push_str(" AND battery_id LIKE ?");
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
||||||
|
(query, count_query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按筛选条件统计电池总数
|
||||||
|
async fn count_batteries(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
count_query: &str,
|
||||||
|
status: Option<i8>,
|
||||||
|
battery_id: Option<&str>,
|
||||||
|
) -> Result<i64, sqlx::Error> {
|
||||||
|
let total: i64 = if let Some(s) = status {
|
||||||
|
if let Some(bid) = battery_id {
|
||||||
|
sqlx::query_scalar(count_query)
|
||||||
|
.bind(s)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(count_query)
|
||||||
|
.bind(s)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
} else if let Some(bid) = battery_id {
|
||||||
|
sqlx::query_scalar(count_query)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar(count_query)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按筛选条件查询当前页电池行
|
||||||
|
async fn fetch_battery_rows(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
query: &str,
|
||||||
|
status: Option<i8>,
|
||||||
|
battery_id: Option<&str>,
|
||||||
|
page_size: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<BatteryRow>, sqlx::Error> {
|
||||||
|
let rows: Vec<BatteryRow> = if let Some(s) = status {
|
||||||
|
if let Some(bid) = battery_id {
|
||||||
|
sqlx::query_as(query)
|
||||||
|
.bind(s)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(query)
|
||||||
|
.bind(s)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
} else if let Some(bid) = battery_id {
|
||||||
|
sqlx::query_as(query)
|
||||||
|
.bind(format!("%{}%", bid))
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
sqlx::query_as(query)
|
||||||
|
.bind(page_size)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将数据库行映射为响应项列表
|
||||||
|
fn batteries_to_items(rows: Vec<BatteryRow>) -> Vec<BatteryItem> {
|
||||||
|
rows.into_iter().map(|r| BatteryItem {
|
||||||
|
id: r.id,
|
||||||
|
battery_id: r.battery_id,
|
||||||
|
soc: r.soc,
|
||||||
|
brand: r.brand,
|
||||||
|
model: r.model,
|
||||||
|
sn: r.sn,
|
||||||
|
status: r.status,
|
||||||
|
status_text: status_text(r.status).to_string(),
|
||||||
|
created_at: r.created_at,
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/batteries — 电池台账列表
|
/// GET /api/batteries — 电池台账列表
|
||||||
pub async fn list_batteries(
|
pub async fn list_batteries(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -64,88 +175,22 @@ pub async fn list_batteries(
|
|||||||
let page_size = params.page_size.unwrap_or(20).min(100);
|
let page_size = params.page_size.unwrap_or(20).min(100);
|
||||||
let offset = (page - 1) * page_size;
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
|
|
||||||
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
|
|
||||||
|
|
||||||
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定
|
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定
|
||||||
if params.status.is_some() {
|
let (query, count_query) = build_battery_sql(¶ms);
|
||||||
query.push_str(" AND status = ?");
|
let battery_id = params.battery_id.as_deref();
|
||||||
count_query.push_str(" AND status = ?");
|
|
||||||
}
|
|
||||||
if params.battery_id.is_some() {
|
|
||||||
query.push_str(" AND battery_id LIKE ?");
|
|
||||||
count_query.push_str(" AND battery_id LIKE ?");
|
|
||||||
}
|
|
||||||
|
|
||||||
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
|
let total = count_batteries(&state.mysql, &count_query, params.status, battery_id).await?;
|
||||||
|
let rows = fetch_battery_rows(
|
||||||
|
&state.mysql,
|
||||||
|
&query,
|
||||||
|
params.status,
|
||||||
|
battery_id,
|
||||||
|
page_size,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let total: i64 = if let Some(s) = params.status {
|
let items = batteries_to_items(rows);
|
||||||
if let Some(ref bid) = params.battery_id {
|
|
||||||
sqlx::query_scalar(&count_query)
|
|
||||||
.bind(s)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(&count_query)
|
|
||||||
.bind(s)
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(ref bid) = params.battery_id {
|
|
||||||
sqlx::query_scalar(&count_query)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(&count_query)
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows: Vec<BatteryRow> = if let Some(s) = params.status {
|
|
||||||
if let Some(ref bid) = params.battery_id {
|
|
||||||
sqlx::query_as(&query)
|
|
||||||
.bind(s)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(&state.mysql)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(&query)
|
|
||||||
.bind(s)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(&state.mysql)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
} else if let Some(ref bid) = params.battery_id {
|
|
||||||
sqlx::query_as(&query)
|
|
||||||
.bind(format!("%{}%", bid))
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(&state.mysql)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as(&query)
|
|
||||||
.bind(page_size)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(&state.mysql)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let items: Vec<BatteryItem> = rows.into_iter().map(|r| BatteryItem {
|
|
||||||
id: r.id,
|
|
||||||
battery_id: r.battery_id,
|
|
||||||
soc: r.soc,
|
|
||||||
brand: r.brand,
|
|
||||||
model: r.model,
|
|
||||||
sn: r.sn,
|
|
||||||
status: r.status,
|
|
||||||
status_text: status_text(r.status).to_string(),
|
|
||||||
created_at: r.created_at,
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use axum::extract::{State, Query};
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
|
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
@ -61,6 +62,57 @@ impl From<CabinetSqlRow> for CabinetRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SQL 常量
|
||||||
|
//
|
||||||
|
// 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、
|
||||||
|
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 全部组织(组织树用)
|
||||||
|
const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id";
|
||||||
|
|
||||||
|
/// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题)
|
||||||
|
const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id";
|
||||||
|
|
||||||
|
/// 各项目下的设备数量
|
||||||
|
const PROJECT_CABINET_COUNTS_SQL: &str =
|
||||||
|
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id";
|
||||||
|
|
||||||
|
/// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留)
|
||||||
|
const CABINET_FILTER_BASE_SQL: &str = "
|
||||||
|
SELECT
|
||||||
|
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
|
||||||
|
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
|
||||||
|
c.status,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb2
|
||||||
|
JOIN compartments comp ON comp.cabin_board_id = cb2.id
|
||||||
|
WHERE cb2.cabinet_id = c.id) as channel_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb3
|
||||||
|
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
|
||||||
|
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb4
|
||||||
|
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
|
||||||
|
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb5
|
||||||
|
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
|
||||||
|
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
|
||||||
|
(SELECT COUNT(*) FROM cabin_boards cb6
|
||||||
|
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
|
||||||
|
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
|
||||||
|
FROM cabinets c
|
||||||
|
";
|
||||||
|
|
||||||
|
/// 设备归属校验(非总管理员):设备必须属于当前用户组织
|
||||||
|
const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \
|
||||||
|
JOIN projects p ON c.project_id = p.id \
|
||||||
|
WHERE c.imei = ? AND p.organization_id = ?)";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 组织树
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
|
/// 组织树接口 — 返回多级组织->项目树(含设备数量)
|
||||||
pub async fn organization_tree(
|
pub async fn organization_tree(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -71,99 +123,107 @@ pub async fn organization_tree(
|
|||||||
let db = &state.mysql;
|
let db = &state.mysql;
|
||||||
|
|
||||||
// 获取所有组织
|
// 获取所有组织
|
||||||
let orgs: Vec<OrganizationRow> = sqlx::query_as(
|
let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?;
|
||||||
"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"
|
|
||||||
)
|
|
||||||
.fetch_all(db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 获取所有项目
|
// 获取所有项目
|
||||||
let projects: Vec<ProjectRow> = sqlx::query_as(
|
let projects: Vec<ProjectRow> = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?;
|
||||||
"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id"
|
|
||||||
)
|
|
||||||
.fetch_all(db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 获取每个项目的设备数量
|
// 获取每个项目的设备数量
|
||||||
let project_counts: Vec<(i64, i64)> = sqlx::query_as(
|
let project_count_map = fetch_project_counts(db).await?;
|
||||||
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id"
|
|
||||||
)
|
|
||||||
.fetch_all(db)
|
|
||||||
.await?;
|
|
||||||
let project_count_map: std::collections::HashMap<i64, i64> = project_counts.into_iter().collect();
|
|
||||||
|
|
||||||
// 递归构建树
|
// 递归构建树
|
||||||
fn build_tree(
|
|
||||||
orgs: &[OrganizationRow],
|
|
||||||
projects: &[ProjectRow],
|
|
||||||
project_count_map: &std::collections::HashMap<i64, i64>,
|
|
||||||
parent_id: Option<i64>,
|
|
||||||
) -> Vec<TreeNode> {
|
|
||||||
orgs
|
|
||||||
.iter()
|
|
||||||
.filter(|o| o.parent_id == parent_id)
|
|
||||||
.map(|org| {
|
|
||||||
// 递归获取子组织
|
|
||||||
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
|
|
||||||
|
|
||||||
// 获取该组织下的项目
|
|
||||||
let org_projects: Vec<TreeNode> = projects
|
|
||||||
.iter()
|
|
||||||
.filter(|p| p.organization_id == Some(org.id))
|
|
||||||
.map(|p| {
|
|
||||||
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
|
|
||||||
TreeNode {
|
|
||||||
id: p.id,
|
|
||||||
name: format!("{} ({})", p.name, count),
|
|
||||||
children: None,
|
|
||||||
abstract_id: None,
|
|
||||||
imei: None,
|
|
||||||
status: None,
|
|
||||||
disabled: None,
|
|
||||||
address: p.address.clone(),
|
|
||||||
latitude: p.latitude.clone(),
|
|
||||||
longitude: p.longitude.clone(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// 合并子组织和项目
|
|
||||||
let mut all_children = children;
|
|
||||||
all_children.extend(org_projects);
|
|
||||||
|
|
||||||
// 计算本组织下的总设备数量
|
|
||||||
let total_count: i64 = all_children.iter()
|
|
||||||
.map(|c| {
|
|
||||||
// 从项目名称中提取数量
|
|
||||||
if let Some(start) = c.name.rfind('(') {
|
|
||||||
if let Some(end) = c.name.rfind(')') {
|
|
||||||
c.name[start+1..end].parse().unwrap_or(0)
|
|
||||||
} else { 0 }
|
|
||||||
} else { 0 }
|
|
||||||
})
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
TreeNode {
|
|
||||||
id: org.id,
|
|
||||||
name: format!("{} ({})", org.name, total_count),
|
|
||||||
children: if all_children.is_empty() { None } else { Some(all_children) },
|
|
||||||
abstract_id: None,
|
|
||||||
imei: None,
|
|
||||||
status: None,
|
|
||||||
disabled: org.disabled,
|
|
||||||
address: None,
|
|
||||||
latitude: None,
|
|
||||||
longitude: None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
let tree = build_tree(&orgs, &projects, &project_count_map, None);
|
let tree = build_tree(&orgs, &projects, &project_count_map, None);
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
|
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查询「项目ID -> 设备数量」映射
|
||||||
|
async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> {
|
||||||
|
let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL)
|
||||||
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(project_counts.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量
|
||||||
|
fn build_tree(
|
||||||
|
orgs: &[OrganizationRow],
|
||||||
|
projects: &[ProjectRow],
|
||||||
|
project_count_map: &HashMap<i64, i64>,
|
||||||
|
parent_id: Option<i64>,
|
||||||
|
) -> Vec<TreeNode> {
|
||||||
|
orgs.iter()
|
||||||
|
.filter(|o| o.parent_id == parent_id)
|
||||||
|
.map(|org| {
|
||||||
|
// 递归获取子组织
|
||||||
|
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
|
||||||
|
|
||||||
|
// 获取该组织下的项目
|
||||||
|
let org_projects: Vec<TreeNode> = projects
|
||||||
|
.iter()
|
||||||
|
.filter(|p| p.organization_id == Some(org.id))
|
||||||
|
.map(|p| {
|
||||||
|
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
|
||||||
|
TreeNode {
|
||||||
|
id: p.id,
|
||||||
|
name: format!("{} ({})", p.name, count),
|
||||||
|
children: None,
|
||||||
|
abstract_id: None,
|
||||||
|
imei: None,
|
||||||
|
status: None,
|
||||||
|
disabled: None,
|
||||||
|
address: p.address.clone(),
|
||||||
|
latitude: p.latitude.clone(),
|
||||||
|
longitude: p.longitude.clone(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 合并子组织和项目
|
||||||
|
let mut all_children = children;
|
||||||
|
all_children.extend(org_projects);
|
||||||
|
|
||||||
|
// 计算本组织下的总设备数量
|
||||||
|
let total_count: i64 = all_children
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
// 从项目名称中提取数量
|
||||||
|
if let Some(start) = c.name.rfind('(') {
|
||||||
|
if let Some(end) = c.name.rfind(')') {
|
||||||
|
c.name[start + 1..end].parse().unwrap_or(0)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
TreeNode {
|
||||||
|
id: org.id,
|
||||||
|
name: format!("{} ({})", org.name, total_count),
|
||||||
|
children: if all_children.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_children)
|
||||||
|
},
|
||||||
|
abstract_id: None,
|
||||||
|
imei: None,
|
||||||
|
status: None,
|
||||||
|
disabled: org.disabled,
|
||||||
|
address: None,
|
||||||
|
latitude: None,
|
||||||
|
longitude: None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 设备列表筛选
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 设备列表筛选参数
|
/// 设备列表筛选参数
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CabinetFilterParams {
|
pub struct CabinetFilterParams {
|
||||||
@ -188,32 +248,37 @@ pub async fn filter_cabinets(
|
|||||||
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
|
let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
|
||||||
let offset = (page - 1) * page_size;
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
// 基础SQL:联表查询获取通道状态统计
|
// 组织/项目过滤 + 已绑定/未绑定过滤
|
||||||
let base_sql = "
|
let (where_clause, binds) = build_filter_where(¶ms, &user);
|
||||||
SELECT
|
|
||||||
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
|
|
||||||
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
|
|
||||||
c.status,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb2
|
|
||||||
JOIN compartments comp ON comp.cabin_board_id = cb2.id
|
|
||||||
WHERE cb2.cabinet_id = c.id) as channel_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb3
|
|
||||||
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
|
|
||||||
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb4
|
|
||||||
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
|
|
||||||
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb5
|
|
||||||
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
|
|
||||||
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
|
|
||||||
(SELECT COUNT(*) FROM cabin_boards cb6
|
|
||||||
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
|
|
||||||
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
|
|
||||||
FROM cabinets c
|
|
||||||
";
|
|
||||||
|
|
||||||
let (mut where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
|
// 总数量
|
||||||
|
let total = fetch_filter_total(db, &where_clause, &binds).await;
|
||||||
|
|
||||||
|
if total == 0 || offset >= total {
|
||||||
|
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页数据
|
||||||
|
let sql_rows = fetch_filter_page(db, &where_clause, &binds, page_size, offset).await?;
|
||||||
|
|
||||||
|
// 转换为CabinetRow
|
||||||
|
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
|
||||||
|
|
||||||
|
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
|
||||||
|
attach_realtime_status(&state, &mut rows).await?;
|
||||||
|
|
||||||
|
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
|
||||||
|
attach_org_disabled(db, &mut rows).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致)
|
||||||
|
fn build_filter_where(
|
||||||
|
params: &CabinetFilterParams,
|
||||||
|
user: &CurrentUser,
|
||||||
|
) -> (String, Vec<String>) {
|
||||||
|
let (where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
|
||||||
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
|
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
|
||||||
} else if let Some(org_id) = params.organization_id {
|
} else if let Some(org_id) = params.organization_id {
|
||||||
("JOIN projects p ON c.project_id = p.id
|
("JOIN projects p ON c.project_id = p.id
|
||||||
@ -230,121 +295,144 @@ pub async fn filter_cabinets(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 已绑定/未绑定过滤(绑定 = 分配到项目)
|
// 已绑定/未绑定过滤(绑定 = 分配到项目)
|
||||||
if let Some(bound) = params.bound {
|
let where_clause = match params.bound.as_deref() {
|
||||||
match bound.as_str() {
|
Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"),
|
||||||
"1" => {
|
Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"),
|
||||||
if where_clause.is_empty() {
|
_ => where_clause,
|
||||||
where_clause = "WHERE c.project_id IS NOT NULL".into();
|
};
|
||||||
} else {
|
|
||||||
where_clause.push_str(" AND c.project_id IS NOT NULL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"0" => {
|
|
||||||
if where_clause.is_empty() {
|
|
||||||
where_clause = "WHERE c.project_id IS NULL".into();
|
|
||||||
} else {
|
|
||||||
where_clause.push_str(" AND c.project_id IS NULL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 总数量
|
(where_clause, binds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接
|
||||||
|
fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String {
|
||||||
|
if where_clause.is_empty() {
|
||||||
|
where_new.to_string()
|
||||||
|
} else {
|
||||||
|
let mut clause = where_clause;
|
||||||
|
clause.push_str(and_tail);
|
||||||
|
clause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
|
||||||
|
async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 {
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
for b in &binds {
|
for b in binds {
|
||||||
count_query = count_query.bind(b);
|
count_query = count_query.bind(b);
|
||||||
}
|
}
|
||||||
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0);
|
count_query.fetch_one(db).await.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
if total == 0 || offset >= total {
|
/// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定)
|
||||||
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
|
async fn fetch_filter_page(
|
||||||
}
|
db: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
// 分页数据
|
binds: &[String],
|
||||||
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", base_sql, where_clause);
|
page_size: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<CabinetSqlRow>, AppError> {
|
||||||
|
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause);
|
||||||
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
|
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
|
||||||
for b in &binds {
|
for b in binds {
|
||||||
data_query = data_query.bind(b);
|
data_query = data_query.bind(b);
|
||||||
}
|
}
|
||||||
let sql_rows: Vec<CabinetSqlRow> = data_query
|
let rows: Vec<CabinetSqlRow> = data_query
|
||||||
.bind(page_size)
|
.bind(page_size)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(db).await?;
|
.fetch_all(db)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
// 转换为CabinetRow
|
/// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返)
|
||||||
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
|
async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> {
|
||||||
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
let mut conn = open_redis_conn(state).await?;
|
||||||
|
|
||||||
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
|
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
|
||||||
{
|
let mut pipe = redis::pipe();
|
||||||
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
for row in rows.iter() {
|
||||||
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
let key = format!("device:{}", row.imei);
|
||||||
})?;
|
pipe.cmd("HGET").arg(&key).arg("online");
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
pipe.cmd("HGET").arg(&key).arg("rssi");
|
||||||
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
|
||||||
AppError::Internal(format!("Redis连接失败: {}", e))
|
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
||||||
})?;
|
|
||||||
|
|
||||||
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
|
|
||||||
let mut pipe = redis::pipe();
|
|
||||||
for row in &rows {
|
|
||||||
let key = format!("device:{}", row.imei);
|
|
||||||
pipe.cmd("HGET").arg(&key).arg("online");
|
|
||||||
pipe.cmd("HGET").arg(&key).arg("rssi");
|
|
||||||
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
|
|
||||||
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一次发送,获取所有结果
|
|
||||||
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
|
|
||||||
|
|
||||||
// 每4个一组解析
|
|
||||||
for (i, row) in rows.iter_mut().enumerate() {
|
|
||||||
let base = i * 4;
|
|
||||||
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
||||||
row.rssi = results.get(base + 1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
|
|
||||||
row.pow_fail_dc = results.get(base + 2).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
||||||
row.pow_fail_ac = results.get(base + 3).and_then(|v| v.as_ref()).map(|v| v == "1");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
|
// 一次发送,获取所有结果
|
||||||
{
|
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
|
||||||
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
|
||||||
if !project_ids.is_empty() {
|
// 每4个一组解析
|
||||||
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
for (i, row) in rows.iter_mut().enumerate() {
|
||||||
let sql = format!(
|
let base = i * 4;
|
||||||
r#"SELECT p.id, o.disabled FROM projects p
|
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
|
||||||
|
row.rssi = results
|
||||||
|
.get(base + 1)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.and_then(|v| v.parse::<i32>().ok());
|
||||||
|
row.pow_fail_dc = results
|
||||||
|
.get(base + 2)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.map(|v| v == "1");
|
||||||
|
row.pow_fail_ac = results
|
||||||
|
.get(base + 3)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.map(|v| v == "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
|
||||||
|
async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) {
|
||||||
|
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
||||||
|
if project_ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
||||||
|
let sql = format!(
|
||||||
|
r#"SELECT p.id, o.disabled FROM projects p
|
||||||
LEFT JOIN organizations o ON p.organization_id = o.id
|
LEFT JOIN organizations o ON p.organization_id = o.id
|
||||||
WHERE p.id IN ({})"#,
|
WHERE p.id IN ({})"#,
|
||||||
placeholders.join(",")
|
placeholders.join(",")
|
||||||
);
|
);
|
||||||
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
||||||
for pid in &project_ids {
|
for pid in &project_ids {
|
||||||
q = q.bind(pid);
|
q = q.bind(pid);
|
||||||
}
|
}
|
||||||
if let Ok(results) = q.fetch_all(db).await {
|
if let Ok(results) = q.fetch_all(db).await {
|
||||||
let disabled_map: std::collections::HashMap<i64, bool> = results
|
let disabled_map: HashMap<i64, bool> =
|
||||||
.into_iter()
|
results.into_iter().map(|(pid, d)| (pid, d == 1)).collect();
|
||||||
.map(|(pid, d)| (pid, d == 1))
|
for row in rows.iter_mut() {
|
||||||
.collect();
|
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
|
||||||
for row in rows.iter_mut() {
|
|
||||||
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 设备实时状态
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// 设备实时状态参数
|
/// 设备实时状态参数
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct DeviceStatusParams {
|
pub struct DeviceStatusParams {
|
||||||
pub imei: String,
|
pub imei: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误)
|
||||||
|
async fn open_redis_conn(state: &AppState) -> Result<redis::aio::MultiplexedConnection, AppError> {
|
||||||
|
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
||||||
|
})?;
|
||||||
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
client.get_multiplexed_async_connection().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Redis连接失败: {}", e))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// 设备实时状态接口 — 返回板级+通道级状态
|
/// 设备实时状态接口 — 返回板级+通道级状态
|
||||||
pub async fn device_realtime_status(
|
pub async fn device_realtime_status(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -354,18 +442,37 @@ pub async fn device_realtime_status(
|
|||||||
auth::check_permission(&user, "device:view")?;
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
// 校验设备归属
|
// 校验设备归属
|
||||||
|
ensure_device_access(&state, &user, ¶ms.imei).await?;
|
||||||
|
|
||||||
|
let mut conn = open_redis_conn(&state).await?;
|
||||||
|
|
||||||
|
let device_status = load_device_status(&mut conn, ¶ms.imei).await;
|
||||||
|
let boards = load_boards_status(&mut conn, ¶ms.imei).await;
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"device": device_status,
|
||||||
|
"boards": boards,
|
||||||
|
},
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织
|
||||||
|
async fn ensure_device_access(
|
||||||
|
state: &AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
|
imei: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
if user.role_level < 2 {
|
if user.role_level < 2 {
|
||||||
if let Some(org_id) = user.organization_id {
|
if let Some(org_id) = user.organization_id {
|
||||||
let has_access: bool = sqlx::query_scalar(
|
let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL)
|
||||||
"SELECT EXISTS(SELECT 1 FROM cabinets c \
|
.bind(imei)
|
||||||
JOIN projects p ON c.project_id = p.id \
|
.bind(org_id)
|
||||||
WHERE c.imei = ? AND p.organization_id = ?)",
|
.fetch_one(&state.mysql)
|
||||||
)
|
.await
|
||||||
.bind(¶ms.imei)
|
.unwrap_or(false);
|
||||||
.bind(org_id)
|
|
||||||
.fetch_one(&state.mysql)
|
|
||||||
.await
|
|
||||||
.unwrap_or(false);
|
|
||||||
if !has_access {
|
if !has_access {
|
||||||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||||||
}
|
}
|
||||||
@ -373,38 +480,43 @@ pub async fn device_realtime_status(
|
|||||||
return Err(AppError::Forbidden("无权访问该设备".into()));
|
return Err(AppError::Forbidden("无权访问该设备".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
|
/// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac)
|
||||||
AppError::Internal(format!("Redis客户端创建失败: {}", e))
|
async fn load_device_status(
|
||||||
})?;
|
conn: &mut redis::aio::MultiplexedConnection,
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
imei: &str,
|
||||||
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| {
|
) -> Value {
|
||||||
AppError::Internal(format!("Redis连接失败: {}", e))
|
let device_key = format!("device:{}", imei);
|
||||||
})?;
|
|
||||||
|
|
||||||
let device_key = format!("device:{}", params.imei);
|
|
||||||
|
|
||||||
// 获取设备级字段
|
// 获取设备级字段
|
||||||
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
|
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
|
||||||
let values: Vec<Option<String>> = redis::cmd("HMGET")
|
let values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
.arg(&device_key)
|
.arg(&device_key)
|
||||||
.arg(&fields)
|
.arg(&fields)
|
||||||
.query_async(&mut conn)
|
.query_async(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let device_status = json!({
|
json!({
|
||||||
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
||||||
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
|
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
|
||||||
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
||||||
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描并组装全部仓控板状态(板级 + 通道级)
|
||||||
|
async fn load_boards_status(
|
||||||
|
conn: &mut redis::aio::MultiplexedConnection,
|
||||||
|
imei: &str,
|
||||||
|
) -> Value {
|
||||||
// 扫描板级key: device:{imei}:board:*
|
// 扫描板级key: device:{imei}:board:*
|
||||||
let board_pattern = format!("device:{}:board:*", params.imei);
|
let board_pattern = format!("device:{}:board:*", imei);
|
||||||
let board_keys: Vec<String> = redis::cmd("KEYS")
|
let board_keys: Vec<String> = redis::cmd("KEYS")
|
||||||
.arg(&board_pattern)
|
.arg(&board_pattern)
|
||||||
.query_async(&mut conn)
|
.query_async(&mut *conn)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
@ -416,77 +528,98 @@ pub async fn device_realtime_status(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取板级字段
|
boards[board_idx] = load_single_board(conn, board_key).await;
|
||||||
let board_fields = vec!["status_hex"];
|
|
||||||
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
|
|
||||||
.arg(board_key)
|
|
||||||
.arg(&board_fields)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut board_data = json!({
|
|
||||||
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
|
|
||||||
let ch_pattern = format!("{}:ch:*", board_key);
|
|
||||||
let ch_keys: Vec<String> = redis::cmd("KEYS")
|
|
||||||
.arg(&ch_pattern)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let mut channels = json!({});
|
|
||||||
for ch_key in &ch_keys {
|
|
||||||
let ch_idx = ch_key.split(":").last().unwrap_or("");
|
|
||||||
if ch_idx.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
|
|
||||||
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
|
|
||||||
.arg(ch_key)
|
|
||||||
.arg(&ch_fields)
|
|
||||||
.query_async(&mut conn)
|
|
||||||
.await
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// 状态映射:fault > full > on > idle
|
|
||||||
let fault = ch_values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
|
|
||||||
let full = ch_values.get(1).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
|
|
||||||
let on = ch_values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
|
|
||||||
|
|
||||||
let status = if fault {
|
|
||||||
"fault"
|
|
||||||
} else if full {
|
|
||||||
"full"
|
|
||||||
} else if on {
|
|
||||||
"charging"
|
|
||||||
} else {
|
|
||||||
"idle"
|
|
||||||
};
|
|
||||||
|
|
||||||
channels[ch_idx] = json!({
|
|
||||||
"status": status,
|
|
||||||
"on": on,
|
|
||||||
"full": full,
|
|
||||||
"fault": fault,
|
|
||||||
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
|
|
||||||
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
board_data["channels"] = channels;
|
|
||||||
boards[board_idx] = board_data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(json!({
|
boards
|
||||||
"code": 0,
|
}
|
||||||
"data": {
|
|
||||||
"device": device_status,
|
/// 读取单块仓控板的板级字段,并扫描其下的通道级 key
|
||||||
"boards": boards,
|
async fn load_single_board(
|
||||||
},
|
conn: &mut redis::aio::MultiplexedConnection,
|
||||||
"message": "ok"
|
board_key: &str,
|
||||||
})))
|
) -> Value {
|
||||||
|
// 获取板级字段
|
||||||
|
let board_fields = vec!["status_hex"];
|
||||||
|
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
|
.arg(board_key)
|
||||||
|
.arg(&board_fields)
|
||||||
|
.query_async(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut board_data = json!({
|
||||||
|
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
|
||||||
|
let ch_pattern = format!("{}:ch:*", board_key);
|
||||||
|
let ch_keys: Vec<String> = redis::cmd("KEYS")
|
||||||
|
.arg(&ch_pattern)
|
||||||
|
.query_async(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let mut channels = json!({});
|
||||||
|
for ch_key in &ch_keys {
|
||||||
|
let ch_idx = ch_key.split(":").last().unwrap_or("");
|
||||||
|
if ch_idx.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
channels[ch_idx] = load_channel_status(conn, ch_key).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
board_data["channels"] = channels;
|
||||||
|
board_data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取单个通道状态(状态映射优先级:fault > full > on > idle)
|
||||||
|
async fn load_channel_status(
|
||||||
|
conn: &mut redis::aio::MultiplexedConnection,
|
||||||
|
ch_key: &str,
|
||||||
|
) -> Value {
|
||||||
|
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
|
||||||
|
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
|
||||||
|
.arg(ch_key)
|
||||||
|
.arg(&ch_fields)
|
||||||
|
.query_async(&mut *conn)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 状态映射:fault > full > on > idle
|
||||||
|
let fault = ch_values
|
||||||
|
.get(0)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.map(|v| v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let full = ch_values
|
||||||
|
.get(1)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.map(|v| v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
let on = ch_values
|
||||||
|
.get(2)
|
||||||
|
.and_then(|v| v.as_ref())
|
||||||
|
.map(|v| v == "1")
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let status = if fault {
|
||||||
|
"fault"
|
||||||
|
} else if full {
|
||||||
|
"full"
|
||||||
|
} else if on {
|
||||||
|
"charging"
|
||||||
|
} else {
|
||||||
|
"idle"
|
||||||
|
};
|
||||||
|
|
||||||
|
json!({
|
||||||
|
"status": status,
|
||||||
|
"on": on,
|
||||||
|
"full": full,
|
||||||
|
"fault": fault,
|
||||||
|
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
|
||||||
|
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -155,6 +155,61 @@ async fn fetch_charge_rows(
|
|||||||
query.fetch_all(pool).await
|
query.fetch_all(pool).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 追加组织数据隔离到 WHERE 子句与绑定值
|
||||||
|
fn append_org_scope(user: &CurrentUser, where_clause: &mut String, bind_values: &mut Vec<String>) {
|
||||||
|
let (org_cond, org_binds) = auth::org_condition(user, "cr");
|
||||||
|
if !org_cond.is_empty() {
|
||||||
|
if where_clause.is_empty() {
|
||||||
|
*where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
|
||||||
|
} else {
|
||||||
|
where_clause.push_str(&org_cond);
|
||||||
|
}
|
||||||
|
bind_values.extend(org_binds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计符合条件的充电记录总数(JOIN 链路需与数据查询保持一致)
|
||||||
|
async fn count_charge_records(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
bind_values: &[String],
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let count_sql = format!(
|
||||||
|
"SELECT COUNT(*) FROM charge_records cr \
|
||||||
|
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
|
||||||
|
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
|
||||||
|
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
|
||||||
|
{}",
|
||||||
|
where_clause
|
||||||
|
);
|
||||||
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
|
for val in bind_values {
|
||||||
|
count_query = count_query.bind(val);
|
||||||
|
}
|
||||||
|
Ok(count_query.fetch_one(pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将充电记录行映射为响应项列表
|
||||||
|
fn charge_rows_to_items(rows: Vec<ChargeRecordRow>) -> Vec<ChargeRecordItem> {
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| ChargeRecordItem {
|
||||||
|
id: r.id,
|
||||||
|
cabinet_id: r.cabinet_id,
|
||||||
|
cabinet_name: r.cabinet_name,
|
||||||
|
cabin_board_id: r.cabin_board_id.unwrap_or(0),
|
||||||
|
board_id: r.board_id.unwrap_or(0),
|
||||||
|
compartment_id: r.compartment_id,
|
||||||
|
channel_id: r.channel_id.unwrap_or(0),
|
||||||
|
start_time: r.start_time.map(|t| t.to_string()),
|
||||||
|
end_time: r.end_time.map(|t| t.to_string()),
|
||||||
|
energy: r.energy,
|
||||||
|
max_temperature: r.max_temperature,
|
||||||
|
status: r.status,
|
||||||
|
status_text: status_text(r.status).to_string(),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/charge-records — 充电记录分页列表
|
/// GET /api/charge-records — 充电记录分页列表
|
||||||
pub async fn list_charge_records(
|
pub async fn list_charge_records(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
@ -173,30 +228,10 @@ pub async fn list_charge_records(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 追加组织数据隔离
|
// 追加组织数据隔离
|
||||||
let (org_cond, org_binds) = auth::org_condition(&user, "cr");
|
append_org_scope(&user, &mut where_clause, &mut bind_values);
|
||||||
if !org_cond.is_empty() {
|
|
||||||
if where_clause.is_empty() {
|
|
||||||
where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
|
|
||||||
} else {
|
|
||||||
where_clause.push_str(&org_cond);
|
|
||||||
}
|
|
||||||
bind_values.extend(org_binds);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查询总数(需要与数据查询保持一致的 JOIN 链路)
|
// 查询总数(需要与数据查询保持一致的 JOIN 链路)
|
||||||
let count_sql = format!(
|
let total = count_charge_records(&state.mysql, &where_clause, &bind_values).await?;
|
||||||
"SELECT COUNT(*) FROM charge_records cr \
|
|
||||||
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
|
|
||||||
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
|
|
||||||
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
|
|
||||||
{}",
|
|
||||||
where_clause
|
|
||||||
);
|
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
|
||||||
for val in &bind_values {
|
|
||||||
count_query = count_query.bind(val);
|
|
||||||
}
|
|
||||||
let total = count_query.fetch_one(&state.mysql).await?;
|
|
||||||
|
|
||||||
// 查询列表
|
// 查询列表
|
||||||
let rows = fetch_charge_rows(
|
let rows = fetch_charge_rows(
|
||||||
@ -208,24 +243,7 @@ pub async fn list_charge_records(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let list: Vec<ChargeRecordItem> = rows
|
let list = charge_rows_to_items(rows);
|
||||||
.into_iter()
|
|
||||||
.map(|r| ChargeRecordItem {
|
|
||||||
id: r.id,
|
|
||||||
cabinet_id: r.cabinet_id,
|
|
||||||
cabinet_name: r.cabinet_name,
|
|
||||||
cabin_board_id: r.cabin_board_id.unwrap_or(0),
|
|
||||||
board_id: r.board_id.unwrap_or(0),
|
|
||||||
compartment_id: r.compartment_id,
|
|
||||||
channel_id: r.channel_id.unwrap_or(0),
|
|
||||||
start_time: r.start_time.map(|t| t.to_string()),
|
|
||||||
end_time: r.end_time.map(|t| t.to_string()),
|
|
||||||
energy: r.energy,
|
|
||||||
max_temperature: r.max_temperature,
|
|
||||||
status: r.status,
|
|
||||||
status_text: status_text(r.status).to_string(),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
@ -307,29 +325,27 @@ fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
|
|||||||
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
|
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 后台异步生成 Excel 文件
|
/// 读取导出任务的文件名与查询参数
|
||||||
async fn generate_charge_records_excel(
|
///
|
||||||
pool: sqlx::MySqlPool,
|
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
|
||||||
|
async fn load_export_task(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
task_id: i64,
|
task_id: i64,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// 更新状态为处理中
|
|
||||||
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
|
|
||||||
.bind(task_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 获取查询参数
|
|
||||||
let row: Option<(String, String)> = sqlx::query_as(
|
let row: Option<(String, String)> = sqlx::query_as(
|
||||||
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
|
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(task_id)
|
.bind(task_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (file_name, query_json) = row.ok_or("任务不存在")?;
|
let (file_name, query_json) = row.ok_or("任务不存在")?;
|
||||||
|
|
||||||
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
||||||
|
Ok((file_name, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
|
||||||
|
fn build_export_where(params: &Value) -> (String, Vec<String>) {
|
||||||
let (mut where_clause, mut bind_values) = build_where_clause(
|
let (mut where_clause, mut bind_values) = build_where_clause(
|
||||||
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
||||||
None,
|
None,
|
||||||
@ -351,15 +367,13 @@ async fn generate_charge_records_excel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
|
(where_clause, bind_values)
|
||||||
|
}
|
||||||
|
|
||||||
// 更新进度 50%
|
/// 构建导出工作簿(表头 + 数据行)
|
||||||
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
fn build_charge_workbook(
|
||||||
.bind(task_id)
|
rows: &[ChargeRecordRow],
|
||||||
.execute(&pool)
|
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 生成 Excel
|
|
||||||
let mut workbook = rust_xlsxwriter::Workbook::new();
|
let mut workbook = rust_xlsxwriter::Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
sheet.set_name("充电记录")?;
|
sheet.set_name("充电记录")?;
|
||||||
@ -391,6 +405,54 @@ async fn generate_charge_records_excel(
|
|||||||
sheet.write_string(row, 8, status_text(record.status))?;
|
sheet.write_string(row, 8, status_text(record.status))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(workbook)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将导出任务标记为完成并记录文件路径
|
||||||
|
async fn mark_task_completed(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
task_id: i64,
|
||||||
|
file_path: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
|
||||||
|
error_message = NULL, completed_at = NOW() WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(file_path)
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后台异步生成 Excel 文件
|
||||||
|
async fn generate_charge_records_excel(
|
||||||
|
pool: sqlx::MySqlPool,
|
||||||
|
task_id: i64,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// 更新状态为处理中
|
||||||
|
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
let (file_name, params) = load_export_task(&pool, task_id).await?;
|
||||||
|
|
||||||
|
// 组织过滤(非总管理员)+ 查询数据
|
||||||
|
let (where_clause, bind_values) = build_export_where(¶ms);
|
||||||
|
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
|
||||||
|
|
||||||
|
// 更新进度 50%
|
||||||
|
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 生成 Excel
|
||||||
|
let mut workbook = build_charge_workbook(&rows)?;
|
||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
let file_dir = "./exports";
|
let file_dir = "./exports";
|
||||||
tokio::fs::create_dir_all(file_dir).await?;
|
tokio::fs::create_dir_all(file_dir).await?;
|
||||||
@ -398,14 +460,7 @@ async fn generate_charge_records_excel(
|
|||||||
workbook.save(&file_path)?;
|
workbook.save(&file_path)?;
|
||||||
|
|
||||||
// 更新任务状态为完成
|
// 更新任务状态为完成
|
||||||
sqlx::query(
|
mark_task_completed(&pool, task_id, &file_path).await?;
|
||||||
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
|
|
||||||
error_message = NULL, completed_at = NOW() WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(&file_path)
|
|
||||||
.bind(task_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
|
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -59,16 +59,11 @@ struct DeviceLogRow {
|
|||||||
created_at: Option<NaiveDateTime>,
|
created_at: Option<NaiveDateTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/device-logs — 设备日志分页列表
|
/// 构建 WHERE 条件与绑定值(含组织数据隔离)
|
||||||
pub async fn list_device_logs(
|
fn build_device_log_filters(
|
||||||
user: CurrentUser,
|
user: &CurrentUser,
|
||||||
State(state): State<super::AppState>,
|
params: &DeviceLogQuery,
|
||||||
Query(params): Query<DeviceLogQuery>,
|
) -> (String, Vec<String>) {
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
auth::check_permission(&user, "device_log:view")?;
|
|
||||||
let offset = (params.page - 1) * params.page_size;
|
|
||||||
|
|
||||||
// 构建 WHERE 条件
|
|
||||||
let mut conditions = Vec::new();
|
let mut conditions = Vec::new();
|
||||||
let mut bind_values: Vec<String> = Vec::new();
|
let mut bind_values: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@ -90,7 +85,7 @@ pub async fn list_device_logs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 追加组织数据隔离
|
// 追加组织数据隔离
|
||||||
let (org_cond, org_binds) = auth::org_condition(&user, "dl");
|
let (org_cond, org_binds) = auth::org_condition(user, "dl");
|
||||||
if !org_cond.is_empty() {
|
if !org_cond.is_empty() {
|
||||||
conditions.push(org_cond.trim_start_matches(" AND "));
|
conditions.push(org_cond.trim_start_matches(" AND "));
|
||||||
bind_values.extend(org_binds);
|
bind_values.extend(org_binds);
|
||||||
@ -101,19 +96,34 @@ pub async fn list_device_logs(
|
|||||||
} else {
|
} else {
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
};
|
};
|
||||||
|
(where_clause, bind_values)
|
||||||
|
}
|
||||||
|
|
||||||
// 查询总数
|
/// 统计符合条件的设备日志总数
|
||||||
|
async fn count_device_logs(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
bind_values: &[String],
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
let count_sql = format!(
|
let count_sql = format!(
|
||||||
"SELECT COUNT(*) FROM device_logs dl {}",
|
"SELECT COUNT(*) FROM device_logs dl {}",
|
||||||
where_clause
|
where_clause
|
||||||
);
|
);
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
for val in &bind_values {
|
for val in bind_values {
|
||||||
count_query = count_query.bind(val);
|
count_query = count_query.bind(val);
|
||||||
}
|
}
|
||||||
let total = count_query.fetch_one(&state.mysql).await?;
|
Ok(count_query.fetch_one(pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
// 查询列表
|
/// 查询设备日志行(关联柜名,分页)
|
||||||
|
async fn fetch_device_log_rows(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
bind_values: &[String],
|
||||||
|
page_size: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<DeviceLogRow>, AppError> {
|
||||||
let list_sql = format!(
|
let list_sql = format!(
|
||||||
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
|
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
|
||||||
dl.log_type, dl.content, dl.created_at \
|
dl.log_type, dl.content, dl.created_at \
|
||||||
@ -124,15 +134,17 @@ pub async fn list_device_logs(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
|
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
|
||||||
for val in &bind_values {
|
for val in bind_values {
|
||||||
list_query = list_query.bind(val);
|
list_query = list_query.bind(val);
|
||||||
}
|
}
|
||||||
list_query = list_query.bind(params.page_size).bind(offset);
|
list_query = list_query.bind(page_size).bind(offset);
|
||||||
|
|
||||||
let rows = list_query.fetch_all(&state.mysql).await?;
|
Ok(list_query.fetch_all(pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
let list: Vec<DeviceLogItem> = rows
|
/// 将设备日志行映射为响应项
|
||||||
.into_iter()
|
fn device_logs_to_items(rows: Vec<DeviceLogRow>) -> Vec<DeviceLogItem> {
|
||||||
|
rows.into_iter()
|
||||||
.map(|r| DeviceLogItem {
|
.map(|r| DeviceLogItem {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
cabinet_id: r.cabinet_id,
|
cabinet_id: r.cabinet_id,
|
||||||
@ -142,7 +154,35 @@ pub async fn list_device_logs(
|
|||||||
content: r.content,
|
content: r.content,
|
||||||
created_at: r.created_at.map(|t| t.to_string()),
|
created_at: r.created_at.map(|t| t.to_string()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/device-logs — 设备日志分页列表
|
||||||
|
pub async fn list_device_logs(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<super::AppState>,
|
||||||
|
Query(params): Query<DeviceLogQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "device_log:view")?;
|
||||||
|
let offset = (params.page - 1) * params.page_size;
|
||||||
|
|
||||||
|
// 构建 WHERE 条件
|
||||||
|
let (where_clause, bind_values) = build_device_log_filters(&user, ¶ms);
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
let total = count_device_logs(&state.mysql, &where_clause, &bind_values).await?;
|
||||||
|
|
||||||
|
// 查询列表
|
||||||
|
let rows = fetch_device_log_rows(
|
||||||
|
&state.mysql,
|
||||||
|
&where_clause,
|
||||||
|
&bind_values,
|
||||||
|
params.page_size,
|
||||||
|
offset,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let list = device_logs_to_items(rows);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|||||||
@ -461,28 +461,27 @@ fn build_export_clause(
|
|||||||
(clause, bind_values)
|
(clause, bind_values)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 后台异步生成能耗 Excel 文件
|
/// 读取导出任务的文件名与查询参数
|
||||||
async fn generate_energy_excel(
|
///
|
||||||
pool: sqlx::MySqlPool,
|
/// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
|
||||||
|
async fn load_export_task(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
task_id: i64,
|
task_id: i64,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
// 更新状态为处理中
|
|
||||||
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
|
|
||||||
.bind(task_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 获取查询参数
|
|
||||||
let row: Option<(String, String)> = sqlx::query_as(
|
let row: Option<(String, String)> = sqlx::query_as(
|
||||||
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
|
"SELECT file_name, error_message FROM download_tasks WHERE id = ?"
|
||||||
)
|
)
|
||||||
.bind(task_id)
|
.bind(task_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let (file_name, query_json) = row.ok_or("任务不存在")?;
|
let (file_name, query_json) = row.ok_or("任务不存在")?;
|
||||||
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
|
||||||
|
Ok((file_name, params))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
|
||||||
|
fn build_export_where(params: &Value) -> (String, Vec<String>) {
|
||||||
let (mut where_clause, mut bind_values) = build_export_clause(
|
let (mut where_clause, mut bind_values) = build_export_clause(
|
||||||
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
params.get("cabinet_id").and_then(|v| v.as_i64()),
|
||||||
params.get("project_id").and_then(|v| v.as_i64()),
|
params.get("project_id").and_then(|v| v.as_i64()),
|
||||||
@ -504,7 +503,15 @@ async fn generate_energy_excel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询能耗明细
|
(where_clause, bind_values)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询能耗明细行
|
||||||
|
async fn fetch_energy_detail_rows(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
bind_values: &[String],
|
||||||
|
) -> Result<Vec<EnergyDetailRow>, sqlx::Error> {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
|
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
|
||||||
p.name AS project_name, \
|
p.name AS project_name, \
|
||||||
@ -518,18 +525,16 @@ async fn generate_energy_excel(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
|
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
|
||||||
for val in &bind_values {
|
for val in bind_values {
|
||||||
query = query.bind(val);
|
query = query.bind(val);
|
||||||
}
|
}
|
||||||
let rows = query.fetch_all(&pool).await?;
|
query.fetch_all(pool).await
|
||||||
|
}
|
||||||
|
|
||||||
// 更新进度 50%
|
/// 构建导出工作簿(表头 + 数据行)
|
||||||
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
fn build_energy_workbook(
|
||||||
.bind(task_id)
|
rows: &[EnergyDetailRow],
|
||||||
.execute(&pool)
|
) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 生成 Excel
|
|
||||||
let mut workbook = rust_xlsxwriter::Workbook::new();
|
let mut workbook = rust_xlsxwriter::Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
sheet.set_name("能耗统计")?;
|
sheet.set_name("能耗统计")?;
|
||||||
@ -551,6 +556,54 @@ async fn generate_energy_excel(
|
|||||||
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
|
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(workbook)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将导出任务标记为完成并记录文件路径
|
||||||
|
async fn mark_task_completed(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
task_id: i64,
|
||||||
|
file_path: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
|
||||||
|
error_message = NULL, completed_at = NOW() WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(file_path)
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 后台异步生成能耗 Excel 文件
|
||||||
|
async fn generate_energy_excel(
|
||||||
|
pool: sqlx::MySqlPool,
|
||||||
|
task_id: i64,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
// 更新状态为处理中
|
||||||
|
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 获取查询参数
|
||||||
|
let (file_name, params) = load_export_task(&pool, task_id).await?;
|
||||||
|
|
||||||
|
// 组织过滤(非总管理员)+ 查询能耗明细
|
||||||
|
let (where_clause, bind_values) = build_export_where(¶ms);
|
||||||
|
let rows = fetch_energy_detail_rows(&pool, &where_clause, &bind_values).await?;
|
||||||
|
|
||||||
|
// 更新进度 50%
|
||||||
|
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
|
||||||
|
.bind(task_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 生成 Excel
|
||||||
|
let mut workbook = build_energy_workbook(&rows)?;
|
||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
let file_dir = "./exports";
|
let file_dir = "./exports";
|
||||||
tokio::fs::create_dir_all(file_dir).await?;
|
tokio::fs::create_dir_all(file_dir).await?;
|
||||||
@ -558,14 +611,7 @@ async fn generate_energy_excel(
|
|||||||
workbook.save(&file_path)?;
|
workbook.save(&file_path)?;
|
||||||
|
|
||||||
// 更新任务状态为完成
|
// 更新任务状态为完成
|
||||||
sqlx::query(
|
mark_task_completed(&pool, task_id, &file_path).await?;
|
||||||
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
|
|
||||||
error_message = NULL, completed_at = NOW() WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(&file_path)
|
|
||||||
.bind(task_id)
|
|
||||||
.execute(&pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
|
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -16,6 +16,7 @@ use axum::Json;
|
|||||||
use redis;
|
use redis;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@ -430,123 +431,128 @@ async fn fetch_fault_alerts(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 项目摘要 SQL(由函数体原样搬移,WHERE/JOIN/SELECT 未改动) ---
|
||||||
|
|
||||||
|
/// 指定组织下的项目
|
||||||
|
const PROJECTS_BY_ORG_SQL: &str = "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id";
|
||||||
|
/// 全部项目(总管理员未指定组织时)
|
||||||
|
const PROJECTS_ALL_SQL: &str = "SELECT id, name FROM projects ORDER BY id";
|
||||||
|
/// 项目下柜子数量
|
||||||
|
const PROJECT_CABINET_COUNT_SQL: &str = "SELECT COUNT(*) FROM cabinets WHERE project_id = ?";
|
||||||
|
/// 项目下充电中仓体数量
|
||||||
|
const PROJECT_CHARGING_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
|
||||||
|
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||||||
|
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||||||
|
WHERE cab.project_id = ? AND comp.status = 1"#;
|
||||||
|
/// 项目下空闲仓体数量
|
||||||
|
const PROJECT_IDLE_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
|
||||||
|
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||||||
|
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||||||
|
WHERE cab.project_id = ? AND comp.status = 0"#;
|
||||||
|
/// 项目下故障仓体数量
|
||||||
|
const PROJECT_FAULT_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
|
||||||
|
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
||||||
|
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
||||||
|
WHERE cab.project_id = ? AND comp.status = 2"#;
|
||||||
|
|
||||||
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
|
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
|
||||||
async fn fetch_project_summaries(
|
async fn fetch_project_summaries(
|
||||||
pool: &sqlx::MySqlPool,
|
pool: &sqlx::MySqlPool,
|
||||||
user: &CurrentUser,
|
user: &CurrentUser,
|
||||||
org_id: Option<i64>,
|
org_id: Option<i64>,
|
||||||
) -> Result<Vec<Value>, AppError> {
|
) -> Result<Vec<Value>, AppError> {
|
||||||
// 获取用户可见的项目(优先使用查询参数 org_id)
|
let projects = fetch_visible_projects(pool, user, org_id).await?;
|
||||||
|
|
||||||
|
let mut result = Vec::with_capacity(projects.len());
|
||||||
|
for p in &projects {
|
||||||
|
result.push(fetch_project_summary(pool, p).await?);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取用户可见的项目(优先使用查询参数 org_id)
|
||||||
|
async fn fetch_visible_projects(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
user: &CurrentUser,
|
||||||
|
org_id: Option<i64>,
|
||||||
|
) -> Result<Vec<ProjectRow>, AppError> {
|
||||||
let filter_org = org_id.or(user.organization_id);
|
let filter_org = org_id.or(user.organization_id);
|
||||||
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
|
let projects: Vec<ProjectRow> = if user.role_level >= 2 {
|
||||||
if let Some(oid) = filter_org {
|
if let Some(oid) = filter_org {
|
||||||
sqlx::query_as::<_, ProjectRow>(
|
sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
|
||||||
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
.bind(oid)
|
||||||
)
|
.fetch_all(pool)
|
||||||
.bind(oid)
|
.await?
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id")
|
sqlx::query_as::<_, ProjectRow>(PROJECTS_ALL_SQL)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
} else if let Some(org_id) = user.organization_id {
|
} else if let Some(org_id) = user.organization_id {
|
||||||
sqlx::query_as::<_, ProjectRow>(
|
sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
|
||||||
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id",
|
.bind(org_id)
|
||||||
)
|
.fetch_all(pool)
|
||||||
.bind(org_id)
|
.await?
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut result = Vec::with_capacity(projects.len());
|
Ok(projects)
|
||||||
for p in &projects {
|
}
|
||||||
let cabinet_count: i64 = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) FROM cabinets WHERE project_id = ?",
|
/// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数
|
||||||
)
|
async fn fetch_project_summary(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
p: &ProjectRow,
|
||||||
|
) -> Result<Value, AppError> {
|
||||||
|
let cabinet_count: i64 = sqlx::query_scalar(PROJECT_CABINET_COUNT_SQL)
|
||||||
.bind(p.id)
|
.bind(p.id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let charging: i64 = sqlx::query_scalar(
|
let charging: i64 = sqlx::query_scalar(PROJECT_CHARGING_COUNT_SQL)
|
||||||
r#"SELECT COUNT(*) FROM compartments comp
|
|
||||||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
|
||||||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
|
||||||
WHERE cab.project_id = ? AND comp.status = 1"#,
|
|
||||||
)
|
|
||||||
.bind(p.id)
|
.bind(p.id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let idle: i64 = sqlx::query_scalar(
|
let idle: i64 = sqlx::query_scalar(PROJECT_IDLE_COUNT_SQL)
|
||||||
r#"SELECT COUNT(*) FROM compartments comp
|
|
||||||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
|
||||||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
|
||||||
WHERE cab.project_id = ? AND comp.status = 0"#,
|
|
||||||
)
|
|
||||||
.bind(p.id)
|
.bind(p.id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let fault: i64 = sqlx::query_scalar(
|
let fault: i64 = sqlx::query_scalar(PROJECT_FAULT_COUNT_SQL)
|
||||||
r#"SELECT COUNT(*) FROM compartments comp
|
|
||||||
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
|
|
||||||
JOIN cabinets cab ON cb.cabinet_id = cab.id
|
|
||||||
WHERE cab.project_id = ? AND comp.status = 2"#,
|
|
||||||
)
|
|
||||||
.bind(p.id)
|
.bind(p.id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
result.push(json!({
|
Ok(json!({
|
||||||
"id": p.id,
|
"id": p.id,
|
||||||
"name": p.name,
|
"name": p.name,
|
||||||
"cabinet_count": cabinet_count,
|
"cabinet_count": cabinet_count,
|
||||||
"charging": charging,
|
"charging": charging,
|
||||||
"idle": idle,
|
"idle": idle,
|
||||||
"fault": fault,
|
"fault": fault,
|
||||||
}));
|
}))
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 设备列表
|
// 设备列表
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
|
// --- 设备列表 SQL(由原函数体逐字搬移;SELECT 字段列表 / 子查询 / CAST 均未改动) ---
|
||||||
pub async fn get_cabinets(
|
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<CabinetListQuery>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
let pool = &state.mysql;
|
|
||||||
|
|
||||||
// 权限校验
|
/// 项目过滤 + 总管理员(不带组织条件)
|
||||||
auth::check_permission(&user, "device:view")?;
|
const CABINETS_BY_PROJECT_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
|
|
||||||
let rows: Vec<H5CabinetRow> = if let Some(pid) = query.project_id {
|
|
||||||
// 按项目过滤(同时验证组织权限)
|
|
||||||
if user.role_level >= 2 {
|
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||||||
FROM cabinets c WHERE c.project_id = ?"#,
|
FROM cabinets c WHERE c.project_id = ?"#;
|
||||||
)
|
|
||||||
.bind(pid)
|
/// 项目过滤 + 企业管理员(额外校验项目归属组织)
|
||||||
.fetch_all(pool)
|
const CABINETS_BY_PROJECT_AND_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
.await?
|
|
||||||
} else if let Some(org_id) = user.organization_id {
|
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -554,20 +560,10 @@ pub async fn get_cabinets(
|
|||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||||||
FROM cabinets c
|
FROM cabinets c
|
||||||
INNER JOIN projects p ON c.project_id = p.id
|
INNER JOIN projects p ON c.project_id = p.id
|
||||||
WHERE c.project_id = ? AND p.organization_id = ?"#,
|
WHERE c.project_id = ? AND p.organization_id = ?"#;
|
||||||
)
|
|
||||||
.bind(pid)
|
/// 总管理员按组织过滤
|
||||||
.bind(org_id)
|
const CABINETS_BY_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
}
|
|
||||||
} else if user.role_level >= 2 {
|
|
||||||
// 总管理员:按组织过滤或看全部
|
|
||||||
if let Some(oid) = query.org_id {
|
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -575,28 +571,19 @@ pub async fn get_cabinets(
|
|||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||||||
FROM cabinets c
|
FROM cabinets c
|
||||||
INNER JOIN projects p ON c.project_id = p.id
|
INNER JOIN projects p ON c.project_id = p.id
|
||||||
WHERE p.organization_id = ?"#,
|
WHERE p.organization_id = ?"#;
|
||||||
)
|
|
||||||
.bind(oid)
|
/// 总管理员查看全部
|
||||||
.fetch_all(pool)
|
const CABINETS_ALL_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||||||
FROM cabinets c"#,
|
FROM cabinets c"#;
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
/// 企业管理员只看本组织
|
||||||
.await?
|
const CABINETS_BY_USER_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
||||||
}
|
|
||||||
} else if let Some(org_id) = user.organization_id {
|
|
||||||
// 企业管理员只看本组织
|
|
||||||
sqlx::query_as::<_, H5CabinetRow>(
|
|
||||||
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
|
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
|
||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
|
||||||
@ -604,25 +591,130 @@ pub async fn get_cabinets(
|
|||||||
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
|
||||||
FROM cabinets c
|
FROM cabinets c
|
||||||
INNER JOIN projects p ON c.project_id = p.id
|
INNER JOIN projects p ON c.project_id = p.id
|
||||||
WHERE p.organization_id = ?"#,
|
WHERE p.organization_id = ?"#;
|
||||||
)
|
|
||||||
.bind(org_id)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
vec![]
|
|
||||||
};
|
|
||||||
|
|
||||||
// 从Redis批量查RSSI + pow_fail
|
/// 柜子所属组织是否禁用(前端徽标用,`{}` 处填入 `?` 占位符列表)
|
||||||
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
const PROJECT_ORG_DISABLED_SQL: &str = r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
|
||||||
let mut dc_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
|
LEFT JOIN organizations o ON p.organization_id = o.id
|
||||||
let mut ac_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new();
|
WHERE p.id IN ({})"#;
|
||||||
let mut temp_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
|
|
||||||
|
/// 设备列表的 Redis 实时状态(按 IMEI 索引)
|
||||||
|
#[derive(Default)]
|
||||||
|
struct CabinetRealtimeMaps {
|
||||||
|
rssi: HashMap<String, Option<i32>>,
|
||||||
|
dc: HashMap<String, Option<bool>>,
|
||||||
|
ac: HashMap<String, Option<bool>>,
|
||||||
|
temp: HashMap<String, Option<i32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
|
||||||
|
pub async fn get_cabinets(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<CabinetListQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
// 权限校验
|
||||||
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
|
let pool = &state.mysql;
|
||||||
|
|
||||||
|
// 组织隔离查询:设备基础信息 + 通道统计
|
||||||
|
let rows = fetch_cabinet_rows(pool, &user, query.project_id, query.org_id).await?;
|
||||||
|
|
||||||
|
// 从Redis批量查RSSI + pow_fail + temp
|
||||||
|
let realtime = load_cabinet_realtime(&state, &rows).await;
|
||||||
|
|
||||||
|
// 查询柜子所属组织是否禁用(用于前端徽标)
|
||||||
|
let org_disabled_map = load_org_disabled_map(pool, &rows).await;
|
||||||
|
|
||||||
|
let list = build_cabinet_list(rows, &realtime, &org_disabled_map);
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": list,
|
||||||
|
"message": "ok"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按「项目 / 角色」优先级查询用户可见的柜子行(分支顺序与原实现逐条对应)
|
||||||
|
async fn fetch_cabinet_rows(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
user: &CurrentUser,
|
||||||
|
project_id: Option<i64>,
|
||||||
|
org_id: Option<i64>,
|
||||||
|
) -> Result<Vec<H5CabinetRow>, AppError> {
|
||||||
|
if let Some(pid) = project_id {
|
||||||
|
// 按项目过滤(同时验证组织权限)
|
||||||
|
fetch_cabinet_rows_by_project(pool, user, pid).await
|
||||||
|
} else if user.role_level >= 2 {
|
||||||
|
// 总管理员:按组织过滤或看全部
|
||||||
|
fetch_cabinet_rows_for_admin(pool, org_id).await
|
||||||
|
} else if let Some(org_id) = user.organization_id {
|
||||||
|
// 企业管理员只看本组织
|
||||||
|
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_USER_ORG_SQL)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
} else {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织
|
||||||
|
async fn fetch_cabinet_rows_by_project(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
user: &CurrentUser,
|
||||||
|
pid: i64,
|
||||||
|
) -> Result<Vec<H5CabinetRow>, AppError> {
|
||||||
|
if user.role_level >= 2 {
|
||||||
|
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_SQL)
|
||||||
|
.bind(pid)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
} else if let Some(org_id) = user.organization_id {
|
||||||
|
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_AND_ORG_SQL)
|
||||||
|
.bind(pid)
|
||||||
|
.bind(org_id)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
} else {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 总管理员分支:指定组织则按组织过滤,否则查看全部
|
||||||
|
async fn fetch_cabinet_rows_for_admin(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
org_id: Option<i64>,
|
||||||
|
) -> Result<Vec<H5CabinetRow>, AppError> {
|
||||||
|
if let Some(oid) = org_id {
|
||||||
|
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_ORG_SQL)
|
||||||
|
.bind(oid)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
} else {
|
||||||
|
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_ALL_SQL)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Redis 批量读取柜子实时状态(Pipeline HMGET,1 次网络往返)
|
||||||
|
///
|
||||||
|
/// 注意:pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐,
|
||||||
|
/// 与重构前的行为完全一致(这是既有约定,不做修正)。
|
||||||
|
async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> CabinetRealtimeMaps {
|
||||||
|
let mut maps = CabinetRealtimeMaps::default();
|
||||||
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||||||
let mut pipe = redis::pipe();
|
let mut pipe = redis::pipe();
|
||||||
for r in &rows {
|
for r in rows {
|
||||||
if let Some(ref imei) = r.imei {
|
if let Some(ref imei) = r.imei {
|
||||||
let key = format!("device:{imei}");
|
let key = format!("device:{imei}");
|
||||||
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
|
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
|
||||||
@ -639,49 +731,56 @@ pub async fn get_cabinets(
|
|||||||
for (i, r) in rows.iter().enumerate() {
|
for (i, r) in rows.iter().enumerate() {
|
||||||
if let Some(ref imei) = r.imei {
|
if let Some(ref imei) = r.imei {
|
||||||
if let Some(vals) = results.get(i) {
|
if let Some(vals) = results.get(i) {
|
||||||
rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
maps.rssi.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||||||
dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
maps.dc.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||||||
ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
maps.ac.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
|
||||||
temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
maps.temp.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maps
|
||||||
|
}
|
||||||
|
|
||||||
// 查询柜子所属组织是否禁用(用于前端徽标)
|
/// 查询柜子所属组织是否禁用,返回「项目ID -> 是否禁用」映射
|
||||||
let mut org_disabled_map: std::collections::HashMap<i64, bool> = std::collections::HashMap::new();
|
async fn load_org_disabled_map(
|
||||||
{
|
pool: &sqlx::MySqlPool,
|
||||||
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
rows: &[H5CabinetRow],
|
||||||
if !project_ids.is_empty() {
|
) -> HashMap<i64, bool> {
|
||||||
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
let mut org_disabled_map: HashMap<i64, bool> = HashMap::new();
|
||||||
let sql = format!(
|
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
|
||||||
r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
|
if !project_ids.is_empty() {
|
||||||
LEFT JOIN organizations o ON p.organization_id = o.id
|
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
|
||||||
WHERE p.id IN ({})"#,
|
// `{}` 处填入占位符列表,等价于原实现的 format! 拼接
|
||||||
placeholders.join(",")
|
let sql = PROJECT_ORG_DISABLED_SQL.replacen("{}", &placeholders.join(","), 1);
|
||||||
);
|
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
||||||
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
|
for pid in &project_ids {
|
||||||
for pid in &project_ids {
|
q = q.bind(pid);
|
||||||
q = q.bind(pid);
|
}
|
||||||
}
|
if let Ok(rows) = q.fetch_all(pool).await {
|
||||||
if let Ok(rows) = q.fetch_all(pool).await {
|
for (pid, disabled) in rows {
|
||||||
for (pid, disabled) in rows {
|
org_disabled_map.insert(pid, disabled == 1);
|
||||||
org_disabled_map.insert(pid, disabled == 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
org_disabled_map
|
||||||
|
}
|
||||||
|
|
||||||
let list: Vec<Value> = rows
|
/// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致)
|
||||||
.into_iter()
|
fn build_cabinet_list(
|
||||||
|
rows: Vec<H5CabinetRow>,
|
||||||
|
realtime: &CabinetRealtimeMaps,
|
||||||
|
org_disabled_map: &HashMap<i64, bool>,
|
||||||
|
) -> Vec<Value> {
|
||||||
|
rows.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
let imei = r.imei.clone();
|
let imei = r.imei.clone();
|
||||||
let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten();
|
let rssi = imei.as_ref().and_then(|i| realtime.rssi.get(i).copied()).flatten();
|
||||||
let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten();
|
let pow_fail_dc = imei.as_ref().and_then(|i| realtime.dc.get(i).copied()).flatten();
|
||||||
let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten();
|
let pow_fail_ac = imei.as_ref().and_then(|i| realtime.ac.get(i).copied()).flatten();
|
||||||
let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten();
|
let temp = imei.as_ref().and_then(|i| realtime.temp.get(i).copied()).flatten();
|
||||||
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
|
||||||
json!({
|
json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@ -706,13 +805,7 @@ pub async fn get_cabinets(
|
|||||||
"org_disabled": org_disabled,
|
"org_disabled": org_disabled,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
|
|
||||||
Ok(Json(json!({
|
|
||||||
"code": 0,
|
|
||||||
"data": list,
|
|
||||||
"message": "ok"
|
|
||||||
})))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -781,74 +874,51 @@ pub async fn get_cabinet_by_abstract_id(
|
|||||||
// 仓体/通道详情
|
// 仓体/通道详情
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 仓体基础信息(id, channel_id, status, cabin_board_id)
|
||||||
|
const COMPARTMENT_BY_ID_SQL: &str =
|
||||||
|
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?";
|
||||||
|
/// 仓体所属仓控板 → 柜子
|
||||||
|
const CABIN_BOARD_CABINET_ID_SQL: &str = "SELECT cabinet_id FROM cabin_boards WHERE id = ?";
|
||||||
|
/// 仓体最近 10 条充电记录
|
||||||
|
const COMPARTMENT_CHARGE_RECORDS_SQL: &str = r#"SELECT id, start_time, end_time, energy
|
||||||
|
FROM charge_records
|
||||||
|
WHERE compartment_id = ?
|
||||||
|
ORDER BY start_time DESC LIMIT 10"#;
|
||||||
|
|
||||||
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
|
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
|
||||||
pub async fn get_compartment_detail(
|
pub async fn get_compartment_detail(
|
||||||
user: CurrentUser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<i64>,
|
Path(id): Path<i64>,
|
||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
let pool = &state.mysql;
|
|
||||||
|
|
||||||
// 权限校验
|
// 权限校验
|
||||||
auth::check_permission(&user, "device:view")?;
|
auth::check_permission(&user, "device:view")?;
|
||||||
|
|
||||||
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 查询仓体基本信息
|
// 查询仓体基本信息
|
||||||
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(
|
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(COMPARTMENT_BY_ID_SQL)
|
||||||
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?",
|
.bind(id)
|
||||||
)
|
.fetch_optional(pool)
|
||||||
.bind(id)
|
.await?;
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let (comp_id, channel_id, status, cabin_board_id) =
|
let (comp_id, channel_id, status, cabin_board_id) =
|
||||||
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
|
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
|
||||||
|
|
||||||
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
|
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
|
||||||
let cabinet_id: Option<(i64,)> = sqlx::query_as(
|
verify_compartment_access(&user, cabin_board_id, pool).await?;
|
||||||
"SELECT cabinet_id FROM cabin_boards WHERE id = ?",
|
|
||||||
)
|
|
||||||
.bind(cabin_board_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((cab_id,)) = cabinet_id {
|
|
||||||
verify_cabinet_access(&user, cab_id, pool).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从 Redis 获取实时数据
|
// 从 Redis 获取实时数据
|
||||||
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
let realtime = load_compartment_realtime(&state, comp_id).await;
|
||||||
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
|
||||||
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
|
||||||
let key = format!("compartment:status:{}", comp_id);
|
|
||||||
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
|
|
||||||
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
|
||||||
} else { json!({}) }
|
|
||||||
} else { json!({}) };
|
|
||||||
|
|
||||||
// 查询最近充电记录
|
// 查询最近充电记录
|
||||||
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
|
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
|
||||||
r#"SELECT id, start_time, end_time, energy
|
COMPARTMENT_CHARGE_RECORDS_SQL,
|
||||||
FROM charge_records
|
|
||||||
WHERE compartment_id = ?
|
|
||||||
ORDER BY start_time DESC LIMIT 10"#,
|
|
||||||
)
|
)
|
||||||
.bind(comp_id)
|
.bind(comp_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let charge_records: Vec<Value> = records
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
json!({
|
|
||||||
"id": r.id,
|
|
||||||
"start_time": r.start_time.map(|t| t.to_string()),
|
|
||||||
"end_time": r.end_time.map(|t| t.to_string()),
|
|
||||||
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
|
|
||||||
"cost": 0,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": {
|
"data": {
|
||||||
@ -863,12 +933,58 @@ pub async fn get_compartment_detail(
|
|||||||
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
|
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
|
||||||
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
|
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
|
||||||
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
|
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
|
||||||
"charge_records": charge_records,
|
"charge_records": map_charge_records(records),
|
||||||
},
|
},
|
||||||
"message": "ok"
|
"message": "ok"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 组织隔离:仓控板 → 柜子 → 项目 → 组织链路校验访问权限
|
||||||
|
async fn verify_compartment_access(
|
||||||
|
user: &CurrentUser,
|
||||||
|
cabin_board_id: i64,
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let cabinet_id: Option<(i64,)> = sqlx::query_as(CABIN_BOARD_CABINET_ID_SQL)
|
||||||
|
.bind(cabin_board_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((cab_id,)) = cabinet_id {
|
||||||
|
verify_cabinet_access(user, cab_id, pool).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 Redis 读取仓体实时数据(key: `compartment:status:{comp_id}`)
|
||||||
|
async fn load_compartment_realtime(state: &AppState, comp_id: i64) -> Value {
|
||||||
|
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
|
||||||
|
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
|
||||||
|
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
|
||||||
|
let key = format!("compartment:status:{}", comp_id);
|
||||||
|
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
|
||||||
|
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||||
|
} else { json!({}) }
|
||||||
|
} else { json!({}) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 充电记录行 → 响应 JSON(`energy_kwh` / `cost` 语义不变)
|
||||||
|
fn map_charge_records(records: Vec<ChargeRecordBriefRow>) -> Vec<Value> {
|
||||||
|
records
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
json!({
|
||||||
|
"id": r.id,
|
||||||
|
"start_time": r.start_time.map(|t| t.to_string()),
|
||||||
|
"end_time": r.end_time.map(|t| t.to_string()),
|
||||||
|
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
|
||||||
|
"cost": 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 扫码验证
|
// 扫码验证
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1032,6 +1148,90 @@ pub async fn open_door(
|
|||||||
// 辅助函数
|
// 辅助函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// --- 指令下发 SQL / Redis key(由原函数体逐字搬移,契约不变) ---
|
||||||
|
|
||||||
|
/// 仓体 → 柜子/IMEI/仓控板/通道 一次查询
|
||||||
|
const COMPARTMENT_TARGET_SQL: &str = r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
|
||||||
|
FROM cabinets cab
|
||||||
|
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
||||||
|
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
||||||
|
WHERE comp.id = ?"#;
|
||||||
|
/// 柜子ID → IMEI
|
||||||
|
const CABINET_IMEI_SQL: &str = "SELECT imei FROM cabinets WHERE id = ?";
|
||||||
|
/// 柜子是否被禁用(status = 2)
|
||||||
|
const CABINET_DISABLED_SQL: &str = "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2";
|
||||||
|
/// 柜子所属组织是否被禁用
|
||||||
|
const CABINET_ORG_DISABLED_SQL: &str = r#"SELECT COUNT(*) > 0 FROM cabinets c
|
||||||
|
INNER JOIN projects p ON c.project_id = p.id
|
||||||
|
INNER JOIN organizations o ON p.organization_id = o.id
|
||||||
|
WHERE c.id = ? AND o.disabled = 1"#;
|
||||||
|
/// 设备指令队列 key(device-server 消费端契约,不可更改)
|
||||||
|
const DEVICE_COMMANDS_KEY: &str = "device:commands";
|
||||||
|
|
||||||
|
/// 校验柜子及其所属组织未被禁用(甲方禁用组织 → 其下柜子全部失效)
|
||||||
|
async fn ensure_cabinet_enabled(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<(), AppError> {
|
||||||
|
// 检查设备是否被禁用
|
||||||
|
let disabled: bool = sqlx::query_scalar(CABINET_DISABLED_SQL)
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if disabled {
|
||||||
|
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
||||||
|
let org_disabled: bool = sqlx::query_scalar(CABINET_ORG_DISABLED_SQL)
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
if org_disabled {
|
||||||
|
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备必须在线:平台对不在线的设备一律不投递
|
||||||
|
async fn ensure_device_online(state: &AppState, imei: &str) -> Result<(), AppError> {
|
||||||
|
if !state.nodes.is_device_online(imei).await {
|
||||||
|
return Err(AppError::BadRequest("设备不在线".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 序列化指令 → 注入内部路由字段 → LPUSH 进 `device:commands` 队列
|
||||||
|
async fn push_device_command(
|
||||||
|
state: &AppState,
|
||||||
|
cmd: &DeviceCommand,
|
||||||
|
imei: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
|
||||||
|
let mut json_val =
|
||||||
|
serde_json::to_value(cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
|
||||||
|
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
|
||||||
|
let json_str = json_val.to_string();
|
||||||
|
let mut conn = state.redis.clone();
|
||||||
|
redis::cmd("LPUSH")
|
||||||
|
.arg(DEVICE_COMMANDS_KEY)
|
||||||
|
.arg(&json_str)
|
||||||
|
.query_async::<()>(&mut conn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 指令下发成功响应(返回码与文案与原实现一致)
|
||||||
|
fn command_dispatched_json() -> Json<Value> {
|
||||||
|
Json(json!({
|
||||||
|
"code": 0,
|
||||||
|
"data": { "success": true },
|
||||||
|
"message": "指令已下发"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
|
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
|
||||||
///
|
///
|
||||||
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
|
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
|
||||||
@ -1045,56 +1245,15 @@ async fn send_compartment_command(
|
|||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
|
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
|
||||||
let row: Option<CabinetImeiRow> = sqlx::query_as(
|
let cab_row = load_compartment_target(pool, compartment_id).await?;
|
||||||
r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
|
|
||||||
FROM cabinets cab
|
|
||||||
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
|
|
||||||
JOIN compartments comp ON comp.cabin_board_id = cb.id
|
|
||||||
WHERE comp.id = ?"#,
|
|
||||||
)
|
|
||||||
.bind(compartment_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?;
|
// 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
|
||||||
|
ensure_cabinet_enabled(pool, cab_row.cabinet_id).await?;
|
||||||
// 检查设备是否被禁用
|
|
||||||
let disabled: bool = sqlx::query_scalar(
|
|
||||||
"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2",
|
|
||||||
)
|
|
||||||
.bind(cab_row.cabinet_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
if disabled {
|
|
||||||
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
|
||||||
let org_disabled: bool = sqlx::query_scalar(
|
|
||||||
r#"SELECT COUNT(*) > 0 FROM cabinets c
|
|
||||||
INNER JOIN projects p ON c.project_id = p.id
|
|
||||||
INNER JOIN organizations o ON p.organization_id = o.id
|
|
||||||
WHERE c.id = ? AND o.disabled = 1"#,
|
|
||||||
)
|
|
||||||
.bind(cab_row.cabinet_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
if org_disabled {
|
|
||||||
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 校验操作权限(组织隔离)
|
|
||||||
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
|
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
|
||||||
|
ensure_device_online(state, &cab_row.imei).await?;
|
||||||
|
|
||||||
let imei = &cab_row.imei;
|
let imei = &cab_row.imei;
|
||||||
|
|
||||||
// 检查是否有设备节点在线
|
|
||||||
if !state.nodes.is_device_online(imei).await {
|
|
||||||
return Err(AppError::BadRequest("设备不在线".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构造协议 id:
|
// 构造协议 id:
|
||||||
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
|
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
|
||||||
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
|
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
|
||||||
@ -1107,29 +1266,27 @@ async fn send_compartment_command(
|
|||||||
params: Some(json!({})),
|
params: Some(json!({})),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
|
push_device_command(state, &cmd, imei).await?;
|
||||||
let mut json_val =
|
|
||||||
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
|
|
||||||
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
|
|
||||||
let json_str = json_val.to_string();
|
|
||||||
let mut conn = state.redis.clone();
|
|
||||||
redis::cmd("LPUSH")
|
|
||||||
.arg("device:commands")
|
|
||||||
.arg(&json_str)
|
|
||||||
.query_async::<()>(&mut conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
|
"[h5] 指令已推入队列 id={} action={} compartment_id={}",
|
||||||
id, action, compartment_id
|
id, action, compartment_id
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(command_dispatched_json())
|
||||||
"code": 0,
|
}
|
||||||
"data": { "success": true },
|
|
||||||
"message": "指令已下发"
|
/// 按仓体ID查询目标设备与通道信息
|
||||||
})))
|
async fn load_compartment_target(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
compartment_id: i64,
|
||||||
|
) -> Result<CabinetImeiRow, AppError> {
|
||||||
|
let row: Option<CabinetImeiRow> = sqlx::query_as(COMPARTMENT_TARGET_SQL)
|
||||||
|
.bind(compartment_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -1151,45 +1308,12 @@ async fn send_cabinet_command(
|
|||||||
) -> Result<Json<Value>, AppError> {
|
) -> Result<Json<Value>, AppError> {
|
||||||
let pool = &state.mysql;
|
let pool = &state.mysql;
|
||||||
|
|
||||||
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?")
|
let imei = load_cabinet_imei(pool, cabinet_id).await?;
|
||||||
.bind(cabinet_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
|
|
||||||
|
|
||||||
// 设备是否被禁用
|
// 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
|
||||||
let disabled: bool =
|
ensure_cabinet_enabled(pool, cabinet_id).await?;
|
||||||
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2")
|
|
||||||
.bind(cabinet_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
if disabled {
|
|
||||||
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
|
|
||||||
let org_disabled: bool = sqlx::query_scalar(
|
|
||||||
r#"SELECT COUNT(*) > 0 FROM cabinets c
|
|
||||||
INNER JOIN projects p ON c.project_id = p.id
|
|
||||||
INNER JOIN organizations o ON p.organization_id = o.id
|
|
||||||
WHERE c.id = ? AND o.disabled = 1"#,
|
|
||||||
)
|
|
||||||
.bind(cabinet_id)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
if org_disabled {
|
|
||||||
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组织数据隔离校验
|
|
||||||
verify_cabinet_access(user, cabinet_id, pool).await?;
|
verify_cabinet_access(user, cabinet_id, pool).await?;
|
||||||
|
ensure_device_online(state, &imei).await?;
|
||||||
// 设备必须在线:平台对不在线的设备一律不投递
|
|
||||||
if !state.nodes.is_device_online(&imei).await {
|
|
||||||
return Err(AppError::BadRequest("设备不在线".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构造协议 id:
|
// 构造协议 id:
|
||||||
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
|
// - `board_id = None` → **柜级**:**不带 id**(省略该字段)
|
||||||
@ -1203,18 +1327,7 @@ async fn send_cabinet_command(
|
|||||||
params: Some(json!({})),
|
params: Some(json!({})),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
|
push_device_command(state, &cmd, &imei).await?;
|
||||||
let mut json_val =
|
|
||||||
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
|
|
||||||
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
|
|
||||||
let json_str = json_val.to_string();
|
|
||||||
let mut conn = state.redis.clone();
|
|
||||||
redis::cmd("LPUSH")
|
|
||||||
.arg("device:commands")
|
|
||||||
.arg(&json_str)
|
|
||||||
.query_async::<()>(&mut conn)
|
|
||||||
.await
|
|
||||||
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
|
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
|
||||||
@ -1224,11 +1337,17 @@ async fn send_cabinet_command(
|
|||||||
board_id
|
board_id
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(command_dispatched_json())
|
||||||
"code": 0,
|
}
|
||||||
"data": { "success": true },
|
|
||||||
"message": "指令已下发"
|
/// 按柜子ID查询 IMEI
|
||||||
})))
|
async fn load_cabinet_imei(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<String, AppError> {
|
||||||
|
let imei: Option<String> = sqlx::query_scalar(CABINET_IMEI_SQL)
|
||||||
|
.bind(cabinet_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
|
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级)
|
||||||
|
|||||||
@ -34,40 +34,51 @@ mod users_perm;
|
|||||||
mod wechat;
|
mod wechat;
|
||||||
mod maps;
|
mod maps;
|
||||||
|
|
||||||
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
|
/// 认证与账户相关路由(需登录)
|
||||||
pub fn build(state: AppState) -> Router {
|
fn auth_routes() -> Router<AppState> {
|
||||||
let protected = Router::<AppState>::new()
|
Router::<AppState>::new()
|
||||||
// 认证相关
|
|
||||||
.route("/api/auth/logout", post(auth::logout))
|
.route("/api/auth/logout", post(auth::logout))
|
||||||
.route("/api/auth/me", get(auth::me))
|
.route("/api/auth/me", get(auth::me))
|
||||||
.route("/api/auth/avatar", put(auth::update_avatar))
|
.route("/api/auth/avatar", put(auth::update_avatar))
|
||||||
.route("/api/auth/avatar/upload", post(auth::upload_avatar))
|
.route("/api/auth/avatar/upload", post(auth::upload_avatar))
|
||||||
// 设备指令下发
|
}
|
||||||
|
|
||||||
|
/// 设备指令下发 + 设备服务节点管理路由
|
||||||
|
fn device_node_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
|
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
|
||||||
.route("/api/devices/stats", get(crate::commands::device_stats))
|
.route("/api/devices/stats", get(crate::commands::device_stats))
|
||||||
// 节点管理
|
|
||||||
.route("/api/nodes/register", post(crate::nodes::register_node))
|
.route("/api/nodes/register", post(crate::nodes::register_node))
|
||||||
.route("/api/nodes/{node_id}/heartbeat", post(crate::nodes::node_heartbeat))
|
.route("/api/nodes/{node_id}/heartbeat", post(crate::nodes::node_heartbeat))
|
||||||
.route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node))
|
.route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node))
|
||||||
.route("/api/nodes", get(crate::nodes::list_nodes))
|
.route("/api/nodes", get(crate::nodes::list_nodes))
|
||||||
// 角色管理
|
}
|
||||||
|
|
||||||
|
/// 角色、权限码与用户权限分配路由
|
||||||
|
fn rbac_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/roles", get(roles::list_roles).post(roles::create_role))
|
.route("/api/roles", get(roles::list_roles).post(roles::create_role))
|
||||||
.route("/api/roles/{id}", put(roles::update_role).delete(roles::delete_role))
|
.route("/api/roles/{id}", put(roles::update_role).delete(roles::delete_role))
|
||||||
.route("/api/roles/{id}/permissions", put(roles::set_role_permissions))
|
.route("/api/roles/{id}/permissions", put(roles::set_role_permissions))
|
||||||
// 权限码列表
|
|
||||||
.route("/api/permissions", get(roles::list_permissions))
|
.route("/api/permissions", get(roles::list_permissions))
|
||||||
// 用户权限管理
|
|
||||||
.route("/api/users/{id}/permissions", get(users_perm::get_user_permissions))
|
.route("/api/users/{id}/permissions", get(users_perm::get_user_permissions))
|
||||||
.route("/api/users/{id}/roles", put(users_perm::set_user_roles))
|
.route("/api/users/{id}/roles", put(users_perm::set_user_roles))
|
||||||
.route("/api/users/{id}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
|
.route("/api/users/{id}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
|
||||||
// 组织管理
|
}
|
||||||
|
|
||||||
|
/// 组织与项目路由
|
||||||
|
fn org_project_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
|
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
|
||||||
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
|
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
|
||||||
.route("/api/organizations/{id}/status", put(organizations::set_organization_status))
|
.route("/api/organizations/{id}/status", put(organizations::set_organization_status))
|
||||||
// 项目管理
|
|
||||||
.route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project))
|
.route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project))
|
||||||
.route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project))
|
.route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project))
|
||||||
// 设备管理
|
}
|
||||||
|
|
||||||
|
/// 设备(充电柜)管理、柜级控制、组织树与级联下拉路由
|
||||||
|
fn cabinet_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
|
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
|
||||||
.route("/api/cabinets", post(cabinets::create_cabinet))
|
.route("/api/cabinets", post(cabinets::create_cabinet))
|
||||||
.route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail))
|
.route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail))
|
||||||
@ -86,38 +97,54 @@ pub fn build(state: AppState) -> Router {
|
|||||||
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
|
.route("/api/organization-tree", get(cabinet_tree::organization_tree))
|
||||||
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets))
|
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets))
|
||||||
.route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status))
|
.route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status))
|
||||||
// 充电记录
|
|
||||||
.route("/api/charge-records", get(charge_records::list_charge_records))
|
|
||||||
.route("/api/charge-records/export", get(charge_records::export_charge_records))
|
|
||||||
// 充电订单
|
|
||||||
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
|
|
||||||
// 电池台账
|
|
||||||
.route("/api/batteries", get(batteries::list_batteries))
|
|
||||||
// 设备日志
|
|
||||||
.route("/api/device-logs", get(device_logs::list_device_logs))
|
|
||||||
// 下载中心
|
|
||||||
.route("/api/downloads", get(downloads::list_downloads))
|
|
||||||
.route("/api/downloads/{id}", get(downloads::get_download))
|
|
||||||
.route("/api/downloads/{id}/file", get(downloads::download_file))
|
|
||||||
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
|
|
||||||
// 设备级联下拉
|
// 设备级联下拉
|
||||||
.route("/api/cabinets/options", get(cabinets::list_cabinet_options))
|
.route("/api/cabinets/options", get(cabinets::list_cabinet_options))
|
||||||
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
|
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
|
||||||
.route("/api/compartments/options", get(cabinets::list_compartment_options))
|
.route("/api/compartments/options", get(cabinets::list_compartment_options))
|
||||||
// 能耗管理
|
}
|
||||||
|
|
||||||
|
/// 充电记录 / 充电订单 / 电池台账 / 设备日志路由
|
||||||
|
fn record_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
|
.route("/api/charge-records", get(charge_records::list_charge_records))
|
||||||
|
.route("/api/charge-records/export", get(charge_records::export_charge_records))
|
||||||
|
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
|
||||||
|
.route("/api/batteries", get(batteries::list_batteries))
|
||||||
|
.route("/api/device-logs", get(device_logs::list_device_logs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载中心路由
|
||||||
|
fn download_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
|
.route("/api/downloads", get(downloads::list_downloads))
|
||||||
|
.route("/api/downloads/{id}", get(downloads::get_download))
|
||||||
|
.route("/api/downloads/{id}/file", get(downloads::download_file))
|
||||||
|
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 能耗管理路由
|
||||||
|
fn energy_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/energy-stats/summary", get(energy_stats::get_summary))
|
.route("/api/energy-stats/summary", get(energy_stats::get_summary))
|
||||||
.route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking))
|
.route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking))
|
||||||
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
|
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
|
||||||
.route("/api/energy-stats/trend", get(energy_stats::get_trend))
|
.route("/api/energy-stats/trend", get(energy_stats::get_trend))
|
||||||
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
|
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
|
||||||
// 用户管理
|
}
|
||||||
|
|
||||||
|
/// 用户管理与操作日志路由
|
||||||
|
fn user_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/users", get(users::list_users).post(users::create_user))
|
.route("/api/users", get(users::list_users).post(users::create_user))
|
||||||
.route("/api/users/{id}", put(users::update_user).delete(users::delete_user))
|
.route("/api/users/{id}", put(users::update_user).delete(users::delete_user))
|
||||||
.route("/api/users/{id}/reset-password", put(users::reset_password))
|
.route("/api/users/{id}/reset-password", put(users::reset_password))
|
||||||
.route("/api/users/{id}/status", put(users::set_user_status))
|
.route("/api/users/{id}/status", put(users::set_user_status))
|
||||||
// 操作日志
|
|
||||||
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
|
.route("/api/operation-logs", get(operation_logs::list_operation_logs))
|
||||||
// H5 用户端接口(与 PC 共用同一套 API 路由)
|
}
|
||||||
|
|
||||||
|
/// H5 用户端接口路由(与 PC 共用同一套 API 路由)+ 问题反馈
|
||||||
|
fn h5_routes() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
.route("/api/dashboard", get(h5::get_dashboard))
|
.route("/api/dashboard", get(h5::get_dashboard))
|
||||||
.route("/api/cabinets", get(h5::get_cabinets))
|
.route("/api/cabinets", get(h5::get_cabinets))
|
||||||
.route("/api/compartments/{id}", get(h5::get_compartment_detail))
|
.route("/api/compartments/{id}", get(h5::get_compartment_detail))
|
||||||
@ -125,13 +152,28 @@ pub fn build(state: AppState) -> Router {
|
|||||||
.route("/api/charge/stop", post(h5::stop_charge))
|
.route("/api/charge/stop", post(h5::stop_charge))
|
||||||
.route("/api/door/open", post(h5::open_door))
|
.route("/api/door/open", post(h5::open_door))
|
||||||
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
|
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
|
||||||
// 问题反馈
|
|
||||||
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
|
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
|
||||||
.route("/api/feedback/{id}", put(h5::update_feedback_status))
|
.route("/api/feedback/{id}", put(h5::update_feedback_status))
|
||||||
.route("/api/feedback/upload", post(h5::upload_feedback_image))
|
.route("/api/feedback/upload", post(h5::upload_feedback_image))
|
||||||
// 认证中间件层
|
}
|
||||||
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
|
||||||
|
|
||||||
|
/// 合并全部受保护路由(不含认证中间件层,便于单测校验路由表无冲突)
|
||||||
|
fn protected_router() -> Router<AppState> {
|
||||||
|
Router::<AppState>::new()
|
||||||
|
.merge(auth_routes())
|
||||||
|
.merge(device_node_routes())
|
||||||
|
.merge(rbac_routes())
|
||||||
|
.merge(org_project_routes())
|
||||||
|
.merge(cabinet_routes())
|
||||||
|
.merge(record_routes())
|
||||||
|
.merge(download_routes())
|
||||||
|
.merge(energy_routes())
|
||||||
|
.merge(user_routes())
|
||||||
|
.merge(h5_routes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 免认证的公开路由(健康检查、登录、微信回调、地图代理、静态头像目录)
|
||||||
|
fn public_routes() -> Router<AppState> {
|
||||||
Router::<AppState>::new()
|
Router::<AppState>::new()
|
||||||
.route("/api/health", get(health::health_check))
|
.route("/api/health", get(health::health_check))
|
||||||
.route("/api/auth/challenge", get(auth::get_challenge))
|
.route("/api/auth/challenge", get(auth::get_challenge))
|
||||||
@ -148,6 +190,15 @@ pub fn build(state: AppState) -> Router {
|
|||||||
.route("/api/maps/place-search", get(maps::place_search))
|
.route("/api/maps/place-search", get(maps::place_search))
|
||||||
// 头像上传目录(免认证,仅用于公开访问静态文件)
|
// 头像上传目录(免认证,仅用于公开访问静态文件)
|
||||||
.nest_service("/uploads", ServeDir::new("uploads"))
|
.nest_service("/uploads", ServeDir::new("uploads"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
|
||||||
|
pub fn build(state: AppState) -> Router {
|
||||||
|
// 认证中间件层:仅作用于受保护路由
|
||||||
|
let protected = protected_router()
|
||||||
|
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
||||||
|
|
||||||
|
public_routes()
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
.layer(CorsLayer::new()
|
.layer(CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
|
|||||||
@ -24,22 +24,23 @@ pub(crate) struct LogQuery {
|
|||||||
page_size: Option<u32>,
|
page_size: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /api/operation-logs — 操作日志列表(分页)
|
/// 操作日志数据库行映射
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct LogRow {
|
||||||
|
id: i64,
|
||||||
|
user_id: i64,
|
||||||
|
user_name: Option<String>,
|
||||||
|
action: String,
|
||||||
|
target_type: Option<String>,
|
||||||
|
target_id: Option<i64>,
|
||||||
|
detail: Option<String>,
|
||||||
|
created_at: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建 WHERE 子句与绑定值
|
||||||
///
|
///
|
||||||
/// 支持 user_id / action / start_time / end_time 过滤。
|
/// 动态拼装过滤条件(使用 ? 占位符防止 SQL 注入),并追加组织数据隔离。
|
||||||
pub async fn list_operation_logs(
|
fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec<String>) {
|
||||||
user: CurrentUser,
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Query(query): Query<LogQuery>,
|
|
||||||
) -> Result<Json<Value>, AppError> {
|
|
||||||
auth::check_permission(&user, "operation_log:view")?;
|
|
||||||
|
|
||||||
let pool = &state.mysql;
|
|
||||||
let page = query.page.unwrap_or(1).max(1);
|
|
||||||
let page_size = query.page_size.unwrap_or(20).min(100);
|
|
||||||
let offset = (page - 1) * page_size;
|
|
||||||
|
|
||||||
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
|
|
||||||
let mut conditions: Vec<String> = Vec::new();
|
let mut conditions: Vec<String> = Vec::new();
|
||||||
let mut bind_values: Vec<String> = Vec::new();
|
let mut bind_values: Vec<String> = Vec::new();
|
||||||
|
|
||||||
@ -67,7 +68,7 @@ pub async fn list_operation_logs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 追加组织过滤
|
// 追加组织过滤
|
||||||
let (org_cond, org_binds) = auth::org_condition_for_logs(&user, "o");
|
let (org_cond, org_binds) = auth::org_condition_for_logs(user, "o");
|
||||||
if !org_cond.is_empty() {
|
if !org_cond.is_empty() {
|
||||||
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
|
conditions.push(org_cond.trim_start_matches(" AND ").to_string());
|
||||||
bind_values.extend(org_binds);
|
bind_values.extend(org_binds);
|
||||||
@ -78,16 +79,31 @@ pub async fn list_operation_logs(
|
|||||||
} else {
|
} else {
|
||||||
format!("WHERE {}", conditions.join(" AND "))
|
format!("WHERE {}", conditions.join(" AND "))
|
||||||
};
|
};
|
||||||
|
(where_clause, bind_values)
|
||||||
|
}
|
||||||
|
|
||||||
// 查询总数
|
/// 统计符合条件的操作日志总数
|
||||||
|
async fn count_operation_logs(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
bind_values: &[String],
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
|
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
|
||||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||||
for val in &bind_values {
|
for val in bind_values {
|
||||||
count_query = count_query.bind(val);
|
count_query = count_query.bind(val);
|
||||||
}
|
}
|
||||||
let total = count_query.fetch_one(pool).await?;
|
Ok(count_query.fetch_one(pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
// 查询列表(关联用户姓名)
|
/// 查询操作日志行(关联用户姓名,分页)
|
||||||
|
async fn fetch_operation_log_rows(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
bind_values: &[String],
|
||||||
|
page_size: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<LogRow>, AppError> {
|
||||||
let list_sql = format!(
|
let list_sql = format!(
|
||||||
r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at
|
r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at
|
||||||
FROM operation_logs o
|
FROM operation_logs o
|
||||||
@ -98,30 +114,20 @@ pub async fn list_operation_logs(
|
|||||||
where_clause
|
where_clause
|
||||||
);
|
);
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct LogRow {
|
|
||||||
id: i64,
|
|
||||||
user_id: i64,
|
|
||||||
user_name: Option<String>,
|
|
||||||
action: String,
|
|
||||||
target_type: Option<String>,
|
|
||||||
target_id: Option<i64>,
|
|
||||||
detail: Option<String>,
|
|
||||||
created_at: chrono::NaiveDateTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
|
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
|
||||||
for val in &bind_values {
|
for val in bind_values {
|
||||||
list_query = list_query.bind(val);
|
list_query = list_query.bind(val);
|
||||||
}
|
}
|
||||||
let rows = list_query
|
Ok(list_query
|
||||||
.bind(page_size)
|
.bind(page_size)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
let list: Vec<Value> = rows
|
/// 将操作日志行映射为响应 JSON 列表
|
||||||
.into_iter()
|
fn operation_logs_to_json(rows: Vec<LogRow>) -> Vec<Value> {
|
||||||
|
rows.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
json!({
|
json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
@ -134,7 +140,34 @@ pub async fn list_operation_logs(
|
|||||||
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/operation-logs — 操作日志列表(分页)
|
||||||
|
///
|
||||||
|
/// 支持 user_id / action / start_time / end_time 过滤。
|
||||||
|
pub async fn list_operation_logs(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(query): Query<LogQuery>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
auth::check_permission(&user, "operation_log:view")?;
|
||||||
|
|
||||||
|
let pool = &state.mysql;
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let page_size = query.page_size.unwrap_or(20).min(100);
|
||||||
|
let offset = (page - 1) * page_size;
|
||||||
|
|
||||||
|
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
|
||||||
|
let (where_clause, bind_values) = build_log_filters(&user, &query);
|
||||||
|
|
||||||
|
// 查询总数
|
||||||
|
let total = count_operation_logs(pool, &where_clause, &bind_values).await?;
|
||||||
|
|
||||||
|
// 查询列表(关联用户姓名)
|
||||||
|
let rows =
|
||||||
|
fetch_operation_log_rows(pool, &where_clause, &bind_values, page_size, offset).await?;
|
||||||
|
let list = operation_logs_to_json(rows);
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
|
|||||||
@ -142,6 +142,126 @@ async fn log_operation(
|
|||||||
// 用户列表
|
// 用户列表
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 构建用户列表的 WHERE 子句与需要绑定的组织ID
|
||||||
|
///
|
||||||
|
/// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询,
|
||||||
|
/// bind_org 为需要追加绑定的组织过滤值(无过滤则为 None)。
|
||||||
|
fn build_user_list_where(has_keyword: bool, org_filter: Option<i64>) -> (String, Option<i64>) {
|
||||||
|
match (has_keyword, org_filter) {
|
||||||
|
(true, Some(org_id)) => (
|
||||||
|
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
|
||||||
|
Some(org_id),
|
||||||
|
),
|
||||||
|
(true, None) => (
|
||||||
|
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(false, Some(org_id)) => (
|
||||||
|
"WHERE organization_id = ?".to_string(),
|
||||||
|
Some(org_id),
|
||||||
|
),
|
||||||
|
(false, None) => (String::new(), None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 统计符合条件的用户总数
|
||||||
|
async fn count_users(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
has_keyword: bool,
|
||||||
|
like_pattern: &str,
|
||||||
|
bind_org: Option<i64>,
|
||||||
|
) -> Result<i64, AppError> {
|
||||||
|
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
|
||||||
|
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
|
||||||
|
if has_keyword {
|
||||||
|
q = q.bind(like_pattern).bind(like_pattern);
|
||||||
|
}
|
||||||
|
if let Some(org_id) = bind_org {
|
||||||
|
q = q.bind(org_id);
|
||||||
|
}
|
||||||
|
let total: (i64,) = q.fetch_one(pool).await?;
|
||||||
|
Ok(total.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询当前页的用户行(分页 + 排序)
|
||||||
|
async fn fetch_user_rows(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
where_clause: &str,
|
||||||
|
has_keyword: bool,
|
||||||
|
like_pattern: &str,
|
||||||
|
bind_org: Option<i64>,
|
||||||
|
page_size: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<UserRow>, AppError> {
|
||||||
|
let sql = format!(
|
||||||
|
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
||||||
|
FROM users u {}
|
||||||
|
ORDER BY u.id DESC
|
||||||
|
LIMIT ? OFFSET ?"#,
|
||||||
|
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query_as::<_, UserRow>(&sql);
|
||||||
|
if has_keyword {
|
||||||
|
q = q.bind(like_pattern).bind(like_pattern);
|
||||||
|
}
|
||||||
|
if let Some(org_id) = bind_org {
|
||||||
|
q = q.bind(org_id);
|
||||||
|
}
|
||||||
|
Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量查询用户所属组织的名称映射(id → name)
|
||||||
|
async fn load_org_name_map(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
rows: &[UserRow],
|
||||||
|
) -> Result<std::collections::HashMap<i64, String>, AppError> {
|
||||||
|
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
|
||||||
|
if org_ids.is_empty() {
|
||||||
|
return Ok(std::collections::HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 IN 查询, 构造占位符
|
||||||
|
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT id, name FROM organizations WHERE id IN ({})",
|
||||||
|
placeholders.join(",")
|
||||||
|
);
|
||||||
|
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
|
||||||
|
for id in &org_ids {
|
||||||
|
q = q.bind(id);
|
||||||
|
}
|
||||||
|
let org_names: Vec<(i64, String)> = q.fetch_all(pool).await?;
|
||||||
|
Ok(org_names.into_iter().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将用户行映射为响应 JSON 列表
|
||||||
|
fn users_to_json(
|
||||||
|
rows: Vec<UserRow>,
|
||||||
|
org_map: &std::collections::HashMap<i64, String>,
|
||||||
|
) -> Vec<Value> {
|
||||||
|
// 构建角色名称映射
|
||||||
|
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
|
||||||
|
json!({
|
||||||
|
"id": r.id,
|
||||||
|
"phone": r.phone,
|
||||||
|
"name": r.name,
|
||||||
|
"role": r.role,
|
||||||
|
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
|
||||||
|
"organization_id": r.organization_id,
|
||||||
|
"organization_name": org_name,
|
||||||
|
"status": r.status,
|
||||||
|
"created_at": r.created_at.to_string(),
|
||||||
|
"updated_at": r.updated_at.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/users — 用户列表(分页)
|
/// GET /api/users — 用户列表(分页)
|
||||||
///
|
///
|
||||||
/// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。
|
/// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。
|
||||||
@ -180,98 +300,30 @@ pub async fn list_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
|
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
|
||||||
let (where_clause, bind_org) = match (has_keyword, org_filter) {
|
let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter);
|
||||||
(true, Some(org_id)) => (
|
|
||||||
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
|
|
||||||
Some(org_id),
|
|
||||||
),
|
|
||||||
(true, None) => (
|
|
||||||
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(false, Some(org_id)) => (
|
|
||||||
"WHERE organization_id = ?".to_string(),
|
|
||||||
Some(org_id),
|
|
||||||
),
|
|
||||||
(false, None) => (String::new(), None),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询总数
|
// 查询总数
|
||||||
let total: (i64,) = {
|
let total = count_users(pool, &where_clause, has_keyword, &like_pattern, bind_org).await?;
|
||||||
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
|
|
||||||
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
|
|
||||||
if has_keyword {
|
|
||||||
q = q.bind(&like_pattern).bind(&like_pattern);
|
|
||||||
}
|
|
||||||
if let Some(org_id) = bind_org {
|
|
||||||
q = q.bind(org_id);
|
|
||||||
}
|
|
||||||
q.fetch_one(pool).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 查询用户列表
|
// 查询用户列表
|
||||||
let rows: Vec<UserRow> = {
|
let rows = fetch_user_rows(
|
||||||
let sql = format!(
|
pool,
|
||||||
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
|
&where_clause,
|
||||||
FROM users u {}
|
has_keyword,
|
||||||
ORDER BY u.id DESC
|
&like_pattern,
|
||||||
LIMIT ? OFFSET ?"#,
|
bind_org,
|
||||||
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
|
page_size,
|
||||||
);
|
offset,
|
||||||
let mut q = sqlx::query_as::<_, UserRow>(&sql);
|
)
|
||||||
if has_keyword {
|
.await?;
|
||||||
q = q.bind(&like_pattern).bind(&like_pattern);
|
|
||||||
}
|
|
||||||
if let Some(org_id) = bind_org {
|
|
||||||
q = q.bind(org_id);
|
|
||||||
}
|
|
||||||
q.bind(page_size).bind(offset).fetch_all(pool).await?
|
|
||||||
};
|
|
||||||
|
|
||||||
// 收集组织ID并批量查询组织名称
|
// 收集组织ID并批量查询组织名称
|
||||||
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
|
let org_map = load_org_name_map(pool, &rows).await?;
|
||||||
let org_names = if org_ids.is_empty() {
|
let list = users_to_json(rows, &org_map);
|
||||||
vec![]
|
|
||||||
} else {
|
|
||||||
// 使用 IN 查询, 构造占位符
|
|
||||||
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT id, name FROM organizations WHERE id IN ({})",
|
|
||||||
placeholders.join(",")
|
|
||||||
);
|
|
||||||
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
|
|
||||||
for id in &org_ids {
|
|
||||||
q = q.bind(id);
|
|
||||||
}
|
|
||||||
q.fetch_all(pool).await?
|
|
||||||
};
|
|
||||||
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
|
|
||||||
|
|
||||||
// 构建角色名称映射
|
|
||||||
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
|
|
||||||
|
|
||||||
let list: Vec<Value> = rows
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
|
|
||||||
json!({
|
|
||||||
"id": r.id,
|
|
||||||
"phone": r.phone,
|
|
||||||
"name": r.name,
|
|
||||||
"role": r.role,
|
|
||||||
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
|
|
||||||
"organization_id": r.organization_id,
|
|
||||||
"organization_name": org_name,
|
|
||||||
"status": r.status,
|
|
||||||
"created_at": r.created_at.to_string(),
|
|
||||||
"updated_at": r.updated_at.to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(json!({
|
Ok(Json(json!({
|
||||||
"code": 0,
|
"code": 0,
|
||||||
"data": { "list": list, "total": total.0 },
|
"data": { "list": list, "total": total },
|
||||||
"message": "ok"
|
"message": "ok"
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
@ -280,6 +332,64 @@ pub async fn list_users(
|
|||||||
// 创建用户
|
// 创建用户
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 校验手机号格式(11 位数字)
|
||||||
|
fn validate_phone(phone: &str) -> Result<(), AppError> {
|
||||||
|
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验组织存在(不存在返回参数错误)
|
||||||
|
async fn ensure_organization_exists(pool: &sqlx::MySqlPool, oid: i64) -> Result<(), AppError> {
|
||||||
|
let org_exists: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM organizations WHERE id = ?"
|
||||||
|
)
|
||||||
|
.bind(oid)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if org_exists.0 == 0 {
|
||||||
|
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验手机号未被占用
|
||||||
|
async fn ensure_phone_available(pool: &sqlx::MySqlPool, phone: &str) -> Result<(), AppError> {
|
||||||
|
let exists: (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(*) FROM users WHERE phone = ?"
|
||||||
|
)
|
||||||
|
.bind(phone)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
if exists.0 > 0 {
|
||||||
|
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按操作者身份与目标角色确定创建用户时的组织归属
|
||||||
|
fn resolve_create_org_id(
|
||||||
|
current_user: &CurrentUser,
|
||||||
|
role: i32,
|
||||||
|
input_org_id: Option<i64>,
|
||||||
|
) -> Result<Option<i64>, AppError> {
|
||||||
|
if current_user.role_level >= 2 {
|
||||||
|
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
|
||||||
|
if role == 2 {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(input_org_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
|
||||||
|
if role == 2 {
|
||||||
|
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
|
||||||
|
}
|
||||||
|
Ok(Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/users — 创建用户
|
/// POST /api/users — 创建用户
|
||||||
///
|
///
|
||||||
/// 权限模型:
|
/// 权限模型:
|
||||||
@ -297,9 +407,7 @@ pub async fn create_user(
|
|||||||
|
|
||||||
// 校验手机号
|
// 校验手机号
|
||||||
let phone = input.phone.trim();
|
let phone = input.phone.trim();
|
||||||
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
|
validate_phone(phone)?;
|
||||||
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
|
||||||
let role = input.role.unwrap_or(0);
|
let role = input.role.unwrap_or(0);
|
||||||
@ -308,47 +416,18 @@ pub async fn create_user(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 确定最终组织归属(按角色和操作者身份)
|
// 确定最终组织归属(按角色和操作者身份)
|
||||||
let org_id = if current_user.role_level >= 2 {
|
let org_id = resolve_create_org_id(¤t_user, role, input.organization_id)?;
|
||||||
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
|
|
||||||
if role == 2 {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(input.organization_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
|
|
||||||
if role == 2 {
|
|
||||||
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
|
|
||||||
}
|
|
||||||
Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 校验组织存在
|
// 校验组织存在
|
||||||
if let Some(oid) = org_id {
|
if let Some(oid) = org_id {
|
||||||
let org_exists: (i64,) = sqlx::query_as(
|
ensure_organization_exists(pool, oid).await?;
|
||||||
"SELECT COUNT(*) FROM organizations WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(oid)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
if org_exists.0 == 0 {
|
|
||||||
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
|
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
|
||||||
let password = auth::hash_password(&plain_password)?;
|
let password = auth::hash_password(&plain_password)?;
|
||||||
|
|
||||||
// 检查手机号是否已存在
|
// 检查手机号是否已存在
|
||||||
let exists: (i64,) = sqlx::query_as(
|
ensure_phone_available(pool, phone).await?;
|
||||||
"SELECT COUNT(*) FROM users WHERE phone = ?"
|
|
||||||
)
|
|
||||||
.bind(phone)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
if exists.0 > 0 {
|
|
||||||
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
|
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
|
||||||
@ -380,6 +459,50 @@ pub async fn create_user(
|
|||||||
// 编辑用户
|
// 编辑用户
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 按操作者身份与目标角色确定编辑后的组织归属
|
||||||
|
///
|
||||||
|
/// 企业管理员(role_level<2)只能编辑本组织用户、不能改自己、不能授予总管理员;
|
||||||
|
/// 总管理员可自由指定,角色2强制无组织。
|
||||||
|
async fn resolve_update_org_id(
|
||||||
|
pool: &sqlx::MySqlPool,
|
||||||
|
current_user: &CurrentUser,
|
||||||
|
user_id: i64,
|
||||||
|
old_org_id: Option<i64>,
|
||||||
|
new_role: i32,
|
||||||
|
input_org_id: Option<i64>,
|
||||||
|
) -> Result<Option<i64>, AppError> {
|
||||||
|
if current_user.role_level < 2 {
|
||||||
|
// 企业管理员作用域校验:组织锁定为本组织
|
||||||
|
let actor_org = current_user.organization_id
|
||||||
|
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
|
||||||
|
if user_id == current_user.user_id {
|
||||||
|
return Err(AppError::BadRequest("不能操作自己的账户".into()));
|
||||||
|
}
|
||||||
|
// 目标必须属于同一组织
|
||||||
|
if old_org_id != Some(actor_org) {
|
||||||
|
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
|
||||||
|
}
|
||||||
|
// 不能改成总管理员
|
||||||
|
if new_role == 2 {
|
||||||
|
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
|
||||||
|
}
|
||||||
|
Ok(Some(actor_org))
|
||||||
|
} else {
|
||||||
|
let org_id = if new_role == 2 {
|
||||||
|
None // 总管理员强制无组织
|
||||||
|
} else {
|
||||||
|
Some(input_org_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
|
||||||
|
};
|
||||||
|
|
||||||
|
// 校验组织存在(组织未变更时无需重复校验)
|
||||||
|
if let Some(oid) = org_id
|
||||||
|
&& Some(oid) != old_org_id {
|
||||||
|
ensure_organization_exists(pool, oid).await?;
|
||||||
|
}
|
||||||
|
Ok(org_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// PUT /api/users/:id — 编辑用户
|
/// PUT /api/users/:id — 编辑用户
|
||||||
pub async fn update_user(
|
pub async fn update_user(
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
@ -407,44 +530,16 @@ pub async fn update_user(
|
|||||||
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 企业管理员作用域校验:只能编辑本组织用户,不能改成总管理员,组织锁定为本组织
|
// 组织归属与作用域校验
|
||||||
let new_org_id: Option<i64> = if current_user.role_level < 2 {
|
let new_org_id = resolve_update_org_id(
|
||||||
let actor_org = current_user.organization_id
|
pool,
|
||||||
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
|
¤t_user,
|
||||||
if user_id == current_user.user_id {
|
user_id,
|
||||||
return Err(AppError::BadRequest("不能操作自己的账户".into()));
|
old_org_id,
|
||||||
}
|
new_role,
|
||||||
// 目标必须属于同一组织
|
input.organization_id,
|
||||||
if old_org_id != Some(actor_org) {
|
)
|
||||||
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
|
.await?;
|
||||||
}
|
|
||||||
// 不能改成总管理员
|
|
||||||
if new_role == 2 {
|
|
||||||
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
|
|
||||||
}
|
|
||||||
Some(actor_org)
|
|
||||||
} else {
|
|
||||||
let org_id = if new_role == 2 {
|
|
||||||
None // 总管理员强制无组织
|
|
||||||
} else {
|
|
||||||
Some(input.organization_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 校验组织存在
|
|
||||||
if let Some(oid) = org_id
|
|
||||||
&& Some(oid) != old_org_id {
|
|
||||||
let org_exists: (i64,) = sqlx::query_as(
|
|
||||||
"SELECT COUNT(*) FROM organizations WHERE id = ?"
|
|
||||||
)
|
|
||||||
.bind(oid)
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await?;
|
|
||||||
if org_exists.0 == 0 {
|
|
||||||
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
org_id
|
|
||||||
};
|
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
|
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"
|
||||||
|
|||||||
@ -1224,13 +1224,7 @@ pub async fn ensure_charge_order(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let active: Option<(i64, String)> = sqlx::query_as(
|
let active = find_active_order(pool, compartment_id).await?;
|
||||||
"SELECT id, battery_id FROM charge_orders
|
|
||||||
WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1",
|
|
||||||
)
|
|
||||||
.bind(compartment_id)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变)
|
// 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变)
|
||||||
let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id);
|
let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id);
|
||||||
@ -1247,30 +1241,18 @@ pub async fn ensure_charge_order(
|
|||||||
Some((order_id, old_battery)) => {
|
Some((order_id, old_battery)) => {
|
||||||
// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
|
// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
|
||||||
// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
|
// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
|
||||||
finalize_order(
|
end_replaced_order(
|
||||||
pool,
|
pool,
|
||||||
redis,
|
redis,
|
||||||
imei,
|
imei,
|
||||||
i64::from(board_id),
|
board_id,
|
||||||
i64::from(channel_id),
|
channel_id,
|
||||||
compartment_id,
|
compartment_id,
|
||||||
None,
|
battery_id,
|
||||||
2,
|
*order_id,
|
||||||
"forced",
|
old_battery,
|
||||||
RECORD_STATUS_ABNORMAL,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
// 老订单结束 ⇒ 老电池台账复位为空闲(新订单随后把新电池置"使用中")
|
|
||||||
if !old_battery.is_empty() {
|
|
||||||
mark_battery_status(pool, old_battery, 0).await;
|
|
||||||
}
|
|
||||||
tracing::warn!(
|
|
||||||
"[order] 通道换电池,老订单强制结束 order_id={} {} -> {} compartment_id={}",
|
|
||||||
order_id,
|
|
||||||
old_battery,
|
|
||||||
battery_id,
|
|
||||||
compartment_id
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1302,6 +1284,62 @@ pub async fn ensure_charge_order(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 查该仓体当前活跃订单(`status = 0`)→ `(订单 id, 电池 id)`;无活跃订单返回 `None`。
|
||||||
|
async fn find_active_order(
|
||||||
|
pool: &MySqlPool,
|
||||||
|
compartment_id: i64,
|
||||||
|
) -> Result<Option<(i64, String)>, sqlx::Error> {
|
||||||
|
sqlx::query_as(
|
||||||
|
"SELECT id, battery_id FROM charge_orders
|
||||||
|
WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.bind(compartment_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 换电池时结束老订单并复位老电池台账([`ensure_charge_order`] 判定为 [`OrderDecision::Replace`] 的分支)。
|
||||||
|
///
|
||||||
|
/// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
|
||||||
|
/// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
|
||||||
|
async fn end_replaced_order(
|
||||||
|
pool: &MySqlPool,
|
||||||
|
redis: &ConnectionManager,
|
||||||
|
imei: &str,
|
||||||
|
board_id: i8,
|
||||||
|
channel_id: i8,
|
||||||
|
compartment_id: i64,
|
||||||
|
battery_id: &str,
|
||||||
|
order_id: i64,
|
||||||
|
old_battery: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
finalize_order(
|
||||||
|
pool,
|
||||||
|
redis,
|
||||||
|
imei,
|
||||||
|
i64::from(board_id),
|
||||||
|
i64::from(channel_id),
|
||||||
|
compartment_id,
|
||||||
|
None,
|
||||||
|
2,
|
||||||
|
"forced",
|
||||||
|
RECORD_STATUS_ABNORMAL,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// 老订单结束 ⇒ 老电池台账复位为空闲(新订单随后把新电池置"使用中")
|
||||||
|
if !old_battery.is_empty() {
|
||||||
|
mark_battery_status(pool, old_battery, 0).await;
|
||||||
|
}
|
||||||
|
tracing::warn!(
|
||||||
|
"[order] 通道换电池,老订单强制结束 order_id={} {} -> {} compartment_id={}",
|
||||||
|
order_id,
|
||||||
|
old_battery,
|
||||||
|
battery_id,
|
||||||
|
compartment_id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 插入一笔进行中的充电订单(`status=0`),并把该仓体/电池标记为**占用**:
|
/// 插入一笔进行中的充电订单(`status=0`),并把该仓体/电池标记为**占用**:
|
||||||
/// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。
|
/// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。
|
||||||
/// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。
|
/// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。
|
||||||
@ -1872,24 +1910,12 @@ async fn handle_bat_out(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账)
|
// 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账)
|
||||||
mark_compartment_status(mysql, compartment_id, 0).await;
|
reset_usage_status(mysql, compartment_id, battery_id).await;
|
||||||
if !battery_id.is_empty() {
|
|
||||||
mark_battery_status(mysql, battery_id, 0).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id
|
// 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id
|
||||||
delete_channel_battery(redis, dev_id, board_id, channel_id).await;
|
delete_channel_battery(redis, dev_id, board_id, channel_id).await;
|
||||||
|
|
||||||
let content = serde_json::json!({
|
log_bat_out(mysql, dev_id, msg, compartment_id, battery_id, end_soc).await;
|
||||||
"dev_id": dev_id,
|
|
||||||
"act": "bat_out",
|
|
||||||
"id": msg.get("id"),
|
|
||||||
"msg_id": msg.get("msg_id"),
|
|
||||||
"compartment_id": compartment_id,
|
|
||||||
"battery_id": battery_id,
|
|
||||||
"end_soc": end_soc,
|
|
||||||
});
|
|
||||||
insert_device_log(mysql, dev_id, 2, content).await;
|
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}",
|
"[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}",
|
||||||
@ -1905,6 +1931,38 @@ async fn handle_bat_out(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 订单结束 ⇒ 占用状态复位:仓体置空闲 + 电池台账置空闲(`battery_id` 为空串时跳过台账)。
|
||||||
|
///
|
||||||
|
/// 两步都只记 ERROR、不阻断设备流程(见 [`mark_compartment_status`] / [`mark_battery_status`])。
|
||||||
|
async fn reset_usage_status(pool: &MySqlPool, compartment_id: i64, battery_id: &str) {
|
||||||
|
mark_compartment_status(pool, compartment_id, 0).await;
|
||||||
|
if !battery_id.is_empty() {
|
||||||
|
mark_battery_status(pool, battery_id, 0).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记 `bat_out` 的 `device_logs`(`log_type = 2`):报文标识 + 仓体定位 + 末帧 SOC 全量留痕。
|
||||||
|
/// 写失败只记 ERROR、不阻断业务(见 [`insert_device_log`])。
|
||||||
|
async fn log_bat_out(
|
||||||
|
mysql: &MySqlPool,
|
||||||
|
dev_id: &str,
|
||||||
|
msg: &serde_json::Value,
|
||||||
|
compartment_id: i64,
|
||||||
|
battery_id: &str,
|
||||||
|
end_soc: Option<i64>,
|
||||||
|
) {
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"dev_id": dev_id,
|
||||||
|
"act": "bat_out",
|
||||||
|
"id": msg.get("id"),
|
||||||
|
"msg_id": msg.get("msg_id"),
|
||||||
|
"compartment_id": compartment_id,
|
||||||
|
"battery_id": battery_id,
|
||||||
|
"end_soc": end_soc,
|
||||||
|
});
|
||||||
|
insert_device_log(mysql, dev_id, 2, content).await;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 设备上报 on / off(不带 suc 的形态)
|
// 设备上报 on / off(不带 suc 的形态)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user