diff --git a/9b0d3b8abab88e25f36e6a793a6e1eec.jpg b/9b0d3b8abab88e25f36e6a793a6e1eec.jpg new file mode 100644 index 0000000..e56b788 Binary files /dev/null and b/9b0d3b8abab88e25f36e6a793a6e1eec.jpg differ diff --git a/AGENTS.md b/AGENTS.md index 1223dec..6a20d52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,13 +63,14 @@ D:\charging-cabinet\ > - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx > `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env` > (**新二进制强制要求环境变量,缺它起不来**)。 -> - **本服务器不部署测试环境**(用户确认:这台就是生产)—— `pms-api-test` / `pms-device-test` -> 已停止并卸载,`:3000` / `:3001` 已释放。 -> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为 -> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API, -> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。** -> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**, -> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。 +> - **测试环境按本表规划独立部署在同一台服务器上**(测试就是测试,与生产完全隔离): +> `pms-api-test :3000` + `pms-device-test :3001`,路径 `/data/pms_dev`,连 `pms_dev` 库 + Redis **db6**; +> `test.anzhizhichong.com` 的 nginx `/api/pms/` 指向 `:3000`。 +> - ⚠️ **绝对不要把测试域名的 API 指到生产端口 `:9000`** —— 历史事故:测试 nginx 曾指向 +> `:3000`,而当时 `:3000` 正是生产 API,等于**测试后台在操作生产数据**。 +> 判断方法:`test` 域名 `/api/pms/` 返回 **401**(有测试后端)是对的;若返回 **502** 说明测试后端没起。 +> - 两个环境必须同时满足:生产 `:9000/:9001`(db5/pms)、测试 `:3000/:3001`(db6/pms_dev), +> 端口或库号任一串了就是事故。 **部署路径:** - 产品介绍页:/data/landing diff --git a/d6e6add1c7a1e8c1fc2b820ea206e000.png b/d6e6add1c7a1e8c1fc2b820ea206e000.png new file mode 100644 index 0000000..5a8a60a Binary files /dev/null and b/d6e6add1c7a1e8c1fc2b820ea206e000.png differ diff --git a/software/api-server/src/captcha.rs b/software/api-server/src/captcha.rs index ce3c9ea..8075c05 100644 --- a/software/api-server/src/captcha.rs +++ b/software/api-server/src/captcha.rs @@ -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. 渐变背景 let c1 = [rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, 255]; let c2 = [rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, 255]; @@ -127,11 +129,12 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) { fill_circle(&mut bg, cx, cy, radius, color); } - // 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; + bg +} - // 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); for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); } 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 sh = (PIECE_SIZE + SHADOW_OFFSET) as u32; 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. 背景画缺口 draw_hole(&mut bg, piece_x as i32, piece_y); diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index 3aa9b35..a3bcb6f 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -510,8 +510,67 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> { Ok(()) } -/// 插入默认角色并分配权限 +/// 创建默认角色并分配权限 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( "INSERT IGNORE INTO roles (name, description) VALUES (?, ?)" @@ -539,22 +598,46 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; - // 为总管理员分配所有权限 - let super_admin_id: Option<(i64,)> = sqlx::query_as( - "SELECT id FROM roles WHERE name = 'super_admin'" + Ok(()) +} + +/// 为指定角色分配全部权限(授权总管理员) +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?; - if let Some((role_id,)) = super_admin_id { - // 查询所有权限ID - let perm_ids: Vec<(i64,)> = sqlx::query_as( - "SELECT id FROM permissions" + for (perm_id,) in perm_ids { + sqlx::query( + "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)" ) - .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?; - for (perm_id,) in perm_ids { + if let Some((perm_id,)) = perm { sqlx::query( "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(()) } diff --git a/software/api-server/src/main.rs b/software/api-server/src/main.rs index 190ea24..19756f9 100644 --- a/software/api-server/src/main.rs +++ b/software/api-server/src/main.rs @@ -21,18 +21,15 @@ use tracing_subscriber::EnvFilter; use commands::{AppState, NodeRegistry, PendingCommands}; -#[tokio::main] -async fn main() { - // 初始化日志 +/// 初始化日志订阅器(默认级别 info,可通过 RUST_LOG 覆盖) +fn init_tracing() { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); +} - // 加载配置 - let cfg = config::Config::from_env(); - tracing::info!("配置加载完成"); - - // 初始化 MySQL 连接池 +/// 初始化 MySQL 连接池并执行自动迁移(失败即退出,启动阶段不降级) +async fn init_database(cfg: &config::Config) -> sqlx::MySqlPool { let db_pool = db::init_pool(&cfg.database_url) .await .expect("MySQL 连接失败"); @@ -44,62 +41,64 @@ async fn main() { .expect("数据库迁移失败"); 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) .await .expect("Redis 连接失败"); tracing::info!("Redis 连接成功"); + redis_conn +} - // 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退) - let jwt_secret: Arc = std::env::var("JWT_SECRET") +/// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退) +fn load_jwt_secret() -> Arc { + std::env::var("JWT_SECRET") .expect("JWT_SECRET environment variable is required") - .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, - }; + .into() +} +/// 启动全部后台协程:设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留 +fn spawn_workers(cfg: &config::Config, state: &AppState) { // 启动后台协程 — 处理设备上报 - let report_redis = redis_conn.clone(); - let report_mysql = db_pool.clone(); + let report_redis = state.redis.clone(); + let report_mysql = state.mysql.clone(); tokio::spawn(async move { 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 { - 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; tokio::spawn(async move { workers::node_checker::run(checker_nodes, heartbeat_timeout).await; }); // 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录) - let timeout_mysql = db_pool.clone(); - let timeout_redis = redis_conn.clone(); + let timeout_mysql = state.mysql.clone(); + let timeout_redis = state.redis.clone(); tokio::spawn(async move { workers::order_timeout::start(timeout_mysql, timeout_redis).await; }); // 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS) - let retention_mysql = db_pool.clone(); + let retention_mysql = state.mysql.clone(); tokio::spawn(async move { workers::log_retention::start(retention_mysql).await; }); +} +/// 构建路由并启动 HTTP 服务(阻塞至进程退出) +async fn serve_http(cfg: &config::Config, state: AppState) { // 构建 HTTP 路由 let app = routes::build(state); @@ -116,3 +115,38 @@ async fn main() { .await .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; +} diff --git a/software/api-server/src/routes/auth.rs b/software/api-server/src/routes/auth.rs index 70e6e79..cf69fe9 100644 --- a/software/api-server/src/routes/auth.rs +++ b/software/api-server/src/routes/auth.rs @@ -244,37 +244,34 @@ struct PhoneInfo { country_code: String, } -/// POST /api/auth/wx-login — 微信小程序手机号快速登录 -/// -/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code, -/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。 -pub async fn wx_login( - State(state): State, - Json(input): Json, -) -> Result, AppError> { +/// 读取微信小程序凭证(环境变量缺失则报内部错误) +fn wechat_credentials() -> Result<(String, String), AppError> { let appid = std::env::var("WECHAT_APPID") .map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?; let secret = std::env::var("WECHAT_APPSECRET") .map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?; + Ok((appid, secret)) +} - let client = Client::new(); - - // 1. 用 code 换取 openid +/// 用 wx.login() 的 code 换取 openid +async fn fetch_openid(client: &Client, appid: &str, secret: &str, code: &str) -> Result { let jscode_url = format!( "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 .map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))? .json().await .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(); AppError::Internal(format!("jscode2session 失败: {}", err)) - })?; + }) +} - // 2. 获取 access_token +/// 获取微信接口调用凭证 access_token +async fn fetch_access_token(client: &Client, appid: &str, secret: &str) -> Result { let token_url = format!( "https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential", appid, secret @@ -284,18 +281,24 @@ pub async fn wx_login( .json().await .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(); 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 { let phone_url = format!( "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}", access_token ); let phone_resp: PhoneNumberResp = client.post(&phone_url) - .json(&json!({ "code": input.phone_code })) + .json(&json!({ "code": phone_code })) .send().await .map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))? .json().await @@ -306,22 +309,51 @@ pub async fn wx_login( let err = phone_resp.errmsg.unwrap_or_default(); 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, i32, i32, Option), AppError> { let user_row = sqlx::query_as::<_, (i64, Option, String, i32, i32, Option)>( "SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?", ) - .bind(&phone_number) - .fetch_optional(&state.mysql) + .bind(phone_number) + .fetch_optional(pool) .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, + Json(input): Json, +) -> Result, 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) = - user_row.ok_or_else(|| { - tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户"); - AppError::Unauthorized("用户不存在,请联系管理员添加".into()) - })?; + find_user_by_phone(&state.mysql, &phone_number).await?; // 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息) sqlx::query("UPDATE users SET openid = ? WHERE id = ?") diff --git a/software/api-server/src/routes/batteries.rs b/software/api-server/src/routes/batteries.rs index 0feece2..c085e6c 100644 --- a/software/api-server/src/routes/batteries.rs +++ b/software/api-server/src/routes/batteries.rs @@ -52,6 +52,117 @@ struct BatteryRow { created_at: Option, } +/// 构建列表 / 计数 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, + battery_id: Option<&str>, +) -> Result { + 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, + battery_id: Option<&str>, + page_size: i64, + offset: i64, +) -> Result, sqlx::Error> { + let rows: Vec = 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) -> Vec { + 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 — 电池台账列表 pub async fn list_batteries( user: CurrentUser, @@ -64,88 +175,22 @@ pub async fn list_batteries( let page_size = params.page_size.unwrap_or(20).min(100); 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,具体取值在下方绑定 - 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 ?"); - } + let (query, count_query) = build_battery_sql(¶ms); + let battery_id = params.battery_id.as_deref(); - 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 { - 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 = 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 = 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(); + let items = batteries_to_items(rows); Ok(Json(json!({ "code": 0, diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index c7b83e5..66dc934 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -8,6 +8,7 @@ use axum::extract::{State, Query}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::HashMap; use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode}; use crate::error::AppError; @@ -61,6 +62,57 @@ impl From 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( user: CurrentUser, @@ -71,99 +123,107 @@ pub async fn organization_tree( let db = &state.mysql; // 获取所有组织 - let orgs: Vec = sqlx::query_as( - "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id" - ) - .fetch_all(db) - .await?; + let orgs: Vec = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?; // 获取所有项目 - let projects: Vec = sqlx::query_as( - "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 projects: Vec = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?; // 获取每个项目的设备数量 - let project_counts: Vec<(i64, i64)> = sqlx::query_as( - "SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id" - ) - .fetch_all(db) - .await?; - let project_count_map: std::collections::HashMap = project_counts.into_iter().collect(); + let project_count_map = fetch_project_counts(db).await?; // 递归构建树 - fn build_tree( - orgs: &[OrganizationRow], - projects: &[ProjectRow], - project_count_map: &std::collections::HashMap, - parent_id: Option, - ) -> Vec { - 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 = 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); Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) } +/// 查询「项目ID -> 设备数量」映射 +async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result, 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, + parent_id: Option, +) -> Vec { + 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 = 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)] 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 offset = (page - 1) * page_size; - // 基础SQL:联表查询获取通道状态统计 - let base_sql = " - 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 (where_clause, binds) = build_filter_where(¶ms, &user); - let (mut where_clause, binds): (String, Vec) = 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 = 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) { + let (where_clause, binds): (String, Vec) = if let Some(proj_id) = params.project_id { ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) } else if let Some(org_id) = params.organization_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 { - match bound.as_str() { - "1" => { - if where_clause.is_empty() { - 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"); - } - } - _ => {} - } - } + let where_clause = match params.bound.as_deref() { + Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"), + Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"), + _ => where_clause, + }; - // 总数量 + (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 mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); - for b in &binds { + for b in binds { 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 { - return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" }))); - } - - // 分页数据 - let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", base_sql, where_clause); +/// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定) +async fn fetch_filter_page( + db: &sqlx::MySqlPool, + where_clause: &str, + binds: &[String], + page_size: i64, + offset: i64, +) -> Result, 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); - for b in &binds { + for b in binds { data_query = data_query.bind(b); } - let sql_rows: Vec = data_query + let rows: Vec = data_query .bind(page_size) .bind(offset) - .fetch_all(db).await?; + .fetch_all(db) + .await?; + Ok(rows) +} - // 转换为CabinetRow - let mut rows: Vec = sql_rows.into_iter().map(CabinetRow::from).collect(); +/// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返) +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次网络往返) - { - let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { - AppError::Internal(format!("Redis客户端创建失败: {}", e)) - })?; - // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection - let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { - AppError::Internal(format!("Redis连接失败: {}", e)) - })?; - - // 用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> = 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::().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"); - } + // 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段) + let mut pipe = redis::pipe(); + for row in rows.iter() { + 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 project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); - if !project_ids.is_empty() { - let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); - let sql = format!( - r#"SELECT p.id, o.disabled FROM projects p + // 一次发送,获取所有结果 + let results: Vec> = 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::().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 = rows.iter().filter_map(|r| r.project_id).collect(); + if project_ids.is_empty() { + return; + } + + let placeholders: Vec = 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 WHERE p.id IN ({})"#, - placeholders.join(",") - ); - let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); - for pid in &project_ids { - q = q.bind(pid); - } - if let Ok(results) = q.fetch_all(db).await { - let disabled_map: std::collections::HashMap = results - .into_iter() - .map(|(pid, d)| (pid, d == 1)) - .collect(); - for row in rows.iter_mut() { - row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied(); - } - } + placeholders.join(",") + ); + let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); + for pid in &project_ids { + q = q.bind(pid); + } + if let Ok(results) = q.fetch_all(db).await { + let disabled_map: HashMap = + results.into_iter().map(|(pid, d)| (pid, d == 1)).collect(); + 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)] pub struct DeviceStatusParams { pub imei: String, } +/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误) +async fn open_redis_conn(state: &AppState) -> Result { + 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( user: CurrentUser, @@ -354,18 +442,37 @@ pub async fn device_realtime_status( 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 let Some(org_id) = user.organization_id { - let has_access: bool = sqlx::query_scalar( - "SELECT EXISTS(SELECT 1 FROM cabinets c \ - JOIN projects p ON c.project_id = p.id \ - WHERE c.imei = ? AND p.organization_id = ?)", - ) - .bind(¶ms.imei) - .bind(org_id) - .fetch_one(&state.mysql) - .await - .unwrap_or(false); + let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL) + .bind(imei) + .bind(org_id) + .fetch_one(&state.mysql) + .await + .unwrap_or(false); if !has_access { return Err(AppError::Forbidden("无权访问该设备".into())); } @@ -373,38 +480,43 @@ pub async fn device_realtime_status( return Err(AppError::Forbidden("无权访问该设备".into())); } } + Ok(()) +} - let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { - AppError::Internal(format!("Redis客户端创建失败: {}", e)) - })?; - // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection - let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { - AppError::Internal(format!("Redis连接失败: {}", e)) - })?; - - let device_key = format!("device:{}", params.imei); +/// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac) +async fn load_device_status( + conn: &mut redis::aio::MultiplexedConnection, + imei: &str, +) -> Value { + let device_key = format!("device:{}", imei); // 获取设备级字段 let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"]; let values: Vec> = redis::cmd("HMGET") .arg(&device_key) .arg(&fields) - .query_async(&mut conn) + .query_async(&mut *conn) .await .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), "rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()), "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), - }); + }) +} +/// 扫描并组装全部仓控板状态(板级 + 通道级) +async fn load_boards_status( + conn: &mut redis::aio::MultiplexedConnection, + imei: &str, +) -> Value { // 扫描板级key: device:{imei}:board:* - let board_pattern = format!("device:{}:board:*", params.imei); + let board_pattern = format!("device:{}:board:*", imei); let board_keys: Vec = redis::cmd("KEYS") .arg(&board_pattern) - .query_async(&mut conn) + .query_async(&mut *conn) .await .unwrap_or_default(); @@ -416,77 +528,98 @@ pub async fn device_realtime_status( continue; } - // 获取板级字段 - let board_fields = vec!["status_hex"]; - let board_values: Vec> = 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 = 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> = 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::().ok()), - }); - } - - board_data["channels"] = channels; - boards[board_idx] = board_data; + boards[board_idx] = load_single_board(conn, board_key).await; } - Ok(Json(json!({ - "code": 0, - "data": { - "device": device_status, - "boards": boards, - }, - "message": "ok" - }))) + boards +} + +/// 读取单块仓控板的板级字段,并扫描其下的通道级 key +async fn load_single_board( + conn: &mut redis::aio::MultiplexedConnection, + board_key: &str, +) -> Value { + // 获取板级字段 + let board_fields = vec!["status_hex"]; + let board_values: Vec> = 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 = 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> = 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::().ok()), + }) } diff --git a/software/api-server/src/routes/charge_records.rs b/software/api-server/src/routes/charge_records.rs index a83bfb9..d9081a0 100644 --- a/software/api-server/src/routes/charge_records.rs +++ b/software/api-server/src/routes/charge_records.rs @@ -155,6 +155,61 @@ async fn fetch_charge_rows( query.fetch_all(pool).await } +/// 追加组织数据隔离到 WHERE 子句与绑定值 +fn append_org_scope(user: &CurrentUser, where_clause: &mut String, bind_values: &mut Vec) { + 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 { + 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) -> Vec { + 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 — 充电记录分页列表 pub async fn list_charge_records( user: CurrentUser, @@ -173,30 +228,10 @@ pub async fn list_charge_records( ); // 追加组织数据隔离 - 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); - } + append_org_scope(&user, &mut where_clause, &mut bind_values); // 查询总数(需要与数据查询保持一致的 JOIN 链路) - 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); - } - let total = count_query.fetch_one(&state.mysql).await?; + let total = count_charge_records(&state.mysql, &where_clause, &bind_values).await?; // 查询列表 let rows = fetch_charge_rows( @@ -208,24 +243,7 @@ pub async fn list_charge_records( ) .await?; - let list: Vec = 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(); + let list = charge_rows_to_items(rows); Ok(Json(json!({ "code": 0, @@ -307,29 +325,27 @@ fn generate_export_filename(params: &ChargeRecordExportQuery) -> String { 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, -) -> Result<(), Box> { - // 更新状态为处理中 - sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?") - .bind(task_id) - .execute(&pool) - .await?; - - // 获取查询参数 +) -> Result<(String, Value), Box> { let row: Option<(String, String)> = sqlx::query_as( "SELECT file_name, error_message FROM download_tasks WHERE id = ?" ) .bind(task_id) - .fetch_optional(&pool) + .fetch_optional(pool) .await?; let (file_name, query_json) = row.ok_or("任务不存在")?; - 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) { let (mut where_clause, mut bind_values) = build_where_clause( params.get("cabinet_id").and_then(|v| v.as_i64()), 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 = ?") - .bind(task_id) - .execute(&pool) - .await?; - - // 生成 Excel +/// 构建导出工作簿(表头 + 数据行) +fn build_charge_workbook( + rows: &[ChargeRecordRow], +) -> Result> { let mut workbook = rust_xlsxwriter::Workbook::new(); let sheet = workbook.add_worksheet(); sheet.set_name("充电记录")?; @@ -391,6 +405,54 @@ async fn generate_charge_records_excel( 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> { + // 更新状态为处理中 + 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"; tokio::fs::create_dir_all(file_dir).await?; @@ -398,14 +460,7 @@ async fn generate_charge_records_excel( workbook.save(&file_path)?; // 更新任务状态为完成 - 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?; + mark_task_completed(&pool, task_id, &file_path).await?; tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path); Ok(()) diff --git a/software/api-server/src/routes/device_logs.rs b/software/api-server/src/routes/device_logs.rs index 7ed8184..87f5901 100644 --- a/software/api-server/src/routes/device_logs.rs +++ b/software/api-server/src/routes/device_logs.rs @@ -59,16 +59,11 @@ struct DeviceLogRow { created_at: Option, } -/// GET /api/device-logs — 设备日志分页列表 -pub async fn list_device_logs( - user: CurrentUser, - State(state): State, - Query(params): Query, -) -> Result, AppError> { - auth::check_permission(&user, "device_log:view")?; - let offset = (params.page - 1) * params.page_size; - - // 构建 WHERE 条件 +/// 构建 WHERE 条件与绑定值(含组织数据隔离) +fn build_device_log_filters( + user: &CurrentUser, + params: &DeviceLogQuery, +) -> (String, Vec) { let mut conditions = Vec::new(); let mut bind_values: Vec = 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() { conditions.push(org_cond.trim_start_matches(" AND ")); bind_values.extend(org_binds); @@ -101,19 +96,34 @@ pub async fn list_device_logs( } else { format!("WHERE {}", conditions.join(" AND ")) }; + (where_clause, bind_values) +} - // 查询总数 +/// 统计符合条件的设备日志总数 +async fn count_device_logs( + pool: &sqlx::MySqlPool, + where_clause: &str, + bind_values: &[String], +) -> Result { let count_sql = format!( "SELECT COUNT(*) FROM device_logs dl {}", where_clause ); 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); } - 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, AppError> { let list_sql = format!( "SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \ 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); - for val in &bind_values { + for val in bind_values { 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 = rows - .into_iter() +/// 将设备日志行映射为响应项 +fn device_logs_to_items(rows: Vec) -> Vec { + rows.into_iter() .map(|r| DeviceLogItem { id: r.id, cabinet_id: r.cabinet_id, @@ -142,7 +154,35 @@ pub async fn list_device_logs( content: r.content, 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, + Query(params): Query, +) -> Result, 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!({ "code": 0, diff --git a/software/api-server/src/routes/energy_stats.rs b/software/api-server/src/routes/energy_stats.rs index 2533783..f9bb4eb 100644 --- a/software/api-server/src/routes/energy_stats.rs +++ b/software/api-server/src/routes/energy_stats.rs @@ -461,28 +461,27 @@ fn build_export_clause( (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, -) -> Result<(), Box> { - // 更新状态为处理中 - sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?") - .bind(task_id) - .execute(&pool) - .await?; - - // 获取查询参数 +) -> Result<(String, Value), Box> { let row: Option<(String, String)> = sqlx::query_as( "SELECT file_name, error_message FROM download_tasks WHERE id = ?" ) .bind(task_id) - .fetch_optional(&pool) + .fetch_optional(pool) .await?; let (file_name, query_json) = row.ok_or("任务不存在")?; 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) { let (mut where_clause, mut bind_values) = build_export_clause( params.get("cabinet_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, sqlx::Error> { let sql = format!( "SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \ p.name AS project_name, \ @@ -518,18 +525,16 @@ async fn generate_energy_excel( ); let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql); - for val in &bind_values { + for val in bind_values { 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 = ?") - .bind(task_id) - .execute(&pool) - .await?; - - // 生成 Excel +/// 构建导出工作簿(表头 + 数据行) +fn build_energy_workbook( + rows: &[EnergyDetailRow], +) -> Result> { let mut workbook = rust_xlsxwriter::Workbook::new(); let sheet = workbook.add_worksheet(); 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)?; } + 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> { + // 更新状态为处理中 + 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"; tokio::fs::create_dir_all(file_dir).await?; @@ -558,14 +611,7 @@ async fn generate_energy_excel( workbook.save(&file_path)?; // 更新任务状态为完成 - 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?; + mark_task_completed(&pool, task_id, &file_path).await?; tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path); Ok(()) diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index ff99bef..f883061 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -16,6 +16,7 @@ use axum::Json; use redis; use serde::Deserialize; use serde_json::{json, Value}; +use std::collections::HashMap; use std::io::Write; use uuid::Uuid; @@ -430,123 +431,128 @@ async fn fetch_fault_alerts( .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( pool: &sqlx::MySqlPool, user: &CurrentUser, org_id: Option, ) -> Result, 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, +) -> Result, AppError> { let filter_org = org_id.or(user.organization_id); let projects: Vec = if user.role_level >= 2 { if let Some(oid) = filter_org { - sqlx::query_as::<_, ProjectRow>( - "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", - ) - .bind(oid) - .fetch_all(pool) - .await? + sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL) + .bind(oid) + .fetch_all(pool) + .await? } else { - sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") + sqlx::query_as::<_, ProjectRow>(PROJECTS_ALL_SQL) .fetch_all(pool) .await? } } else if let Some(org_id) = user.organization_id { - sqlx::query_as::<_, ProjectRow>( - "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", - ) - .bind(org_id) - .fetch_all(pool) - .await? + sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL) + .bind(org_id) + .fetch_all(pool) + .await? } else { vec![] }; - let mut result = Vec::with_capacity(projects.len()); - for p in &projects { - let cabinet_count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM cabinets WHERE project_id = ?", - ) + Ok(projects) +} + +/// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数 +async fn fetch_project_summary( + pool: &sqlx::MySqlPool, + p: &ProjectRow, +) -> Result { + let cabinet_count: i64 = sqlx::query_scalar(PROJECT_CABINET_COUNT_SQL) .bind(p.id) .fetch_one(pool) .await?; - let charging: i64 = sqlx::query_scalar( - 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"#, - ) + let charging: i64 = sqlx::query_scalar(PROJECT_CHARGING_COUNT_SQL) .bind(p.id) .fetch_one(pool) .await?; - let idle: i64 = sqlx::query_scalar( - 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"#, - ) + let idle: i64 = sqlx::query_scalar(PROJECT_IDLE_COUNT_SQL) .bind(p.id) .fetch_one(pool) .await?; - let fault: i64 = sqlx::query_scalar( - 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"#, - ) + let fault: i64 = sqlx::query_scalar(PROJECT_FAULT_COUNT_SQL) .bind(p.id) .fetch_one(pool) .await?; - result.push(json!({ - "id": p.id, - "name": p.name, - "cabinet_count": cabinet_count, - "charging": charging, - "idle": idle, - "fault": fault, - })); - } - - Ok(result) + Ok(json!({ + "id": p.id, + "name": p.name, + "cabinet_count": cabinet_count, + "charging": charging, + "idle": idle, + "fault": fault, + })) } // --------------------------------------------------------------------------- // 设备列表 // --------------------------------------------------------------------------- -/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离) -pub async fn get_cabinets( - user: CurrentUser, - State(state): State, - Query(query): Query, -) -> Result, AppError> { - let pool = &state.mysql; +// --- 设备列表 SQL(由原函数体逐字搬移;SELECT 字段列表 / 子查询 / CAST 均未改动) --- - // 权限校验 - auth::check_permission(&user, "device:view")?; - - let rows: Vec = 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, +/// 项目过滤 + 总管理员(不带组织条件) +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, (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 = 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 = 3) as fault_count - FROM cabinets c WHERE c.project_id = ?"#, - ) - .bind(pid) - .fetch_all(pool) - .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, + FROM cabinets c WHERE c.project_id = ?"#; + +/// 项目过滤 + 企业管理员(额外校验项目归属组织) +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, (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 = 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 FROM cabinets c INNER JOIN projects p ON c.project_id = p.id - WHERE c.project_id = ? AND p.organization_id = ?"#, - ) - .bind(pid) - .bind(org_id) - .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, + WHERE c.project_id = ? AND p.organization_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, (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 = 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 FROM cabinets c INNER JOIN projects p ON c.project_id = p.id - WHERE p.organization_id = ?"#, - ) - .bind(oid) - .fetch_all(pool) - .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, + WHERE p.organization_id = ?"#; + +/// 总管理员查看全部 +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, (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 = 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 = 3) as fault_count - FROM cabinets c"#, - ) - .fetch_all(pool) - .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, + FROM cabinets c"#; + +/// 企业管理员只看本组织 +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, (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 = 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 FROM cabinets c INNER JOIN projects p ON c.project_id = p.id - WHERE p.organization_id = ?"#, - ) - .bind(org_id) - .fetch_all(pool) - .await? - } else { - vec![] - }; + WHERE p.organization_id = ?"#; - // 从Redis批量查RSSI + pow_fail - let mut rssi_map: std::collections::HashMap> = std::collections::HashMap::new(); - let mut dc_map: std::collections::HashMap> = std::collections::HashMap::new(); - let mut ac_map: std::collections::HashMap> = std::collections::HashMap::new(); - let mut temp_map: std::collections::HashMap> = std::collections::HashMap::new(); +/// 柜子所属组织是否禁用(前端徽标用,`{}` 处填入 `?` 占位符列表) +const PROJECT_ORG_DISABLED_SQL: &str = r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p + LEFT JOIN organizations o ON p.organization_id = o.id + WHERE p.id IN ({})"#; + +/// 设备列表的 Redis 实时状态(按 IMEI 索引) +#[derive(Default)] +struct CabinetRealtimeMaps { + rssi: HashMap>, + dc: HashMap>, + ac: HashMap>, + temp: HashMap>, +} + +/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离) +pub async fn get_cabinets( + user: CurrentUser, + State(state): State, + Query(query): Query, +) -> Result, 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, + org_id: Option, +) -> Result, 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, 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, +) -> Result, 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()) { // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let mut pipe = redis::pipe(); - for r in &rows { + for r in rows { if let Some(ref imei) = r.imei { let key = format!("device:{imei}"); 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() { if let Some(ref imei) = r.imei { 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::().ok())); - dc_map.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")); - temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + maps.rssi.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + maps.dc.insert(imei.clone(), vals.get(1).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")); + maps.temp.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); } } } } } } + maps +} - // 查询柜子所属组织是否禁用(用于前端徽标) - let mut org_disabled_map: std::collections::HashMap = std::collections::HashMap::new(); - { - let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); - if !project_ids.is_empty() { - let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); - let sql = format!( - r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p - LEFT JOIN organizations o ON p.organization_id = o.id - WHERE p.id IN ({})"#, - placeholders.join(",") - ); - let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); - for pid in &project_ids { - q = q.bind(pid); - } - if let Ok(rows) = q.fetch_all(pool).await { - for (pid, disabled) in rows { - org_disabled_map.insert(pid, disabled == 1); - } +/// 查询柜子所属组织是否禁用,返回「项目ID -> 是否禁用」映射 +async fn load_org_disabled_map( + pool: &sqlx::MySqlPool, + rows: &[H5CabinetRow], +) -> HashMap { + let mut org_disabled_map: HashMap = HashMap::new(); + let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); + if !project_ids.is_empty() { + let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); + // `{}` 处填入占位符列表,等价于原实现的 format! 拼接 + let sql = PROJECT_ORG_DISABLED_SQL.replacen("{}", &placeholders.join(","), 1); + let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); + for pid in &project_ids { + q = q.bind(pid); + } + if let Ok(rows) = q.fetch_all(pool).await { + for (pid, disabled) in rows { + org_disabled_map.insert(pid, disabled == 1); } } } + org_disabled_map +} - let list: Vec = rows - .into_iter() +/// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致) +fn build_cabinet_list( + rows: Vec, + realtime: &CabinetRealtimeMaps, + org_disabled_map: &HashMap, +) -> Vec { + rows.into_iter() .map(|r| { let imei = r.imei.clone(); - let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten(); - let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten(); - let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten(); - let temp = imei.as_ref().and_then(|i| temp_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| realtime.dc.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| realtime.temp.get(i).copied()).flatten(); let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); json!({ "id": r.id, @@ -706,13 +805,7 @@ pub async fn get_cabinets( "org_disabled": org_disabled, }) }) - .collect(); - - Ok(Json(json!({ - "code": 0, - "data": list, - "message": "ok" - }))) + .collect() } // --------------------------------------------------------------------------- @@ -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 — 仓体详情(含充电记录,带权限校验) pub async fn get_compartment_detail( user: CurrentUser, State(state): State, Path(id): Path, ) -> Result, AppError> { - let pool = &state.mysql; - // 权限校验 auth::check_permission(&user, "device:view")?; + let pool = &state.mysql; + // 查询仓体基本信息 - let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as( - "SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?", - ) - .bind(id) - .fetch_optional(pool) - .await?; + let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(COMPARTMENT_BY_ID_SQL) + .bind(id) + .fetch_optional(pool) + .await?; let (comp_id, channel_id, status, cabin_board_id) = comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?; // 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限 - let cabinet_id: Option<(i64,)> = sqlx::query_as( - "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?; - } + verify_compartment_access(&user, cabin_board_id, pool).await?; // 从 Redis 获取实时数据 - let realtime: 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 = 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 realtime = load_compartment_realtime(&state, comp_id).await; // 查询最近充电记录 let records: Vec = sqlx::query_as::<_, ChargeRecordBriefRow>( - r#"SELECT id, start_time, end_time, energy - FROM charge_records - WHERE compartment_id = ? - ORDER BY start_time DESC LIMIT 10"#, + COMPARTMENT_CHARGE_RECORDS_SQL, ) .bind(comp_id) .fetch_all(pool) .await?; - let charge_records: Vec = 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::().unwrap_or(0), - "cost": 0, - }) - }) - .collect(); - Ok(Json(json!({ "code": 0, "data": { @@ -863,12 +933,58 @@ pub async fn get_compartment_detail( "cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), "cell_temps": realtime.get("cell_temps").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" }))) } +/// 组织隔离:仓控板 → 柜子 → 项目 → 组织链路校验访问权限 +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 = 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) -> Vec { + 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::().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 { + Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "指令已下发" + })) +} + /// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。 /// /// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。 @@ -1045,56 +1245,15 @@ async fn send_compartment_command( let pool = &state.mysql; // 一次查询拿到 cabinet_id + IMEI + board_id + channel_id - let row: Option = sqlx::query_as( - 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 = load_compartment_target(pool, compartment_id).await?; - let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; - - // 检查设备是否被禁用 - 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())); - } - - // 校验操作权限(组织隔离) + // 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致) + ensure_cabinet_enabled(pool, cab_row.cabinet_id).await?; verify_cabinet_access(user, cab_row.cabinet_id, pool).await?; + ensure_device_online(state, &cab_row.imei).await?; let imei = &cab_row.imei; - // 检查是否有设备节点在线 - if !state.nodes.is_device_online(imei).await { - return Err(AppError::BadRequest("设备不在线".into())); - } - // 构造协议 id: // 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零) // 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定 @@ -1107,29 +1266,27 @@ async fn send_compartment_command( params: Some(json!({})), }; - // 注入内部路由字段(目标设备 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") - .arg(&json_str) - .query_async::<()>(&mut conn) - .await - .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; + push_device_command(state, &cmd, imei).await?; tracing::info!( "[h5] 指令已推入队列 id={} action={} compartment_id={}", id, action, compartment_id ); - Ok(Json(json!({ - "code": 0, - "data": { "success": true }, - "message": "指令已下发" - }))) + Ok(command_dispatched_json()) +} + +/// 按仓体ID查询目标设备与通道信息 +async fn load_compartment_target( + pool: &sqlx::MySqlPool, + compartment_id: i64, +) -> Result { + let row: Option = 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, AppError> { let pool = &state.mysql; - let imei: Option = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") - .bind(cabinet_id) - .fetch_optional(pool) - .await?; - let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; + let imei = load_cabinet_imei(pool, cabinet_id).await?; - // 设备是否被禁用 - let disabled: bool = - 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())); - } - - // 组织数据隔离校验 + // 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致) + ensure_cabinet_enabled(pool, cabinet_id).await?; verify_cabinet_access(user, cabinet_id, pool).await?; - - // 设备必须在线:平台对不在线的设备一律不投递 - if !state.nodes.is_device_online(&imei).await { - return Err(AppError::BadRequest("设备不在线".into())); - } + ensure_device_online(state, &imei).await?; // 构造协议 id: // - `board_id = None` → **柜级**:**不带 id**(省略该字段) @@ -1203,18 +1327,7 @@ async fn send_cabinet_command( params: Some(json!({})), }; - // 注入内部路由字段(目标设备 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") - .arg(&json_str) - .query_async::<()>(&mut conn) - .await - .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; + push_device_command(state, &cmd, &imei).await?; tracing::info!( "[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}", @@ -1224,11 +1337,17 @@ async fn send_cabinet_command( board_id ); - Ok(Json(json!({ - "code": 0, - "data": { "success": true }, - "message": "指令已下发" - }))) + Ok(command_dispatched_json()) +} + +/// 按柜子ID查询 IMEI +async fn load_cabinet_imei(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result { + let imei: Option = 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 — **解锁柜门**(柜级) diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index b14d264..c16b157 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -34,40 +34,51 @@ mod users_perm; mod wechat; mod maps; -/// 构建完整路由树(需要传入 AppState 用于认证中间件) -pub fn build(state: AppState) -> Router { - let protected = Router::::new() - // 认证相关 +/// 认证与账户相关路由(需登录) +fn auth_routes() -> Router { + Router::::new() .route("/api/auth/logout", post(auth::logout)) .route("/api/auth/me", get(auth::me)) .route("/api/auth/avatar", put(auth::update_avatar)) .route("/api/auth/avatar/upload", post(auth::upload_avatar)) - // 设备指令下发 +} + +/// 设备指令下发 + 设备服务节点管理路由 +fn device_node_routes() -> Router { + Router::::new() .route("/api/devices/{dev_id}/command", post(crate::commands::send_command)) .route("/api/devices/stats", get(crate::commands::device_stats)) - // 节点管理 .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}/deregister", post(crate::nodes::deregister_node)) .route("/api/nodes", get(crate::nodes::list_nodes)) - // 角色管理 +} + +/// 角色、权限码与用户权限分配路由 +fn rbac_routes() -> Router { + Router::::new() .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}/permissions", put(roles::set_role_permissions)) - // 权限码列表 .route("/api/permissions", get(roles::list_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}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes)) - // 组织管理 +} + +/// 组织与项目路由 +fn org_project_routes() -> Router { + Router::::new() .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}/status", put(organizations::set_organization_status)) - // 项目管理 .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)) - // 设备管理 +} + +/// 设备(充电柜)管理、柜级控制、组织树与级联下拉路由 +fn cabinet_routes() -> Router { + Router::::new() .route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets)) .route("/api/cabinets", post(cabinets::create_cabinet)) .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/cabinets/filter", get(cabinet_tree::filter_cabinets)) .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/cabin-boards/options", get(cabinets::list_cabin_board_options)) .route("/api/compartments/options", get(cabinets::list_compartment_options)) - // 能耗管理 +} + +/// 充电记录 / 充电订单 / 电池台账 / 设备日志路由 +fn record_routes() -> Router { + Router::::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 { + Router::::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 { + Router::::new() .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/projects", get(energy_stats::get_project_stats)) .route("/api/energy-stats/trend", get(energy_stats::get_trend)) .route("/api/energy-stats/export", get(energy_stats::export_energy_stats)) - // 用户管理 +} + +/// 用户管理与操作日志路由 +fn user_routes() -> Router { + Router::::new() .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}/reset-password", put(users::reset_password)) .route("/api/users/{id}/status", put(users::set_user_status)) - // 操作日志 .route("/api/operation-logs", get(operation_logs::list_operation_logs)) - // H5 用户端接口(与 PC 共用同一套 API 路由) +} + +/// H5 用户端接口路由(与 PC 共用同一套 API 路由)+ 问题反馈 +fn h5_routes() -> Router { + Router::::new() .route("/api/dashboard", get(h5::get_dashboard)) .route("/api/cabinets", get(h5::get_cabinets)) .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/door/open", post(h5::open_door)) .route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet)) - // 问题反馈 .route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback)) .route("/api/feedback/{id}", put(h5::update_feedback_status)) .route("/api/feedback/upload", post(h5::upload_feedback_image)) - // 认证中间件层 - .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); +} +/// 合并全部受保护路由(不含认证中间件层,便于单测校验路由表无冲突) +fn protected_router() -> Router { + Router::::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 { Router::::new() .route("/api/health", get(health::health_check)) .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)) // 头像上传目录(免认证,仅用于公开访问静态文件) .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) .layer(CorsLayer::new() .allow_origin(Any) diff --git a/software/api-server/src/routes/operation_logs.rs b/software/api-server/src/routes/operation_logs.rs index 4de1cbf..12333a9 100644 --- a/software/api-server/src/routes/operation_logs.rs +++ b/software/api-server/src/routes/operation_logs.rs @@ -24,22 +24,23 @@ pub(crate) struct LogQuery { page_size: Option, } -/// GET /api/operation-logs — 操作日志列表(分页) +/// 操作日志数据库行映射 +#[derive(sqlx::FromRow)] +struct LogRow { + id: i64, + user_id: i64, + user_name: Option, + action: String, + target_type: Option, + target_id: Option, + detail: Option, + created_at: chrono::NaiveDateTime, +} + +/// 构建 WHERE 子句与绑定值 /// -/// 支持 user_id / action / start_time / end_time 过滤。 -pub async fn list_operation_logs( - user: CurrentUser, - State(state): State, - Query(query): Query, -) -> Result, 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 注入) +/// 动态拼装过滤条件(使用 ? 占位符防止 SQL 注入),并追加组织数据隔离。 +fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec) { let mut conditions: Vec = Vec::new(); let mut bind_values: Vec = 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() { conditions.push(org_cond.trim_start_matches(" AND ").to_string()); bind_values.extend(org_binds); @@ -78,16 +79,31 @@ pub async fn list_operation_logs( } else { format!("WHERE {}", conditions.join(" AND ")) }; + (where_clause, bind_values) +} - // 查询总数 +/// 统计符合条件的操作日志总数 +async fn count_operation_logs( + pool: &sqlx::MySqlPool, + where_clause: &str, + bind_values: &[String], +) -> Result { let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause); 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); } - 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, AppError> { 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 FROM operation_logs o @@ -98,30 +114,20 @@ pub async fn list_operation_logs( where_clause ); - #[derive(sqlx::FromRow)] - struct LogRow { - id: i64, - user_id: i64, - user_name: Option, - action: String, - target_type: Option, - target_id: Option, - detail: Option, - created_at: chrono::NaiveDateTime, - } - 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); } - let rows = list_query + Ok(list_query .bind(page_size) .bind(offset) .fetch_all(pool) - .await?; + .await?) +} - let list: Vec = rows - .into_iter() +/// 将操作日志行映射为响应 JSON 列表 +fn operation_logs_to_json(rows: Vec) -> Vec { + rows.into_iter() .map(|r| { json!({ "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(), }) }) - .collect(); + .collect() +} + +/// GET /api/operation-logs — 操作日志列表(分页) +/// +/// 支持 user_id / action / start_time / end_time 过滤。 +pub async fn list_operation_logs( + user: CurrentUser, + State(state): State, + Query(query): Query, +) -> Result, 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!({ "code": 0, diff --git a/software/api-server/src/routes/users.rs b/software/api-server/src/routes/users.rs index 3686384..1390f0b 100644 --- a/software/api-server/src/routes/users.rs +++ b/software/api-server/src/routes/users.rs @@ -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) -> (String, Option) { + 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, +) -> Result { + 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, + page_size: u32, + offset: u32, +) -> Result, 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, AppError> { + let org_ids: Vec = rows.iter().filter_map(|r| r.organization_id).collect(); + if org_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + // 使用 IN 查询, 构造占位符 + let placeholders: Vec = 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, + org_map: &std::collections::HashMap, +) -> Vec { + // 构建角色名称映射 + 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 — 用户列表(分页) /// /// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。 @@ -180,98 +300,30 @@ pub async fn list_users( } // 构建通用 WHERE 子句(避免 count/list 重复维护3个分支) - let (where_clause, bind_org) = 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), - }; + let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter); // 查询总数 - let total: (i64,) = { - 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 total = count_users(pool, &where_clause, has_keyword, &like_pattern, bind_org).await?; // 查询用户列表 - let rows: Vec = { - 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); - } - q.bind(page_size).bind(offset).fetch_all(pool).await? - }; + let rows = fetch_user_rows( + pool, + &where_clause, + has_keyword, + &like_pattern, + bind_org, + page_size, + offset, + ) + .await?; // 收集组织ID并批量查询组织名称 - let org_ids: Vec = rows.iter().filter_map(|r| r.organization_id).collect(); - let org_names = if org_ids.is_empty() { - vec![] - } else { - // 使用 IN 查询, 构造占位符 - let placeholders: Vec = 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 = org_names.into_iter().collect(); - - // 构建角色名称映射 - let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"]; - - let list: Vec = 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(); + let org_map = load_org_name_map(pool, &rows).await?; + let list = users_to_json(rows, &org_map); Ok(Json(json!({ "code": 0, - "data": { "list": list, "total": total.0 }, + "data": { "list": list, "total": total }, "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, +) -> Result, 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 — 创建用户 /// /// 权限模型: @@ -297,9 +407,7 @@ pub async fn create_user( // 校验手机号 let phone = input.phone.trim(); - if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) { - return Err(AppError::BadRequest("手机号必须为11位数字".into())); - } + validate_phone(phone)?; let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); 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 { - // 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织 - 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()))?) - }; + let org_id = resolve_create_org_id(¤t_user, role, input.organization_id)?; // 校验组织存在 if let Some(oid) = 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))); - } + ensure_organization_exists(pool, oid).await?; } let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?; let password = auth::hash_password(&plain_password)?; // 检查手机号是否已存在 - 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))); - } + ensure_phone_available(pool, phone).await?; let result = sqlx::query( "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, + new_role: i32, + input_org_id: Option, +) -> Result, 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 — 编辑用户 pub async fn update_user( current_user: CurrentUser, @@ -407,44 +530,16 @@ pub async fn update_user( return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into())); } - // 企业管理员作用域校验:只能编辑本组织用户,不能改成总管理员,组织锁定为本组织 - let new_org_id: Option = 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())); - } - 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 new_org_id = resolve_update_org_id( + pool, + ¤t_user, + user_id, + old_org_id, + new_role, + input.organization_id, + ) + .await?; let rows = sqlx::query( "UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?" diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index 8304c6a..3840737 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -1224,13 +1224,7 @@ pub async fn ensure_charge_order( } }; - let active: Option<(i64, String)> = 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?; + let active = find_active_order(pool, compartment_id).await?; // 是否需要"换电池"处理:两边电池 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)) => { // 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。 // 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。 - finalize_order( + end_replaced_order( pool, redis, imei, - i64::from(board_id), - i64::from(channel_id), + board_id, + channel_id, compartment_id, - None, - 2, - "forced", - RECORD_STATUS_ABNORMAL, + battery_id, + *order_id, + old_battery, ) .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(()) } +/// 查该仓体当前活跃订单(`status = 0`)→ `(订单 id, 电池 id)`;无活跃订单返回 `None`。 +async fn find_active_order( + pool: &MySqlPool, + compartment_id: i64, +) -> Result, 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`),并把该仓体/电池标记为**占用**: /// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。 /// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。 @@ -1872,24 +1910,12 @@ async fn handle_bat_out( } // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) - mark_compartment_status(mysql, compartment_id, 0).await; - if !battery_id.is_empty() { - mark_battery_status(mysql, battery_id, 0).await; - } + reset_usage_status(mysql, compartment_id, battery_id).await; // 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id delete_channel_battery(redis, dev_id, board_id, channel_id).await; - 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; + log_bat_out(mysql, dev_id, msg, compartment_id, battery_id, end_soc).await; tracing::info!( "[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}", @@ -1905,6 +1931,38 @@ async fn handle_bat_out( 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, +) { + 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 的形态) // --------------------------------------------------------------------------- diff --git a/营业执照.jpg b/营业执照.jpg new file mode 100644 index 0000000..cb42495 Binary files /dev/null and b/营业执照.jpg differ