refactor(api-server): 23 个超 80 行函数拆分为 ≤80 行(纯重构,行为不变)

拆分范围(14 个文件):migrate / users / operation_logs / energy_stats / charge_records /
batteries / device_logs / auth / captcha / main / routes::mod / h5 / cabinet_tree / report_worker

手法:长 SQL 抽模块级 const、拼装与映射与校验抽私有 fn、路由按业务域拆 Router 再 merge、
Excel 导出按"取数/表头/逐行/收尾"分阶段、连续代码块原样搬移+传参。

行为不变的证据:
- 路由 79 → 79 条逐条比对一致(路径+handler+方法链;中间件/CORS/with_state 作用域保持)
- 字符串字面量多重集比对:11 个文件唯一集合完全一致;report_worker 零差异(435 条字面量全等)
- 关键契约计数不变:CAST 14→14、AS SIGNED 4→4、COALESCE 6→6;SQL 文本与 Redis key 零增删
- 未新增 #[allow] / unwrap / expect / panic;captcha 的 RNG 消耗顺序保持(验证码分布不变)

验收:cargo check 0 error / 0 warning;cargo test --bins 39 passed / 0 failed;全仓已无 >80 行函数。
端到端回归:先部署测试环境跑 TCP 协议联调 13/13,且数据库终态与重构前逐字段一致
(orders.energy=22、start_soc=850、end_soc=1000、records.energy=22.00、max_temp=29800、占用状态正确复位)。
This commit is contained in:
12451 2026-09-20 00:01:01 +08:00
parent 999885c38f
commit 8bc7812733
18 changed files with 2004 additions and 1230 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@ -63,13 +63,14 @@ D:\charging-cabinet\
> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx > - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx
> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env` > `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env`
> (**新二进制强制要求环境变量,缺它起不来**)。 > (**新二进制强制要求环境变量,缺它起不来**)。
> - **本服务器不部署测试环境**(用户确认:这台就是生产)—— `pms-api-test` / `pms-device-test` > - **测试环境按本表规划独立部署在同一台服务器上**(测试就是测试,与生产完全隔离):
> 已停止并卸载,`:3000` / `:3001` 已释放。 > `pms-api-test :3000` + `pms-device-test :3001`,路径 `/data/pms_dev`,连 `pms_dev` 库 + Redis **db6**;
> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为 > `test.anzhizhichong.com` 的 nginx `/api/pms/` 指向 `:3000`。
> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API, > - ⚠️ **绝对不要把测试域名的 API 指到生产端口 `:9000`** —— 历史事故:测试 nginx 曾指向
> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。** > `:3000`,而当时 `:3000` 正是生产 API,等于**测试后台在操作生产数据**。
> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**, > 判断方法:`test` 域名 `/api/pms/` 返回 **401**(有测试后端)是对的;若返回 **502** 说明测试后端没起。
> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。 > - 两个环境必须同时满足:生产 `:9000/:9001`(db5/pms)、测试 `:3000/:3001`(db6/pms_dev),
> 端口或库号任一串了就是事故。
**部署路径:** **部署路径:**
- 产品介绍页:/data/landing - 产品介绍页:/data/landing

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@ -92,9 +92,11 @@ pub(crate) async fn verify_offset(redis_url: &str, challenge_id: &str, user_x: i
} }
} }
fn generate_images() -> (RgbaImage, RgbaImage, i32) { /// 生成背景:渐变底 + 随机纹理线条 + 随机纹理圆点
let mut rng = rand::thread_rng(); ///
/// 注意:按固定顺序消耗随机数(渐变 → 线条 → 圆点),
/// 调用方随后取拼图位置,保持与拆分前一致的随机序列。
fn generate_background(rng: &mut impl Rng) -> RgbaImage {
// 1. 渐变背景 // 1. 渐变背景
let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255]; let c1 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255]; let c2 = [rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, rng.r#gen::<u8>() % 180 + 50, 255];
@ -127,11 +129,12 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) {
fill_circle(&mut bg, cx, cy, radius, color); fill_circle(&mut bg, cx, cy, radius, color);
} }
// 4. 拼图块位置 bg
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2; }
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
// 5. 提取拼图块像素 /// 按拼图轮廓从背景中提取原始像素块
fn extract_piece_pixels(bg: &RgbaImage, piece_x: u32, piece_y: i32) -> RgbaImage {
// 提取拼图块像素
let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32); let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32);
for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); } for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); }
for py in 0..PIECE_SIZE { for py in 0..PIECE_SIZE {
@ -141,8 +144,11 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) {
} }
} }
} }
raw
}
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移) /// 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
fn build_piece_with_shadow(raw: &RgbaImage) -> RgbaImage {
let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32; let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32;
let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32; let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32;
let mut piece = RgbaImage::new(sw, sh); let mut piece = RgbaImage::new(sw, sh);
@ -170,6 +176,25 @@ fn generate_images() -> (RgbaImage, RgbaImage, i32) {
} }
} }
piece
}
fn generate_images() -> (RgbaImage, RgbaImage, i32) {
let mut rng = rand::thread_rng();
// 1-3. 渐变背景 + 纹理线条 + 纹理圆点
let mut bg = generate_background(&mut rng);
// 4. 拼图块位置
let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2;
let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32;
// 5. 提取拼图块像素
let raw = extract_piece_pixels(&bg, piece_x, piece_y);
// 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移)
let piece = build_piece_with_shadow(&raw);
// 7. 背景画缺口 // 7. 背景画缺口
draw_hole(&mut bg, piece_x as i32, piece_y); draw_hole(&mut bg, piece_x as i32, piece_y);

View File

@ -510,8 +510,67 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
Ok(()) Ok(())
} }
/// 插入默认角色并分配权限 /// 创建默认角色并分配权限
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
insert_default_roles(pool).await?;
// 为总管理员分配所有权限
let super_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'super_admin'"
)
.fetch_optional(pool)
.await?;
if let Some((role_id,)) = super_admin_id {
grant_all_permissions(pool, role_id).await?;
}
// 为企业管理员分配非系统管理权限
let org_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'org_admin'"
)
.fetch_optional(pool)
.await?;
let org_perm_codes = [
"device:view", "device:operate", "device:create", "device:edit",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "charge_record:export",
"device_log:view", "energy:view", "energy:export",
"user:view", "user:create", "user:edit",
"operation_log:view",
// 组织管理
"org:view", "org:create", "org:edit", "org:delete",
// 项目管理
"project:view", "project:create", "project:edit", "project:delete",
];
if let Some((role_id,)) = org_admin_id {
grant_permissions_by_codes(pool, role_id, &org_perm_codes).await?;
}
// 为操作员分配查看和操作权限
let normal_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'normal_user'"
)
.fetch_optional(pool)
.await?;
let normal_perm_codes = [
"device:view", "device:operate",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "device_log:view", "energy:view",
];
if let Some((role_id,)) = normal_id {
grant_permissions_by_codes(pool, role_id, &normal_perm_codes).await?;
}
tracing::info!("默认角色及权限分配完成");
Ok(())
}
/// 创建三个默认角色(INSERT IGNORE,已存在则跳过)
async fn insert_default_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 创建总管理员角色 // 创建总管理员角色
sqlx::query( sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)" "INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
@ -539,22 +598,46 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
.execute(pool) .execute(pool)
.await?; .await?;
// 为总管理员分配所有权限 Ok(())
let super_admin_id: Option<(i64,)> = sqlx::query_as( }
"SELECT id FROM roles WHERE name = 'super_admin'"
/// 为指定角色分配全部权限(授权总管理员)
async fn grant_all_permissions(pool: &MySqlPool, role_id: i64) -> Result<(), sqlx::Error> {
// 查询所有权限ID
let perm_ids: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions"
) )
.fetch_optional(pool) .fetch_all(pool)
.await?; .await?;
if let Some((role_id,)) = super_admin_id { for (perm_id,) in perm_ids {
// 查询所有权限ID sqlx::query(
let perm_ids: Vec<(i64,)> = sqlx::query_as( "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
"SELECT id FROM permissions"
) )
.fetch_all(pool) .bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
Ok(())
}
/// 按权限码逐个为指定角色授权(权限码不存在则跳过)
async fn grant_permissions_by_codes(
pool: &MySqlPool,
role_id: i64,
codes: &[&str],
) -> Result<(), sqlx::Error> {
for code in codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?; .await?;
for (perm_id,) in perm_ids { if let Some((perm_id,)) = perm {
sqlx::query( sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)" "INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
) )
@ -565,81 +648,5 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
} }
} }
// 为企业管理员分配非系统管理权限
let org_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'org_admin'"
)
.fetch_optional(pool)
.await?;
let org_perm_codes = [
"device:view", "device:operate", "device:create", "device:edit",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "charge_record:export",
"device_log:view", "energy:view", "energy:export",
"user:view", "user:create", "user:edit",
"operation_log:view",
// 组织管理
"org:view", "org:create", "org:edit", "org:delete",
// 项目管理
"project:view", "project:create", "project:edit", "project:delete",
];
if let Some((role_id,)) = org_admin_id {
for code in &org_perm_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
}
// 为操作员分配查看和操作权限
let normal_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'normal_user'"
)
.fetch_optional(pool)
.await?;
let normal_perm_codes = [
"device:view", "device:operate",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "device_log:view", "energy:view",
];
if let Some((role_id,)) = normal_id {
for code in &normal_perm_codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
}
tracing::info!("默认角色及权限分配完成");
Ok(()) Ok(())
} }

View File

@ -21,18 +21,15 @@ use tracing_subscriber::EnvFilter;
use commands::{AppState, NodeRegistry, PendingCommands}; use commands::{AppState, NodeRegistry, PendingCommands};
#[tokio::main] /// 初始化日志订阅器(默认级别 info,可通过 RUST_LOG 覆盖)
async fn main() { fn init_tracing() {
// 初始化日志
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.init(); .init();
}
// 加载配置 /// 初始化 MySQL 连接池并执行自动迁移(失败即退出,启动阶段不降级)
let cfg = config::Config::from_env(); async fn init_database(cfg: &config::Config) -> sqlx::MySqlPool {
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池
let db_pool = db::init_pool(&cfg.database_url) let db_pool = db::init_pool(&cfg.database_url)
.await .await
.expect("MySQL 连接失败"); .expect("MySQL 连接失败");
@ -44,62 +41,64 @@ async fn main() {
.expect("数据库迁移失败"); .expect("数据库迁移失败");
tracing::info!("数据库迁移完成"); tracing::info!("数据库迁移完成");
// 初始化 Redis 连接 db_pool
}
/// 初始化 Redis 连接管理器(失败即退出)
async fn init_redis(cfg: &config::Config) -> ::redis::aio::ConnectionManager {
let redis_conn = redis::init_connection(&cfg.redis_url) let redis_conn = redis::init_connection(&cfg.redis_url)
.await .await
.expect("Redis 连接失败"); .expect("Redis 连接失败");
tracing::info!("Redis 连接成功"); tracing::info!("Redis 连接成功");
redis_conn
}
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退) /// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
let jwt_secret: Arc<str> = std::env::var("JWT_SECRET") fn load_jwt_secret() -> Arc<str> {
std::env::var("JWT_SECRET")
.expect("JWT_SECRET environment variable is required") .expect("JWT_SECRET environment variable is required")
.into(); .into()
}
// 初始化共享状态
let pending_commands = PendingCommands::new();
let nodes = NodeRegistry::new(redis_conn.clone());
let state = AppState {
redis: redis_conn.clone(),
mysql: db_pool.clone(),
pending_commands: pending_commands.clone(),
nodes: nodes.clone(),
redis_url: cfg.redis_url.clone(),
jwt_secret,
};
/// 启动全部后台协程:设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留
fn spawn_workers(cfg: &config::Config, state: &AppState) {
// 启动后台协程 — 处理设备上报 // 启动后台协程 — 处理设备上报
let report_redis = redis_conn.clone(); let report_redis = state.redis.clone();
let report_mysql = db_pool.clone(); let report_mysql = state.mysql.clone();
tokio::spawn(async move { tokio::spawn(async move {
workers::report_worker::start(report_redis, report_mysql).await; workers::report_worker::start(report_redis, report_mysql).await;
}); });
// 启动后台协程 — 处理指令响应 // 启动后台协程 — 处理指令响应
let reply_redis = redis_conn.clone(); let reply_redis = state.redis.clone();
let reply_pending = state.pending_commands.clone();
tokio::spawn(async move { tokio::spawn(async move {
workers::reply_worker::start(reply_redis, pending_commands).await; workers::reply_worker::start(reply_redis, reply_pending).await;
}); });
// 启动后台协程 — 节点心跳超时检测 // 启动后台协程 — 节点心跳超时检测
let checker_nodes = nodes.clone(); let checker_nodes = state.nodes.clone();
let heartbeat_timeout = cfg.node_heartbeat_timeout; let heartbeat_timeout = cfg.node_heartbeat_timeout;
tokio::spawn(async move { tokio::spawn(async move {
workers::node_checker::run(checker_nodes, heartbeat_timeout).await; workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
}); });
// 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录) // 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录)
let timeout_mysql = db_pool.clone(); let timeout_mysql = state.mysql.clone();
let timeout_redis = redis_conn.clone(); let timeout_redis = state.redis.clone();
tokio::spawn(async move { tokio::spawn(async move {
workers::order_timeout::start(timeout_mysql, timeout_redis).await; workers::order_timeout::start(timeout_mysql, timeout_redis).await;
}); });
// 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS) // 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS)
let retention_mysql = db_pool.clone(); let retention_mysql = state.mysql.clone();
tokio::spawn(async move { tokio::spawn(async move {
workers::log_retention::start(retention_mysql).await; workers::log_retention::start(retention_mysql).await;
}); });
}
/// 构建路由并启动 HTTP 服务(阻塞至进程退出)
async fn serve_http(cfg: &config::Config, state: AppState) {
// 构建 HTTP 路由 // 构建 HTTP 路由
let app = routes::build(state); let app = routes::build(state);
@ -116,3 +115,38 @@ async fn main() {
.await .await
.expect("服务器运行失败"); .expect("服务器运行失败");
} }
#[tokio::main]
async fn main() {
// 初始化日志
init_tracing();
// 加载配置
let cfg = config::Config::from_env();
tracing::info!("配置加载完成");
// 初始化 MySQL 连接池 + 自动迁移
let db_pool = init_database(&cfg).await;
// 初始化 Redis 连接
let redis_conn = init_redis(&cfg).await;
// 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退)
let jwt_secret = load_jwt_secret();
// 初始化共享状态
let state = AppState {
redis: redis_conn.clone(),
mysql: db_pool.clone(),
pending_commands: PendingCommands::new(),
nodes: NodeRegistry::new(redis_conn),
redis_url: cfg.redis_url.clone(),
jwt_secret,
};
// 启动后台协程(设备上报 / 指令响应 / 节点心跳 / 订单超时 / 日志保留)
spawn_workers(&cfg, &state);
// 构建路由并启动 HTTP 服务
serve_http(&cfg, state).await;
}

View File

@ -244,37 +244,34 @@ struct PhoneInfo {
country_code: String, country_code: String,
} }
/// POST /api/auth/wx-login — 微信小程序手机号快速登录 /// 读取微信小程序凭证(环境变量缺失则报内部错误)
/// fn wechat_credentials() -> Result<(String, String), AppError> {
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
pub async fn wx_login(
State(state): State<AppState>,
Json(input): Json<WxLoginInput>,
) -> Result<Json<Value>, AppError> {
let appid = std::env::var("WECHAT_APPID") let appid = std::env::var("WECHAT_APPID")
.map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?; .map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?;
let secret = std::env::var("WECHAT_APPSECRET") let secret = std::env::var("WECHAT_APPSECRET")
.map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?; .map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?;
Ok((appid, secret))
}
let client = Client::new(); /// 用 wx.login() 的 code 换取 openid
async fn fetch_openid(client: &Client, appid: &str, secret: &str, code: &str) -> Result<String, AppError> {
// 1. 用 code 换取 openid
let jscode_url = format!( let jscode_url = format!(
"https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code", "https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code",
appid, secret, input.code appid, secret, code
); );
let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await
.map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))? .map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))?
.json().await .json().await
.map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?; .map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?;
let openid = js_resp.openid.ok_or_else(|| { js_resp.openid.ok_or_else(|| {
let err = js_resp.errmsg.unwrap_or_default(); let err = js_resp.errmsg.unwrap_or_default();
AppError::Internal(format!("jscode2session 失败: {}", err)) AppError::Internal(format!("jscode2session 失败: {}", err))
})?; })
}
// 2. 获取 access_token /// 获取微信接口调用凭证 access_token
async fn fetch_access_token(client: &Client, appid: &str, secret: &str) -> Result<String, AppError> {
let token_url = format!( let token_url = format!(
"https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential", "https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential",
appid, secret appid, secret
@ -284,18 +281,24 @@ pub async fn wx_login(
.json().await .json().await
.map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?; .map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?;
let access_token = token_resp.access_token.ok_or_else(|| { token_resp.access_token.ok_or_else(|| {
let err = token_resp.errmsg.unwrap_or_default(); let err = token_resp.errmsg.unwrap_or_default();
AppError::Internal(format!("获取 access_token 失败: {}", err)) AppError::Internal(format!("获取 access_token 失败: {}", err))
})?; })
}
// 3. 用 phone_code 换取手机号 /// 用 phone_code 换取微信绑定的手机号
async fn fetch_phone_number(
client: &Client,
access_token: &str,
phone_code: &str,
) -> Result<String, AppError> {
let phone_url = format!( let phone_url = format!(
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}", "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}",
access_token access_token
); );
let phone_resp: PhoneNumberResp = client.post(&phone_url) let phone_resp: PhoneNumberResp = client.post(&phone_url)
.json(&json!({ "code": input.phone_code })) .json(&json!({ "code": phone_code }))
.send().await .send().await
.map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))? .map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))?
.json().await .json().await
@ -306,22 +309,51 @@ pub async fn wx_login(
let err = phone_resp.errmsg.unwrap_or_default(); let err = phone_resp.errmsg.unwrap_or_default();
AppError::Internal(format!("获取手机号失败: {}", err)) AppError::Internal(format!("获取手机号失败: {}", err))
})?; })?;
let phone_number = phone.pure_phone_number; Ok(phone.pure_phone_number)
}
// 4. 按手机号查找用户(手机号是唯一标识) /// 按手机号查找用户(手机号是唯一标识)
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户"); async fn find_user_by_phone(
pool: &sqlx::MySqlPool,
phone_number: &str,
) -> Result<(i64, Option<String>, String, i32, i32, Option<i64>), AppError> {
let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>( let user_row = sqlx::query_as::<_, (i64, Option<String>, String, i32, i32, Option<i64>)>(
"SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?", "SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?",
) )
.bind(&phone_number) .bind(phone_number)
.fetch_optional(&state.mysql) .fetch_optional(pool)
.await?; .await?;
user_row.ok_or_else(|| {
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
})
}
/// POST /api/auth/wx-login — 微信小程序手机号快速登录
///
/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code,
/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。
pub async fn wx_login(
State(state): State<AppState>,
Json(input): Json<WxLoginInput>,
) -> Result<Json<Value>, AppError> {
let (appid, secret) = wechat_credentials()?;
let client = Client::new();
// 1. 用 code 换取 openid
let openid = fetch_openid(&client, &appid, &secret, &input.code).await?;
// 2. 获取 access_token
let access_token = fetch_access_token(&client, &appid, &secret).await?;
// 3. 用 phone_code 换取手机号
let phone_number = fetch_phone_number(&client, &access_token, &input.phone_code).await?;
// 4. 按手机号查找用户(手机号是唯一标识)
tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户");
let (id, name, _phone, status, role_level, organization_id) = let (id, name, _phone, status, role_level, organization_id) =
user_row.ok_or_else(|| { find_user_by_phone(&state.mysql, &phone_number).await?;
tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户");
AppError::Unauthorized("用户不存在,请联系管理员添加".into())
})?;
// 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息) // 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息)
sqlx::query("UPDATE users SET openid = ? WHERE id = ?") sqlx::query("UPDATE users SET openid = ? WHERE id = ?")

View File

@ -52,6 +52,117 @@ struct BatteryRow {
created_at: Option<NaiveDateTime>, created_at: Option<NaiveDateTime>,
} }
/// 构建列表 / 计数 SQL
///
/// 仅判断筛选条件是否存在以拼接 SQL,具体取值由调用方绑定。
fn build_battery_sql(params: &BatteryQuery) -> (String, String) {
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
if params.status.is_some() {
query.push_str(" AND status = ?");
count_query.push_str(" AND status = ?");
}
if params.battery_id.is_some() {
query.push_str(" AND battery_id LIKE ?");
count_query.push_str(" AND battery_id LIKE ?");
}
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?");
(query, count_query)
}
/// 按筛选条件统计电池总数
async fn count_batteries(
pool: &sqlx::MySqlPool,
count_query: &str,
status: Option<i8>,
battery_id: Option<&str>,
) -> Result<i64, sqlx::Error> {
let total: i64 = if let Some(s) = status {
if let Some(bid) = battery_id {
sqlx::query_scalar(count_query)
.bind(s)
.bind(format!("%{}%", bid))
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(count_query)
.bind(s)
.fetch_one(pool)
.await?
}
} else if let Some(bid) = battery_id {
sqlx::query_scalar(count_query)
.bind(format!("%{}%", bid))
.fetch_one(pool)
.await?
} else {
sqlx::query_scalar(count_query)
.fetch_one(pool)
.await?
};
Ok(total)
}
/// 按筛选条件查询当前页电池行
async fn fetch_battery_rows(
pool: &sqlx::MySqlPool,
query: &str,
status: Option<i8>,
battery_id: Option<&str>,
page_size: i64,
offset: i64,
) -> Result<Vec<BatteryRow>, sqlx::Error> {
let rows: Vec<BatteryRow> = if let Some(s) = status {
if let Some(bid) = battery_id {
sqlx::query_as(query)
.bind(s)
.bind(format!("%{}%", bid))
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(query)
.bind(s)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
}
} else if let Some(bid) = battery_id {
sqlx::query_as(query)
.bind(format!("%{}%", bid))
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
} else {
sqlx::query_as(query)
.bind(page_size)
.bind(offset)
.fetch_all(pool)
.await?
};
Ok(rows)
}
/// 将数据库行映射为响应项列表
fn batteries_to_items(rows: Vec<BatteryRow>) -> Vec<BatteryItem> {
rows.into_iter().map(|r| BatteryItem {
id: r.id,
battery_id: r.battery_id,
soc: r.soc,
brand: r.brand,
model: r.model,
sn: r.sn,
status: r.status,
status_text: status_text(r.status).to_string(),
created_at: r.created_at,
}).collect()
}
/// GET /api/batteries — 电池台账列表 /// GET /api/batteries — 电池台账列表
pub async fn list_batteries( pub async fn list_batteries(
user: CurrentUser, user: CurrentUser,
@ -64,88 +175,22 @@ pub async fn list_batteries(
let page_size = params.page_size.unwrap_or(20).min(100); let page_size = params.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size; let offset = (page - 1) * page_size;
let mut query = "SELECT * FROM batteries WHERE 1=1".to_string();
let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string();
// 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定 // 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定
if params.status.is_some() { let (query, count_query) = build_battery_sql(&params);
query.push_str(" AND status = ?"); let battery_id = params.battery_id.as_deref();
count_query.push_str(" AND status = ?");
}
if params.battery_id.is_some() {
query.push_str(" AND battery_id LIKE ?");
count_query.push_str(" AND battery_id LIKE ?");
}
query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); let total = count_batteries(&state.mysql, &count_query, params.status, battery_id).await?;
let rows = fetch_battery_rows(
&state.mysql,
&query,
params.status,
battery_id,
page_size,
offset,
)
.await?;
let total: i64 = if let Some(s) = params.status { let items = batteries_to_items(rows);
if let Some(ref bid) = params.battery_id {
sqlx::query_scalar(&count_query)
.bind(s)
.bind(format!("%{}%", bid))
.fetch_one(&state.mysql)
.await?
} else {
sqlx::query_scalar(&count_query)
.bind(s)
.fetch_one(&state.mysql)
.await?
}
} else if let Some(ref bid) = params.battery_id {
sqlx::query_scalar(&count_query)
.bind(format!("%{}%", bid))
.fetch_one(&state.mysql)
.await?
} else {
sqlx::query_scalar(&count_query)
.fetch_one(&state.mysql)
.await?
};
let rows: Vec<BatteryRow> = if let Some(s) = params.status {
if let Some(ref bid) = params.battery_id {
sqlx::query_as(&query)
.bind(s)
.bind(format!("%{}%", bid))
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
} else {
sqlx::query_as(&query)
.bind(s)
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
}
} else if let Some(ref bid) = params.battery_id {
sqlx::query_as(&query)
.bind(format!("%{}%", bid))
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
} else {
sqlx::query_as(&query)
.bind(page_size)
.bind(offset)
.fetch_all(&state.mysql)
.await?
};
let items: Vec<BatteryItem> = rows.into_iter().map(|r| BatteryItem {
id: r.id,
battery_id: r.battery_id,
soc: r.soc,
brand: r.brand,
model: r.model,
sn: r.sn,
status: r.status,
status_text: status_text(r.status).to_string(),
created_at: r.created_at,
}).collect();
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,

View File

@ -8,6 +8,7 @@ use axum::extract::{State, Query};
use axum::Json; use axum::Json;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap;
use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode}; use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
use crate::error::AppError; use crate::error::AppError;
@ -61,6 +62,57 @@ impl From<CabinetSqlRow> for CabinetRow {
} }
} }
// ---------------------------------------------------------------------------
// SQL 常量
//
// 说明:以下 SQL 均由原函数体**原样搬移**,未改动任何 SELECT 字段列表、
// 子查询、JOIN 条件与 WHERE 条件(含 CAST(... AS CHAR) 与 DBX 占位符 `?`)。
// ---------------------------------------------------------------------------
/// 全部组织(组织树用)
const ORGS_ALL_SQL: &str = "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id";
/// 全部项目(经纬度转 CHAR,规避 sqlx 的 DECIMAL 解码问题)
const PROJECTS_ALL_SQL: &str = "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id";
/// 各项目下的设备数量
const PROJECT_CABINET_COUNTS_SQL: &str =
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id";
/// 设备列表基础 SQL:联表查询获取通道状态统计(子查询 idle_count/charging_count/... 原样保留)
const CABINET_FILTER_BASE_SQL: &str = "
SELECT
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
c.status,
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
(SELECT COUNT(*) FROM cabin_boards cb2
JOIN compartments comp ON comp.cabin_board_id = cb2.id
WHERE cb2.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM cabin_boards cb3
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
(SELECT COUNT(*) FROM cabin_boards cb4
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
(SELECT COUNT(*) FROM cabin_boards cb5
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
(SELECT COUNT(*) FROM cabin_boards cb6
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
FROM cabinets c
";
/// 设备归属校验(非总管理员):设备必须属于当前用户组织
const DEVICE_ACCESS_SQL: &str = "SELECT EXISTS(SELECT 1 FROM cabinets c \
JOIN projects p ON c.project_id = p.id \
WHERE c.imei = ? AND p.organization_id = ?)";
// ---------------------------------------------------------------------------
// 组织树
// ---------------------------------------------------------------------------
/// 组织树接口 — 返回多级组织->项目树(含设备数量) /// 组织树接口 — 返回多级组织->项目树(含设备数量)
pub async fn organization_tree( pub async fn organization_tree(
user: CurrentUser, user: CurrentUser,
@ -71,99 +123,107 @@ pub async fn organization_tree(
let db = &state.mysql; let db = &state.mysql;
// 获取所有组织 // 获取所有组织
let orgs: Vec<OrganizationRow> = sqlx::query_as( let orgs: Vec<OrganizationRow> = sqlx::query_as(ORGS_ALL_SQL).fetch_all(db).await?;
"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"
)
.fetch_all(db)
.await?;
// 获取所有项目 // 获取所有项目
let projects: Vec<ProjectRow> = sqlx::query_as( let projects: Vec<ProjectRow> = sqlx::query_as(PROJECTS_ALL_SQL).fetch_all(db).await?;
"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id"
)
.fetch_all(db)
.await?;
// 获取每个项目的设备数量 // 获取每个项目的设备数量
let project_counts: Vec<(i64, i64)> = sqlx::query_as( let project_count_map = fetch_project_counts(db).await?;
"SELECT project_id, COUNT(*) as cnt FROM cabinets GROUP BY project_id"
)
.fetch_all(db)
.await?;
let project_count_map: std::collections::HashMap<i64, i64> = project_counts.into_iter().collect();
// 递归构建树 // 递归构建树
fn build_tree(
orgs: &[OrganizationRow],
projects: &[ProjectRow],
project_count_map: &std::collections::HashMap<i64, i64>,
parent_id: Option<i64>,
) -> Vec<TreeNode> {
orgs
.iter()
.filter(|o| o.parent_id == parent_id)
.map(|org| {
// 递归获取子组织
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
// 获取该组织下的项目
let org_projects: Vec<TreeNode> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.map(|p| {
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
TreeNode {
id: p.id,
name: format!("{} ({})", p.name, count),
children: None,
abstract_id: None,
imei: None,
status: None,
disabled: None,
address: p.address.clone(),
latitude: p.latitude.clone(),
longitude: p.longitude.clone(),
}
})
.collect();
// 合并子组织和项目
let mut all_children = children;
all_children.extend(org_projects);
// 计算本组织下的总设备数量
let total_count: i64 = all_children.iter()
.map(|c| {
// 从项目名称中提取数量
if let Some(start) = c.name.rfind('(') {
if let Some(end) = c.name.rfind(')') {
c.name[start+1..end].parse().unwrap_or(0)
} else { 0 }
} else { 0 }
})
.sum();
TreeNode {
id: org.id,
name: format!("{} ({})", org.name, total_count),
children: if all_children.is_empty() { None } else { Some(all_children) },
abstract_id: None,
imei: None,
status: None,
disabled: org.disabled,
address: None,
latitude: None,
longitude: None,
}
})
.collect()
}
let tree = build_tree(&orgs, &projects, &project_count_map, None); let tree = build_tree(&orgs, &projects, &project_count_map, None);
Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" })))
} }
/// 查询「项目ID -> 设备数量」映射
async fn fetch_project_counts(db: &sqlx::MySqlPool) -> Result<HashMap<i64, i64>, AppError> {
let project_counts: Vec<(i64, i64)> = sqlx::query_as(PROJECT_CABINET_COUNTS_SQL)
.fetch_all(db)
.await?;
Ok(project_counts.into_iter().collect())
}
/// 递归构建「组织 -> 项目」树节点,项目名称携带设备数量
fn build_tree(
orgs: &[OrganizationRow],
projects: &[ProjectRow],
project_count_map: &HashMap<i64, i64>,
parent_id: Option<i64>,
) -> Vec<TreeNode> {
orgs.iter()
.filter(|o| o.parent_id == parent_id)
.map(|org| {
// 递归获取子组织
let children = build_tree(orgs, projects, project_count_map, Some(org.id));
// 获取该组织下的项目
let org_projects: Vec<TreeNode> = projects
.iter()
.filter(|p| p.organization_id == Some(org.id))
.map(|p| {
let count = project_count_map.get(&p.id).copied().unwrap_or(0);
TreeNode {
id: p.id,
name: format!("{} ({})", p.name, count),
children: None,
abstract_id: None,
imei: None,
status: None,
disabled: None,
address: p.address.clone(),
latitude: p.latitude.clone(),
longitude: p.longitude.clone(),
}
})
.collect();
// 合并子组织和项目
let mut all_children = children;
all_children.extend(org_projects);
// 计算本组织下的总设备数量
let total_count: i64 = all_children
.iter()
.map(|c| {
// 从项目名称中提取数量
if let Some(start) = c.name.rfind('(') {
if let Some(end) = c.name.rfind(')') {
c.name[start + 1..end].parse().unwrap_or(0)
} else {
0
}
} else {
0
}
})
.sum();
TreeNode {
id: org.id,
name: format!("{} ({})", org.name, total_count),
children: if all_children.is_empty() {
None
} else {
Some(all_children)
},
abstract_id: None,
imei: None,
status: None,
disabled: org.disabled,
address: None,
latitude: None,
longitude: None,
}
})
.collect()
}
// ---------------------------------------------------------------------------
// 设备列表筛选
// ---------------------------------------------------------------------------
/// 设备列表筛选参数 /// 设备列表筛选参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CabinetFilterParams { pub struct CabinetFilterParams {
@ -188,32 +248,37 @@ pub async fn filter_cabinets(
let page_size = params.page_size.unwrap_or(12).clamp(1, 100); let page_size = params.page_size.unwrap_or(12).clamp(1, 100);
let offset = (page - 1) * page_size; let offset = (page - 1) * page_size;
// 基础SQL:联表查询获取通道状态统计 // 组织/项目过滤 + 已绑定/未绑定过滤
let base_sql = " let (where_clause, binds) = build_filter_where(&params, &user);
SELECT
c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address,
(SELECT address FROM projects WHERE id = c.project_id) as project_address,
c.status,
(SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count,
(SELECT COUNT(*) FROM cabin_boards cb2
JOIN compartments comp ON comp.cabin_board_id = cb2.id
WHERE cb2.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM cabin_boards cb3
JOIN compartments comp2 ON comp2.cabin_board_id = cb3.id
WHERE cb3.cabinet_id = c.id AND comp2.status = 0) as idle_count,
(SELECT COUNT(*) FROM cabin_boards cb4
JOIN compartments comp3 ON comp3.cabin_board_id = cb4.id
WHERE cb4.cabinet_id = c.id AND comp3.status = 1) as charging_count,
(SELECT COUNT(*) FROM cabin_boards cb5
JOIN compartments comp4 ON comp4.cabin_board_id = cb5.id
WHERE cb5.cabinet_id = c.id AND comp4.status = 2) as full_count,
(SELECT COUNT(*) FROM cabin_boards cb6
JOIN compartments comp5 ON comp5.cabin_board_id = cb6.id
WHERE cb6.cabinet_id = c.id AND comp5.status = 3) as fault_count
FROM cabinets c
";
let (mut where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id { // 总数量
let total = fetch_filter_total(db, &where_clause, &binds).await;
if total == 0 || offset >= total {
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" })));
}
// 分页数据
let sql_rows = fetch_filter_page(db, &where_clause, &binds, page_size, offset).await?;
// 转换为CabinetRow
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect();
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返)
attach_realtime_status(&state, &mut rows).await?;
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标)
attach_org_disabled(db, &mut rows).await;
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
}
/// 构造设备列表的 WHERE/JOIN 片段及其绑定参数(分支顺序与语义与原实现一致)
fn build_filter_where(
params: &CabinetFilterParams,
user: &CurrentUser,
) -> (String, Vec<String>) {
let (where_clause, binds): (String, Vec<String>) = if let Some(proj_id) = params.project_id {
("WHERE c.project_id = ?".into(), vec![proj_id.to_string()]) ("WHERE c.project_id = ?".into(), vec![proj_id.to_string()])
} else if let Some(org_id) = params.organization_id { } else if let Some(org_id) = params.organization_id {
("JOIN projects p ON c.project_id = p.id ("JOIN projects p ON c.project_id = p.id
@ -230,121 +295,144 @@ pub async fn filter_cabinets(
}; };
// 已绑定/未绑定过滤(绑定 = 分配到项目) // 已绑定/未绑定过滤(绑定 = 分配到项目)
if let Some(bound) = params.bound { let where_clause = match params.bound.as_deref() {
match bound.as_str() { Some("1") => append_bound_filter(where_clause, "WHERE c.project_id IS NOT NULL", " AND c.project_id IS NOT NULL"),
"1" => { Some("0") => append_bound_filter(where_clause, "WHERE c.project_id IS NULL", " AND c.project_id IS NULL"),
if where_clause.is_empty() { _ => where_clause,
where_clause = "WHERE c.project_id IS NOT NULL".into(); };
} else {
where_clause.push_str(" AND c.project_id IS NOT NULL");
}
}
"0" => {
if where_clause.is_empty() {
where_clause = "WHERE c.project_id IS NULL".into();
} else {
where_clause.push_str(" AND c.project_id IS NULL");
}
}
_ => {}
}
}
// 总数量 (where_clause, binds)
}
/// 追加「已绑定/未绑定」条件:片段为空时用 `where_new`,否则用 `and_tail` 续接
fn append_bound_filter(where_clause: String, where_new: &str, and_tail: &str) -> String {
if where_clause.is_empty() {
where_new.to_string()
} else {
let mut clause = where_clause;
clause.push_str(and_tail);
clause
}
}
/// 统计符合条件的设备总数(查询失败按 0 处理,与原实现一致)
async fn fetch_filter_total(db: &sqlx::MySqlPool, where_clause: &str, binds: &[String]) -> i64 {
let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause); let count_sql = format!("SELECT COUNT(*) FROM cabinets c {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for b in &binds { for b in binds {
count_query = count_query.bind(b); count_query = count_query.bind(b);
} }
let total: i64 = count_query.fetch_one(db).await.unwrap_or(0); count_query.fetch_one(db).await.unwrap_or(0)
}
if total == 0 || offset >= total { /// 查询当前页设备行(分页参数由 WHERE 之后的 LIMIT ? OFFSET ? 绑定)
return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" }))); async fn fetch_filter_page(
} db: &sqlx::MySqlPool,
where_clause: &str,
// 分页数据 binds: &[String],
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", base_sql, where_clause); page_size: i64,
offset: i64,
) -> Result<Vec<CabinetSqlRow>, AppError> {
let data_sql = format!("{} {} ORDER BY c.id LIMIT ? OFFSET ?", CABINET_FILTER_BASE_SQL, where_clause);
let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql); let mut data_query = sqlx::query_as::<_, CabinetSqlRow>(&data_sql);
for b in &binds { for b in binds {
data_query = data_query.bind(b); data_query = data_query.bind(b);
} }
let sql_rows: Vec<CabinetSqlRow> = data_query let rows: Vec<CabinetSqlRow> = data_query
.bind(page_size) .bind(page_size)
.bind(offset) .bind(offset)
.fetch_all(db).await?; .fetch_all(db)
.await?;
Ok(rows)
}
// 转换为CabinetRow /// 从 Redis 批量补齐实时状态(Pipeline HGET,1 次网络往返)
let mut rows: Vec<CabinetRow> = sql_rows.into_iter().map(CabinetRow::from).collect(); async fn attach_realtime_status(state: &AppState, rows: &mut [CabinetRow]) -> Result<(), AppError> {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
let mut conn = open_redis_conn(state).await?;
// 从Redis获取实时状态(Pipeline批量HGET,1次网络往返) // 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
{ let mut pipe = redis::pipe();
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { for row in rows.iter() {
AppError::Internal(format!("Redis客户端创建失败: {}", e)) let key = format!("device:{}", row.imei);
})?; pipe.cmd("HGET").arg(&key).arg("online");
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection pipe.cmd("HGET").arg(&key).arg("rssi");
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
AppError::Internal(format!("Redis连接失败: {}", e)) pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
})?;
// 用Pipeline一次发所有HGET命令(每个设备1次HGET取4个字段)
let mut pipe = redis::pipe();
for row in &rows {
let key = format!("device:{}", row.imei);
pipe.cmd("HGET").arg(&key).arg("online");
pipe.cmd("HGET").arg(&key).arg("rssi");
pipe.cmd("HGET").arg(&key).arg("pow_fail_dc");
pipe.cmd("HGET").arg(&key).arg("pow_fail_ac");
}
// 一次发送,获取所有结果
let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
// 每4个一组解析
for (i, row) in rows.iter_mut().enumerate() {
let base = i * 4;
row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
row.rssi = results.get(base + 1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok());
row.pow_fail_dc = results.get(base + 2).and_then(|v| v.as_ref()).map(|v| v == "1");
row.pow_fail_ac = results.get(base + 3).and_then(|v| v.as_ref()).map(|v| v == "1");
}
} }
// 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标) // 一次发送,获取所有结果
{ let results: Vec<Option<String>> = pipe.query_async(&mut conn).await.unwrap_or_default();
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if !project_ids.is_empty() { // 每4个一组解析
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect(); for (i, row) in rows.iter_mut().enumerate() {
let sql = format!( let base = i * 4;
r#"SELECT p.id, o.disabled FROM projects p row.is_online = results.get(base).and_then(|v| v.as_ref()).map(|v| v == "1");
row.rssi = results
.get(base + 1)
.and_then(|v| v.as_ref())
.and_then(|v| v.parse::<i32>().ok());
row.pow_fail_dc = results
.get(base + 2)
.and_then(|v| v.as_ref())
.map(|v| v == "1");
row.pow_fail_ac = results
.get(base + 3)
.and_then(|v| v.as_ref())
.map(|v| v == "1");
}
Ok(())
}
/// 标记柜子所属组织是否被禁用(甲方禁用客户组织 → 前端显示徽标)
async fn attach_org_disabled(db: &sqlx::MySqlPool, rows: &mut [CabinetRow]) {
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
if project_ids.is_empty() {
return;
}
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
r#"SELECT p.id, o.disabled FROM projects p
LEFT JOIN organizations o ON p.organization_id = o.id LEFT JOIN organizations o ON p.organization_id = o.id
WHERE p.id IN ({})"#, WHERE p.id IN ({})"#,
placeholders.join(",") placeholders.join(",")
); );
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
for pid in &project_ids { for pid in &project_ids {
q = q.bind(pid); q = q.bind(pid);
} }
if let Ok(results) = q.fetch_all(db).await { if let Ok(results) = q.fetch_all(db).await {
let disabled_map: std::collections::HashMap<i64, bool> = results let disabled_map: HashMap<i64, bool> =
.into_iter() results.into_iter().map(|(pid, d)| (pid, d == 1)).collect();
.map(|(pid, d)| (pid, d == 1)) for row in rows.iter_mut() {
.collect(); row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
for row in rows.iter_mut() {
row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied();
}
}
} }
} }
Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" })))
} }
// ---------------------------------------------------------------------------
// 设备实时状态
// ---------------------------------------------------------------------------
/// 设备实时状态参数 /// 设备实时状态参数
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct DeviceStatusParams { pub struct DeviceStatusParams {
pub imei: String, pub imei: String,
} }
/// 打开 Redis 多路复用连接(失败按原错误信息返回内部错误)
async fn open_redis_conn(state: &AppState) -> Result<redis::aio::MultiplexedConnection, AppError> {
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| {
AppError::Internal(format!("Redis客户端创建失败: {}", e))
})?;
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
client.get_multiplexed_async_connection().await.map_err(|e| {
AppError::Internal(format!("Redis连接失败: {}", e))
})
}
/// 设备实时状态接口 — 返回板级+通道级状态 /// 设备实时状态接口 — 返回板级+通道级状态
pub async fn device_realtime_status( pub async fn device_realtime_status(
user: CurrentUser, user: CurrentUser,
@ -354,18 +442,37 @@ pub async fn device_realtime_status(
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
// 校验设备归属 // 校验设备归属
ensure_device_access(&state, &user, &params.imei).await?;
let mut conn = open_redis_conn(&state).await?;
let device_status = load_device_status(&mut conn, &params.imei).await;
let boards = load_boards_status(&mut conn, &params.imei).await;
Ok(Json(json!({
"code": 0,
"data": {
"device": device_status,
"boards": boards,
},
"message": "ok"
})))
}
/// 设备归属校验:非总管理员必须能通过「设备 → 项目 → 组织」链路命中本组织
async fn ensure_device_access(
state: &AppState,
user: &CurrentUser,
imei: &str,
) -> Result<(), AppError> {
if user.role_level < 2 { if user.role_level < 2 {
if let Some(org_id) = user.organization_id { if let Some(org_id) = user.organization_id {
let has_access: bool = sqlx::query_scalar( let has_access: bool = sqlx::query_scalar(DEVICE_ACCESS_SQL)
"SELECT EXISTS(SELECT 1 FROM cabinets c \ .bind(imei)
JOIN projects p ON c.project_id = p.id \ .bind(org_id)
WHERE c.imei = ? AND p.organization_id = ?)", .fetch_one(&state.mysql)
) .await
.bind(&params.imei) .unwrap_or(false);
.bind(org_id)
.fetch_one(&state.mysql)
.await
.unwrap_or(false);
if !has_access { if !has_access {
return Err(AppError::Forbidden("无权访问该设备".into())); return Err(AppError::Forbidden("无权访问该设备".into()));
} }
@ -373,38 +480,43 @@ pub async fn device_realtime_status(
return Err(AppError::Forbidden("无权访问该设备".into())); return Err(AppError::Forbidden("无权访问该设备".into()));
} }
} }
Ok(())
}
let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { /// 读取设备级字段(online / rssi / pow_fail_dc / pow_fail_ac)
AppError::Internal(format!("Redis客户端创建失败: {}", e)) async fn load_device_status(
})?; conn: &mut redis::aio::MultiplexedConnection,
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection imei: &str,
let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { ) -> Value {
AppError::Internal(format!("Redis连接失败: {}", e)) let device_key = format!("device:{}", imei);
})?;
let device_key = format!("device:{}", params.imei);
// 获取设备级字段 // 获取设备级字段
let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"]; let fields = vec!["online", "rssi", "pow_fail_dc", "pow_fail_ac"];
let values: Vec<Option<String>> = redis::cmd("HMGET") let values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(&device_key) .arg(&device_key)
.arg(&fields) .arg(&fields)
.query_async(&mut conn) .query_async(&mut *conn)
.await .await
.unwrap_or_default(); .unwrap_or_default();
let device_status = json!({ json!({
"online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), "online": values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
"rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()), "rssi": values.get(1).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()),
"pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), "pow_fail_dc": values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
"pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false), "pow_fail_ac": values.get(3).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false),
}); })
}
/// 扫描并组装全部仓控板状态(板级 + 通道级)
async fn load_boards_status(
conn: &mut redis::aio::MultiplexedConnection,
imei: &str,
) -> Value {
// 扫描板级key: device:{imei}:board:* // 扫描板级key: device:{imei}:board:*
let board_pattern = format!("device:{}:board:*", params.imei); let board_pattern = format!("device:{}:board:*", imei);
let board_keys: Vec<String> = redis::cmd("KEYS") let board_keys: Vec<String> = redis::cmd("KEYS")
.arg(&board_pattern) .arg(&board_pattern)
.query_async(&mut conn) .query_async(&mut *conn)
.await .await
.unwrap_or_default(); .unwrap_or_default();
@ -416,77 +528,98 @@ pub async fn device_realtime_status(
continue; continue;
} }
// 获取板级字段 boards[board_idx] = load_single_board(conn, board_key).await;
let board_fields = vec!["status_hex"];
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(board_key)
.arg(&board_fields)
.query_async(&mut conn)
.await
.unwrap_or_default();
let mut board_data = json!({
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
});
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
let ch_pattern = format!("{}:ch:*", board_key);
let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut conn)
.await
.unwrap_or_default();
let mut channels = json!({});
for ch_key in &ch_keys {
let ch_idx = ch_key.split(":").last().unwrap_or("");
if ch_idx.is_empty() {
continue;
}
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
.query_async(&mut conn)
.await
.unwrap_or_default();
// 状态映射:fault > full > on > idle
let fault = ch_values.get(0).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
let full = ch_values.get(1).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
let on = ch_values.get(2).and_then(|v| v.as_ref()).map(|v| v == "1").unwrap_or(false);
let status = if fault {
"fault"
} else if full {
"full"
} else if on {
"charging"
} else {
"idle"
};
channels[ch_idx] = json!({
"status": status,
"on": on,
"full": full,
"fault": fault,
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
});
}
board_data["channels"] = channels;
boards[board_idx] = board_data;
} }
Ok(Json(json!({ boards
"code": 0, }
"data": {
"device": device_status, /// 读取单块仓控板的板级字段,并扫描其下的通道级 key
"boards": boards, async fn load_single_board(
}, conn: &mut redis::aio::MultiplexedConnection,
"message": "ok" board_key: &str,
}))) ) -> Value {
// 获取板级字段
let board_fields = vec!["status_hex"];
let board_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(board_key)
.arg(&board_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let mut board_data = json!({
"status_hex": board_values.get(0).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
});
// 扫描通道级key: device:{imei}:board:{idx}:ch:*
let ch_pattern = format!("{}:ch:*", board_key);
let ch_keys: Vec<String> = redis::cmd("KEYS")
.arg(&ch_pattern)
.query_async(&mut *conn)
.await
.unwrap_or_default();
let mut channels = json!({});
for ch_key in &ch_keys {
let ch_idx = ch_key.split(":").last().unwrap_or("");
if ch_idx.is_empty() {
continue;
}
channels[ch_idx] = load_channel_status(conn, ch_key).await;
}
board_data["channels"] = channels;
board_data
}
/// 读取单个通道状态(状态映射优先级:fault > full > on > idle)
async fn load_channel_status(
conn: &mut redis::aio::MultiplexedConnection,
ch_key: &str,
) -> Value {
let ch_fields = vec!["on", "full", "fault", "hex", "temp"];
let ch_values: Vec<Option<String>> = redis::cmd("HMGET")
.arg(ch_key)
.arg(&ch_fields)
.query_async(&mut *conn)
.await
.unwrap_or_default();
// 状态映射:fault > full > on > idle
let fault = ch_values
.get(0)
.and_then(|v| v.as_ref())
.map(|v| v == "1")
.unwrap_or(false);
let full = ch_values
.get(1)
.and_then(|v| v.as_ref())
.map(|v| v == "1")
.unwrap_or(false);
let on = ch_values
.get(2)
.and_then(|v| v.as_ref())
.map(|v| v == "1")
.unwrap_or(false);
let status = if fault {
"fault"
} else if full {
"full"
} else if on {
"charging"
} else {
"idle"
};
json!({
"status": status,
"on": on,
"full": full,
"fault": fault,
"hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(),
"temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i64>().ok()),
})
} }

View File

@ -155,6 +155,61 @@ async fn fetch_charge_rows(
query.fetch_all(pool).await query.fetch_all(pool).await
} }
/// 追加组织数据隔离到 WHERE 子句与绑定值
fn append_org_scope(user: &CurrentUser, where_clause: &mut String, bind_values: &mut Vec<String>) {
let (org_cond, org_binds) = auth::org_condition(user, "cr");
if !org_cond.is_empty() {
if where_clause.is_empty() {
*where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
} else {
where_clause.push_str(&org_cond);
}
bind_values.extend(org_binds);
}
}
/// 统计符合条件的充电记录总数(JOIN 链路需与数据查询保持一致)
async fn count_charge_records(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<i64, AppError> {
let count_sql = format!(
"SELECT COUNT(*) FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in bind_values {
count_query = count_query.bind(val);
}
Ok(count_query.fetch_one(pool).await?)
}
/// 将充电记录行映射为响应项列表
fn charge_rows_to_items(rows: Vec<ChargeRecordRow>) -> Vec<ChargeRecordItem> {
rows.into_iter()
.map(|r| ChargeRecordItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
cabin_board_id: r.cabin_board_id.unwrap_or(0),
board_id: r.board_id.unwrap_or(0),
compartment_id: r.compartment_id,
channel_id: r.channel_id.unwrap_or(0),
start_time: r.start_time.map(|t| t.to_string()),
end_time: r.end_time.map(|t| t.to_string()),
energy: r.energy,
max_temperature: r.max_temperature,
status: r.status,
status_text: status_text(r.status).to_string(),
})
.collect()
}
/// GET /api/charge-records — 充电记录分页列表 /// GET /api/charge-records — 充电记录分页列表
pub async fn list_charge_records( pub async fn list_charge_records(
user: CurrentUser, user: CurrentUser,
@ -173,30 +228,10 @@ pub async fn list_charge_records(
); );
// 追加组织数据隔离 // 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "cr"); append_org_scope(&user, &mut where_clause, &mut bind_values);
if !org_cond.is_empty() {
if where_clause.is_empty() {
where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
} else {
where_clause.push_str(&org_cond);
}
bind_values.extend(org_binds);
}
// 查询总数(需要与数据查询保持一致的 JOIN 链路) // 查询总数(需要与数据查询保持一致的 JOIN 链路)
let count_sql = format!( let total = count_charge_records(&state.mysql, &where_clause, &bind_values).await?;
"SELECT COUNT(*) FROM charge_records cr \
LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
{}",
where_clause
);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values {
count_query = count_query.bind(val);
}
let total = count_query.fetch_one(&state.mysql).await?;
// 查询列表 // 查询列表
let rows = fetch_charge_rows( let rows = fetch_charge_rows(
@ -208,24 +243,7 @@ pub async fn list_charge_records(
) )
.await?; .await?;
let list: Vec<ChargeRecordItem> = rows let list = charge_rows_to_items(rows);
.into_iter()
.map(|r| ChargeRecordItem {
id: r.id,
cabinet_id: r.cabinet_id,
cabinet_name: r.cabinet_name,
cabin_board_id: r.cabin_board_id.unwrap_or(0),
board_id: r.board_id.unwrap_or(0),
compartment_id: r.compartment_id,
channel_id: r.channel_id.unwrap_or(0),
start_time: r.start_time.map(|t| t.to_string()),
end_time: r.end_time.map(|t| t.to_string()),
energy: r.energy,
max_temperature: r.max_temperature,
status: r.status,
status_text: status_text(r.status).to_string(),
})
.collect();
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,
@ -307,29 +325,27 @@ fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end) format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
} }
/// 后台异步生成 Excel 文件 /// 读取导出任务的文件名与查询参数
async fn generate_charge_records_excel( ///
pool: sqlx::MySqlPool, /// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
async fn load_export_task(
pool: &sqlx::MySqlPool,
task_id: i64, task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as( let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?" "SELECT file_name, error_message FROM download_tasks WHERE id = ?"
) )
.bind(task_id) .bind(task_id)
.fetch_optional(&pool) .fetch_optional(pool)
.await?; .await?;
let (file_name, query_json) = row.ok_or("任务不存在")?; let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({})); let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
Ok((file_name, params))
}
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
fn build_export_where(params: &Value) -> (String, Vec<String>) {
let (mut where_clause, mut bind_values) = build_where_clause( let (mut where_clause, mut bind_values) = build_where_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()), params.get("cabinet_id").and_then(|v| v.as_i64()),
None, None,
@ -351,15 +367,13 @@ async fn generate_charge_records_excel(
} }
} }
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?; (where_clause, bind_values)
}
// 更新进度 50% /// 构建导出工作簿(表头 + 数据行)
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?") fn build_charge_workbook(
.bind(task_id) rows: &[ChargeRecordRow],
.execute(&pool) ) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new(); let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet(); let sheet = workbook.add_worksheet();
sheet.set_name("充电记录")?; sheet.set_name("充电记录")?;
@ -391,6 +405,54 @@ async fn generate_charge_records_excel(
sheet.write_string(row, 8, status_text(record.status))?; sheet.write_string(row, 8, status_text(record.status))?;
} }
Ok(workbook)
}
/// 将导出任务标记为完成并记录文件路径
async fn mark_task_completed(
pool: &sqlx::MySqlPool,
task_id: i64,
file_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(file_path)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
/// 后台异步生成 Excel 文件
async fn generate_charge_records_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let (file_name, params) = load_export_task(&pool, task_id).await?;
// 组织过滤(非总管理员)+ 查询数据
let (where_clause, bind_values) = build_export_where(&params);
let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = build_charge_workbook(&rows)?;
// 保存文件 // 保存文件
let file_dir = "./exports"; let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?; tokio::fs::create_dir_all(file_dir).await?;
@ -398,14 +460,7 @@ async fn generate_charge_records_excel(
workbook.save(&file_path)?; workbook.save(&file_path)?;
// 更新任务状态为完成 // 更新任务状态为完成
sqlx::query( mark_task_completed(&pool, task_id, &file_path).await?;
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path); tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
Ok(()) Ok(())

View File

@ -59,16 +59,11 @@ struct DeviceLogRow {
created_at: Option<NaiveDateTime>, created_at: Option<NaiveDateTime>,
} }
/// GET /api/device-logs — 设备日志分页列表 /// 构建 WHERE 条件与绑定值(含组织数据隔离)
pub async fn list_device_logs( fn build_device_log_filters(
user: CurrentUser, user: &CurrentUser,
State(state): State<super::AppState>, params: &DeviceLogQuery,
Query(params): Query<DeviceLogQuery>, ) -> (String, Vec<String>) {
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device_log:view")?;
let offset = (params.page - 1) * params.page_size;
// 构建 WHERE 条件
let mut conditions = Vec::new(); let mut conditions = Vec::new();
let mut bind_values: Vec<String> = Vec::new(); let mut bind_values: Vec<String> = Vec::new();
@ -90,7 +85,7 @@ pub async fn list_device_logs(
} }
// 追加组织数据隔离 // 追加组织数据隔离
let (org_cond, org_binds) = auth::org_condition(&user, "dl"); let (org_cond, org_binds) = auth::org_condition(user, "dl");
if !org_cond.is_empty() { if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND ")); conditions.push(org_cond.trim_start_matches(" AND "));
bind_values.extend(org_binds); bind_values.extend(org_binds);
@ -101,19 +96,34 @@ pub async fn list_device_logs(
} else { } else {
format!("WHERE {}", conditions.join(" AND ")) format!("WHERE {}", conditions.join(" AND "))
}; };
(where_clause, bind_values)
}
// 查询总数 /// 统计符合条件的设备日志总数
async fn count_device_logs(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<i64, AppError> {
let count_sql = format!( let count_sql = format!(
"SELECT COUNT(*) FROM device_logs dl {}", "SELECT COUNT(*) FROM device_logs dl {}",
where_clause where_clause
); );
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values { for val in bind_values {
count_query = count_query.bind(val); count_query = count_query.bind(val);
} }
let total = count_query.fetch_one(&state.mysql).await?; Ok(count_query.fetch_one(pool).await?)
}
// 查询列表 /// 查询设备日志行(关联柜名,分页)
async fn fetch_device_log_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
page_size: i64,
offset: i64,
) -> Result<Vec<DeviceLogRow>, AppError> {
let list_sql = format!( let list_sql = format!(
"SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \ "SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
dl.log_type, dl.content, dl.created_at \ dl.log_type, dl.content, dl.created_at \
@ -124,15 +134,17 @@ pub async fn list_device_logs(
); );
let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql); let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
for val in &bind_values { for val in bind_values {
list_query = list_query.bind(val); list_query = list_query.bind(val);
} }
list_query = list_query.bind(params.page_size).bind(offset); list_query = list_query.bind(page_size).bind(offset);
let rows = list_query.fetch_all(&state.mysql).await?; Ok(list_query.fetch_all(pool).await?)
}
let list: Vec<DeviceLogItem> = rows /// 将设备日志行映射为响应项
.into_iter() fn device_logs_to_items(rows: Vec<DeviceLogRow>) -> Vec<DeviceLogItem> {
rows.into_iter()
.map(|r| DeviceLogItem { .map(|r| DeviceLogItem {
id: r.id, id: r.id,
cabinet_id: r.cabinet_id, cabinet_id: r.cabinet_id,
@ -142,7 +154,35 @@ pub async fn list_device_logs(
content: r.content, content: r.content,
created_at: r.created_at.map(|t| t.to_string()), created_at: r.created_at.map(|t| t.to_string()),
}) })
.collect(); .collect()
}
/// GET /api/device-logs — 设备日志分页列表
pub async fn list_device_logs(
user: CurrentUser,
State(state): State<super::AppState>,
Query(params): Query<DeviceLogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "device_log:view")?;
let offset = (params.page - 1) * params.page_size;
// 构建 WHERE 条件
let (where_clause, bind_values) = build_device_log_filters(&user, &params);
// 查询总数
let total = count_device_logs(&state.mysql, &where_clause, &bind_values).await?;
// 查询列表
let rows = fetch_device_log_rows(
&state.mysql,
&where_clause,
&bind_values,
params.page_size,
offset,
)
.await?;
let list = device_logs_to_items(rows);
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,

View File

@ -461,28 +461,27 @@ fn build_export_clause(
(clause, bind_values) (clause, bind_values)
} }
/// 后台异步生成能耗 Excel 文件 /// 读取导出任务的文件名与查询参数
async fn generate_energy_excel( ///
pool: sqlx::MySqlPool, /// 任务参数以 JSON 形式临时存放在 download_tasks.error_message 中。
async fn load_export_task(
pool: &sqlx::MySqlPool,
task_id: i64, task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(String, Value), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let row: Option<(String, String)> = sqlx::query_as( let row: Option<(String, String)> = sqlx::query_as(
"SELECT file_name, error_message FROM download_tasks WHERE id = ?" "SELECT file_name, error_message FROM download_tasks WHERE id = ?"
) )
.bind(task_id) .bind(task_id)
.fetch_optional(&pool) .fetch_optional(pool)
.await?; .await?;
let (file_name, query_json) = row.ok_or("任务不存在")?; let (file_name, query_json) = row.ok_or("任务不存在")?;
let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({})); let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
Ok((file_name, params))
}
/// 按导出参数构建 WHERE 子句与绑定值(含组织过滤)
fn build_export_where(params: &Value) -> (String, Vec<String>) {
let (mut where_clause, mut bind_values) = build_export_clause( let (mut where_clause, mut bind_values) = build_export_clause(
params.get("cabinet_id").and_then(|v| v.as_i64()), params.get("cabinet_id").and_then(|v| v.as_i64()),
params.get("project_id").and_then(|v| v.as_i64()), params.get("project_id").and_then(|v| v.as_i64()),
@ -504,7 +503,15 @@ async fn generate_energy_excel(
} }
} }
// 查询能耗明细 (where_clause, bind_values)
}
/// 查询能耗明细行
async fn fetch_energy_detail_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<Vec<EnergyDetailRow>, sqlx::Error> {
let sql = format!( let sql = format!(
"SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \ "SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
p.name AS project_name, \ p.name AS project_name, \
@ -518,18 +525,16 @@ async fn generate_energy_excel(
); );
let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql); let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
for val in &bind_values { for val in bind_values {
query = query.bind(val); query = query.bind(val);
} }
let rows = query.fetch_all(&pool).await?; query.fetch_all(pool).await
}
// 更新进度 50% /// 构建导出工作簿(表头 + 数据行)
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?") fn build_energy_workbook(
.bind(task_id) rows: &[EnergyDetailRow],
.execute(&pool) ) -> Result<rust_xlsxwriter::Workbook, Box<dyn std::error::Error + Send + Sync>> {
.await?;
// 生成 Excel
let mut workbook = rust_xlsxwriter::Workbook::new(); let mut workbook = rust_xlsxwriter::Workbook::new();
let sheet = workbook.add_worksheet(); let sheet = workbook.add_worksheet();
sheet.set_name("能耗统计")?; sheet.set_name("能耗统计")?;
@ -551,6 +556,54 @@ async fn generate_energy_excel(
sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?; sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
} }
Ok(workbook)
}
/// 将导出任务标记为完成并记录文件路径
async fn mark_task_completed(
pool: &sqlx::MySqlPool,
task_id: i64,
file_path: &str,
) -> Result<(), sqlx::Error> {
sqlx::query(
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(file_path)
.bind(task_id)
.execute(pool)
.await?;
Ok(())
}
/// 后台异步生成能耗 Excel 文件
async fn generate_energy_excel(
pool: sqlx::MySqlPool,
task_id: i64,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 更新状态为处理中
sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 获取查询参数
let (file_name, params) = load_export_task(&pool, task_id).await?;
// 组织过滤(非总管理员)+ 查询能耗明细
let (where_clause, bind_values) = build_export_where(&params);
let rows = fetch_energy_detail_rows(&pool, &where_clause, &bind_values).await?;
// 更新进度 50%
sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
.bind(task_id)
.execute(&pool)
.await?;
// 生成 Excel
let mut workbook = build_energy_workbook(&rows)?;
// 保存文件 // 保存文件
let file_dir = "./exports"; let file_dir = "./exports";
tokio::fs::create_dir_all(file_dir).await?; tokio::fs::create_dir_all(file_dir).await?;
@ -558,14 +611,7 @@ async fn generate_energy_excel(
workbook.save(&file_path)?; workbook.save(&file_path)?;
// 更新任务状态为完成 // 更新任务状态为完成
sqlx::query( mark_task_completed(&pool, task_id, &file_path).await?;
"UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
error_message = NULL, completed_at = NOW() WHERE id = ?"
)
.bind(&file_path)
.bind(task_id)
.execute(&pool)
.await?;
tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path); tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
Ok(()) Ok(())

View File

@ -16,6 +16,7 @@ use axum::Json;
use redis; use redis;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::Write; use std::io::Write;
use uuid::Uuid; use uuid::Uuid;
@ -430,123 +431,128 @@ async fn fetch_fault_alerts(
.collect()) .collect())
} }
// --- 项目摘要 SQL(由函数体原样搬移,WHERE/JOIN/SELECT 未改动) ---
/// 指定组织下的项目
const PROJECTS_BY_ORG_SQL: &str = "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id";
/// 全部项目(总管理员未指定组织时)
const PROJECTS_ALL_SQL: &str = "SELECT id, name FROM projects ORDER BY id";
/// 项目下柜子数量
const PROJECT_CABINET_COUNT_SQL: &str = "SELECT COUNT(*) FROM cabinets WHERE project_id = ?";
/// 项目下充电中仓体数量
const PROJECT_CHARGING_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 1"#;
/// 项目下空闲仓体数量
const PROJECT_IDLE_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 0"#;
/// 项目下故障仓体数量
const PROJECT_FAULT_COUNT_SQL: &str = r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 2"#;
/// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计) /// 查询用户可见的项目摘要(含柜子/充电/空闲/故障统计)
async fn fetch_project_summaries( async fn fetch_project_summaries(
pool: &sqlx::MySqlPool, pool: &sqlx::MySqlPool,
user: &CurrentUser, user: &CurrentUser,
org_id: Option<i64>, org_id: Option<i64>,
) -> Result<Vec<Value>, AppError> { ) -> Result<Vec<Value>, AppError> {
// 获取用户可见的项目(优先使用查询参数 org_id) let projects = fetch_visible_projects(pool, user, org_id).await?;
let mut result = Vec::with_capacity(projects.len());
for p in &projects {
result.push(fetch_project_summary(pool, p).await?);
}
Ok(result)
}
/// 获取用户可见的项目(优先使用查询参数 org_id)
async fn fetch_visible_projects(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
org_id: Option<i64>,
) -> Result<Vec<ProjectRow>, AppError> {
let filter_org = org_id.or(user.organization_id); let filter_org = org_id.or(user.organization_id);
let projects: Vec<ProjectRow> = if user.role_level >= 2 { let projects: Vec<ProjectRow> = if user.role_level >= 2 {
if let Some(oid) = filter_org { if let Some(oid) = filter_org {
sqlx::query_as::<_, ProjectRow>( sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", .bind(oid)
) .fetch_all(pool)
.bind(oid) .await?
.fetch_all(pool)
.await?
} else { } else {
sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") sqlx::query_as::<_, ProjectRow>(PROJECTS_ALL_SQL)
.fetch_all(pool) .fetch_all(pool)
.await? .await?
} }
} else if let Some(org_id) = user.organization_id { } else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, ProjectRow>( sqlx::query_as::<_, ProjectRow>(PROJECTS_BY_ORG_SQL)
"SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", .bind(org_id)
) .fetch_all(pool)
.bind(org_id) .await?
.fetch_all(pool)
.await?
} else { } else {
vec![] vec![]
}; };
let mut result = Vec::with_capacity(projects.len()); Ok(projects)
for p in &projects { }
let cabinet_count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM cabinets WHERE project_id = ?", /// 汇总单个项目的柜子数 / 充电中 / 空闲 / 故障仓体数
) async fn fetch_project_summary(
pool: &sqlx::MySqlPool,
p: &ProjectRow,
) -> Result<Value, AppError> {
let cabinet_count: i64 = sqlx::query_scalar(PROJECT_CABINET_COUNT_SQL)
.bind(p.id) .bind(p.id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
let charging: i64 = sqlx::query_scalar( let charging: i64 = sqlx::query_scalar(PROJECT_CHARGING_COUNT_SQL)
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 1"#,
)
.bind(p.id) .bind(p.id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
let idle: i64 = sqlx::query_scalar( let idle: i64 = sqlx::query_scalar(PROJECT_IDLE_COUNT_SQL)
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 0"#,
)
.bind(p.id) .bind(p.id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
let fault: i64 = sqlx::query_scalar( let fault: i64 = sqlx::query_scalar(PROJECT_FAULT_COUNT_SQL)
r#"SELECT COUNT(*) FROM compartments comp
JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
JOIN cabinets cab ON cb.cabinet_id = cab.id
WHERE cab.project_id = ? AND comp.status = 2"#,
)
.bind(p.id) .bind(p.id)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
result.push(json!({ Ok(json!({
"id": p.id, "id": p.id,
"name": p.name, "name": p.name,
"cabinet_count": cabinet_count, "cabinet_count": cabinet_count,
"charging": charging, "charging": charging,
"idle": idle, "idle": idle,
"fault": fault, "fault": fault,
})); }))
}
Ok(result)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 设备列表 // 设备列表
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离) // --- 设备列表 SQL(由原函数体逐字搬移;SELECT 字段列表 / 子查询 / CAST 均未改动) ---
pub async fn get_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验 /// 项目过滤 + 总管理员(不带组织条件)
auth::check_permission(&user, "device:view")?; const CABINETS_BY_PROJECT_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
let rows: Vec<H5CabinetRow> = if let Some(pid) = query.project_id {
// 按项目过滤(同时验证组织权限)
if user.role_level >= 2 {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c WHERE c.project_id = ?"#, FROM cabinets c WHERE c.project_id = ?"#;
)
.bind(pid) /// 项目过滤 + 企业管理员(额外校验项目归属组织)
.fetch_all(pool) const CABINETS_BY_PROJECT_AND_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
.await?
} else if let Some(org_id) = user.organization_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -554,20 +560,10 @@ pub async fn get_cabinets(
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id INNER JOIN projects p ON c.project_id = p.id
WHERE c.project_id = ? AND p.organization_id = ?"#, WHERE c.project_id = ? AND p.organization_id = ?"#;
)
.bind(pid) /// 总管理员按组织过滤
.bind(org_id) const CABINETS_BY_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
.fetch_all(pool)
.await?
} else {
vec![]
}
} else if user.role_level >= 2 {
// 总管理员:按组织过滤或看全部
if let Some(oid) = query.org_id {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -575,28 +571,19 @@ pub async fn get_cabinets(
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id INNER JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?"#, WHERE p.organization_id = ?"#;
)
.bind(oid) /// 总管理员查看全部
.fetch_all(pool) const CABINETS_ALL_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
.await?
} else {
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 2) as full_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c"#, FROM cabinets c"#;
)
.fetch_all(pool) /// 企业管理员只看本组织
.await? const CABINETS_BY_USER_ORG_SQL: &str = r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
}
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
sqlx::query_as::<_, H5CabinetRow>(
r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count,
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count,
@ -604,25 +591,130 @@ pub async fn get_cabinets(
(SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 3) as fault_count
FROM cabinets c FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id INNER JOIN projects p ON c.project_id = p.id
WHERE p.organization_id = ?"#, WHERE p.organization_id = ?"#;
)
.bind(org_id)
.fetch_all(pool)
.await?
} else {
vec![]
};
// 从Redis批量查RSSI + pow_fail /// 柜子所属组织是否禁用(前端徽标用,`{}` 处填入 `?` 占位符列表)
let mut rssi_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new(); const PROJECT_ORG_DISABLED_SQL: &str = r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p
let mut dc_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new(); LEFT JOIN organizations o ON p.organization_id = o.id
let mut ac_map: std::collections::HashMap<String, Option<bool>> = std::collections::HashMap::new(); WHERE p.id IN ({})"#;
let mut temp_map: std::collections::HashMap<String, Option<i32>> = std::collections::HashMap::new();
/// 设备列表的 Redis 实时状态(按 IMEI 索引)
#[derive(Default)]
struct CabinetRealtimeMaps {
rssi: HashMap<String, Option<i32>>,
dc: HashMap<String, Option<bool>>,
ac: HashMap<String, Option<bool>>,
temp: HashMap<String, Option<i32>>,
}
/// GET /api/h5/cabinets — H5 设备列表(可按项目过滤,带组织隔离)
pub async fn get_cabinets(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<CabinetListQuery>,
) -> Result<Json<Value>, AppError> {
// 权限校验
auth::check_permission(&user, "device:view")?;
let pool = &state.mysql;
// 组织隔离查询:设备基础信息 + 通道统计
let rows = fetch_cabinet_rows(pool, &user, query.project_id, query.org_id).await?;
// 从Redis批量查RSSI + pow_fail + temp
let realtime = load_cabinet_realtime(&state, &rows).await;
// 查询柜子所属组织是否禁用(用于前端徽标)
let org_disabled_map = load_org_disabled_map(pool, &rows).await;
let list = build_cabinet_list(rows, &realtime, &org_disabled_map);
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
}
/// 按「项目 / 角色」优先级查询用户可见的柜子行(分支顺序与原实现逐条对应)
async fn fetch_cabinet_rows(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
project_id: Option<i64>,
org_id: Option<i64>,
) -> Result<Vec<H5CabinetRow>, AppError> {
if let Some(pid) = project_id {
// 按项目过滤(同时验证组织权限)
fetch_cabinet_rows_by_project(pool, user, pid).await
} else if user.role_level >= 2 {
// 总管理员:按组织过滤或看全部
fetch_cabinet_rows_for_admin(pool, org_id).await
} else if let Some(org_id) = user.organization_id {
// 企业管理员只看本组织
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_USER_ORG_SQL)
.bind(org_id)
.fetch_all(pool)
.await?;
Ok(rows)
} else {
Ok(vec![])
}
}
/// 项目过滤分支:总管理员不带组织条件,企业管理员额外校验项目归属组织
async fn fetch_cabinet_rows_by_project(
pool: &sqlx::MySqlPool,
user: &CurrentUser,
pid: i64,
) -> Result<Vec<H5CabinetRow>, AppError> {
if user.role_level >= 2 {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_SQL)
.bind(pid)
.fetch_all(pool)
.await?;
Ok(rows)
} else if let Some(org_id) = user.organization_id {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_PROJECT_AND_ORG_SQL)
.bind(pid)
.bind(org_id)
.fetch_all(pool)
.await?;
Ok(rows)
} else {
Ok(vec![])
}
}
/// 总管理员分支:指定组织则按组织过滤,否则查看全部
async fn fetch_cabinet_rows_for_admin(
pool: &sqlx::MySqlPool,
org_id: Option<i64>,
) -> Result<Vec<H5CabinetRow>, AppError> {
if let Some(oid) = org_id {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_BY_ORG_SQL)
.bind(oid)
.fetch_all(pool)
.await?;
Ok(rows)
} else {
let rows = sqlx::query_as::<_, H5CabinetRow>(CABINETS_ALL_SQL)
.fetch_all(pool)
.await?;
Ok(rows)
}
}
/// 从 Redis 批量读取柜子实时状态(Pipeline HMGET,1 次网络往返)
///
/// 注意:pipeline 只对带 IMEI 的行下发命令,结果按「行下标」对齐,
/// 与重构前的行为完全一致(这是既有约定,不做修正)。
async fn load_cabinet_realtime(state: &AppState, rows: &[H5CabinetRow]) -> CabinetRealtimeMaps {
let mut maps = CabinetRealtimeMaps::default();
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await { if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let mut pipe = redis::pipe(); let mut pipe = redis::pipe();
for r in &rows { for r in rows {
if let Some(ref imei) = r.imei { if let Some(ref imei) = r.imei {
let key = format!("device:{imei}"); let key = format!("device:{imei}");
pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]); pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]);
@ -639,49 +731,56 @@ pub async fn get_cabinets(
for (i, r) in rows.iter().enumerate() { for (i, r) in rows.iter().enumerate() {
if let Some(ref imei) = r.imei { if let Some(ref imei) = r.imei {
if let Some(vals) = results.get(i) { if let Some(vals) = results.get(i) {
rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok())); maps.rssi.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1")); maps.dc.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"));
ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1")); maps.ac.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"));
temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok())); maps.temp.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::<i32>().ok()));
} }
} }
} }
} }
} }
} }
maps
}
// 查询柜子所属组织是否禁用(用于前端徽标) /// 查询柜子所属组织是否禁用,返回「项目ID -> 是否禁用」映射
let mut org_disabled_map: std::collections::HashMap<i64, bool> = std::collections::HashMap::new(); async fn load_org_disabled_map(
{ pool: &sqlx::MySqlPool,
let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect(); rows: &[H5CabinetRow],
if !project_ids.is_empty() { ) -> HashMap<i64, bool> {
let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect(); let mut org_disabled_map: HashMap<i64, bool> = HashMap::new();
let sql = format!( let project_ids: Vec<i64> = rows.iter().filter_map(|r| r.project_id).collect();
r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p if !project_ids.is_empty() {
LEFT JOIN organizations o ON p.organization_id = o.id let placeholders: Vec<String> = project_ids.iter().map(|_| "?".to_string()).collect();
WHERE p.id IN ({})"#, // `{}` 处填入占位符列表,等价于原实现的 format! 拼接
placeholders.join(",") let sql = PROJECT_ORG_DISABLED_SQL.replacen("{}", &placeholders.join(","), 1);
); let mut q = sqlx::query_as::<_, (i64, i8)>(&sql);
let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); for pid in &project_ids {
for pid in &project_ids { q = q.bind(pid);
q = q.bind(pid); }
} if let Ok(rows) = q.fetch_all(pool).await {
if let Ok(rows) = q.fetch_all(pool).await { for (pid, disabled) in rows {
for (pid, disabled) in rows { org_disabled_map.insert(pid, disabled == 1);
org_disabled_map.insert(pid, disabled == 1);
}
} }
} }
} }
org_disabled_map
}
let list: Vec<Value> = rows /// 行结构体 + 实时状态 → 响应 JSON 列表(字段名与语义与原实现逐字一致)
.into_iter() fn build_cabinet_list(
rows: Vec<H5CabinetRow>,
realtime: &CabinetRealtimeMaps,
org_disabled_map: &HashMap<i64, bool>,
) -> Vec<Value> {
rows.into_iter()
.map(|r| { .map(|r| {
let imei = r.imei.clone(); let imei = r.imei.clone();
let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten(); let rssi = imei.as_ref().and_then(|i| realtime.rssi.get(i).copied()).flatten();
let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten(); let pow_fail_dc = imei.as_ref().and_then(|i| realtime.dc.get(i).copied()).flatten();
let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten(); let pow_fail_ac = imei.as_ref().and_then(|i| realtime.ac.get(i).copied()).flatten();
let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten(); let temp = imei.as_ref().and_then(|i| realtime.temp.get(i).copied()).flatten();
let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false);
json!({ json!({
"id": r.id, "id": r.id,
@ -706,13 +805,7 @@ pub async fn get_cabinets(
"org_disabled": org_disabled, "org_disabled": org_disabled,
}) })
}) })
.collect(); .collect()
Ok(Json(json!({
"code": 0,
"data": list,
"message": "ok"
})))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -781,74 +874,51 @@ pub async fn get_cabinet_by_abstract_id(
// 仓体/通道详情 // 仓体/通道详情
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 仓体基础信息(id, channel_id, status, cabin_board_id)
const COMPARTMENT_BY_ID_SQL: &str =
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?";
/// 仓体所属仓控板 → 柜子
const CABIN_BOARD_CABINET_ID_SQL: &str = "SELECT cabinet_id FROM cabin_boards WHERE id = ?";
/// 仓体最近 10 条充电记录
const COMPARTMENT_CHARGE_RECORDS_SQL: &str = r#"SELECT id, start_time, end_time, energy
FROM charge_records
WHERE compartment_id = ?
ORDER BY start_time DESC LIMIT 10"#;
/// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验) /// GET /api/h5/compartments/:id — 仓体详情(含充电记录,带权限校验)
pub async fn get_compartment_detail( pub async fn get_compartment_detail(
user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let pool = &state.mysql;
// 权限校验 // 权限校验
auth::check_permission(&user, "device:view")?; auth::check_permission(&user, "device:view")?;
let pool = &state.mysql;
// 查询仓体基本信息 // 查询仓体基本信息
let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as( let comp: Option<(i64, i8, i8, i64)> = sqlx::query_as(COMPARTMENT_BY_ID_SQL)
"SELECT id, channel_id, status, cabin_board_id FROM compartments WHERE id = ?", .bind(id)
) .fetch_optional(pool)
.bind(id) .await?;
.fetch_optional(pool)
.await?;
let (comp_id, channel_id, status, cabin_board_id) = let (comp_id, channel_id, status, cabin_board_id) =
comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?; comp.ok_or_else(|| AppError::NotFound("仓体不存在".into()))?;
// 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限 // 组织隔离:通过 cabin_board → cabinet → project 链路验证访问权限
let cabinet_id: Option<(i64,)> = sqlx::query_as( verify_compartment_access(&user, cabin_board_id, pool).await?;
"SELECT cabinet_id FROM cabin_boards WHERE id = ?",
)
.bind(cabin_board_id)
.fetch_optional(pool)
.await?;
if let Some((cab_id,)) = cabinet_id {
verify_cabinet_access(&user, cab_id, pool).await?;
}
// 从 Redis 获取实时数据 // 从 Redis 获取实时数据
let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { let realtime = load_compartment_realtime(&state, comp_id).await;
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let key = format!("compartment:status:{}", comp_id);
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
} else { json!({}) }
} else { json!({}) };
// 查询最近充电记录 // 查询最近充电记录
let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>( let records: Vec<ChargeRecordBriefRow> = sqlx::query_as::<_, ChargeRecordBriefRow>(
r#"SELECT id, start_time, end_time, energy COMPARTMENT_CHARGE_RECORDS_SQL,
FROM charge_records
WHERE compartment_id = ?
ORDER BY start_time DESC LIMIT 10"#,
) )
.bind(comp_id) .bind(comp_id)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
let charge_records: Vec<Value> = records
.into_iter()
.map(|r| {
json!({
"id": r.id,
"start_time": r.start_time.map(|t| t.to_string()),
"end_time": r.end_time.map(|t| t.to_string()),
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
"cost": 0,
})
})
.collect();
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,
"data": { "data": {
@ -863,12 +933,58 @@ pub async fn get_compartment_detail(
"cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), "cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])),
"cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])), "cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])),
"bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])), "bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])),
"charge_records": charge_records, "charge_records": map_charge_records(records),
}, },
"message": "ok" "message": "ok"
}))) })))
} }
/// 组织隔离:仓控板 → 柜子 → 项目 → 组织链路校验访问权限
async fn verify_compartment_access(
user: &CurrentUser,
cabin_board_id: i64,
pool: &sqlx::MySqlPool,
) -> Result<(), AppError> {
let cabinet_id: Option<(i64,)> = sqlx::query_as(CABIN_BOARD_CABINET_ID_SQL)
.bind(cabin_board_id)
.fetch_optional(pool)
.await?;
if let Some((cab_id,)) = cabinet_id {
verify_cabinet_access(user, cab_id, pool).await?;
}
Ok(())
}
/// 从 Redis 读取仓体实时数据(key: `compartment:status:{comp_id}`)
async fn load_compartment_realtime(state: &AppState, comp_id: i64) -> Value {
if let Ok(client) = redis::Client::open(state.redis_url.as_str()) {
// 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection
if let Ok(mut conn) = client.get_multiplexed_async_connection().await {
let key = format!("compartment:status:{}", comp_id);
let val: Option<String> = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None);
val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
} else { json!({}) }
} else { json!({}) }
}
/// 充电记录行 → 响应 JSON(`energy_kwh` / `cost` 语义不变)
fn map_charge_records(records: Vec<ChargeRecordBriefRow>) -> Vec<Value> {
records
.into_iter()
.map(|r| {
json!({
"id": r.id,
"start_time": r.start_time.map(|t| t.to_string()),
"end_time": r.end_time.map(|t| t.to_string()),
"energy_kwh": r.energy.unwrap_or_default().parse::<i64>().unwrap_or(0),
"cost": 0,
})
})
.collect()
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 扫码验证 // 扫码验证
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1032,6 +1148,90 @@ pub async fn open_door(
// 辅助函数 // 辅助函数
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// --- 指令下发 SQL / Redis key(由原函数体逐字搬移,契约不变) ---
/// 仓体 → 柜子/IMEI/仓控板/通道 一次查询
const COMPARTMENT_TARGET_SQL: &str = r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#;
/// 柜子ID → IMEI
const CABINET_IMEI_SQL: &str = "SELECT imei FROM cabinets WHERE id = ?";
/// 柜子是否被禁用(status = 2)
const CABINET_DISABLED_SQL: &str = "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2";
/// 柜子所属组织是否被禁用
const CABINET_ORG_DISABLED_SQL: &str = r#"SELECT COUNT(*) > 0 FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
INNER JOIN organizations o ON p.organization_id = o.id
WHERE c.id = ? AND o.disabled = 1"#;
/// 设备指令队列 key(device-server 消费端契约,不可更改)
const DEVICE_COMMANDS_KEY: &str = "device:commands";
/// 校验柜子及其所属组织未被禁用(甲方禁用组织 → 其下柜子全部失效)
async fn ensure_cabinet_enabled(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<(), AppError> {
// 检查设备是否被禁用
let disabled: bool = sqlx::query_scalar(CABINET_DISABLED_SQL)
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
}
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
let org_disabled: bool = sqlx::query_scalar(CABINET_ORG_DISABLED_SQL)
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if org_disabled {
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
}
Ok(())
}
/// 设备必须在线:平台对不在线的设备一律不投递
async fn ensure_device_online(state: &AppState, imei: &str) -> Result<(), AppError> {
if !state.nodes.is_device_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
Ok(())
}
/// 序列化指令 → 注入内部路由字段 → LPUSH 进 `device:commands` 队列
async fn push_device_command(
state: &AppState,
cmd: &DeviceCommand,
imei: &str,
) -> Result<(), AppError> {
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见)
let mut json_val =
serde_json::to_value(cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
let json_str = json_val.to_string();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg(DEVICE_COMMANDS_KEY)
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
Ok(())
}
/// 指令下发成功响应(返回码与文案与原实现一致)
fn command_dispatched_json() -> Json<Value> {
Json(json!({
"code": 0,
"data": { "success": true },
"message": "指令已下发"
}))
}
/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。 /// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。
/// ///
/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。 /// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。
@ -1045,56 +1245,15 @@ async fn send_compartment_command(
let pool = &state.mysql; let pool = &state.mysql;
// 一次查询拿到 cabinet_id + IMEI + board_id + channel_id // 一次查询拿到 cabinet_id + IMEI + board_id + channel_id
let row: Option<CabinetImeiRow> = sqlx::query_as( let cab_row = load_compartment_target(pool, compartment_id).await?;
r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id
FROM cabinets cab
JOIN cabin_boards cb ON cb.cabinet_id = cab.id
JOIN compartments comp ON comp.cabin_board_id = cb.id
WHERE comp.id = ?"#,
)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; // 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
ensure_cabinet_enabled(pool, cab_row.cabinet_id).await?;
// 检查设备是否被禁用
let disabled: bool = sqlx::query_scalar(
"SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2",
)
.bind(cab_row.cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
}
// 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
let org_disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
INNER JOIN organizations o ON p.organization_id = o.id
WHERE c.id = ? AND o.disabled = 1"#,
)
.bind(cab_row.cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if org_disabled {
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
}
// 校验操作权限(组织隔离)
verify_cabinet_access(user, cab_row.cabinet_id, pool).await?; verify_cabinet_access(user, cab_row.cabinet_id, pool).await?;
ensure_device_online(state, &cab_row.imei).await?;
let imei = &cab_row.imei; let imei = &cab_row.imei;
// 检查是否有设备节点在线
if !state.nodes.is_device_online(imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构造协议 id: // 构造协议 id:
// 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零) // 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零)
// 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定 // 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定
@ -1107,29 +1266,27 @@ async fn send_compartment_command(
params: Some(json!({})), params: Some(json!({})),
}; };
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) push_device_command(state, &cmd, imei).await?;
let mut json_val =
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
let json_str = json_val.to_string();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
tracing::info!( tracing::info!(
"[h5] 指令已推入队列 id={} action={} compartment_id={}", "[h5] 指令已推入队列 id={} action={} compartment_id={}",
id, action, compartment_id id, action, compartment_id
); );
Ok(Json(json!({ Ok(command_dispatched_json())
"code": 0, }
"data": { "success": true },
"message": "指令已下发" /// 按仓体ID查询目标设备与通道信息
}))) async fn load_compartment_target(
pool: &sqlx::MySqlPool,
compartment_id: i64,
) -> Result<CabinetImeiRow, AppError> {
let row: Option<CabinetImeiRow> = sqlx::query_as(COMPARTMENT_TARGET_SQL)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1151,45 +1308,12 @@ async fn send_cabinet_command(
) -> Result<Json<Value>, AppError> { ) -> Result<Json<Value>, AppError> {
let pool = &state.mysql; let pool = &state.mysql;
let imei: Option<String> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") let imei = load_cabinet_imei(pool, cabinet_id).await?;
.bind(cabinet_id)
.fetch_optional(pool)
.await?;
let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?;
// 设备是否被禁用 // 设备禁用 → 组织禁用 → 组织隔离 → 在线 校验(顺序与原实现一致)
let disabled: bool = ensure_cabinet_enabled(pool, cabinet_id).await?;
sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2")
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if disabled {
return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into()));
}
// 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效)
let org_disabled: bool = sqlx::query_scalar(
r#"SELECT COUNT(*) > 0 FROM cabinets c
INNER JOIN projects p ON c.project_id = p.id
INNER JOIN organizations o ON p.organization_id = o.id
WHERE c.id = ? AND o.disabled = 1"#,
)
.bind(cabinet_id)
.fetch_one(pool)
.await
.map_err(AppError::Database)?;
if org_disabled {
return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into()));
}
// 组织数据隔离校验
verify_cabinet_access(user, cabinet_id, pool).await?; verify_cabinet_access(user, cabinet_id, pool).await?;
ensure_device_online(state, &imei).await?;
// 设备必须在线:平台对不在线的设备一律不投递
if !state.nodes.is_device_online(&imei).await {
return Err(AppError::BadRequest("设备不在线".into()));
}
// 构造协议 id: // 构造协议 id:
// - `board_id = None` → **柜级**:**不带 id**(省略该字段) // - `board_id = None` → **柜级**:**不带 id**(省略该字段)
@ -1203,18 +1327,7 @@ async fn send_cabinet_command(
params: Some(json!({})), params: Some(json!({})),
}; };
// 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) push_device_command(state, &cmd, &imei).await?;
let mut json_val =
serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?;
json_val[ROUTE_DEV_ID_FIELD] = json!(imei);
let json_str = json_val.to_string();
let mut conn = state.redis.clone();
redis::cmd("LPUSH")
.arg("device:commands")
.arg(&json_str)
.query_async::<()>(&mut conn)
.await
.map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?;
tracing::info!( tracing::info!(
"[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}", "[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}",
@ -1224,11 +1337,17 @@ async fn send_cabinet_command(
board_id board_id
); );
Ok(Json(json!({ Ok(command_dispatched_json())
"code": 0, }
"data": { "success": true },
"message": "指令已下发" /// 按柜子ID查询 IMEI
}))) async fn load_cabinet_imei(pool: &sqlx::MySqlPool, cabinet_id: i64) -> Result<String, AppError> {
let imei: Option<String> = sqlx::query_scalar(CABINET_IMEI_SQL)
.bind(cabinet_id)
.fetch_optional(pool)
.await?;
imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))
} }
/// POST /api/cabinets/:id/open — **解锁柜门**(柜级) /// POST /api/cabinets/:id/open — **解锁柜门**(柜级)

View File

@ -34,40 +34,51 @@ mod users_perm;
mod wechat; mod wechat;
mod maps; mod maps;
/// 构建完整路由树(需要传入 AppState 用于认证中间件) /// 认证与账户相关路由(需登录)
pub fn build(state: AppState) -> Router { fn auth_routes() -> Router<AppState> {
let protected = Router::<AppState>::new() Router::<AppState>::new()
// 认证相关
.route("/api/auth/logout", post(auth::logout)) .route("/api/auth/logout", post(auth::logout))
.route("/api/auth/me", get(auth::me)) .route("/api/auth/me", get(auth::me))
.route("/api/auth/avatar", put(auth::update_avatar)) .route("/api/auth/avatar", put(auth::update_avatar))
.route("/api/auth/avatar/upload", post(auth::upload_avatar)) .route("/api/auth/avatar/upload", post(auth::upload_avatar))
// 设备指令下发 }
/// 设备指令下发 + 设备服务节点管理路由
fn device_node_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/devices/{dev_id}/command", post(crate::commands::send_command)) .route("/api/devices/{dev_id}/command", post(crate::commands::send_command))
.route("/api/devices/stats", get(crate::commands::device_stats)) .route("/api/devices/stats", get(crate::commands::device_stats))
// 节点管理
.route("/api/nodes/register", post(crate::nodes::register_node)) .route("/api/nodes/register", post(crate::nodes::register_node))
.route("/api/nodes/{node_id}/heartbeat", post(crate::nodes::node_heartbeat)) .route("/api/nodes/{node_id}/heartbeat", post(crate::nodes::node_heartbeat))
.route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node)) .route("/api/nodes/{node_id}/deregister", post(crate::nodes::deregister_node))
.route("/api/nodes", get(crate::nodes::list_nodes)) .route("/api/nodes", get(crate::nodes::list_nodes))
// 角色管理 }
/// 角色、权限码与用户权限分配路由
fn rbac_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/roles", get(roles::list_roles).post(roles::create_role)) .route("/api/roles", get(roles::list_roles).post(roles::create_role))
.route("/api/roles/{id}", put(roles::update_role).delete(roles::delete_role)) .route("/api/roles/{id}", put(roles::update_role).delete(roles::delete_role))
.route("/api/roles/{id}/permissions", put(roles::set_role_permissions)) .route("/api/roles/{id}/permissions", put(roles::set_role_permissions))
// 权限码列表
.route("/api/permissions", get(roles::list_permissions)) .route("/api/permissions", get(roles::list_permissions))
// 用户权限管理
.route("/api/users/{id}/permissions", get(users_perm::get_user_permissions)) .route("/api/users/{id}/permissions", get(users_perm::get_user_permissions))
.route("/api/users/{id}/roles", put(users_perm::set_user_roles)) .route("/api/users/{id}/roles", put(users_perm::set_user_roles))
.route("/api/users/{id}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes)) .route("/api/users/{id}/scopes", put(users_perm::set_user_scopes).get(users_perm::get_user_scopes))
// 组织管理 }
/// 组织与项目路由
fn org_project_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization)) .route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization))
.route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization)) .route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization))
.route("/api/organizations/{id}/status", put(organizations::set_organization_status)) .route("/api/organizations/{id}/status", put(organizations::set_organization_status))
// 项目管理
.route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project)) .route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project))
.route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project)) .route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project))
// 设备管理 }
/// 设备(充电柜)管理、柜级控制、组织树与级联下拉路由
fn cabinet_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets)) .route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets))
.route("/api/cabinets", post(cabinets::create_cabinet)) .route("/api/cabinets", post(cabinets::create_cabinet))
.route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail)) .route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail))
@ -86,38 +97,54 @@ pub fn build(state: AppState) -> Router {
.route("/api/organization-tree", get(cabinet_tree::organization_tree)) .route("/api/organization-tree", get(cabinet_tree::organization_tree))
.route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets)) .route("/api/cabinets/filter", get(cabinet_tree::filter_cabinets))
.route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status)) .route("/api/cabinets/realtime-status", get(cabinet_tree::device_realtime_status))
// 充电记录
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
// 充电订单
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
// 电池台账
.route("/api/batteries", get(batteries::list_batteries))
// 设备日志
.route("/api/device-logs", get(device_logs::list_device_logs))
// 下载中心
.route("/api/downloads", get(downloads::list_downloads))
.route("/api/downloads/{id}", get(downloads::get_download))
.route("/api/downloads/{id}/file", get(downloads::download_file))
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
// 设备级联下拉 // 设备级联下拉
.route("/api/cabinets/options", get(cabinets::list_cabinet_options)) .route("/api/cabinets/options", get(cabinets::list_cabinet_options))
.route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options)) .route("/api/cabin-boards/options", get(cabinets::list_cabin_board_options))
.route("/api/compartments/options", get(cabinets::list_compartment_options)) .route("/api/compartments/options", get(cabinets::list_compartment_options))
// 能耗管理 }
/// 充电记录 / 充电订单 / 电池台账 / 设备日志路由
fn record_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/charge-records", get(charge_records::list_charge_records))
.route("/api/charge-records/export", get(charge_records::export_charge_records))
.route("/api/charge-orders", get(charge_orders::list_charge_orders))
.route("/api/batteries", get(batteries::list_batteries))
.route("/api/device-logs", get(device_logs::list_device_logs))
}
/// 下载中心路由
fn download_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/downloads", get(downloads::list_downloads))
.route("/api/downloads/{id}", get(downloads::get_download))
.route("/api/downloads/{id}/file", get(downloads::download_file))
.route("/api/downloads/cleanup", post(downloads::cleanup_downloads))
}
/// 能耗管理路由
fn energy_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/energy-stats/summary", get(energy_stats::get_summary)) .route("/api/energy-stats/summary", get(energy_stats::get_summary))
.route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking)) .route("/api/energy-stats/cabinets", get(energy_stats::get_cabinet_ranking))
.route("/api/energy-stats/projects", get(energy_stats::get_project_stats)) .route("/api/energy-stats/projects", get(energy_stats::get_project_stats))
.route("/api/energy-stats/trend", get(energy_stats::get_trend)) .route("/api/energy-stats/trend", get(energy_stats::get_trend))
.route("/api/energy-stats/export", get(energy_stats::export_energy_stats)) .route("/api/energy-stats/export", get(energy_stats::export_energy_stats))
// 用户管理 }
/// 用户管理与操作日志路由
fn user_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/users", get(users::list_users).post(users::create_user)) .route("/api/users", get(users::list_users).post(users::create_user))
.route("/api/users/{id}", put(users::update_user).delete(users::delete_user)) .route("/api/users/{id}", put(users::update_user).delete(users::delete_user))
.route("/api/users/{id}/reset-password", put(users::reset_password)) .route("/api/users/{id}/reset-password", put(users::reset_password))
.route("/api/users/{id}/status", put(users::set_user_status)) .route("/api/users/{id}/status", put(users::set_user_status))
// 操作日志
.route("/api/operation-logs", get(operation_logs::list_operation_logs)) .route("/api/operation-logs", get(operation_logs::list_operation_logs))
// H5 用户端接口(与 PC 共用同一套 API 路由) }
/// H5 用户端接口路由(与 PC 共用同一套 API 路由)+ 问题反馈
fn h5_routes() -> Router<AppState> {
Router::<AppState>::new()
.route("/api/dashboard", get(h5::get_dashboard)) .route("/api/dashboard", get(h5::get_dashboard))
.route("/api/cabinets", get(h5::get_cabinets)) .route("/api/cabinets", get(h5::get_cabinets))
.route("/api/compartments/{id}", get(h5::get_compartment_detail)) .route("/api/compartments/{id}", get(h5::get_compartment_detail))
@ -125,13 +152,28 @@ pub fn build(state: AppState) -> Router {
.route("/api/charge/stop", post(h5::stop_charge)) .route("/api/charge/stop", post(h5::stop_charge))
.route("/api/door/open", post(h5::open_door)) .route("/api/door/open", post(h5::open_door))
.route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet)) .route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet))
// 问题反馈
.route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback)) .route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback))
.route("/api/feedback/{id}", put(h5::update_feedback_status)) .route("/api/feedback/{id}", put(h5::update_feedback_status))
.route("/api/feedback/upload", post(h5::upload_feedback_image)) .route("/api/feedback/upload", post(h5::upload_feedback_image))
// 认证中间件层 }
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
/// 合并全部受保护路由(不含认证中间件层,便于单测校验路由表无冲突)
fn protected_router() -> Router<AppState> {
Router::<AppState>::new()
.merge(auth_routes())
.merge(device_node_routes())
.merge(rbac_routes())
.merge(org_project_routes())
.merge(cabinet_routes())
.merge(record_routes())
.merge(download_routes())
.merge(energy_routes())
.merge(user_routes())
.merge(h5_routes())
}
/// 免认证的公开路由(健康检查、登录、微信回调、地图代理、静态头像目录)
fn public_routes() -> Router<AppState> {
Router::<AppState>::new() Router::<AppState>::new()
.route("/api/health", get(health::health_check)) .route("/api/health", get(health::health_check))
.route("/api/auth/challenge", get(auth::get_challenge)) .route("/api/auth/challenge", get(auth::get_challenge))
@ -148,6 +190,15 @@ pub fn build(state: AppState) -> Router {
.route("/api/maps/place-search", get(maps::place_search)) .route("/api/maps/place-search", get(maps::place_search))
// 头像上传目录(免认证,仅用于公开访问静态文件) // 头像上传目录(免认证,仅用于公开访问静态文件)
.nest_service("/uploads", ServeDir::new("uploads")) .nest_service("/uploads", ServeDir::new("uploads"))
}
/// 构建完整路由树(需要传入 AppState 用于认证中间件)
pub fn build(state: AppState) -> Router {
// 认证中间件层:仅作用于受保护路由
let protected = protected_router()
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
public_routes()
.merge(protected) .merge(protected)
.layer(CorsLayer::new() .layer(CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)

View File

@ -24,22 +24,23 @@ pub(crate) struct LogQuery {
page_size: Option<u32>, page_size: Option<u32>,
} }
/// GET /api/operation-logs — 操作日志列表(分页) /// 操作日志数据库行映射
#[derive(sqlx::FromRow)]
struct LogRow {
id: i64,
user_id: i64,
user_name: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: chrono::NaiveDateTime,
}
/// 构建 WHERE 子句与绑定值
/// ///
/// 支持 user_id / action / start_time / end_time 过滤。 /// 动态拼装过滤条件(使用 ? 占位符防止 SQL 注入),并追加组织数据隔离。
pub async fn list_operation_logs( fn build_log_filters(user: &CurrentUser, query: &LogQuery) -> (String, Vec<String>) {
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
let mut conditions: Vec<String> = Vec::new(); let mut conditions: Vec<String> = Vec::new();
let mut bind_values: Vec<String> = Vec::new(); let mut bind_values: Vec<String> = Vec::new();
@ -67,7 +68,7 @@ pub async fn list_operation_logs(
} }
// 追加组织过滤 // 追加组织过滤
let (org_cond, org_binds) = auth::org_condition_for_logs(&user, "o"); let (org_cond, org_binds) = auth::org_condition_for_logs(user, "o");
if !org_cond.is_empty() { if !org_cond.is_empty() {
conditions.push(org_cond.trim_start_matches(" AND ").to_string()); conditions.push(org_cond.trim_start_matches(" AND ").to_string());
bind_values.extend(org_binds); bind_values.extend(org_binds);
@ -78,16 +79,31 @@ pub async fn list_operation_logs(
} else { } else {
format!("WHERE {}", conditions.join(" AND ")) format!("WHERE {}", conditions.join(" AND "))
}; };
(where_clause, bind_values)
}
// 查询总数 /// 统计符合条件的操作日志总数
async fn count_operation_logs(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
) -> Result<i64, AppError> {
let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause); let count_sql = format!("SELECT COUNT(*) FROM operation_logs o {}", where_clause);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql); let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
for val in &bind_values { for val in bind_values {
count_query = count_query.bind(val); count_query = count_query.bind(val);
} }
let total = count_query.fetch_one(pool).await?; Ok(count_query.fetch_one(pool).await?)
}
// 查询列表(关联用户姓名) /// 查询操作日志行(关联用户姓名,分页)
async fn fetch_operation_log_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
bind_values: &[String],
page_size: u32,
offset: u32,
) -> Result<Vec<LogRow>, AppError> {
let list_sql = format!( let list_sql = format!(
r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at r#"SELECT o.id, o.user_id, u.name AS user_name, o.action, o.target_type, o.target_id, o.detail, o.created_at
FROM operation_logs o FROM operation_logs o
@ -98,30 +114,20 @@ pub async fn list_operation_logs(
where_clause where_clause
); );
#[derive(sqlx::FromRow)]
struct LogRow {
id: i64,
user_id: i64,
user_name: Option<String>,
action: String,
target_type: Option<String>,
target_id: Option<i64>,
detail: Option<String>,
created_at: chrono::NaiveDateTime,
}
let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql); let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql);
for val in &bind_values { for val in bind_values {
list_query = list_query.bind(val); list_query = list_query.bind(val);
} }
let rows = list_query Ok(list_query
.bind(page_size) .bind(page_size)
.bind(offset) .bind(offset)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?)
}
let list: Vec<Value> = rows /// 将操作日志行映射为响应 JSON 列表
.into_iter() fn operation_logs_to_json(rows: Vec<LogRow>) -> Vec<Value> {
rows.into_iter()
.map(|r| { .map(|r| {
json!({ json!({
"id": r.id, "id": r.id,
@ -134,7 +140,34 @@ pub async fn list_operation_logs(
"created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), "created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(),
}) })
}) })
.collect(); .collect()
}
/// GET /api/operation-logs — 操作日志列表(分页)
///
/// 支持 user_id / action / start_time / end_time 过滤。
pub async fn list_operation_logs(
user: CurrentUser,
State(state): State<AppState>,
Query(query): Query<LogQuery>,
) -> Result<Json<Value>, AppError> {
auth::check_permission(&user, "operation_log:view")?;
let pool = &state.mysql;
let page = query.page.unwrap_or(1).max(1);
let page_size = query.page_size.unwrap_or(20).min(100);
let offset = (page - 1) * page_size;
// 动态构建 WHERE 条件(使用 ? 占位符防止 SQL 注入)
let (where_clause, bind_values) = build_log_filters(&user, &query);
// 查询总数
let total = count_operation_logs(pool, &where_clause, &bind_values).await?;
// 查询列表(关联用户姓名)
let rows =
fetch_operation_log_rows(pool, &where_clause, &bind_values, page_size, offset).await?;
let list = operation_logs_to_json(rows);
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,

View File

@ -142,6 +142,126 @@ async fn log_operation(
// 用户列表 // 用户列表
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 构建用户列表的 WHERE 子句与需要绑定的组织ID
///
/// 返回 (where_clause, bind_org):where_clause 用于 COUNT 与列表查询,
/// bind_org 为需要追加绑定的组织过滤值(无过滤则为 None)。
fn build_user_list_where(has_keyword: bool, org_filter: Option<i64>) -> (String, Option<i64>) {
match (has_keyword, org_filter) {
(true, Some(org_id)) => (
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
Some(org_id),
),
(true, None) => (
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
None,
),
(false, Some(org_id)) => (
"WHERE organization_id = ?".to_string(),
Some(org_id),
),
(false, None) => (String::new(), None),
}
}
/// 统计符合条件的用户总数
async fn count_users(
pool: &sqlx::MySqlPool,
where_clause: &str,
has_keyword: bool,
like_pattern: &str,
bind_org: Option<i64>,
) -> Result<i64, AppError> {
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
if has_keyword {
q = q.bind(like_pattern).bind(like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
let total: (i64,) = q.fetch_one(pool).await?;
Ok(total.0)
}
/// 查询当前页的用户行(分页 + 排序)
async fn fetch_user_rows(
pool: &sqlx::MySqlPool,
where_clause: &str,
has_keyword: bool,
like_pattern: &str,
bind_org: Option<i64>,
page_size: u32,
offset: u32,
) -> Result<Vec<UserRow>, AppError> {
let sql = format!(
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at
FROM users u {}
ORDER BY u.id DESC
LIMIT ? OFFSET ?"#,
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE")
);
let mut q = sqlx::query_as::<_, UserRow>(&sql);
if has_keyword {
q = q.bind(like_pattern).bind(like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
Ok(q.bind(page_size).bind(offset).fetch_all(pool).await?)
}
/// 批量查询用户所属组织的名称映射(id → name)
async fn load_org_name_map(
pool: &sqlx::MySqlPool,
rows: &[UserRow],
) -> Result<std::collections::HashMap<i64, String>, AppError> {
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect();
if org_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
// 使用 IN 查询, 构造占位符
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT id, name FROM organizations WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
for id in &org_ids {
q = q.bind(id);
}
let org_names: Vec<(i64, String)> = q.fetch_all(pool).await?;
Ok(org_names.into_iter().collect())
}
/// 将用户行映射为响应 JSON 列表
fn users_to_json(
rows: Vec<UserRow>,
org_map: &std::collections::HashMap<i64, String>,
) -> Vec<Value> {
// 构建角色名称映射
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
rows.into_iter()
.map(|r| {
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
json!({
"id": r.id,
"phone": r.phone,
"name": r.name,
"role": r.role,
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
"organization_id": r.organization_id,
"organization_name": org_name,
"status": r.status,
"created_at": r.created_at.to_string(),
"updated_at": r.updated_at.to_string(),
})
})
.collect()
}
/// GET /api/users — 用户列表(分页) /// GET /api/users — 用户列表(分页)
/// ///
/// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。 /// 支持 keyword 搜索(匹配手机号/姓名),page 从 1 开始。
@ -180,98 +300,30 @@ pub async fn list_users(
} }
// 构建通用 WHERE 子句(避免 count/list 重复维护3个分支) // 构建通用 WHERE 子句(避免 count/list 重复维护3个分支)
let (where_clause, bind_org) = match (has_keyword, org_filter) { let (where_clause, bind_org) = build_user_list_where(has_keyword, org_filter);
(true, Some(org_id)) => (
"WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(),
Some(org_id),
),
(true, None) => (
"WHERE phone LIKE ? OR name LIKE ?".to_string(),
None,
),
(false, Some(org_id)) => (
"WHERE organization_id = ?".to_string(),
Some(org_id),
),
(false, None) => (String::new(), None),
};
// 查询总数 // 查询总数
let total: (i64,) = { let total = count_users(pool, &where_clause, has_keyword, &like_pattern, bind_org).await?;
let sql = format!("SELECT COUNT(*) FROM users {}", where_clause);
let mut q = sqlx::query_as::<_, (i64,)>(&sql);
if has_keyword {
q = q.bind(&like_pattern).bind(&like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
q.fetch_one(pool).await?
};
// 查询用户列表 // 查询用户列表
let rows: Vec<UserRow> = { let rows = fetch_user_rows(
let sql = format!( pool,
r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at &where_clause,
FROM users u {} has_keyword,
ORDER BY u.id DESC &like_pattern,
LIMIT ? OFFSET ?"#, bind_org,
where_clause.replace("phone LIKE", "u.phone LIKE").replace("name LIKE", "u.name LIKE") page_size,
); offset,
let mut q = sqlx::query_as::<_, UserRow>(&sql); )
if has_keyword { .await?;
q = q.bind(&like_pattern).bind(&like_pattern);
}
if let Some(org_id) = bind_org {
q = q.bind(org_id);
}
q.bind(page_size).bind(offset).fetch_all(pool).await?
};
// 收集组织ID并批量查询组织名称 // 收集组织ID并批量查询组织名称
let org_ids: Vec<i64> = rows.iter().filter_map(|r| r.organization_id).collect(); let org_map = load_org_name_map(pool, &rows).await?;
let org_names = if org_ids.is_empty() { let list = users_to_json(rows, &org_map);
vec![]
} else {
// 使用 IN 查询, 构造占位符
let placeholders: Vec<String> = org_ids.iter().map(|_| "?".to_string()).collect();
let sql = format!(
"SELECT id, name FROM organizations WHERE id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_as::<_, (i64, String)>(&sql);
for id in &org_ids {
q = q.bind(id);
}
q.fetch_all(pool).await?
};
let org_map: std::collections::HashMap<i64, String> = org_names.into_iter().collect();
// 构建角色名称映射
let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"];
let list: Vec<Value> = rows
.into_iter()
.map(|r| {
let org_name = r.organization_id.and_then(|oid| org_map.get(&oid).cloned());
json!({
"id": r.id,
"phone": r.phone,
"name": r.name,
"role": r.role,
"role_text": role_names.get(r.role as usize).copied().unwrap_or("未知"),
"organization_id": r.organization_id,
"organization_name": org_name,
"status": r.status,
"created_at": r.created_at.to_string(),
"updated_at": r.updated_at.to_string(),
})
})
.collect();
Ok(Json(json!({ Ok(Json(json!({
"code": 0, "code": 0,
"data": { "list": list, "total": total.0 }, "data": { "list": list, "total": total },
"message": "ok" "message": "ok"
}))) })))
} }
@ -280,6 +332,64 @@ pub async fn list_users(
// 创建用户 // 创建用户
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 校验手机号格式(11 位数字)
fn validate_phone(phone: &str) -> Result<(), AppError> {
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) {
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
}
Ok(())
}
/// 校验组织存在(不存在返回参数错误)
async fn ensure_organization_exists(pool: &sqlx::MySqlPool, oid: i64) -> Result<(), AppError> {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
Ok(())
}
/// 校验手机号未被占用
async fn ensure_phone_available(pool: &sqlx::MySqlPool, phone: &str) -> Result<(), AppError> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM users WHERE phone = ?"
)
.bind(phone)
.fetch_one(pool)
.await?;
if exists.0 > 0 {
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
}
Ok(())
}
/// 按操作者身份与目标角色确定创建用户时的组织归属
fn resolve_create_org_id(
current_user: &CurrentUser,
role: i32,
input_org_id: Option<i64>,
) -> Result<Option<i64>, AppError> {
if current_user.role_level >= 2 {
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
if role == 2 {
Ok(None)
} else {
Ok(Some(input_org_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?))
}
} else {
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
if role == 2 {
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
}
Ok(Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?))
}
}
/// POST /api/users — 创建用户 /// POST /api/users — 创建用户
/// ///
/// 权限模型: /// 权限模型:
@ -297,9 +407,7 @@ pub async fn create_user(
// 校验手机号 // 校验手机号
let phone = input.phone.trim(); let phone = input.phone.trim();
if phone.len() != 11 || !phone.chars().all(|c| c.is_ascii_digit()) { validate_phone(phone)?;
return Err(AppError::BadRequest("手机号必须为11位数字".into()));
}
let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string());
let role = input.role.unwrap_or(0); let role = input.role.unwrap_or(0);
@ -308,47 +416,18 @@ pub async fn create_user(
} }
// 确定最终组织归属(按角色和操作者身份) // 确定最终组织归属(按角色和操作者身份)
let org_id = if current_user.role_level >= 2 { let org_id = resolve_create_org_id(&current_user, role, input.organization_id)?;
// 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织
if role == 2 {
None
} else {
Some(input.organization_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
}
} else {
// 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织
if role == 2 {
return Err(AppError::Forbidden("企业管理员不能创建总管理员".into()));
}
Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?)
};
// 校验组织存在 // 校验组织存在
if let Some(oid) = org_id { if let Some(oid) = org_id {
let org_exists: (i64,) = sqlx::query_as( ensure_organization_exists(pool, oid).await?;
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
} }
let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?; let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?;
let password = auth::hash_password(&plain_password)?; let password = auth::hash_password(&plain_password)?;
// 检查手机号是否已存在 // 检查手机号是否已存在
let exists: (i64,) = sqlx::query_as( ensure_phone_available(pool, phone).await?;
"SELECT COUNT(*) FROM users WHERE phone = ?"
)
.bind(phone)
.fetch_one(pool)
.await?;
if exists.0 > 0 {
return Err(AppError::BadRequest(format!("手机号 {} 已存在", phone)));
}
let result = sqlx::query( let result = sqlx::query(
"INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)" "INSERT INTO users (phone, name, password, role, organization_id) VALUES (?, ?, ?, ?, ?)"
@ -380,6 +459,50 @@ pub async fn create_user(
// 编辑用户 // 编辑用户
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// 按操作者身份与目标角色确定编辑后的组织归属
///
/// 企业管理员(role_level<2)只能编辑本组织用户、不能改自己、不能授予总管理员;
/// 总管理员可自由指定,角色2强制无组织。
async fn resolve_update_org_id(
pool: &sqlx::MySqlPool,
current_user: &CurrentUser,
user_id: i64,
old_org_id: Option<i64>,
new_role: i32,
input_org_id: Option<i64>,
) -> Result<Option<i64>, AppError> {
if current_user.role_level < 2 {
// 企业管理员作用域校验:组织锁定为本组织
let actor_org = current_user.organization_id
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?;
if user_id == current_user.user_id {
return Err(AppError::BadRequest("不能操作自己的账户".into()));
}
// 目标必须属于同一组织
if old_org_id != Some(actor_org) {
return Err(AppError::Forbidden("只能操作本组织的用户".into()));
}
// 不能改成总管理员
if new_role == 2 {
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
}
Ok(Some(actor_org))
} else {
let org_id = if new_role == 2 {
None // 总管理员强制无组织
} else {
Some(input_org_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
};
// 校验组织存在(组织未变更时无需重复校验)
if let Some(oid) = org_id
&& Some(oid) != old_org_id {
ensure_organization_exists(pool, oid).await?;
}
Ok(org_id)
}
}
/// PUT /api/users/:id — 编辑用户 /// PUT /api/users/:id — 编辑用户
pub async fn update_user( pub async fn update_user(
current_user: CurrentUser, current_user: CurrentUser,
@ -407,44 +530,16 @@ pub async fn update_user(
return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into())); return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into()));
} }
// 企业管理员作用域校验:只能编辑本组织用户,不能改成总管理员,组织锁定为本组织 // 组织归属与作用域校验
let new_org_id: Option<i64> = if current_user.role_level < 2 { let new_org_id = resolve_update_org_id(
let actor_org = current_user.organization_id pool,
.ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?; &current_user,
if user_id == current_user.user_id { user_id,
return Err(AppError::BadRequest("不能操作自己的账户".into())); old_org_id,
} new_role,
// 目标必须属于同一组织 input.organization_id,
if old_org_id != Some(actor_org) { )
return Err(AppError::Forbidden("只能操作本组织的用户".into())); .await?;
}
// 不能改成总管理员
if new_role == 2 {
return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into()));
}
Some(actor_org)
} else {
let org_id = if new_role == 2 {
None // 总管理员强制无组织
} else {
Some(input.organization_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?)
};
// 校验组织存在
if let Some(oid) = org_id
&& Some(oid) != old_org_id {
let org_exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM organizations WHERE id = ?"
)
.bind(oid)
.fetch_one(pool)
.await?;
if org_exists.0 == 0 {
return Err(AppError::BadRequest(format!("组织 {} 不存在", oid)));
}
}
org_id
};
let rows = sqlx::query( let rows = sqlx::query(
"UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?" "UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?"

View File

@ -1224,13 +1224,7 @@ pub async fn ensure_charge_order(
} }
}; };
let active: Option<(i64, String)> = sqlx::query_as( let active = find_active_order(pool, compartment_id).await?;
"SELECT id, battery_id FROM charge_orders
WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1",
)
.bind(compartment_id)
.fetch_optional(pool)
.await?;
// 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变) // 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变)
let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id); let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id);
@ -1247,30 +1241,18 @@ pub async fn ensure_charge_order(
Some((order_id, old_battery)) => { Some((order_id, old_battery)) => {
// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。 // 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。 // 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
finalize_order( end_replaced_order(
pool, pool,
redis, redis,
imei, imei,
i64::from(board_id), board_id,
i64::from(channel_id), channel_id,
compartment_id, compartment_id,
None, battery_id,
2, *order_id,
"forced", old_battery,
RECORD_STATUS_ABNORMAL,
) )
.await?; .await?;
// 老订单结束 ⇒ 老电池台账复位为空闲(新订单随后把新电池置"使用中")
if !old_battery.is_empty() {
mark_battery_status(pool, old_battery, 0).await;
}
tracing::warn!(
"[order] 通道换电池,老订单强制结束 order_id={} {} -> {} compartment_id={}",
order_id,
old_battery,
battery_id,
compartment_id
);
} }
} }
@ -1302,6 +1284,62 @@ pub async fn ensure_charge_order(
Ok(()) Ok(())
} }
/// 查该仓体当前活跃订单(`status = 0`)→ `(订单 id, 电池 id)`;无活跃订单返回 `None`。
async fn find_active_order(
pool: &MySqlPool,
compartment_id: i64,
) -> Result<Option<(i64, String)>, sqlx::Error> {
sqlx::query_as(
"SELECT id, battery_id FROM charge_orders
WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1",
)
.bind(compartment_id)
.fetch_optional(pool)
.await
}
/// 换电池时结束老订单并复位老电池台账([`ensure_charge_order`] 判定为 [`OrderDecision::Replace`] 的分支)。
///
/// 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。
/// 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。
async fn end_replaced_order(
pool: &MySqlPool,
redis: &ConnectionManager,
imei: &str,
board_id: i8,
channel_id: i8,
compartment_id: i64,
battery_id: &str,
order_id: i64,
old_battery: &str,
) -> Result<(), sqlx::Error> {
finalize_order(
pool,
redis,
imei,
i64::from(board_id),
i64::from(channel_id),
compartment_id,
None,
2,
"forced",
RECORD_STATUS_ABNORMAL,
)
.await?;
// 老订单结束 ⇒ 老电池台账复位为空闲(新订单随后把新电池置"使用中")
if !old_battery.is_empty() {
mark_battery_status(pool, old_battery, 0).await;
}
tracing::warn!(
"[order] 通道换电池,老订单强制结束 order_id={} {} -> {} compartment_id={}",
order_id,
old_battery,
battery_id,
compartment_id
);
Ok(())
}
/// 插入一笔进行中的充电订单(`status=0`),并把该仓体/电池标记为**占用**: /// 插入一笔进行中的充电订单(`status=0`),并把该仓体/电池标记为**占用**:
/// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。 /// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。
/// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。 /// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。
@ -1872,24 +1910,12 @@ async fn handle_bat_out(
} }
// 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账)
mark_compartment_status(mysql, compartment_id, 0).await; reset_usage_status(mysql, compartment_id, battery_id).await;
if !battery_id.is_empty() {
mark_battery_status(mysql, battery_id, 0).await;
}
// 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id // 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id
delete_channel_battery(redis, dev_id, board_id, channel_id).await; delete_channel_battery(redis, dev_id, board_id, channel_id).await;
let content = serde_json::json!({ log_bat_out(mysql, dev_id, msg, compartment_id, battery_id, end_soc).await;
"dev_id": dev_id,
"act": "bat_out",
"id": msg.get("id"),
"msg_id": msg.get("msg_id"),
"compartment_id": compartment_id,
"battery_id": battery_id,
"end_soc": end_soc,
});
insert_device_log(mysql, dev_id, 2, content).await;
tracing::info!( tracing::info!(
"[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}", "[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}",
@ -1905,6 +1931,38 @@ async fn handle_bat_out(
Ok(()) Ok(())
} }
/// 订单结束 ⇒ 占用状态复位:仓体置空闲 + 电池台账置空闲(`battery_id` 为空串时跳过台账)。
///
/// 两步都只记 ERROR、不阻断设备流程(见 [`mark_compartment_status`] / [`mark_battery_status`])。
async fn reset_usage_status(pool: &MySqlPool, compartment_id: i64, battery_id: &str) {
mark_compartment_status(pool, compartment_id, 0).await;
if !battery_id.is_empty() {
mark_battery_status(pool, battery_id, 0).await;
}
}
/// 记 `bat_out` 的 `device_logs`(`log_type = 2`):报文标识 + 仓体定位 + 末帧 SOC 全量留痕。
/// 写失败只记 ERROR、不阻断业务(见 [`insert_device_log`])。
async fn log_bat_out(
mysql: &MySqlPool,
dev_id: &str,
msg: &serde_json::Value,
compartment_id: i64,
battery_id: &str,
end_soc: Option<i64>,
) {
let content = serde_json::json!({
"dev_id": dev_id,
"act": "bat_out",
"id": msg.get("id"),
"msg_id": msg.get("msg_id"),
"compartment_id": compartment_id,
"battery_id": battery_id,
"end_soc": end_soc,
});
insert_device_log(mysql, dev_id, 2, content).await;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 设备上报 on / off(不带 suc 的形态) // 设备上报 on / off(不带 suc 的形态)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

BIN
营业执照.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 KiB