From 999885c38fb136060f966cad56c66ac337748c8f Mon Sep 17 00:00:00 2001 From: 12451 <12451@example.com> Date: Sat, 19 Sep 2026 23:40:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(protocol):=20TCP=20v1.1=20=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=AF=B9=E9=BD=90=20+=20=E5=B9=B3=E5=8F=B0=E4=BE=A7?= =?UTF-8?q?=E5=85=9C=E5=BA=95=20+=20=E7=94=9F=E4=BA=A7=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过 --- .gitignore | 19 +- .workbuddy/memory/2026-07-07.md | 32 + .workbuddy/memory/MEMORY.md | 11 + AGENTS.md | 263 +- README.md | 294 +- deploy/deploy-prod.ps1 | 156 + deploy/deploy-prod.sh | 69 +- deploy/deploy-test.sh | 70 +- deploy/landing/index.html | 8 +- deploy/nginx/app.anzhizhichong.com.conf | 26 +- deploy/pms-api-prod.service | 2 +- deploy/pms-device-prod.service | 2 +- docs/API.md | 569 +++- ...8-15-sql-injection-format-param-binding.md | 47 + docs/compound-notes/TEMPLATE.md | 9 + docs/审查报告-数据单位一致性-2026-07-06.md | 167 + docs/开发文档.md | 73 +- docs/柜控板固件对接文档.md | 1445 +++++++++ docs/项目深度分析报告-2026-07-06.md | 273 ++ ea4410ba9c9721b1e64825dd3ba74b7e.jpg | Bin 0 -> 168294 bytes logo/圆切带文字.png | Bin 0 -> 110068 bytes logo/圆切带文字svg.svg | 1 + logo/圆切独立LOGO.png | Bin 0 -> 79884 bytes logo/圆切独立LOGO.svg | 1 + logo/白色横向.svg | 1 + logo/白色横向@2x.png | Bin 0 -> 75771 bytes logo/白色竖向.svg | 1 + logo/白色竖向@2x.png | Bin 0 -> 94798 bytes logo/纯色横向.svg | 1 + logo/纯色横向@2x.png | Bin 0 -> 76523 bytes logo/纯色竖向.svg | 1 + logo/纯色竖向@2x.png | Bin 0 -> 87972 bytes opencode.json | 4 + software/api-server/.env.example | 18 + software/api-server/.env.production | 15 - software/api-server/.env.test | 15 - software/api-server/Cargo.lock | 418 ++- software/api-server/Cargo.toml | 10 +- software/api-server/src/captcha.rs | 265 ++ software/api-server/src/commands.rs | 54 +- software/api-server/src/config.rs | 18 +- software/api-server/src/db/migrate.rs | 251 +- software/api-server/src/main.rs | 22 +- software/api-server/src/middleware/auth.rs | 99 +- software/api-server/src/protocol.rs | 95 +- software/api-server/src/routes/auth.rs | 346 ++- software/api-server/src/routes/batteries.rs | 160 + .../api-server/src/routes/cabinet_tree.rs | 101 +- software/api-server/src/routes/cabinets.rs | 435 ++- .../api-server/src/routes/charge_orders.rs | 230 ++ .../api-server/src/routes/charge_records.rs | 21 +- .../api-server/src/routes/energy_stats.rs | 50 +- software/api-server/src/routes/h5.rs | 702 ++++- software/api-server/src/routes/maps.rs | 155 + software/api-server/src/routes/mod.rs | 42 + .../api-server/src/routes/operation_logs.rs | 4 +- .../api-server/src/routes/organizations.rs | 152 +- software/api-server/src/routes/projects.rs | 88 +- software/api-server/src/routes/scope_guard.rs | 70 + software/api-server/src/routes/users.rs | 266 +- software/api-server/src/routes/wechat.rs | 63 + software/api-server/src/workers/energy.rs | 288 ++ .../api-server/src/workers/log_retention.rs | 108 + software/api-server/src/workers/mod.rs | 3 + .../api-server/src/workers/order_timeout.rs | 92 + .../api-server/src/workers/reply_worker.rs | 22 +- .../api-server/src/workers/report_worker.rs | 2419 +++++++++++++-- software/device-server/.env.example | 20 + software/device-server/.env.production | 17 - software/device-server/.env.test | 17 - software/device-server/Cargo.lock | 1 + software/device-server/Cargo.toml | 1 + software/device-server/src/config.rs | 8 +- software/device-server/src/main.rs | 134 +- software/device-server/src/tcp/connection.rs | 235 +- software/device-server/src/tcp/handler.rs | 57 +- software/device-server/src/tcp/protocol.rs | 159 +- software/device-server/src/tcp/server.rs | 235 +- software/h5-taro/babel.config.js | 9 - software/h5-taro/config/dev.js | 17 - software/h5-taro/config/index.js | 58 - software/h5-taro/config/prod.js | 6 - software/h5-taro/package.json | 32 - software/h5-taro/project.config.json | 30 - software/h5-taro/src/api/index.ts | 161 - software/h5-taro/src/app.config.ts | 43 - software/h5-taro/src/app.css | 66 - software/h5-taro/src/app.tsx | 44 - software/h5-taro/src/assets/device-active.svg | 1 - software/h5-taro/src/assets/device.svg | 1 - software/h5-taro/src/assets/home-active.svg | 1 - software/h5-taro/src/assets/home.svg | 1 - software/h5-taro/src/assets/me-active.svg | 1 - software/h5-taro/src/assets/me.svg | 1 - .../h5-taro/src/components/ChannelCard.tsx | 44 - .../h5-taro/src/components/ConfirmDialog.tsx | 35 - .../h5-taro/src/components/ErrorState.tsx | 24 - software/h5-taro/src/components/Loading.tsx | 12 - .../h5-taro/src/components/StatusBadge.tsx | 27 - software/h5-taro/src/index.html | 28 - .../pages/compartment-detail/index.config.ts | 3 - .../src/pages/compartment-detail/index.tsx | 243 -- .../src/pages/device-detail/index.config.ts | 3 - .../h5-taro/src/pages/device-detail/index.tsx | 84 - .../h5-taro/src/pages/devices/index.config.ts | 3 - software/h5-taro/src/pages/devices/index.tsx | 62 - .../h5-taro/src/pages/home/index.config.ts | 3 - software/h5-taro/src/pages/home/index.tsx | 121 - .../h5-taro/src/pages/login/index.config.ts | 4 - software/h5-taro/src/pages/login/index.tsx | 94 - software/h5-taro/src/pages/me/index.config.ts | 3 - software/h5-taro/src/pages/me/index.tsx | 70 - software/h5-taro/src/store/auth.ts | 53 - software/h5-taro/tsconfig.json | 21 - software/h5-uniapp/index.html | 22 - software/h5-uniapp/package.json | 6 +- software/h5-uniapp/src/App.vue | 50 +- software/h5-uniapp/src/api/index.ts | 124 +- software/h5-uniapp/src/components/AppIcon.vue | 48 + software/h5-uniapp/src/manifest.json | 24 +- software/h5-uniapp/src/pages.json | 14 +- .../src/pages/compartment-detail/index.vue | 479 ++- .../src/pages/device-detail/index.vue | 462 +-- .../h5-uniapp/src/pages/feedback/index.vue | 144 + software/h5-uniapp/src/pages/home/index.vue | 730 +---- software/h5-uniapp/src/pages/login/index.vue | 180 +- software/h5-uniapp/src/pages/me/index.vue | 185 +- software/h5-uniapp/src/pages/orders/index.vue | 247 +- software/h5-uniapp/src/static/logo.png | Bin 0 -> 87972 bytes software/h5-uniapp/src/static/scan-icon.png | Bin 0 -> 591 bytes software/h5-uniapp/src/static/wechat-icon.png | Bin 0 -> 64108 bytes software/h5-uniapp/src/store/auth.ts | 20 +- software/h5-uniapp/src/utils/deviceStatus.ts | 87 + software/h5-uniapp/src/utils/format.ts | 24 + software/h5-uniapp/vite.config.ts | 7 +- software/server/Cargo.lock | 2695 ----------------- software/server/Cargo.toml | 28 - software/server/src/config.rs | 52 - software/server/src/db/migrate.rs | 406 --- software/server/src/db/mod.rs | 17 - software/server/src/error.rs | 66 - software/server/src/main.rs | 82 - software/server/src/middleware/auth.rs | 286 -- software/server/src/middleware/mod.rs | 3 - software/server/src/redis.rs | 12 - software/server/src/routes/auth.rs | 118 - software/server/src/routes/cabinet_tree.rs | 113 - software/server/src/routes/cabinets.rs | 425 --- software/server/src/routes/charge_records.rs | 397 --- software/server/src/routes/device_logs.rs | 157 - software/server/src/routes/downloads.rs | 280 -- software/server/src/routes/energy_stats.rs | 553 ---- software/server/src/routes/h5.rs | 989 ------ software/server/src/routes/health.rs | 11 - software/server/src/routes/mod.rs | 107 - software/server/src/routes/operation_logs.rs | 144 - software/server/src/routes/organizations.rs | 255 -- software/server/src/routes/projects.rs | 135 - software/server/src/routes/roles.rs | 248 -- software/server/src/routes/users.rs | 564 ---- software/server/src/routes/users_perm.rs | 245 -- software/server/src/tcp/commands.rs | 78 - software/server/src/tcp/connection.rs | 114 - software/server/src/tcp/handler.rs | 297 -- software/server/src/tcp/mod.rs | 12 - software/server/src/tcp/protocol.rs | 82 - software/server/src/tcp/server.rs | 150 - software/web/public/logo.png | Bin 0 -> 76523 bytes software/web/src/App.tsx | 6 + software/web/src/api/devices.ts | 141 +- software/web/src/api/user.ts | 30 + .../web/src/components/LocationPicker.tsx | 186 ++ software/web/src/h5/api.ts | 154 - software/web/src/h5/auth.ts | 56 - software/web/src/h5/components.tsx | 249 -- software/web/src/h5/layout.tsx | 36 - .../web/src/h5/pages/compartment-detail.tsx | 253 -- software/web/src/h5/pages/device-detail.tsx | 84 - software/web/src/h5/pages/devices.tsx | 129 - software/web/src/h5/pages/home.tsx | 102 - software/web/src/h5/pages/login.tsx | 101 - software/web/src/h5/pages/me.tsx | 63 - software/web/src/h5/routes.tsx | 83 - software/web/src/index.css | 26 +- software/web/src/layouts/AdminLayout.tsx | 32 +- software/web/src/pages/Login.tsx | 278 +- software/web/src/pages/batteries/index.tsx | 176 ++ .../web/src/pages/charge-orders/index.tsx | 202 ++ .../pages/charge-records/DownloadCenter.tsx | 3 +- .../web/src/pages/charge-records/index.tsx | 13 +- software/web/src/pages/device-logs/index.tsx | 3 +- .../web/src/pages/devices/AddCabinetModal.tsx | 6 +- .../web/src/pages/devices/CabinetGrid.tsx | 232 +- .../src/pages/devices/OrganizationTree.tsx | 2 + software/web/src/pages/devices/cabinet.tsx | 659 +++- software/web/src/pages/devices/index.tsx | 291 +- software/web/src/pages/energy/index.tsx | 17 +- software/web/src/pages/settings/feedback.tsx | 194 ++ .../web/src/pages/settings/operation-logs.tsx | 3 +- software/web/src/pages/settings/roles.tsx | 3 +- software/web/src/pages/settings/users.tsx | 318 +- software/web/src/stores/auth.ts | 39 +- software/web/src/utils/deviceStatus.ts | 100 + software/web/src/utils/permission.ts | 2 +- software/web/tsconfig.app.json | 1 + software/web/vite.config.ts | 35 +- test/tcp_e2e_smoke.py | 156 + test/tcp_latency_probe.py | 78 + 生产环境.txt | 15 +- 209 files changed, 14117 insertions(+), 15349 deletions(-) create mode 100644 .workbuddy/memory/2026-07-07.md create mode 100644 deploy/deploy-prod.ps1 create mode 100644 docs/compound-notes/2026-08-15-sql-injection-format-param-binding.md create mode 100644 docs/compound-notes/TEMPLATE.md create mode 100644 docs/审查报告-数据单位一致性-2026-07-06.md create mode 100644 docs/柜控板固件对接文档.md create mode 100644 docs/项目深度分析报告-2026-07-06.md create mode 100644 ea4410ba9c9721b1e64825dd3ba74b7e.jpg create mode 100644 logo/圆切带文字.png create mode 100644 logo/圆切带文字svg.svg create mode 100644 logo/圆切独立LOGO.png create mode 100644 logo/圆切独立LOGO.svg create mode 100644 logo/白色横向.svg create mode 100644 logo/白色横向@2x.png create mode 100644 logo/白色竖向.svg create mode 100644 logo/白色竖向@2x.png create mode 100644 logo/纯色横向.svg create mode 100644 logo/纯色横向@2x.png create mode 100644 logo/纯色竖向.svg create mode 100644 logo/纯色竖向@2x.png create mode 100644 opencode.json create mode 100644 software/api-server/.env.example delete mode 100644 software/api-server/.env.production delete mode 100644 software/api-server/.env.test create mode 100644 software/api-server/src/captcha.rs create mode 100644 software/api-server/src/routes/batteries.rs create mode 100644 software/api-server/src/routes/charge_orders.rs create mode 100644 software/api-server/src/routes/maps.rs create mode 100644 software/api-server/src/routes/scope_guard.rs create mode 100644 software/api-server/src/routes/wechat.rs create mode 100644 software/api-server/src/workers/energy.rs create mode 100644 software/api-server/src/workers/log_retention.rs create mode 100644 software/api-server/src/workers/order_timeout.rs create mode 100644 software/device-server/.env.example delete mode 100644 software/device-server/.env.production delete mode 100644 software/device-server/.env.test delete mode 100644 software/h5-taro/babel.config.js delete mode 100644 software/h5-taro/config/dev.js delete mode 100644 software/h5-taro/config/index.js delete mode 100644 software/h5-taro/config/prod.js delete mode 100644 software/h5-taro/package.json delete mode 100644 software/h5-taro/project.config.json delete mode 100644 software/h5-taro/src/api/index.ts delete mode 100644 software/h5-taro/src/app.config.ts delete mode 100644 software/h5-taro/src/app.css delete mode 100644 software/h5-taro/src/app.tsx delete mode 100644 software/h5-taro/src/assets/device-active.svg delete mode 100644 software/h5-taro/src/assets/device.svg delete mode 100644 software/h5-taro/src/assets/home-active.svg delete mode 100644 software/h5-taro/src/assets/home.svg delete mode 100644 software/h5-taro/src/assets/me-active.svg delete mode 100644 software/h5-taro/src/assets/me.svg delete mode 100644 software/h5-taro/src/components/ChannelCard.tsx delete mode 100644 software/h5-taro/src/components/ConfirmDialog.tsx delete mode 100644 software/h5-taro/src/components/ErrorState.tsx delete mode 100644 software/h5-taro/src/components/Loading.tsx delete mode 100644 software/h5-taro/src/components/StatusBadge.tsx delete mode 100644 software/h5-taro/src/index.html delete mode 100644 software/h5-taro/src/pages/compartment-detail/index.config.ts delete mode 100644 software/h5-taro/src/pages/compartment-detail/index.tsx delete mode 100644 software/h5-taro/src/pages/device-detail/index.config.ts delete mode 100644 software/h5-taro/src/pages/device-detail/index.tsx delete mode 100644 software/h5-taro/src/pages/devices/index.config.ts delete mode 100644 software/h5-taro/src/pages/devices/index.tsx delete mode 100644 software/h5-taro/src/pages/home/index.config.ts delete mode 100644 software/h5-taro/src/pages/home/index.tsx delete mode 100644 software/h5-taro/src/pages/login/index.config.ts delete mode 100644 software/h5-taro/src/pages/login/index.tsx delete mode 100644 software/h5-taro/src/pages/me/index.config.ts delete mode 100644 software/h5-taro/src/pages/me/index.tsx delete mode 100644 software/h5-taro/src/store/auth.ts delete mode 100644 software/h5-taro/tsconfig.json delete mode 100644 software/h5-uniapp/index.html create mode 100644 software/h5-uniapp/src/components/AppIcon.vue create mode 100644 software/h5-uniapp/src/pages/feedback/index.vue create mode 100644 software/h5-uniapp/src/static/logo.png create mode 100644 software/h5-uniapp/src/static/scan-icon.png create mode 100644 software/h5-uniapp/src/static/wechat-icon.png create mode 100644 software/h5-uniapp/src/utils/deviceStatus.ts create mode 100644 software/h5-uniapp/src/utils/format.ts delete mode 100644 software/server/Cargo.lock delete mode 100644 software/server/Cargo.toml delete mode 100644 software/server/src/config.rs delete mode 100644 software/server/src/db/migrate.rs delete mode 100644 software/server/src/db/mod.rs delete mode 100644 software/server/src/error.rs delete mode 100644 software/server/src/main.rs delete mode 100644 software/server/src/middleware/auth.rs delete mode 100644 software/server/src/middleware/mod.rs delete mode 100644 software/server/src/redis.rs delete mode 100644 software/server/src/routes/auth.rs delete mode 100644 software/server/src/routes/cabinet_tree.rs delete mode 100644 software/server/src/routes/cabinets.rs delete mode 100644 software/server/src/routes/charge_records.rs delete mode 100644 software/server/src/routes/device_logs.rs delete mode 100644 software/server/src/routes/downloads.rs delete mode 100644 software/server/src/routes/energy_stats.rs delete mode 100644 software/server/src/routes/h5.rs delete mode 100644 software/server/src/routes/health.rs delete mode 100644 software/server/src/routes/mod.rs delete mode 100644 software/server/src/routes/operation_logs.rs delete mode 100644 software/server/src/routes/organizations.rs delete mode 100644 software/server/src/routes/projects.rs delete mode 100644 software/server/src/routes/roles.rs delete mode 100644 software/server/src/routes/users.rs delete mode 100644 software/server/src/routes/users_perm.rs delete mode 100644 software/server/src/tcp/commands.rs delete mode 100644 software/server/src/tcp/connection.rs delete mode 100644 software/server/src/tcp/handler.rs delete mode 100644 software/server/src/tcp/mod.rs delete mode 100644 software/server/src/tcp/protocol.rs delete mode 100644 software/server/src/tcp/server.rs create mode 100644 software/web/public/logo.png create mode 100644 software/web/src/components/LocationPicker.tsx delete mode 100644 software/web/src/h5/api.ts delete mode 100644 software/web/src/h5/auth.ts delete mode 100644 software/web/src/h5/components.tsx delete mode 100644 software/web/src/h5/layout.tsx delete mode 100644 software/web/src/h5/pages/compartment-detail.tsx delete mode 100644 software/web/src/h5/pages/device-detail.tsx delete mode 100644 software/web/src/h5/pages/devices.tsx delete mode 100644 software/web/src/h5/pages/home.tsx delete mode 100644 software/web/src/h5/pages/login.tsx delete mode 100644 software/web/src/h5/pages/me.tsx delete mode 100644 software/web/src/h5/routes.tsx create mode 100644 software/web/src/pages/batteries/index.tsx create mode 100644 software/web/src/pages/charge-orders/index.tsx create mode 100644 software/web/src/pages/settings/feedback.tsx create mode 100644 software/web/src/utils/deviceStatus.ts create mode 100644 test/tcp_e2e_smoke.py create mode 100644 test/tcp_latency_probe.py diff --git a/.gitignore b/.gitignore index 3e7bf93..841b23b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,10 @@ package-lock.json .DS_Store Thumbs.db -# Environment -.env -.env.local +# Environment / 凭据 +# ⚠️ 用 **/.env* 覆盖所有层级的 .env / .env.production / .env.test 等变体;仅放行示例模板 +**/.env* +!**/.env.example # Temporary *.tmp @@ -38,6 +39,18 @@ dist/ # SWC compiler platform plugins (auto-downloaded by Taro) .swc/ +# Uploads +uploads/ + # Test 换电柜用锂电池充电器1TN标准通信协议*.pdf 生产环境.txt +.code-graph/ + +# 本地 cargo check 的独立 target-dir(避免与主 target 抢锁,体积大,不入库) +.cargo-check-*/ + +# 本地临时/工具产物 +tmp/ +.codebuddy/ + diff --git a/.workbuddy/memory/2026-07-07.md b/.workbuddy/memory/2026-07-07.md new file mode 100644 index 0000000..c63f26f --- /dev/null +++ b/.workbuddy/memory/2026-07-07.md @@ -0,0 +1,32 @@ +# 2026-07-07 Session Notes + +## 完成工作:安知充H5应用设计稿 — 修复全部7屏填充色问题 + +### 问题诊断 +- 用户反馈"位置都是乱的",截图验证发现所有7屏几乎全白/空白 +- 根因:上一轮重建时所有frame节点的 `fills: []` 为空(容器无背景色),只有text节点有fills所以能看到零星文字 +- batch_read 确认:用户卡片、菜单卡片、设备卡片、订单卡片、通道卡片等所有容器的 fills 均为空数组 + +### 修复操作(共6个batch_edit批次,约120+个U()操作) +1. **Batch 1 (23 ops)**: 7屏背景色(登录渐变+其余#f5f5f5) + 登录表单元素(输入框白底边框+渐变按钮) + 首页卡片(组织切换/筛选标签/统计行/设备卡×2) + TabBar×2 +2. **Batch 2 (10 ops)**: 设备列表卡×3 + 仓体详情卡片×3(状态/参数/记录) + 操作按钮×2(停止橙色/开门线框) + 订单卡×3 +3. **Batch 3 (8 ops)**: 设备详情-信息卡白色 + 开门按钮蓝色 + 板头×6(灰蓝色调) +4. **Batch 4 (22 ops)**: 通道卡1-12(板1-2): 白底圆角6px + 彩色状态条(绿/蓝/红/紫) +5. **Batch 5 (22 ops)**: 通道卡13-24(板3离线灰色 + 板4混合状态) +6. **Batch 6 (22 ops)**: 通道卡25-36(板5-6混合状态) +7. **我的页补完**: 用户卡片白底 + 头像蓝底#dbeafe + 菜单卡片白底 + 退出登录按钮(红字红边框) + 底部TabBar(SVG图标) + +### 关键技术发现 +- ardot batch_edit 中 Update 已有节点:`fills` 必须用 `[{type:"SOLID", color:{r,g,b}}]` 数组格式 +- `strokes` 必须用数组格式 `[{type:"SOLID", color:{r,g,b}, width:N}]` +- Insert 新节点时 `fill:"#FFF"` 简写可用,但 Update 时不可靠 + +### 颜色修正(用户反馈"颜色不对"后修复) +- 对照全部7个源文件的CSS,逐一修正了所有颜色 +- **登录页**: 输入框背景 #f8fafc(更正白色), 边框 #e2e8f0(更正#e5e7eb) +- **首页**: 组织切换器背景 #f3f4f6(更正白色), 筛选标签激活态 #2563eb蓝(更正#dbeafe浅蓝) +- **设备列表**: 状态徽章添加背景圆角#f0fdf4/#f9fafb +- **设备详情**: 在线状态徽章/DC/AC标签添加圆角背景#f0fdf4, 导航栏添加底部边框 +- **仓体详情**: 状态徽章添加圆角背景#eff6ff, 记录项添加底部边框 +- **订单**: 状态徽章添加圆角背景(正常#f0fdf4/异常#fef2f2/停止#fffbeb), 订单体添加顶部边框 +- 均用 `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]` 数组格式设置边框 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 770b791..52e1dd3 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -4,3 +4,14 @@ - **产品介绍页风格**:偏好明亮/浅色风格,不要暗色科技风;单屏不滚动布局 - **生图能力**:项目有 ImageGen 文生图工具可用,可用于生成产品渲染图等 - **操作规则**:开发/修改程序前必须确认需求,不可自说自话操作;生产环境服务器文件和数据必须用户授权后才可动 + +## Ardot 设计稿经验(充电柜H5应用) +- **设计稿文件ID**: `701384244279693`("安知充H5应用设计稿") +- **7屏完整列表**: 登录(2:1004) 首页(2:1022) 设备详情(2:1094,高1260) 设备列表(2:1334) 仓体详情(2:1359) 订单(2:1411) 我的(2:1468) +- **关键教训 — frame fills 必须用数组格式**: `fills` 属性必须用 `[{type:"SOLID", color:{r,g,b}}]` RGB对象数组。`fill:"#FFF"` 十六进制简写在 batch_edit 的 Update 操作中对已有frame节点可能不生效(Insert 新节点时可用简写,Update 已有节点必须用数组格式) +- **strokes 必须用数组格式**: `strokes: [{type:"SOLID", color:{r,g,b}, width:N}]`,不是对象形式 +- **cornerRadius 必须是数字**: 不能传数组 +- **屏幕背景色**: tab页面 #f5f5f5,子页面(设备详情/列表/仓体详情) 也是 #f5f5f5;登录页用渐变 linear-gradient(135deg,#f0f4ff,#e0e7ff,#f0f9ff) +- **卡片统一白色**: 所有卡片/容器 #FFFFFF + 圆角 +- **颜色系统**: 主蓝#2563eb, 在线绿#16a34a(bg:#f0fdf4), 离线灰#9ca3af(bg:#f9fafb), 故障红#dc2626(bg:#fef2f2), 停止橙#f59e0b(bg:#fffbeb), 充电中蓝#2563eb(bg:#eff6ff), 满电紫#7c3aed +- **设备详情36仓**: 6板×6通道=36, 通道卡片白底+顶部3px彩色状态条(色条用RECTANGLE子节点fill表示状态色) diff --git a/AGENTS.md b/AGENTS.md index a6e9ba3..1223dec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 项目简介 充电柜矩阵系统(Power Matrix System / PMS) -产品名:安知充(anzhizhichong),已有充电站平台在运行,充电柜是新增产品线。 +产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。 仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。 ## 设备标识 @@ -11,13 +11,13 @@ - **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID) - **设备ID**:4G SIM卡15位IMEI(10进制) - **仓体全局ID**:`平台层ID-仓板ID-仓体ID` -- **扫码链接**:`https://app.anzhizhichong.com/h5/{抽象ID}` +- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`) ## 技术栈 - 后端:Rust + Axum - 前端:React + Arco Design Pro(React版) -- H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序) +- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin) - 数据库:MySQL(阿里云RDS),开发/生产共用 - 缓存:Redis(阿里云Redis),开发/生产共用 - 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1) @@ -38,12 +38,14 @@ D:\charging-cabinet\ ├── software/ │ ├── api-server/ # Rust API服务 │ ├── device-server/ # Rust TCP设备服务 -│ ├── h5-uniapp/ # H5用户端(uni-app + Vue 3) +│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) │ └── web/ # React前端(WEB管理端) ├── hardware/ # 硬件资料(原理图/PCB等) └── test/ # 测试用例/报告 ``` +> **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。 + ## 部署架构 ### 环境规划 @@ -53,14 +55,26 @@ D:\charging-cabinet\ | 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ | | 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ | -**服务端口分配:** -- 测试环境:API 3000,TCP 3001 +**服务端口分配(规划):** - 正式环境:API 9000,TCP 9001 +- 测试环境:API 3000,TCP 3001 + +> ✅ **实际部署现状(2026-09 已按规划完成迁移)**: +> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx +> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env` +> (**新二进制强制要求环境变量,缺它起不来**)。 +> - **本服务器不部署测试环境**(用户确认:这台就是生产)—— `pms-api-test` / `pms-device-test` +> 已停止并卸载,`:3000` / `:3001` 已释放。 +> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为 +> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API, +> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。** +> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**, +> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。 **部署路径:** - 产品介绍页:/data/landing -- 正式环境:/data/pms(api-server、device-server、web、h5) -- 测试环境:/data/pms_dev(api-server、device-server、web、h5) +- 正式环境:/data/pms(api-server、device-server、web) +- 测试环境:/data/pms_dev(api-server、device-server、web) **Nginx配置:** - 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf @@ -140,17 +154,19 @@ python tools/db.py import data.sql ### 禁止操作 +- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知 - **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制 - **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理 - **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore) ### 编码任务下发 -- 任务需求写到 `tasks/` 目录的md文件 -- 通过Agent子代理包裹opencode执行,异步通知 -- 质量约束模板固定追加到每个任务文件 -- opencode超时必须报告用户,不能fallback手动执行 -- 审查用不同模型(不用开发同款) +- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md) +- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json) +- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件 +- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落 +- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议 +- 已完成的任务文件及时清理 ### 质量约束模板 @@ -166,9 +182,10 @@ python tools/db.py import data.sql ### 验收流程 -1. 静态校验命令通过 -2. 跑起来看效果 -3. 抽查关键逻辑(异常处理、权限校验、协议解析) +1. `cargo check` 两个服务(api-server + device-server)零错误 +2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束) +3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号) +4. 审查通过 → 清理任务文件,更新文档 --- @@ -185,16 +202,22 @@ python tools/db.py import data.sql - 使用 `tokio` 运行时,数据库/Redis 操作用 `.await` - 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环 -- Redis 队列消费:`blpop` 阻塞读取 + `tokio::spawn` 并发处理 +- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** —— + 本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`, + 这种组合下 `BRPOP` 语义不可靠(实测 2026-09): + - `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**; + - `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。 + **规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致), + 延迟稳定在 200ms 内;取到消息后 `tokio::spawn` 并发处理。 ### 模块划分 ``` src/ ├── routes/ # HTTP 路由处理器(按业务拆分) -├── middleware/ # 中间件(认证、日志、CORS) +├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验) ├── db/ # 数据库连接池、迁移 -├── commands/ # 设备指令下发逻辑 +├── commands/ # 设备指令下发逻辑(含 AppState 定义) ├── nodes/ # 设备服务节点管理 ├── workers/ # 后台 Worker(report/reply) └── error.rs # 统一错误定义 @@ -222,12 +245,11 @@ src/ - 页面级状态用 `useState` / `useEffect` - 全局状态用 `zustand`(如用户信息、权限) -- 服务端数据用 `@tanstack/react-query`,配置缓存策略 ### 样式规范 -- 使用 Tailwind CSS,禁止内联 style(除动态计算值) -- shadcn/ui 组件优先,不重复造轮子 +- 使用 Arco Design 组件库,优先使用组件自带的样式 +- 内联 style 仅用于动态计算值,静态样式用 CSS class - 表格列统一 `min-width` + `align="center"`,无 `fixed="right"` - 分页:上下双分页,`page-sizes="[10,20,30,50]"` @@ -318,7 +340,7 @@ src/ 示例: - `feat(cabinets): 添加设备RSSI信号显示` -- `fix(h5): 修复登录后token未持久化问题` +- `fix(mp): 修复登录后token未持久化问题` - `docs(api): 更新充电记录接口文档` --- @@ -356,10 +378,17 @@ test_check_permission_user_returns_forbidden ### 认证授权 -- JWT Token 有效期:7天,过期需重新登录 +- JWT Token 有效期:24小时,过期需重新登录 +- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret - 密码存储:argon2 哈希,禁止明文或 MD5/SHA1 - RBAC 权限码:精确到按钮级别(如 `device:door:unlock`) +- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免 - 敏感操作(删除/禁用/开门)需二次确认 +- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递 +- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`) +- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`** +- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定 +- **未登录连接不予保持**:建连后 30 秒内未 `login` 成功即主动断开 ### 输入校验 @@ -532,7 +561,7 @@ server { # PMS API 入口(微服务统一前缀 /api/{服务}) location /api/pms/ { - proxy_pass http://127.0.0.1:3000/api/; + proxy_pass http://127.0.0.1:9000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -545,10 +574,13 @@ server { try_files $uri $uri/ /pms/index.html; } - # H5用户端 - location /h5/ { - alias /data/pms/h5/; - try_files $uri $uri/ /h5/index.html; + # 微信回调(免认证) + location /api/wechat/ { + proxy_pass http://127.0.0.1:9000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } ``` @@ -586,16 +618,13 @@ server { try_files $uri $uri/ /pms/index.html; } - # H5用户端 - location /h5/ { - alias /data/pms_dev/h5/; - try_files $uri $uri/ /h5/index.html; - } - - # H5用户端 - location /h5/ { - alias /data/pms_dev/h5/; - try_files $uri $uri/ /h5/index.html; + # 微信回调(免认证) + location /api/wechat/ { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } ``` @@ -604,55 +633,80 @@ server { ### 部署步骤 +> ✅ **推荐用一键脚本(在本地开发机执行)**: +> ```bash +> pwsh deploy/deploy-prod.ps1 # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收 +> pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,只重发现有产物 +> pwsh deploy/deploy-prod.ps1 -DryRun # 演练,不做任何改动 +> pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端 +> ``` +> 脚本会自动做:产物路径校验、**上传后 `chmod +x`**、**传 `.new` 再原子 `mv`**(避开 ETXTBSY)、 +> `nginx -t` 后才 reload、验收(服务/端口/域名/ERROR 数)、以及**用不存在的 IMEI 探测上报消费链路** +> (不碰真实设备数据),最后打印回滚方法。 +> +> ⛔ **`deploy/deploy-prod.sh` 与 `deploy/deploy-test.sh` 已废弃**(执行即报错退出): +> 它们在 `target/release/` 找产物、会安装重复的 `pms-api-prod` 单元、并会用本地 `.env` +> 覆盖线上 `.env`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。 + **1. 本地编译** ```bash -# 后端(Rust) +# 后端(Rust)— Windows 交叉编译 Linux 目标 cd software/api-server -cargo build --release +cargo zigbuild --release --target x86_64-unknown-linux-gnu cd software/device-server -cargo build --release +cargo zigbuild --release --target x86_64-unknown-linux-gnu -# 前端(React) +# 前端(React 管理端) cd software/web npm run build + +# 微信小程序用户端(uni-app) +cd software/h5-uniapp +npm run build:mp-weixin +# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传 ``` **2. 传输到服务器** ```bash # 传输后端二进制(测试环境) -scp software/api-server/target/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/ -scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms_dev/device-server/ +scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/ +scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms_dev/device-server/ # 传输后端二进制(正式环境) -scp software/api-server/target/release/pms-api root@118.31.119.206:/data/pms/api-server/ -scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms/device-server/ +scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/ +scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms/device-server/ # 传输前端静态文件 scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境 scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境 - -# 传输H5用户端(tar.gz打包方式,保留UTF-8编码) -cd software/h5-uniapp/dist/build/h5 -tar -czf /tmp/h5-build.tar.gz . -pscp -pw "Hbhyg731024@" /tmp/h5-build.tar.gz root@118.31.119.206:/tmp/ -plink -ssh -pw "Hbhyg731024@" root@118.31.119.206 "rm -rf /data/pms/h5/* && cd /data/pms/h5 && tar -xzf /tmp/h5-build.tar.gz" ``` -> ⚠️ H5部署必须用tar.gz打包方式,不要用plink/pscp管道传单文件(会丢失UTF-8编码导致中文变问号)。 +> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。 +> +> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`); +> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势: +> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。 +> +> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。 + +**2.1 微信小程序上传** + +小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传: + +1. 打开微信开发者工具 +2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin` +3. 填入 AppID(已配置在 `manifest.json`) +4. 预览/真机调试 → 上传体验版/正式版 **3. 服务器重启服务** ```bash # SSH到服务器 echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@" -# 重启测试环境 -systemctl restart pms-api-test -systemctl restart pms-device-test - -# 重启正式环境 -systemctl restart pms-api-prod -systemctl restart pms-device-prod +# 正式环境 +systemctl restart pms-api +systemctl restart pms-device # 重启Nginx systemctl restart nginx @@ -662,13 +716,13 @@ systemctl restart nginx ```bash # 查看服务状态 -systemctl status pms-api-test # 测试环境API -systemctl status pms-api-prod # 正式环境API +systemctl status pms-api # 正式环境API +systemctl status pms-device # 正式环境TCP systemctl status nginx # 查看日志 -journalctl -u pms-api-test -f -journalctl -u pms-api-prod -f +journalctl -u pms-api -f +journalctl -u pms-device -f tail -f /var/log/nginx/error.log ``` @@ -685,12 +739,11 @@ tail -f /var/log/nginx/error.log ### 做 - 后台管理系统(Web端) -- 用户端H5页面(`/pms/{id}`,微信扫码可访问) +- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传) - 设备TCP通讯服务(协议文档已就绪) ### 不做 -- 微信小程序(名额暂不可用,先用H5,uni-app可后续转换) - 支付/计费(本系统不涉及) ## 组织架构 @@ -779,26 +832,63 @@ device:{imei} # 设备级 Hash ## TCP通讯协议 -详见 [docs/API.md](docs/API.md#tcp-设备通讯协议) — 包含签名机制、指令清单、指令详情、充电结束原因。 +详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。 ## 数据单位规范 详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。 -**现有浮点问题(需清理):** +> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。 -| 文件 | 位置 | 问题 | 处理 | -|------|------|------|------| -| `src/routes/charge_records.rs` | L46-47, L77-78 | `Option` 读 DECIMAL | 需 CAST AS DOUBLE [有CAST] | -| `src/routes/energy_stats.rs` | L106, L116 | `query_scalar::<_, f64>` 直接读 DECIMAL 无 CAST ❌ | 需加 CAST AS DOUBLE 或改用整数 | -| `src/routes/energy_stats.rs` | L43-72 | 多个 `Option` 字段 | 待迁移为 i64 | -| `src/db/migrate.rs` | L76-77, L97 | `DECIMAL` 列定义 | 待改为 `BIGINT` | +## 微信小程序用户端 -## H5用户端 +### 小程序配置 + +| 参数 | 值 | +|------|-----| +| AppID | `wx226061c550c52e85` | +| AppSecret | `f11dfee9dc73265b75bf54277ebb1047` | +| 账号 | `13951516272@163.com` | +| 消息加解密方式 | 明文模式 | +| 数据格式 | XML | +| 服务器地址(URL) | `https://app.anzhizhichong.com/api/wechat/callback` | +| Token(令牌) | `7GW16XdnFyak0mE32rc8ZPij` | +| EncodingAESKey | `kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu` | + +> ⚠️ 以上密钥仅记录在内网 Git 仓库 `生产环境.txt`,不入代码仓库。 + +### 构建命令 + +```bash +cd software/h5-uniapp +npm run build:mp-weixin +# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传 +``` + +### API 域名配置 + +小程序请求后端 API 必须配置合法域名(HTTPS): + +- 后台「开发管理 → 开发设置 → 服务器域名」 +- request 合法域名:`https://app.anzhizhichong.com` +- 开发阶段可在 `manifest.json` 设 `urlCheck: false` 跳过校验 + +### API base URL + +小程序用全 URL(`api/index.ts` 条件编译): + +```typescript +// #ifdef MP-WEIXIN +const BASE_URL = 'https://app.anzhizhichong.com/api/pms' +// #endif +// #ifndef MP-WEIXIN +const BASE_URL = '/api/pms' +// #endif +``` ### 信号强度显示(RSSI) -设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和 H5 移动端共用同一套逻辑。 +设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。 **RSSI 映射:** @@ -810,7 +900,7 @@ device:{imei} # 设备级 Hash | ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 | | < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) | -**实现方式(移动端 H5):** +**实现方式(小程序):** ```vue @@ -838,8 +928,8 @@ HSET device:{imei} online 1 rssi 85 **相关文件:** - PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件 -- H5: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板 -- H5 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline +- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板 +- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline - 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi` ### 近期 UI 改动 @@ -854,3 +944,16 @@ HSET device:{imei} online 1 rssi 85 2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠 3. **内联 SVG + v-if**:Vue 3 支持在 `` 上用 `v-if` 条件渲染,适用于动态图标 4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `` 上写死 + +## 代码审查豁免 + +以下模式已确认无需修改: + +| 审查项 | 原因 | +|--------|------| +| TCP keepalive 配置 | 我们是 TCP server,设备才是 client。设备每 60s 发 status_post 就是应用层保活,不需要在 server 侧设 keepalive。 | +| SHA256 签名只取 8 位 | MCU 硬件协议固定,不可改 | +| AGENTS.md 明文密码 | 内网 Git 仓库,agent 需要直连 | +| 小程序 token 存 Storage | 小程序无 HttpOnly cookie,纯前端场景 | +| 8-char sign 长度 | 硬件约束,设备一次只能获取一次 auth_str | +| ServerResponse / DeviceCommand 含额外内部字段(`_dev_id`、`_close`) | `_dev_id` = 目标设备 IMEI,**供 device-server 路由指令**(协议 `id` 已不含 IMEI,只表示动作目标);`_close` 指示发送后断开连接(`login` 失败时)。两者**下发前均由 device-server 摘除**(`split_internal_flags`),设备侧不可见 | diff --git a/README.md b/README.md index e906b3a..f188222 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 充电柜矩阵系统 (Power Matrix System / PMS) -产品名:安知充(anzhizhichong) +产品名:安知充(anzhizhichong),品牌名:艾动智能 ## 项目简介 @@ -10,7 +10,7 @@ - **后端**:Rust + Axum - **前端**:React + Arco Design Pro(管理端) -- **H5用户端**:uni-app + Vue 3(跨平台,支持 H5 / 微信小程序) +- **微信小程序用户端**:uni-app + Vue 3(构建目标 mp-weixin) - **数据库**:MySQL(阿里云RDS) - **缓存**:Redis(阿里云Redis) - **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1) @@ -25,12 +25,14 @@ charging-cabinet/ │ ├── api-server/ # Rust API服务 │ ├── device-server/ # Rust TCP设备服务 │ ├── web/ # React管理端前端 -│ └── h5-uniapp/ # H5用户端(uni-app + Vue 3) +│ └── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) ├── hardware/ # 硬件资料(原理图/PCB等) ├── deploy/ # 部署配置 └── test/ # 测试用例/报告 ``` +> 本仓库不存放技能库(skills)。开发期技能从全局技能目录加载(`~/.dsh/skills`、`~/.agents/skills`),请勿在仓库内重新 vendor。 + ## 部署架构 | 环境 | 域名 | 说明 | @@ -49,7 +51,7 @@ charging-cabinet/ ``` ┌─────────────────────────────────────────────────────────────┐ │ API服务 (api-server) — 主服务 │ -│ - HTTP API(后台管理 + H5)端口: 3000 │ +│ - HTTP API(后台管理 + 小程序)端口: 3000 │ │ - 前端轮询刷新(每5秒) │ │ - 业务逻辑处理(验证/存DB/告警) │ │ - 节点管理(设备服务注册/心跳) │ @@ -85,284 +87,20 @@ charging-cabinet/ - 每60秒心跳,180秒无心跳视为离线 - 支持多节点扩展(当前单节点) -## TCP通讯协议 +## TCP 设备通讯协议 -### 通信规则 +> 完整协议文档见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,唯一权威 TCP 文档) -- TCP长连接,无需PING包 -- 空闲1分钟上报,充电15秒上报 -- 超时5000ms -- JSON去空格回车省流量,LF处理粘包 -- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID -- `msg_id`:自增会话ID -- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`) +- TCP 长连接,空闲 60 秒/充电 15 秒上报 +- 所有设备上报消息平台都会回复 `{"act":"<同请求>","msg_id":N,"suc":1}` 确认 +- 响应扁平化,不加 `data` 包裹 +- 签名机制:`SHA256(dev_id + auth_str + timestamp)`,取 `hex[56..64]`,**8 位**小写十六进制 +- `id` 字段:仓体级 `IMEI-板-通道`,柜级 `IMEI` +- 全局整数单位:voltage mV, current mA, temp K×100, soc ‰ -### 签名机制 +**11 条指令:** login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off / open / pow_on / pow_off / reset -- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次) -- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)` -- 时间戳秒级,平台校验±60秒 - -### 指令清单 - -| 动作 | 方向 | id格式 | 说明 | -|------|------|--------|------| -| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) | -| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) | -| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) | -| `pow_fail` | 设备→平台 | 无 | 电源掉电上报(含info区分交直流) | -| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入(含BMS数据,字段待定) | -| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 | -| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) | -| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) | -| `open` | 平台→设备 | `IMEI` | 开门 | -| `pow_on` | 平台→设备 | `IMEI` | 接触器合闸(恢复380V供电) | -| `pow_off` | 平台→设备 | `IMEI` | 接触器分闸(切断380V供电) | -| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 | - -### status_post格式 - -```json -{ - "act": "status_post", - "msg_id": 42, - "dev_id": "123456789012345", - "rssi": 85, - "status": { - "123456789012345-01": "000100020003000400050006", - "123456789012345-02": "000100020003000400050006" - } -} -``` - -- key: `IMEI-仓控板ID` -- value: 6个通道状态拼接(每通道2字节=4个HEX字符) -- 平台从key解析仓控板数量 - -### 指令详情 - -#### login(设备→平台) - -设备登录,验证签名。 - -```json -// 请求 -{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"} - -// 响应 -{"suc":1,"msg_id":1,"cell":6} -``` - -**字段说明:** -- `sign` — 签名(SHA256(dev_id+auth_str+timestamp)后8位) -- `timestamp` — 秒级时间戳(平台校验±60秒) -- `h_ver` — 硬件版本 -- `s_ver` — 软件版本 -- `cell` — 仓控板数量 - ---- - -#### auth_str(设备→平台) - -获取8位安全码,仅首次连接调用。 - -```json -// 请求 -{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"} - -// 响应 -{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}} -``` - -**字段说明:** -- `ccid` — SIM卡ICCID号 -- `auth_str` — 8位随机安全码(仅返回一次) - ---- - -#### status_post(设备→平台) - -定时上报,空闲60秒/充电15秒。 - -```json -// 请求 -{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,"status":{"123456789012345-01":"000100020003000400050006","123456789012345-02":"000100020003000400050006"}} - -// 响应 -{"suc":1,"msg_id":10} -``` - -**status字段说明:** -- key: `IMEI-仓控板ID` -- value: 6个通道状态拼接(每通道2字节=4个HEX字符) -- 平台从key解析仓控板数量 - ---- - -#### pow_fail(设备→平台) - -电源掉电上报。 - -```json -// 请求 -{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"} - -// 响应 -{"suc":1,"msg_id":15} -``` - -**字段说明:** -- `info` — 停电类型:`0`=直流停电(12V掉电),`1`=交流停电(380V掉电) - ---- - -#### pow_on(平台→设备) - -接触器合闸,恢复380V充电电源供电。 - -```json -// 请求 -{"act":"pow_on","msg_id":16,"dev_id":"123456789012345"} - -// 响应 -{"suc":1,"msg_id":16} -``` - ---- - -#### pow_off(平台→设备) - -接触器分闸,切断380V充电电源供电。 - -```json -// 请求 -{"act":"pow_off","msg_id":16,"dev_id":"123456789012345"} - -// 响应 -{"suc":1,"msg_id":16} -``` - ---- - -#### bat_in(设备→平台) - -电池插入事件,立即上报。 - -```json -// 请求 -{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}} - -// 响应 -{"suc":1,"msg_id":20} -``` - -**平台处理:** -1. 记录电池信息 -2. 验证电池合规性 -3. 自动下发 `on` 指令 - -**BMS数据字段待定。** - ---- - -#### bat_out(设备→平台) - -电池拔出事件,立即上报。 - -```json -// 请求 -{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"} - -// 响应 -{"suc":1,"msg_id":21} -``` - ---- - -#### on(双向) - -**平台→设备:** 开始充电 - -```json -// 请求 -{"act":"on","msg_id":30,"id":"123456789012345-01-6"} - -// 响应 -{"suc":1,"msg_id":30} -``` - -**设备→平台:** 充电开始确认(自动充电模式) - -```json -// 上报 -{"act":"on","msg_id":30,"id":"123456789012345-01-6"} -``` - ---- - -#### off(双向) - -**平台→设备:** 手动停止充电 - -```json -// 请求 -{"act":"off","msg_id":31,"id":"123456789012345-01-6"} - -// 响应 -{"suc":1,"msg_id":31} -``` - -**设备→平台:** 充满自动停止 - -```json -// 上报 -{"act":"off","msg_id":31,"id":"123456789012345-01-6"} -``` - ---- - -#### open(平台→设备) - -开门,只需IMEI。 - -```json -// 请求 -{"act":"open","msg_id":40,"id":"123456789012345"} - -// 响应 -{"suc":1,"msg_id":40} -``` - -**错误码63 = 门锁故障。** - ---- - -#### reset(平台→设备) - -重启整柜或仓控板。 - -```json -// 重启整柜 -{"act":"reset","msg_id":60,"id":"123456789012345"} - -// 重启仓控板 -{"act":"reset","msg_id":61,"id":"123456789012345-01"} - -// 响应 -{"suc":1,"msg_id":60} -``` - -### 充电结束原因(平台判断) - -| 平台收到 | 判断原因 | -|---------|---------| -| `off` (设备→平台) | `normal` — 正常充满 | -| `off` (平台→设备) | `user_stop` — 用户手动停止 | -| `bat_out` | `normal` — 电池拔出 | -| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 | -| 超时未响应 | `timeout` — 通信超时 | - -## 开发规范 +完整请求/响应示例、字段表、签名自测向量、超时与自愈流程 → [docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md) - 任务需求写到 `tasks/` 目录的md文件 - 通过Agent子代理包裹opencode执行,异步通知 diff --git a/deploy/deploy-prod.ps1 b/deploy/deploy-prod.ps1 new file mode 100644 index 0000000..1f1262f --- /dev/null +++ b/deploy/deploy-prod.ps1 @@ -0,0 +1,156 @@ +# ============================================================================= +# PMS 正式环境部署脚本(在**本地开发机**执行,不要拿到服务器上跑) +# +# 为什么是 PowerShell 而不是原 deploy-prod.sh: +# 原脚本有 3 个致命问题(2026-09 修复)—— +# 1. 它在 `target/release/` 找二进制,而实际构建产物在 +# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件 +# 2. 它安装并重启 `pms-api-prod` / `pms-device-prod`,而线上真实单元名是 +# `pms-api` / `pms-device` → 会**多出一套重复单元**且重启失败 +# 3. 它会用本地 `.env.production` **覆盖线上 `.env`** → 一旦本地端口/主机写错, +# 生产直接起不来(历史上 `.env` 还带行内注释,更是必挂) +# 本脚本按"本地构建 → 校验产物 → 服务器备份 → 传 `.new` → 原子替换 → 重启 → 验收" +# 的顺序执行,并在收尾打印回滚方法。 +# +# 用法: +# pwsh deploy/deploy-prod.ps1 # 完整部署(构建 + 上传 + 重启 + 验收) +# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署 +# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动 +# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端 +# ============================================================================= +param( + [switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build + [switch]$SkipWeb, # 跳过前端 + [switch]$DryRun # 只演练,不执行 +) + +$ErrorActionPreference = 'Stop' + +# ---- 环境参数(正式环境)--------------------------------------------------- +$Host_ = '118.31.119.206' +$User = 'root' +$Pwd_ = 'Hbhyg731024@' # 内网仓库明文密码(见 AGENTS.md「代码审查豁免」) +$Dst = '/data/pms' +$ApiPort = 9000 +$TcpPort = 9001 +$Domain = 'app.anzhizhichong.com' + +# ---- 路径 ------------------------------------------------------------------- +$Repo = Split-Path -Parent $PSScriptRoot +$ApiBin = Join-Path $Repo 'software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api' +$DevBin = Join-Path $Repo 'software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server' +$WebDist = Join-Path $Repo 'software/web/dist' + +function Step($msg) { Write-Host "`n=== $msg ===" -ForegroundColor Cyan } +function Say($msg) { Write-Host " $msg" } + +function Invoke-Remote([string]$cmd) { + if ($DryRun) { Say "[DryRun] ssh: $cmd"; return } + & plink -batch -pw $Pwd_ "$User@$Host_" $cmd + if ($LASTEXITCODE -ne 0) { throw "远端命令失败(exit $LASTEXITCODE):$cmd" } +} +function Send-File([string]$local, [string]$remote) { + if ($DryRun) { Say "[DryRun] scp: $local -> $remote"; return } + & pscp -batch -pw $Pwd_ $local "$User@$Host_`:$remote" + if ($LASTEXITCODE -ne 0) { throw "上传失败(exit $LASTEXITCODE):$local" } +} + +# ---- 1. 构建 --------------------------------------------------------------- +if (-not $SkipBuild) { + Step '1/7 本地交叉编译(服务器 2核/3.5G 禁止编译,必须本地构建)' + Say 'api-server ...' + if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/api-server/Cargo.toml') } + Say 'device-server ...' + if (-not $DryRun) { cargo zigbuild --release --target x86_64-unknown-linux-gnu --manifest-path (Join-Path $Repo 'software/device-server/Cargo.toml') } + if (-not $SkipWeb) { + Say 'web ...' + if (-not $DryRun) { Push-Location (Join-Path $Repo 'software/web'); npm run build; Pop-Location } + } +} + +# ---- 2. 校验产物 + 静态检查 ------------------------------------------------ +Step '2/7 校验产物与静态检查' +foreach ($f in @($ApiBin, $DevBin)) { + if (-not (Test-Path $f)) { throw "找不到构建产物:$f`n(请先执行 cargo zigbuild,或用 -SkipBuild 前确认已构建)" } + Say ("✅ {0} ({1:N1} MB)" -f (Split-Path $f -Leaf), ((Get-Item $f).Length / 1MB)) +} +if (-not $SkipWeb) { + if (-not (Test-Path (Join-Path $WebDist 'index.html'))) { throw "找不到前端产物:$WebDist" } + Say ("✅ web/dist ({0} 个文件)" -f (Get-ChildItem $WebDist -Recurse -File).Count) +} + +# ---- 3. 服务器备份(可回滚)------------------------------------------------ +Step '3/7 服务器备份(保留旧二进制/.env/nginx/单元/前端)' +Invoke-Remote @" +set -e +TS=`$(date +%Y%m%d-%H%M%S) +mkdir -p $Dst/backup-`$TS +cp -a $Dst/api-server/pms-api $Dst/backup-`$TS/ 2>/dev/null || true +cp -a $Dst/device-server/pms-device-server $Dst/backup-`$TS/ 2>/dev/null || true +cp -a $Dst/api-server/.env $Dst/device-server/.env $Dst/backup-`$TS/ 2>/dev/null || true +mkdir -p $Dst/backup-`$TS/web && cp -a $Dst/web/. $Dst/backup-`$TS/web/ 2>/dev/null || true +echo `$TS > /tmp/pms_last_backup +echo " 备份目录: $Dst/backup-`$TS" +"@ + +# ---- 4. 上传(临时名,避免 ETXTBSY)---------------------------------------- +Step '4/7 上传(二进制传 .new,运行中的二进制不可直接覆盖)' +Send-File $ApiBin "$Dst/api-server/pms-api.new" +Send-File $DevBin "$Dst/device-server/pms-device-server.new" +if (-not $SkipWeb) { + $tgz = Join-Path $env:TEMP 'pms-dist.tgz' + if (-not $DryRun) { tar -czf $tgz -C $WebDist . } + Send-File $tgz '/tmp/pms-dist.tgz' +} + +# ---- 5. 切换 + 重启 -------------------------------------------------------- +Step '5/7 原子替换并重启服务' +Invoke-Remote @" +set -e +chmod +x $Dst/api-server/pms-api.new $Dst/device-server/pms-device-server.new +systemctl stop pms-api pms-device +mv -f $Dst/api-server/pms-api.new $Dst/api-server/pms-api +mv -f $Dst/device-server/pms-device-server.new $Dst/device-server/pms-device-server +$(if (-not $SkipWeb) { "rm -rf $Dst/web/* && tar -xzf /tmp/pms-dist.tgz -C $Dst/web/ && rm -f /tmp/pms-dist.tgz" } else { 'true' }) +systemctl daemon-reload +systemctl start pms-device pms-api +sleep 8 +nginx -t && systemctl reload nginx +"@ + +# ---- 6. 验收 --------------------------------------------------------------- +Step '6/7 验收(服务/端口/域名/日志)' +Invoke-Remote @" +echo '--- 服务 ---'; systemctl is-active pms-api pms-device nginx | paste -sd' ' +echo '--- 端口(应为 $ApiPort / $TcpPort)---'; ss -lntp | grep -E ':$ApiPort|:$TcpPort' | awk '{print `" `" `$4}' +echo '--- 域名 ---' +curl -sk -m 8 -o /dev/null -w ' /api/pms/cabinets -> %{http_code}\n' https://$Domain/api/pms/cabinets +curl -sk -m 8 -o /dev/null -w ' /pms/ -> %{http_code}\n' https://$Domain/pms/ +echo '--- 启动日志尾部 ---'; journalctl -u pms-api --no-pager -n 4 | tail -4 +echo '--- 新进程 ERROR 数(应为 0)---' +MAIN=`$(systemctl show -p MainPID --value pms-api) +journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^/ ERROR 行数: /' +"@ + +# ---- 7. 消费链路探测(无副作用)------------------------------------------- +Step '7/7 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)' +if (-not $DryRun) { + $probe = @' +import redis, json, time +r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5) +r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1})) +time.sleep(3) +left = r.llen('device:reports') +print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}") +'@ + $probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py" + python "$env:TEMP\pms_probe.py" +} + +Write-Host "`n=== 部署完成 ===" -ForegroundColor Green +Say "正式环境:https://$Domain/pms/" +Say "API :$ApiPort TCP :$TcpPort" +Say '' +Say '回滚方法(如需):' +Say " plink -batch -pw root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-/* /data/pms/... && systemctl start pms-api pms-device`"" +Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)' diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh index d172527..73c1448 100644 --- a/deploy/deploy-prod.sh +++ b/deploy/deploy-prod.sh @@ -1,50 +1,21 @@ #!/bin/bash -# PMS 部署脚本 - 正式环境 - -set -e - -echo "=== PMS 正式环境部署 ===" - -# 1. 创建目录 -echo "1. 创建部署目录..." -mkdir -p /data/pms/api-server -mkdir -p /data/pms/device-server -mkdir -p /data/pms/web - -# 2. 复制文件 -echo "2. 复制服务文件..." -cp software/api-server/target/release/pms-api /data/pms/api-server/ -cp software/api-server/.env.production /data/pms/api-server/.env - -cp software/device-server/target/release/device-server /data/pms/device-server/ -cp software/device-server/.env.production /data/pms/device-server/.env - -# 3. 复制前端静态文件 -echo "3. 复制前端文件..." -cp -r software/web/dist/* /data/pms/web/ - -# 3. 复制systemd服务 -echo "3. 安装systemd服务..." -cp deploy/pms-api-prod.service /etc/systemd/system/ -cp deploy/pms-device-prod.service /etc/systemd/system/ - -# 4. 复制nginx配置 -echo "4. 安装nginx配置..." -cp deploy/nginx/app.anzhizhichong.com.conf /etc/nginx/conf.d/ - -# 5. 重启服务 -echo "5. 重启服务..." -systemctl daemon-reload -systemctl restart pms-api-prod -systemctl restart pms-device-prod -systemctl restart nginx - -# 6. 设置开机启动 -echo "6. 设置开机启动..." -systemctl enable pms-api-prod -systemctl enable pms-device-prod - -echo "=== 部署完成 ===" -echo "正式环境: https://app.anzhizhichong.com" -echo "API端口: 9000" -echo "TCP端口: 9001" +# ============================================================================= +# ⛔ 本脚本已废弃(2026-09),请勿执行!改用:deploy/deploy-prod.ps1 +# +# 废弃原因(原脚本会直接搞坏生产): +# 1. 在 `target/release/` 找二进制 —— 实际产物在 +# `target/x86_64-unknown-linux-gnu/release/`(zigbuild 交叉编译)→ 找不到文件 +# 2. 安装并重启 `pms-api-prod` / `pms-device-prod` —— 线上真实单元名是 +# `pms-api` / `pms-device`;照原样执行会**多出一套重复单元**且重启失败 +# 3. 用本地 `.env.production` **覆盖线上 `.env`** —— 历史上该文件带行内注释 +# (`PORT=9000 # 注释` 会让解析失败),照原样执行会让**生产直接起不来** +# 4. 它假定"服务器上有仓库源码",而实际流程是**本地交叉编译后传二进制** +# +# 正确做法(在本地开发机执行): +# pwsh deploy/deploy-prod.ps1 # 完整部署 +# pwsh deploy/deploy-prod.ps1 -SkipBuild # 只重发产物 +# pwsh deploy/deploy-prod.ps1 -DryRun # 演练 +# ============================================================================= +echo "⛔ deploy-prod.sh 已废弃,请使用:pwsh deploy/deploy-prod.ps1" >&2 +echo " (原因见本文件头部注释:原脚本会装重复 systemd 单元、覆盖线上 .env 导致生产起不来)" >&2 +exit 1 diff --git a/deploy/deploy-test.sh b/deploy/deploy-test.sh index 5b92aed..c90f15c 100644 --- a/deploy/deploy-test.sh +++ b/deploy/deploy-test.sh @@ -1,50 +1,22 @@ #!/bin/bash -# PMS 部署脚本 - 测试环境 - -set -e - -echo "=== PMS 测试环境部署 ===" - -# 1. 创建目录 -echo "1. 创建部署目录..." -mkdir -p /data/pms_dev/api-server -mkdir -p /data/pms_dev/device-server -mkdir -p /data/pms_dev/web - -# 2. 复制文件 -echo "2. 复制服务文件..." -cp software/api-server/target/release/pms-api /data/pms_dev/api-server/ -cp software/api-server/.env.test /data/pms_dev/api-server/.env - -cp software/device-server/target/release/device-server /data/pms_dev/device-server/ -cp software/device-server/.env.test /data/pms_dev/device-server/.env - -# 3. 复制前端静态文件 -echo "3. 复制前端文件..." -cp -r software/web/dist/* /data/pms_dev/web/ - -# 3. 复制systemd服务 -echo "3. 安装systemd服务..." -cp deploy/pms-api-test.service /etc/systemd/system/ -cp deploy/pms-device-test.service /etc/systemd/system/ - -# 4. 复制nginx配置 -echo "4. 安装nginx配置..." -cp deploy/nginx/test.anzhizhichong.com.conf /etc/nginx/conf.d/ - -# 5. 重启服务 -echo "5. 重启服务..." -systemctl daemon-reload -systemctl restart pms-api-test -systemctl restart pms-device-test -systemctl restart nginx - -# 6. 设置开机启动 -echo "6. 设置开机启动..." -systemctl enable pms-api-test -systemctl enable pms-device-test - -echo "=== 部署完成 ===" -echo "测试环境: https://test.anzhizhichong.com" -echo "API端口: 3000" -echo "TCP端口: 3001" +# ============================================================================= +# ⛔ 本脚本已废弃(2026-09),请勿在 118.31.119.206 上执行! +# +# 两条重要约束: +# 1. **118.31.119.206 就是生产服务器**(用户已确认),上面**不部署测试环境**。 +# 原先的 `pms-api-test` / `pms-device-test` 单元已卸载,:3000 / :3001 已释放。 +# 2. 即使要在别处搭测试环境,也不要用本脚本 —— 它与 deploy-prod.sh 有同样的缺陷 +# (错误产物路径、单元名不匹配、用本地 .env 覆盖线上 .env)。 +# +# 如需临时联调环境(另一台机器,或本机): +# - 参考 `deploy/deploy-prod.ps1` 的流程改写,端口用 3000/3001 +# - 环境文件用 `software/*/.env.test`(连 pms_dev 库 + Redis db6) +# - 注意 `/data/pms_dev` 下的旧文件与 `pms_dev` 库、Redis db6 都还保留着 +# +# ⚠️ 另请注意:`test.anzhizhichong.com` 的 nginx `/api/pms/` 现指向 :3000, +# 而该端口**没有后端**,所以返回 502 —— 这是**刻意的**(历史上它指向 :3000, +# 而那时 :3000 正是生产 API,等于测试后台在操作生产数据)。 +# **绝不要把测试域名的 API 指回生产端口(9000)。** +# ============================================================================= +echo "⛔ deploy-test.sh 已废弃,且本服务器为生产环境,不部署测试环境。" >&2 +exit 1 diff --git a/deploy/landing/index.html b/deploy/landing/index.html index 5142e92..4709e68 100644 --- a/deploy/landing/index.html +++ b/deploy/landing/index.html @@ -3,7 +3,7 @@ -安知充 — 智能充电柜矩阵系统 +艾动智能 — 智能充电柜矩阵系统 艾动智能 \ No newline at end of file diff --git a/logo/圆切独立LOGO.png b/logo/圆切独立LOGO.png new file mode 100644 index 0000000000000000000000000000000000000000..904c176c1ddf47d765f3b7ba509f2a7ebb2eeeef GIT binary patch literal 79884 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%0Dr3IRht$^FTsS3E!A<%|>jE7w%fQQYw1 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 \ No newline at end of file diff --git a/logo/白色横向.svg b/logo/白色横向.svg new file mode 100644 index 0000000..29dff74 --- /dev/null +++ b/logo/白色横向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/白色横向@2x.png b/logo/白色横向@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..56bbfc6f24730404a6e1b7e113196a85ab84e8ed GIT binary patch literal 75771 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 literal 0 HcmV?d00001 diff --git a/logo/白色竖向.svg b/logo/白色竖向.svg new file mode 100644 index 0000000..9c447f0 --- /dev/null +++ b/logo/白色竖向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/白色竖向@2x.png b/logo/白色竖向@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..f50f5c93297b4977a7d4cf239cc55c8cc22cf3c6 GIT binary patch literal 94798 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 \ No newline at end of file diff --git a/logo/纯色横向@2x.png b/logo/纯色横向@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..3793ff171ed5c0856751c4739f3f4b6dba8d9c43 GIT binary patch literal 76523 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 Rdxd;yf}4vcx!O7C#{Ua@;a>m% literal 0 HcmV?d00001 diff --git a/logo/纯色竖向.svg b/logo/纯色竖向.svg new file mode 100644 index 0000000..9b0e098 --- /dev/null +++ b/logo/纯色竖向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/纯色竖向@2x.png b/logo/纯色竖向@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..7f59d7d09599114da48a15d610943b52ef632dc2 GIT binary patch literal 87972 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

S{zG-e*hTWN{%Vo1n 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“eGvBa&TkRoBz0{AvFJK@2Fs}ScdiJw?4;* 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 ND9NioDU~%3`aew|5tje} literal 0 HcmV?d00001 diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..95a950b --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "sensenova/glm-5.2" +} diff --git a/software/api-server/.env.example b/software/api-server/.env.example new file mode 100644 index 0000000..fc4ffee --- /dev/null +++ b/software/api-server/.env.example @@ -0,0 +1,18 @@ +# API 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值) +# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库 + +# MySQL 连接(阿里云 RDS) +DATABASE_URL=mysql://root:CHANGE_ME@HOST:3306/pms + +# Redis 连接(阿里云 Redis) +REDIS_URL=redis://:CHANGE_ME@HOST:6379/5 + +# 监听 +HOST=0.0.0.0 +PORT=9000 + +# JWT 密钥(缺失则服务启动失败,禁止硬编码回退) +JWT_SECRET=CHANGE_ME + +# 设备节点心跳超时(秒) +NODE_HEARTBEAT_TIMEOUT=60 \ No newline at end of file diff --git a/software/api-server/.env.production b/software/api-server/.env.production deleted file mode 100644 index 56d619e..0000000 --- a/software/api-server/.env.production +++ /dev/null @@ -1,15 +0,0 @@ -# MySQL 连接(正式环境 pms 库) -DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms - -# Redis 连接(生产 db5) -REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5 - -# HTTP 服务 -HOST=0.0.0.0 -PORT=9000 # 正式环境API - -# JWT -JWT_SECRET=pms-dev-secret-change-me-in-production - -# 节点心跳超时(秒) -NODE_HEARTBEAT_TIMEOUT=180 diff --git a/software/api-server/.env.test b/software/api-server/.env.test deleted file mode 100644 index 741f4bf..0000000 --- a/software/api-server/.env.test +++ /dev/null @@ -1,15 +0,0 @@ -# MySQL 连接(pms_dev 库由迁移自动创建) -DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms_dev - -# Redis 连接(测试 db6) -REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6 - -# HTTP 服务 -HOST=0.0.0.0 -PORT=3000 # 测试环境API - -# JWT -JWT_SECRET=pms-test-secret - -# 节点心跳超时(秒) -NODE_HEARTBEAT_TIMEOUT=180 diff --git a/software/api-server/Cargo.lock b/software/api-server/Cargo.lock index f152e3b..d83768d 100644 --- a/software/api-server/Cargo.lock +++ b/software/api-server/Cargo.lock @@ -58,7 +58,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -102,6 +102,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -189,12 +190,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.0" @@ -217,6 +230,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -275,6 +305,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -390,6 +429,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -434,6 +482,15 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -605,8 +662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -707,6 +767,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -737,6 +803,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", ] [[package]] @@ -745,13 +828,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.4", "tokio", "tower-service", + "tracing", ] [[package]] @@ -881,6 +972,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -891,6 +995,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itoa" version = "1.0.18" @@ -987,6 +1097,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1012,6 +1128,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.2" @@ -1024,6 +1146,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1045,6 +1177,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1075,7 +1234,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1158,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ "base64ct", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -1232,17 +1391,22 @@ version = "0.1.0" dependencies = [ "argon2", "axum", + "base64", "bytes", "chrono", "dotenvy", "futures", "hex", + "image", "jsonwebtoken", - "rand", + "md5", + "rand 0.8.6", "redis", + "reqwest", "rust_xlsxwriter", "serde", "serde_json", + "sha1", "sha2", "sqlx", "thiserror", @@ -1251,9 +1415,23 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "urlencoding", "uuid", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1287,6 +1465,68 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.4", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.4", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -1310,7 +1550,18 @@ checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1320,7 +1571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1332,6 +1583,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redis" version = "0.28.2" @@ -1391,6 +1657,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.8", +] + [[package]] name = "ring" version = "0.17.14" @@ -1418,7 +1722,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -1434,6 +1738,12 @@ dependencies = [ "zip", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustls" version = "0.23.41" @@ -1454,6 +1764,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -1559,7 +1870,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1576,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1612,7 +1923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1805,7 +2116,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -1844,7 +2155,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.6", "serde", "serde_json", "sha2", @@ -1920,6 +2231,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2044,6 +2358,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2092,10 +2416,23 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", + "futures-core", + "futures-util", "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", + "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -2172,12 +2509,24 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2223,6 +2572,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2258,6 +2613,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2283,6 +2647,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.126" @@ -2315,6 +2689,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/software/api-server/Cargo.toml b/software/api-server/Cargo.toml index f680314..c42490f 100644 --- a/software/api-server/Cargo.toml +++ b/software/api-server/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = "0.8" +axum = { version = "0.8", features = ["multipart"] } tokio = { version = "1", features = ["full"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] } redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] } @@ -14,15 +14,21 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } thiserror = "2" chrono = { version = "0.4", features = ["serde"] } -tower-http = { version = "0.6", features = ["cors"] } +tower-http = { version = "0.6", features = ["cors", "fs"] } uuid = { version = "1", features = ["v4"] } sha2 = "0.10" +sha1 = "0.10" +md5 = "0.7" +urlencoding = "2.1" tokio-util = { version = "0.7", features = ["codec"] } bytes = "1" futures = "0.3" rand = "0.8" +image = { version = "0.25", default-features = false, features = ["png"] } +base64 = "0.22" hex = "0.4" rust_xlsxwriter = "0.80" jsonwebtoken = "9" argon2 = "0.5" dotenvy = "0.15" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/software/api-server/src/captcha.rs b/software/api-server/src/captcha.rs new file mode 100644 index 0000000..ce3c9ea --- /dev/null +++ b/software/api-server/src/captcha.rs @@ -0,0 +1,265 @@ +//! 滑块验证码生成与校验 +//! +//! 参考 go-captcha 视觉风格: +//! - 渐变背景(双色渐变) +//! - 丰富纹理(线条+圆点) +//! - 拼图块带投影 +//! - 缺口带白色描边 + +use base64::Engine; +use image::{Rgba, RgbaImage}; +use rand::Rng; +use std::io::Cursor; + +use crate::error::AppError; + +const WIDTH: u32 = 300; +const HEIGHT: u32 = 150; +const PIECE_SIZE: i32 = 44; +const BUMP_RADIUS: i32 = 7; +const TOLERANCE: i32 = 5; +const PIECE_WIDTH: i32 = PIECE_SIZE + BUMP_RADIUS; +const CHALLENGE_TTL: u32 = 300; +const SHADOW_OFFSET: i32 = 3; + +pub(crate) struct CaptchaData { + pub(crate) challenge_id: String, + pub(crate) bg_image: String, + pub(crate) piece_image: String, +} + +pub(crate) async fn create_challenge(redis_url: &str) -> Result { + let challenge_id = uuid::Uuid::new_v4().to_string(); + let key = format!("challenge:{}", challenge_id); + + let (bg_b64, piece_b64, correct_x) = tokio::task::spawn_blocking(|| { + let (bg, piece, correct_x) = generate_images(); + let bg_b64 = encode_png(&bg); + let piece_b64 = encode_png(&piece); + (bg_b64, piece_b64, correct_x) + }) + .await + .map_err(|e| { + tracing::error!("[captcha] spawn_blocking 失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + + let client = redis::Client::open(redis_url).map_err(|e| { + tracing::error!("[captcha] Redis client 创建失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { + tracing::error!("[captcha] Redis 连接失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + + redis::cmd("SET").arg(&key).arg(&correct_x.to_string()).arg("EX").arg(CHALLENGE_TTL) + .query_async::<()>(&mut conn).await.map_err(|e| { + tracing::error!("[captcha] Redis 写入失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + + tracing::debug!("[captcha] 生成完成 cid={} x={}", challenge_id, correct_x); + Ok(CaptchaData { challenge_id, bg_image: bg_b64, piece_image: piece_b64 }) +} + +pub(crate) async fn verify_offset(redis_url: &str, challenge_id: &str, user_x: i32) -> Result { + let key = format!("challenge:{}", challenge_id); + let client = redis::Client::open(redis_url).map_err(|e| { + tracing::error!("[captcha] Redis client 创建失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { + tracing::error!("[captcha] Redis 连接失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + + let val: Option = redis::cmd("GETDEL").arg(&key).query_async(&mut conn).await.map_err(|e| { + tracing::error!("[captcha] Redis 查询失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + + match val { + Some(s) => { + let correct_x: i32 = s.parse().unwrap_or(i32::MAX); + let ok = (user_x - correct_x).abs() <= TOLERANCE; + tracing::debug!("[captcha] 校验 cid={} user_x={} correct_x={} ok={}", challenge_id, user_x, correct_x, ok); + Ok(ok) + } + None => Ok(false), + } +} + +fn generate_images() -> (RgbaImage, RgbaImage, i32) { + let mut rng = rand::thread_rng(); + + // 1. 渐变背景 + let c1 = [rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, 255]; + let c2 = [rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, 255]; + let mut bg = RgbaImage::new(WIDTH, HEIGHT); + for y in 0..HEIGHT { + let t = y as f32 / HEIGHT as f32; + let r = c1[0] as f32 * (1.0 - t) + c2[0] as f32 * t; + let g = c1[1] as f32 * (1.0 - t) + c2[1] as f32 * t; + let b = c1[2] as f32 * (1.0 - t) + c2[2] as f32 * t; + let pix = Rgba([r as u8, g as u8, b as u8, 255]); + for x in 0..WIDTH { bg.put_pixel(x, y, pix); } + } + + // 2. 纹理线条(半透明随机线条) + for _ in 0..6 { + let x1 = rng.gen_range(0..WIDTH) as i32; + let y1 = rng.gen_range(0..HEIGHT) as i32; + let x2 = rng.gen_range(0..WIDTH) as i32; + let y2 = rng.gen_range(0..HEIGHT) as i32; + let color = Rgba([rng.r#gen::(), rng.r#gen::(), rng.r#gen::(), rng.gen_range(15..50u8)]); + draw_line(&mut bg, x1, y1, x2, y2, color); + } + + // 3. 纹理圆点 + for _ in 0..15 { + let cx = rng.gen_range(0..WIDTH); + let cy = rng.gen_range(0..HEIGHT); + let radius = rng.gen_range(3..15u32); + let color = Rgba([rng.r#gen::(), rng.r#gen::(), rng.r#gen::(), rng.gen_range(20..80u8)]); + fill_circle(&mut bg, cx, cy, radius, color); + } + + // 4. 拼图块位置 + let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2; + let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32; + + // 5. 提取拼图块像素 + let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32); + for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); } + for py in 0..PIECE_SIZE { + for px in 0..PIECE_WIDTH { + if is_in_piece(px, py) { + raw.put_pixel(px as u32, py as u32, *bg.get_pixel((piece_x as i32 + px) as u32, (piece_y + py) as u32)); + } + } + } + + // 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移) + let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32; + let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32; + let mut piece = RgbaImage::new(sw, sh); + for p in piece.pixels_mut() { *p = Rgba([0, 0, 0, 0]); } + // 先画阴影 + for py in 0..PIECE_SIZE { + for px in 0..PIECE_WIDTH { + if is_in_piece(px, py) { + let sx = px + SHADOW_OFFSET; + let sy = py + SHADOW_OFFSET; + if sx < sw as i32 && sy < sh as i32 { + let d = piece.get_pixel(sx as u32, sy as u32); + piece.put_pixel(sx as u32, sy as u32, alpha_blend(*d, Rgba([0, 0, 0, 80]))); + } + } + } + } + // 再画拼图 + for py in 0..PIECE_SIZE { + for px in 0..PIECE_WIDTH { + let p = raw.get_pixel(px as u32, py as u32); + if p[3] > 0 { + piece.put_pixel(px as u32, py as u32, *p); + } + } + } + + // 7. 背景画缺口 + draw_hole(&mut bg, piece_x as i32, piece_y); + + (bg, piece, piece_x as i32) +} + +fn is_in_piece(px: i32, py: i32) -> bool { + if px >= 0 && px < PIECE_SIZE && py >= 0 && py < PIECE_SIZE { return true; } + let bcx = PIECE_SIZE; + let bcy = PIECE_SIZE / 2; + let dx = px - bcx; + let dy = py - bcy; + dx >= 0 && dx * dx + dy * dy <= BUMP_RADIUS * BUMP_RADIUS +} + +fn draw_hole(img: &mut RgbaImage, hx: i32, hy: i32) { + for y in 0..HEIGHT as i32 { + for x in 0..WIDTH as i32 { + let lx = x - hx; + let ly = y - hy; + if is_in_piece(lx, ly) { + let edge = (lx >= 0 && lx < 2) || (lx >= PIECE_WIDTH - 3 && lx < PIECE_WIDTH) + || (ly >= 0 && ly < 2) || (ly >= PIECE_SIZE - 3 && ly < PIECE_SIZE) + || { + let bcx = PIECE_SIZE; + let bcy = PIECE_SIZE / 2; + let dx = lx - bcx; + let dy = ly - bcy; + dx >= 0 && (dx * dx + dy * dy - BUMP_RADIUS * BUMP_RADIUS).abs() <= 20 + }; + if edge { + img.put_pixel(x as u32, y as u32, Rgba([255, 255, 255, 220])); + } else { + let o = img.get_pixel(x as u32, y as u32); + img.put_pixel(x as u32, y as u32, Rgba([ + (o[0] as u16 * 2 / 3 + 85) as u8, + (o[1] as u16 * 2 / 3 + 85) as u8, + (o[2] as u16 * 2 / 3 + 85) as u8, 255, + ])); + } + } + } + } +} + +fn fill_circle(img: &mut RgbaImage, cx: u32, cy: u32, r: u32, color: Rgba) { + let r = r as i32; + let cx = cx as i32; + let cy = cy as i32; + for y in (cy - r).max(0)..=(cy + r).min(HEIGHT as i32 - 1) { + for x in (cx - r).max(0)..=(cx + r).min(WIDTH as i32 - 1) { + if (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r * r { + img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color)); + } + } + } +} + +fn draw_line(img: &mut RgbaImage, x1: i32, y1: i32, x2: i32, y2: i32, color: Rgba) { + let mut x = x1; + let mut y = y1; + let dx = (x2 - x1).abs(); + let dy = -(y2 - y1).abs(); + let sx = if x1 < x2 { 1 } else { -1 }; + let sy = if y1 < y2 { 1 } else { -1 }; + let mut err = dx + dy; + loop { + if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 { + img.put_pixel(x as u32, y as u32, alpha_blend(*img.get_pixel(x as u32, y as u32), color)); + } + if x == x2 && y == y2 { break; } + let e2 = 2 * err; + if e2 >= dy { err += dy; x += sx; } + if e2 <= dx { err += dx; y += sy; } + } +} + +fn alpha_blend(dst: Rgba, src: Rgba) -> Rgba { + let a = src[3] as u32; + let inv = 255 - a; + Rgba([ + ((src[0] as u32 * a + dst[0] as u32 * inv) / 255) as u8, + ((src[1] as u32 * a + dst[1] as u32 * inv) / 255) as u8, + ((src[2] as u32 * a + dst[2] as u32 * inv) / 255) as u8, + dst[3].max(src[3]), + ]) +} + +fn encode_png(img: &RgbaImage) -> String { + let mut buf = Vec::new(); + img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png).expect("PNG 编码失败"); + base64::engine::general_purpose::STANDARD.encode(&buf) +} diff --git a/software/api-server/src/commands.rs b/software/api-server/src/commands.rs index fb35499..b8a30c1 100644 --- a/software/api-server/src/commands.rs +++ b/software/api-server/src/commands.rs @@ -176,7 +176,7 @@ impl NodeRegistry { /// 检查设备是否在线(通过 Redis 中的在线标记) pub async fn is_device_online(&self, imei: &str) -> bool { - let key = format!("device:online:{}", imei); + let key = format!("device:{}", imei); let mut conn = self.redis.clone(); let exists: Result = redis::cmd("EXISTS") .arg(&key) @@ -195,6 +195,8 @@ pub struct AppState { pub pending_commands: PendingCommands, pub nodes: NodeRegistry, pub redis_url: String, + /// JWT 签名密钥 — 启动阶段从环境变量读取,缺失则 panic(禁止硬编码回退) + pub jwt_secret: Arc, } /// 指令下发请求体 @@ -203,39 +205,46 @@ pub struct CommandRequest { pub act: String, #[serde(default)] pub msg_id: u32, - pub sub_device_id: Option, #[serde(flatten)] pub params: Option, } +/// 内部路由字段名:推入 `device:commands` 时附加,值 = 目标设备 IMEI。 +/// +/// 用途:device-server 据此决定把指令发给哪条连接;**下发前摘除,设备侧不可见**。 +/// 为什么需要它:协议定稿后 `id` 只表示**动作目标**(柜级省略 / 一段=单板 / 两段=仓体级), +/// **不再含 IMEI**,所以设备身份必须另用内部字段传递。 +pub const ROUTE_DEV_ID_FIELD: &str = "_dev_id"; + /// 设备指令结构(序列化后推入 Redis) +/// +/// 协议格式(v1.1):`id` 只表示**动作目标**,**不含 IMEI**(设备身份由 TCP 连接确定) +/// - **柜级**指令(open/pow_on/pow_off/reset 整柜):**不带 `id`**(字段省略) +/// - **单仓控板**指令(reset 单板):`仓控板`(如 `"01"`) +/// - **仓体级**指令(on/off):`仓控板-通道`(如 `"01-3"`) #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct DeviceCommand { pub act: String, pub msg_id: u32, + /// 动作目标(柜级为 `None`),序列化为 "id" #[serde(skip_serializing_if = "Option::is_none")] - pub dev_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sub_device_id: Option, - /// 扩展参数(开门指令、充电参数等) + pub id: Option, + /// 扩展参数(充电参数等) #[serde(flatten)] pub params: Option, } -impl DeviceCommand { - /// 序列化为 JSON 字符串(紧凑格式)。 - pub fn to_json(&self) -> String { - serde_json::to_string(self).expect("DeviceCommand 序列化不应失败") - } -} - /// 向设备发送指令(通过 Redis 队列) pub async fn send_device_command( redis: &redis::aio::ConnectionManager, - _dev_id: &str, + dev_id: &str, cmd: &DeviceCommand, ) -> Result<(), String> { - let json_str = cmd.to_json(); + let mut json_val = + serde_json::to_value(cmd).map_err(|e| format!("指令序列化失败: {}", e))?; + // 内部路由字段:告知 device-server 这条指令发给哪台设备(下发前摘除) + json_val[ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id); + let json_str = json_val.to_string(); let mut conn = redis.clone(); redis::cmd("LPUSH") .arg("device:commands") @@ -273,11 +282,22 @@ pub async fn send_command( ) -> Result, AppError> { auth::check_permission(&user, "device:operate")?; + // 检查设备是否被禁用 + let disabled: bool = sqlx::query_scalar( + r#"SELECT COUNT(*) > 0 FROM cabinets WHERE imei = ? AND status = 2"#, + ) + .bind(&dev_id) + .fetch_one(&state.mysql) + .await + .map_err(AppError::Database)?; + if disabled { + return Err(AppError::BadRequest("设备已被禁用,无法下发指令".into())); + } + let cmd = DeviceCommand { act: req.act, msg_id: req.msg_id, - dev_id: Some(dev_id.clone()), - sub_device_id: req.sub_device_id, + id: Some(dev_id.clone()), params: req.params, }; diff --git a/software/api-server/src/config.rs b/software/api-server/src/config.rs index c6327dd..a87d969 100644 --- a/software/api-server/src/config.rs +++ b/software/api-server/src/config.rs @@ -2,11 +2,8 @@ //! //! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。 -/// 默认 JWT 密钥(开发环境用,生产环境必须通过环境变量覆盖) -pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production"; - -/// 用户默认密码(创建用户或重置密码时使用) -pub const DEFAULT_PASSWORD: &str = "123456"; +/// JWT 密钥(必设,生产环境必须通过环境变量设置) +const JWT_SECRET_ENV: &str = "JWT_SECRET"; /// 应用配置 #[allow(dead_code)] @@ -39,8 +36,8 @@ impl Config { .unwrap_or_else(|_| "3000".to_string()) .parse() .expect("PORT must be a valid number"), - jwt_secret: std::env::var("JWT_SECRET") - .unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()), + jwt_secret: std::env::var(JWT_SECRET_ENV) + .expect("JWT_SECRET must be set"), export_dir: std::env::var("EXPORT_DIR") .unwrap_or_else(|_| "./exports".to_string()), node_heartbeat_timeout: std::env::var("NODE_HEARTBEAT_TIMEOUT") @@ -49,11 +46,4 @@ impl Config { .expect("NODE_HEARTBEAT_TIMEOUT must be a valid number"), } } - - /// 校验配置安全性 - pub fn validate(&self) { - if self.jwt_secret == JWT_SECRET_DEFAULT { - tracing::warn!("使用默认 JWT 密钥,生产环境请设置 JWT_SECRET 环境变量"); - } - } } diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index e0b237c..3aa9b35 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -2,12 +2,13 @@ use sqlx::MySqlPool; -/// 执行数据库迁移,创建所有必要的数据表 -pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { - let sql = r#" +/// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。 +/// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。 +const SCHEMA_SQL: &str = r#" CREATE TABLE IF NOT EXISTS organizations ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL COMMENT '组织名称', + parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; @@ -26,7 +27,11 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { project_id BIGINT, abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001', imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI', + iccid VARCHAR(20) COMMENT 'SIM卡ICCID', auth_str VARCHAR(8) COMMENT '签名安全码', + auth_str_issued BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发', + cell TINYINT DEFAULT 6 COMMENT '仓控板数量', + work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式', name VARCHAR(100) COMMENT '柜子名称', status TINYINT DEFAULT 0 COMMENT '0离线 1在线', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -34,6 +39,53 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { FOREIGN KEY (project_id) REFERENCES projects(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS sim_change_logs ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + imei VARCHAR(15) NOT NULL COMMENT '设备IMEI', + old_iccid VARCHAR(20) COMMENT '旧ICCID', + new_iccid VARCHAR(20) NOT NULL COMMENT '新ICCID', + changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间', + INDEX idx_imei (imei) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + CREATE TABLE IF NOT EXISTS batteries ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + battery_id VARCHAR(50) NOT NULL UNIQUE COMMENT '电池唯一标识', + soc INT DEFAULT 0 COMMENT '电量(‰)', + brand VARCHAR(50) COMMENT '品牌(由 brand_code 映射出的名称,代码表未定前为空)', + model VARCHAR(50) COMMENT '型号(由 model_code 映射出的名称,代码表未定前为空)', + sn VARCHAR(100) COMMENT '序列号', + -- 以下为协议 §5.5「电池属性」原值(硬件代码,平台侧负责映射,见交付文档 §10 定稿第 7 条) + brand_code VARCHAR(20) COMMENT '厂家代码(原值透传)', + model_code VARCHAR(20) COMMENT '电池型号代码(原值透传)', + material_code VARCHAR(20) COMMENT '电芯材料代码(原值透传)', + cell_count INT COMMENT '电芯节数', + rated_voltage BIGINT COMMENT '额定电压(mV)', + rated_capacity BIGINT COMMENT '额定容量(mAh)', + status TINYINT DEFAULT 0 COMMENT '0空闲 1使用中 2故障 3报废', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + + CREATE TABLE IF NOT EXISTS charge_orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + compartment_id BIGINT NOT NULL COMMENT '仓体ID', + cabinet_id BIGINT NOT NULL COMMENT '柜子ID', + battery_id VARCHAR(50) NOT NULL COMMENT '电池ID', + start_time TIMESTAMP NULL COMMENT '充电开始时间', + end_time TIMESTAMP NULL COMMENT '充电结束时间', + start_soc INT COMMENT '起始电量(‰)', + end_soc INT COMMENT '结束电量(‰)', + energy INT DEFAULT 0 COMMENT '充电量(Wh)', + reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout', + status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (compartment_id) REFERENCES compartments(id), + FOREIGN KEY (cabinet_id) REFERENCES cabinets(id), + INDEX idx_battery (battery_id), + INDEX idx_compartment (compartment_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + CREATE TABLE IF NOT EXISTS cabin_boards ( id BIGINT AUTO_INCREMENT PRIMARY KEY, cabinet_id BIGINT NOT NULL, @@ -59,7 +111,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号', name VARCHAR(50) COMMENT '姓名', password VARCHAR(255) NOT NULL, - role TINYINT DEFAULT 0 COMMENT '0普通 1企业管理员 2总管理员', + role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员', organization_id BIGINT, status TINYINT DEFAULT 1 COMMENT '0禁用 1启用', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -74,7 +126,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { start_time TIMESTAMP NULL COMMENT '充电开始时间', end_time TIMESTAMP NULL COMMENT '充电结束时间', energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)', - max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(0.1°C)', + max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)', status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (compartment_id) REFERENCES compartments(id), @@ -174,10 +226,22 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - "#; - // 逐条执行,避免多语句执行问题 - for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + CREATE TABLE IF NOT EXISTS user_feedback ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL COMMENT '提交用户ID', + content TEXT NOT NULL COMMENT '反馈内容', + contact VARCHAR(200) COMMENT '联系方式', + status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +"#; + +/// 执行数据库迁移,创建所有必要的数据表 +pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { + // 逐条执行全量建表语句,避免多语句执行问题 + for statement in SCHEMA_SQL.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { sqlx::query(statement).execute(pool).await?; } @@ -185,9 +249,34 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#) .execute(pool).await; + // 更新 charge_records.max_temperature 注释(0.1°C → K×100) + // 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY + ensure_column_comment( + pool, + "charge_records", + "max_temperature", + "K×100", + r#"ALTER TABLE charge_records MODIFY max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)'"#, + ) + .await?; + + // 更新 charge_orders.reason 注释:新增 timeout + // (订单超过 10 小时由 order_timeout 定时任务自动结束,落该枚举值) + ensure_column_comment( + pool, + "charge_orders", + "reason", + "timeout", + r#"ALTER TABLE charge_orders MODIFY COLUMN reason VARCHAR(20) NULL COMMENT '结束原因: normal/user_stop/forced/timeout'"#, + ) + .await?; + // 创建关键索引(IF NOT EXISTS 避免重复) create_indexes(pool).await?; + // 兼容已有数据库:新增字段(幂等,列已存在则跳过) + add_columns_if_not_exists(pool).await?; + // 初始化默认权限码(INSERT IGNORE 避免重复插入) seed_permissions(pool).await?; // 初始化默认角色 @@ -227,11 +316,147 @@ async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> { ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_id").await?; ensure_index(pool, "charge_records", "idx_charge_records_cabinet", "cabinet_id").await?; ensure_index(pool, "device_logs", "idx_device_logs_cabinet", "cabinet_id").await?; + // 订单超时兜底扫描(status = 0 AND start_time < ...)走该联合索引,避免全表扫描 + ensure_index(pool, "charge_orders", "idx_charge_orders_status_start", "status, start_time").await?; + // device_logs 保留策略按 created_at 分批删除,必须有该列索引,否则每次清理全表扫描 + ensure_index(pool, "device_logs", "idx_device_logs_created", "created_at").await?; tracing::info!("数据库索引创建完成"); Ok(()) } +/// 列级幂等迁移:列不存在时才 ALTER TABLE 补列 +/// +/// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」, +/// 对**已存在**的表永远不会补后加的列,导致线上 schema 漂移。 +/// 安全性:表名/列名/DDL 全部来自本文件内的白名单常量,不含任何外部输入拼接。 +async fn ensure_column( + pool: &MySqlPool, + table: &str, + column: &str, + ddl: &str, +) -> Result<(), sqlx::Error> { + let exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + ) + .bind(table) + .bind(column) + .fetch_one(pool) + .await?; + + if exists.0 == 0 { + let alter = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, ddl); + sqlx::query(&alter).execute(pool).await?; + tracing::info!("[migrate] 新增列 {}.{}", table, column); + } + Ok(()) +} + +/// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER +/// +/// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新; +/// 而 MODIFY 会重建整表,因此必须先比对注释,避免每次启动都白跑一次 DDL。 +async fn ensure_column_comment( + pool: &MySqlPool, + table: &str, + column: &str, + marker: &str, + ddl: &str, +) -> Result<(), sqlx::Error> { + // ⚠️ `information_schema.COLUMNS.COLUMN_COMMENT` 在 MySQL 8 上会被 sqlx 报告为 + // **VARBINARY**,直接按 `Option` 解码会 panic(实测阿里云 RDS MySQL 8.0.36): + // `mismatched types; Rust type String (as SQL type VARCHAR) is not compatible with SQL type VARBINARY` + // 必须显式 `CAST(... AS CHAR)`。 + let comment: Option = sqlx::query_scalar( + "SELECT CAST(COLUMN_COMMENT AS CHAR) FROM information_schema.COLUMNS \ + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + ) + .bind(table) + .bind(column) + .fetch_optional(pool) + .await?; + + if let Some(current) = comment { + if !current.contains(marker) { + sqlx::query(ddl).execute(pool).await?; + tracing::info!("[migrate] 更新列注释 {}.{}", table, column); + } + } + Ok(()) +} + +/// 逐列补齐历史版本遗漏的列(幂等,兼容线上已存在的旧表) +/// +/// 线上 pms 库曾因缺少 `cabinets.auth_str_issued` / `cabinets.cell`, +/// 导致设备认证链路直接报 `1054 Unknown column`,只能人工 ALTER 补救; +/// 本函数就是把这个人工步骤固化进启动迁移。 +async fn add_columns_if_not_exists(pool: &MySqlPool) -> Result<(), sqlx::Error> { + // users:头像 + 微信 openid + ensure_column(pool, "users", "avatar", "VARCHAR(500) NULL COMMENT '头像URL'").await?; + ensure_column(pool, "users", "openid", "VARCHAR(64) NULL UNIQUE COMMENT '微信openid'") + .await?; + + // organizations:父组织(组织树层级)+ 禁用标记(甲方禁用客户组织,其下柜子全部失效) + ensure_column(pool, "organizations", "parent_id", "BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'") + .await?; + ensure_column( + pool, + "organizations", + "disabled", + "TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1禁用'", + ) + .await?; + + // projects:地址 + 经纬度 + ensure_column(pool, "projects", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?; + ensure_column(pool, "projects", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?; + ensure_column(pool, "projects", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?; + + // cabinets:地址 + 经纬度 + ensure_column(pool, "cabinets", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?; + ensure_column(pool, "cabinets", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?; + ensure_column(pool, "cabinets", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?; + + // cabinets:设备认证 / 拓扑 / 工作模式(线上曾缺失,必须补齐) + ensure_column( + pool, + "cabinets", + "auth_str_issued", + "BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发'", + ) + .await?; + ensure_column(pool, "cabinets", "cell", "TINYINT DEFAULT 6 COMMENT '仓控板数量'").await?; + ensure_column( + pool, + "cabinets", + "work_mode", + "TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式'", + ) + .await?; + + // batteries:协议 §5.5「电池属性」的原值字段(老库缺失,必须补齐,否则台账只能丢到 device_logs) + ensure_column(pool, "batteries", "brand_code", "VARCHAR(20) NULL COMMENT '厂家代码(原值透传)'").await?; + ensure_column(pool, "batteries", "model_code", "VARCHAR(20) NULL COMMENT '电池型号代码(原值透传)'").await?; + ensure_column(pool, "batteries", "material_code", "VARCHAR(20) NULL COMMENT '电芯材料代码(原值透传)'") + .await?; + ensure_column(pool, "batteries", "cell_count", "INT NULL COMMENT '电芯节数'").await?; + ensure_column(pool, "batteries", "rated_voltage", "BIGINT NULL COMMENT '额定电压(mV)'").await?; + ensure_column(pool, "batteries", "rated_capacity", "BIGINT NULL COMMENT '额定容量(mAh)'").await?; + + // user_feedback:反馈图片(JSON 数组字符串) + ensure_column( + pool, + "user_feedback", + "images", + "TEXT NULL COMMENT '反馈图片URL列表(JSON数组)'", + ) + .await?; + + tracing::info!("数据库列级迁移完成"); + Ok(()) +} + /// 插入默认权限码 async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> { let perms: Vec<(&str, &str)> = vec![ @@ -240,6 +465,8 @@ async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> { ("device:create", "添加设备"), ("device:edit", "编辑设备"), ("device:delete", "删除设备"), + // 安全码重置(仅总管理员,不授予企业管理员/操作员) + ("device:auth_str:reset", "重置安全码"), ("charge:start", "开始充电"), ("charge:stop", "停止充电"), ("charge:open_door", "开门"), @@ -303,12 +530,12 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { .execute(pool) .await?; - // 创建普通用户角色 + // 创建操作员角色 sqlx::query( "INSERT IGNORE INTO roles (name, description) VALUES (?, ?)" ) .bind("normal_user") - .bind("普通用户 — 查看/操作指定范围设备") + .bind("操作员 — 查看/操作指定范围设备") .execute(pool) .await?; @@ -350,7 +577,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { "charge:start", "charge:stop", "charge:open_door", "charge_record:view", "charge_record:export", "device_log:view", "energy:view", "energy:export", - "user:view", "user:edit", + "user:view", "user:create", "user:edit", "operation_log:view", // 组织管理 "org:view", "org:create", "org:edit", "org:delete", @@ -379,7 +606,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { } } - // 为普通用户分配查看和操作权限 + // 为操作员分配查看和操作权限 let normal_id: Option<(i64,)> = sqlx::query_as( "SELECT id FROM roles WHERE name = 'normal_user'" ) diff --git a/software/api-server/src/main.rs b/software/api-server/src/main.rs index 8b635b7..190ea24 100644 --- a/software/api-server/src/main.rs +++ b/software/api-server/src/main.rs @@ -3,6 +3,7 @@ //! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。 //! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。 +mod captcha; mod commands; mod config; mod db; @@ -15,6 +16,7 @@ mod routes; mod workers; use std::net::SocketAddr; +use std::sync::Arc; use tracing_subscriber::EnvFilter; use commands::{AppState, NodeRegistry, PendingCommands}; @@ -28,7 +30,6 @@ async fn main() { // 加载配置 let cfg = config::Config::from_env(); - cfg.validate(); tracing::info!("配置加载完成"); // 初始化 MySQL 连接池 @@ -49,6 +50,11 @@ async fn main() { .expect("Redis 连接失败"); tracing::info!("Redis 连接成功"); + // 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退) + let jwt_secret: Arc = std::env::var("JWT_SECRET") + .expect("JWT_SECRET environment variable is required") + .into(); + // 初始化共享状态 let pending_commands = PendingCommands::new(); let nodes = NodeRegistry::new(redis_conn.clone()); @@ -58,6 +64,7 @@ async fn main() { pending_commands: pending_commands.clone(), nodes: nodes.clone(), redis_url: cfg.redis_url.clone(), + jwt_secret, }; // 启动后台协程 — 处理设备上报 @@ -80,6 +87,19 @@ async fn main() { workers::node_checker::run(checker_nodes, heartbeat_timeout).await; }); + // 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录) + let timeout_mysql = db_pool.clone(); + let timeout_redis = redis_conn.clone(); + tokio::spawn(async move { + workers::order_timeout::start(timeout_mysql, timeout_redis).await; + }); + + // 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS) + let retention_mysql = db_pool.clone(); + tokio::spawn(async move { + workers::log_retention::start(retention_mysql).await; + }); + // 构建 HTTP 路由 let app = routes::build(state); diff --git a/software/api-server/src/middleware/auth.rs b/software/api-server/src/middleware/auth.rs index 2e97757..d76935c 100644 --- a/software/api-server/src/middleware/auth.rs +++ b/software/api-server/src/middleware/auth.rs @@ -24,12 +24,8 @@ use serde::{Deserialize, Serialize}; use sqlx::MySqlPool; use crate::commands::AppState; -use crate::config::JWT_SECRET_DEFAULT; use crate::error::AppError; -/// JWT 签名密钥环境变量名 -const JWT_SECRET_ENV: &str = "JWT_SECRET"; - /// JWT Claims 结构 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Claims { @@ -53,7 +49,7 @@ pub struct CurrentUser { pub phone: String, /// 用户拥有的权限码集合 pub permissions: HashSet, - /// 角色等级:0普通 1企业管理员 2总管理员 + /// 角色等级:0操作员 1企业管理员 2总管理员 pub role_level: i32, /// 所属组织ID(总管理员为 None) pub organization_id: Option, @@ -81,9 +77,8 @@ pub async fn auth_middleware( } }; - // 获取 JWT 密钥 - let secret = std::env::var(JWT_SECRET_ENV) - .unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()); + // 从 AppState 获取 JWT 密钥(启动时已校验,无硬编码回退) + let secret = state.jwt_secret.as_ref(); // 解码并验证 JWT let mut validation = Validation::new(Algorithm::HS256); @@ -195,11 +190,10 @@ async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32, Ok(result.unwrap_or((0, None))) } -/// 生成 JWT token(7天有效期) -pub fn generate_token(user_id: i64, phone: &str) -> Result { - let secret = std::env::var(JWT_SECRET_ENV) - .unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()); - +/// 生成 JWT token(24小时有效期) +/// +/// `secret` 由调用方从 AppState.jwt_secret 传入,禁止硬编码回退。 +pub fn generate_token(user_id: i64, phone: &str, secret: &str) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_err(|e| AppError::Internal(format!("时间错误: {}", e)))? @@ -208,7 +202,7 @@ pub fn generate_token(user_id: i64, phone: &str) -> Result { let claims = Claims { sub: user_id, phone: phone.to_string(), - exp: now + 86400 * 7, + exp: now + 86400, // 24小时 iat: now, }; @@ -284,3 +278,80 @@ pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, V (" AND 1=0".to_string(), vec![]) } } + +// --------------------------------------------------------------------------- +// 单元测试 +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// org_condition 总管理员不限制组织 + #[test] + fn test_org_condition_super_admin() { + let user = CurrentUser { + user_id: 1, + phone: "13800138000".into(), + role_level: 2, + organization_id: None, + permissions: HashSet::new(), + }; + let (cond, params) = org_condition(&user, "cr"); + assert!(cond.is_empty()); + assert!(params.is_empty()); + } + + /// org_condition 普通管理员按组织过滤 + #[test] + fn test_org_condition_normal_admin() { + let user = CurrentUser { + user_id: 2, + phone: "13800138001".into(), + role_level: 1, + organization_id: Some(42), + permissions: HashSet::new(), + }; + let (cond, params) = org_condition(&user, "cr"); + assert!(cond.contains("organization_id = ?"), "应有组织过滤条件"); + assert_eq!(params, vec!["42"]); + } + + /// org_condition 无组织用户应 1=0(看不到任何数据) + #[test] + fn test_org_condition_no_org() { + let user = CurrentUser { + user_id: 3, + phone: "13800138002".into(), + role_level: 0, + organization_id: None, + permissions: HashSet::new(), + }; + let (cond, _params) = org_condition(&user, "cr"); + assert!(cond.contains("1=0"), "无组织应返回 1=0"); + } + + /// verify_password:正确密码应返回 true(round-trip:hash→verify) + #[test] + fn test_verify_password_correct() { + // 运行时生成 argon2 哈希再验证 + let hash = hash_password("test123").unwrap(); + let result = verify_password("test123", &hash).unwrap(); + assert!(result, "正确密码应验证通过"); + } + + /// verify_password:错误密码应返回 false(使用同一哈希比对) + #[test] + fn test_verify_password_wrong() { + let hash = hash_password("test123").unwrap(); + let result = verify_password("wrongpass", &hash).unwrap(); + assert!(!result, "错误密码应验证不通过"); + } + + /// verify_password:无效哈希格式应返回 Err + #[test] + fn test_verify_password_invalid_hash() { + let result = verify_password("test123", "not-a-valid-hash"); + assert!(result.is_err(), "无效哈希应返回错误"); + } +} diff --git a/software/api-server/src/protocol.rs b/software/api-server/src/protocol.rs index 972adff..a296644 100644 --- a/software/api-server/src/protocol.rs +++ b/software/api-server/src/protocol.rs @@ -6,34 +6,21 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -/// 设备发往平台的消息。 -/// -/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。 -#[derive(Debug, Clone, Deserialize, Serialize)] -#[allow(dead_code)] -pub struct DeviceMessage { - /// 动作类型:login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off - pub act: String, - pub dev_id: Option, - pub msg_id: Option, - /// 登录签名时的时间戳(秒级) - pub timestamp: Option, - /// SHA256 签名尾巴 7 位 - pub sign: Option, - /// 485 子设备 ID(仓控板 ID 1-6) - pub sub_device_id: Option, - /// 状态上报时的 HEX 数据 - pub status: Option, - /// 4G 信号强度 - pub rssi: Option, - /// 未在顶层定义的扩展字段 - #[serde(flatten)] - pub extra: Option, -} +// 说明:**上行报文的权威结构定义在 `device-server`**(`software/device-server/src/tcp/protocol.rs`)。 +// api-server 收到的是 `device-server` 转发到 Redis 的 JSON,直接按 `serde_json::Value` 解析, +// 因此这里**不再保留** `DeviceMessage` 副本(旧副本曾带着已废弃的 `status` / `parsed_status` 字段,易误导)。 /// 平台回复设备的消息。 +/// +/// 协议要求(交付文档 §3.1 / §3.3 / §3.5): +/// - **`act` 必带**,且**与请求报文的 `act` 完全一致** +/// - **`msg_id` 必带**,且**与请求报文的 `msg_id` 完全一致** +/// - `suc` 必带;`suc=0` 时 `err` 必带 +/// - `err` 一律为**英文小写短语**(§3.3 err 取值全集),例:`invalid signature` / `invalid id` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerResponse { + /// 动作类型 —— **与请求的 `act` 一致**(协议 §3.1:`act` 双向全部报文必带) + pub act: String, /// 1=成功,0=失败 pub suc: u8, pub msg_id: Option, @@ -41,17 +28,52 @@ pub struct ServerResponse { pub err: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auth_str: Option, + /// login 成功时返回:柜子规格,固定 4 位(前 2 位仓控板数 + 后 2 位每板通道数) + /// 例 `"0606"` = 6 块仓控板 × 每板 6 仓(取代旧的 `cell`) + #[serde(skip_serializing_if = "Option::is_none")] + pub spec: Option, + /// login 成功时返回:充电工作模式 `"0"`=平台指令模式 / `"1"`=自动充电工作模式 + #[serde(skip_serializing_if = "Option::is_none")] + pub work_mode: Option, } impl ServerResponse { - /// 创建成功响应。 - pub fn success(msg_id: Option) -> Self { - Self { suc: 1, msg_id, err: None, auth_str: None } + /// 创建成功响应。`act` 必须与请求一致。 + pub fn success(act: impl Into, msg_id: Option) -> Self { + Self { + act: act.into(), + suc: 1, + msg_id, + err: None, + auth_str: None, + spec: None, + work_mode: None, + } } - /// 创建失败响应。 - pub fn error(msg_id: Option, err: impl Into) -> Self { - Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None } + /// 创建失败响应(`err` 必须传英文短语)。`act` 必须与请求一致。 + pub fn error(act: impl Into, msg_id: Option, err: impl Into) -> Self { + Self { + act: act.into(), + suc: 0, + msg_id, + err: Some(err.into()), + auth_str: None, + spec: None, + work_mode: None, + } + } + + /// 从**设备请求报文**构造成功应答:自动取请求里的 `act` 与 `msg_id`。 + /// + /// 协议要求应答的 `act` / `msg_id` 必须与请求一致,直接从请求取值最不易出错。 + pub fn success_for(req: &Value) -> Self { + Self::success(act_of(req), msg_id_of(req)) + } + + /// 从**设备请求报文**构造失败应答(`err` 必须传英文短语)。 + pub fn error_for(req: &Value, err: impl Into) -> Self { + Self::error(act_of(req), msg_id_of(req), err) } /// 序列化为 JSON 字符串(紧凑格式,无多余空格)。 @@ -59,3 +81,16 @@ impl ServerResponse { serde_json::to_string(self).expect("ServerResponse 序列化不应失败") } } + +/// 取请求报文里的 `act`(应答必须原样回带;未知 `act` 也照原值回带)。 +fn act_of(req: &Value) -> String { + req.get("act") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() +} + +/// 取请求报文里的 `msg_id`(应答必须原样回带)。 +fn msg_id_of(req: &Value) -> Option { + req.get("msg_id").and_then(|v| v.as_u64()).map(|v| v as u32) +} diff --git a/software/api-server/src/routes/auth.rs b/software/api-server/src/routes/auth.rs index 52723d5..70e6e79 100644 --- a/software/api-server/src/routes/auth.rs +++ b/software/api-server/src/routes/auth.rs @@ -1,23 +1,58 @@ //! 认证路由 — 登录/登出/当前用户信息 //! -//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token -//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态) -//! - GET /api/auth/me — 获取当前登录用户信息及权限码 +//! - GET /api/auth/challenge — 获取登录 challenge(防暴力破解) +//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token +//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态) +//! - GET /api/auth/me — 获取当前登录用户信息及权限码 +//! - POST /api/auth/wx-login — 微信小程序手机号快速登录 +//! - PUT /api/auth/avatar — 更新用户头像 URL -use axum::extract::State; +use axum::extract::{Multipart, State}; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; +use std::io::Write; +use uuid::Uuid; +const AVATAR_DIR: &str = "uploads/avatars"; +use reqwest::Client; + +use crate::captcha; +use crate::commands::AppState; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; -use crate::commands::AppState; /// 登录请求体 #[derive(Debug, Deserialize)] pub(crate) struct LoginInput { phone: String, password: String, + /// 登录 challenge token,从 GET /api/auth/challenge 获取 + #[serde(default)] + challenge_id: String, + /// 滑块验证码偏移量(前端拖拽拼图后获得) + #[serde(default)] + captcha_x: Option, +} + +/// GET /api/auth/challenge — 获取登录滑块验证码 +/// +/// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。 +pub async fn get_challenge( + State(state): State, +) -> Result, AppError> { + let data = captcha::create_challenge(&state.redis_url).await?; + + tracing::debug!("[challenge] 生成 cid={}", data.challenge_id); + Ok(Json(json!({ + "code": 0, + "data": { + "challenge_id": data.challenge_id, + "bg_image": data.bg_image, + "piece_image": data.piece_image, + }, + "message": "ok" + }))) } /// POST /api/auth/login — 用户登录 @@ -31,6 +66,16 @@ pub async fn login( return Err(AppError::BadRequest("手机号和密码不能为空".into())); } + // 校验滑块验证码(防暴力破解 + 人机验证) + if input.challenge_id.is_empty() { + return Err(AppError::BadRequest("缺少登录验证参数".into())); + } + let captcha_x = input.captcha_x.unwrap_or(i32::MAX); + let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?; + if !valid { + return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into())); + } + // 查询用户(含角色和组织信息) let user_row: Option<(i64, Option, String, String, i32, i32, Option)> = sqlx::query_as( "SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?", @@ -55,7 +100,7 @@ pub async fn login( } // 生成 JWT token - let token = auth::generate_token(id, &phone)?; + let token = auth::generate_token(id, &phone, &state.jwt_secret)?; // 查询用户权限码 let permissions = auth::load_user_permissions(pool, id).await?; @@ -92,14 +137,14 @@ pub async fn me( ) -> Result, AppError> { let pool = &state.mysql; // 查询用户详细信息 - let row: Option<(i64, Option, String, i32, Option)> = sqlx::query_as( - "SELECT id, name, phone, role, organization_id FROM users WHERE id = ?" + let row: Option<(i64, Option, String, i32, Option, Option)> = sqlx::query_as( + "SELECT id, name, phone, role, organization_id, avatar FROM users WHERE id = ?" ) .bind(user.user_id) .fetch_optional(pool) .await?; - let (id, name, phone, role, org_id) = + let (id, name, phone, role, org_id, avatar) = row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?; let perm_list: Vec = user.permissions.into_iter().collect(); @@ -112,8 +157,291 @@ pub async fn me( "phone": phone, "role": role, "organization_id": org_id, + "avatar": avatar, "permissions": perm_list, }, "message": "ok" }))) } + +/// 微信扫码登录请求体 +#[derive(Debug, Deserialize)] +pub struct WxLoginInput { + /// wx.login() 获取的临时 code + pub code: String, + /// getPhoneNumber 获取的 phone code + pub phone_code: String, +} + +/// 微信 jscode2session 响应 +/// +/// 字段与微信开放平台返回体一一对齐(当前仅读取 openid/errmsg,其余为预留能力, +/// 见各字段注释),故整体允许 dead_code,避免误删上游协议字段。 +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct JsCode2SessionResp { + #[serde(default)] + openid: Option, + /// 会话密钥:预留给敏感数据解密(如 encryptedData/水印校验),当前登录流程不需要 + #[serde(default)] + session_key: Option, + /// 微信错误码:预留用于后续按错误码细分排障(当前仅用 errmsg 文案) + #[serde(default)] + errcode: Option, + #[serde(default)] + errmsg: Option, +} + +/// 微信 access_token 响应 +/// +/// 字段与微信开放平台返回体一一对齐(当前仅读取 access_token/errmsg,其余为预留能力), +/// 故整体允许 dead_code。 +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct TokenResp { + #[serde(default)] + access_token: Option, + /// 有效期秒数:预留用于 access_token 本地缓存(当前每次登录都重新获取) + #[serde(default)] + expires_in: Option, + /// 微信错误码:预留用于后续按错误码细分排障 + #[serde(default)] + errcode: Option, + #[serde(default)] + errmsg: Option, +} + +/// 微信 getuserphonenumber 响应 +/// +/// 字段与微信开放平台返回体一一对齐(当前仅读取 phone_info/errmsg),故整体允许 dead_code。 +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct PhoneNumberResp { + /// 微信错误码:预留用于后续按错误码细分排障 + #[serde(default)] + errcode: Option, + #[serde(default)] + errmsg: Option, + #[serde(default)] + phone_info: Option, +} + +/// 微信手机号信息 +/// +/// 当前业务用 pure_phone_number 匹配用户;带国码的 phone_number 与 country_code +/// 预留用于国际号段/归属地展示,故整体允许 dead_code。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +struct PhoneInfo { + /// 带国码手机号(如 8613951516272),预留国际号段场景 + #[serde(default)] + phone_number: String, + #[serde(default)] + pure_phone_number: String, + /// 国家码(如 86),预留号段归属展示 + #[serde(default)] + country_code: String, +} + +/// POST /api/auth/wx-login — 微信小程序手机号快速登录 +/// +/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code, +/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。 +pub async fn wx_login( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let appid = std::env::var("WECHAT_APPID") + .map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?; + let secret = std::env::var("WECHAT_APPSECRET") + .map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?; + + let client = Client::new(); + + // 1. 用 code 换取 openid + let jscode_url = format!( + "https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code", + appid, secret, input.code + ); + let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await + .map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))? + .json().await + .map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?; + + let openid = js_resp.openid.ok_or_else(|| { + let err = js_resp.errmsg.unwrap_or_default(); + AppError::Internal(format!("jscode2session 失败: {}", err)) + })?; + + // 2. 获取 access_token + let token_url = format!( + "https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential", + appid, secret + ); + let token_resp: TokenResp = client.get(&token_url).send().await + .map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))? + .json().await + .map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?; + + let access_token = token_resp.access_token.ok_or_else(|| { + let err = token_resp.errmsg.unwrap_or_default(); + AppError::Internal(format!("获取 access_token 失败: {}", err)) + })?; + + // 3. 用 phone_code 换取手机号 + let phone_url = format!( + "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}", + access_token + ); + let phone_resp: PhoneNumberResp = client.post(&phone_url) + .json(&json!({ "code": input.phone_code })) + .send().await + .map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))? + .json().await + .map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?; + + let phone = phone_resp.phone_info + .ok_or_else(|| { + let err = phone_resp.errmsg.unwrap_or_default(); + AppError::Internal(format!("获取手机号失败: {}", err)) + })?; + let phone_number = phone.pure_phone_number; + + // 4. 按手机号查找用户(手机号是唯一标识) + tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户"); + let user_row = sqlx::query_as::<_, (i64, Option, String, i32, i32, Option)>( + "SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?", + ) + .bind(&phone_number) + .fetch_optional(&state.mysql) + .await?; + + let (id, name, _phone, status, role_level, organization_id) = + user_row.ok_or_else(|| { + tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户"); + AppError::Unauthorized("用户不存在,请联系管理员添加".into()) + })?; + + // 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息) + sqlx::query("UPDATE users SET openid = ? WHERE id = ?") + .bind(&openid) + .bind(id) + .execute(&state.mysql) + .await?; + + if status == 0 { + return Err(AppError::Forbidden("账户已被禁用".into())); + } + + let token = auth::generate_token(id, &_phone, &state.jwt_secret)?; + let permissions = auth::load_user_permissions(&state.mysql, id).await?; + let perm_list: Vec = permissions.into_iter().collect(); + + Ok(Json(json!({ + "code": 0, + "data": { + "token": token, + "user": { + "id": id, + "name": name, + "phone": _phone, + "role_level": role_level, + "organization_id": organization_id, + "permissions": perm_list, + } + }, + "message": "登录成功" + }))) +} + +/// 微信头像更新请求体 +#[derive(Debug, Deserialize)] +pub struct AvatarInput { + pub avatar: String, +} + +/// PUT /api/auth/avatar — 更新当前用户头像 URL +pub async fn update_avatar( + user: CurrentUser, + State(state): State, + Json(input): Json, +) -> Result, AppError> { + sqlx::query("UPDATE users SET avatar = ? WHERE id = ?") + .bind(&input.avatar) + .bind(user.user_id) + .execute(&state.mysql) + .await?; + + Ok(Json(json!({ "code": 0, "data": { "avatar": input.avatar }, "message": "ok" }))) +} + +/// GET /api/auth/avatar/upload — 头像文件上传 +pub async fn upload_avatar( + user: CurrentUser, + State(state): State, + mut multipart: Multipart, +) -> Result, AppError> { + let mut file_data: Option> = None; + let mut ext = "".to_string(); + + while let Some(field) = multipart.next_field().await + .map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? { + let filename = field.file_name().unwrap_or("").to_string(); + let data = field.bytes().await + .map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?; + + // 提取扩展名 + if let Some(pos) = filename.rfind('.') { + ext = filename[pos..].to_string(); + } + file_data = Some(data.to_vec()); + } + + let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?; + if data.is_empty() { + return Err(AppError::BadRequest("上传内容为空".into())); + } + + // 限制大小(2MB) + if data.len() > 2 * 1024 * 1024 { + return Err(AppError::BadRequest("头像文件不能超过 2MB".into())); + } + + // 白名单扩展名 + if ext.is_empty() { + ext = ".jpg".to_string(); + } + let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]; + if !allowed.contains(&ext.as_str()) { + ext = ".jpg".to_string(); + } + + // 保存文件 + let dir = std::path::Path::new(AVATAR_DIR); + std::fs::create_dir_all(dir) + .map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?; + + let filename = format!("{}{}", Uuid::new_v4().simple(), ext); + let filepath = dir.join(&filename); + let mut f = std::fs::File::create(&filepath) + .map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?; + f.write_all(&data) + .map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?; + + // 更新用户头像 + let avatar_url = format!("/uploads/avatars/{}", filename); + sqlx::query("UPDATE users SET avatar = ? WHERE id = ?") + .bind(&avatar_url) + .bind(user.user_id) + .execute(&state.mysql) + .await?; + + tracing::info!(user_id = user.user_id, path = %avatar_url, "头像上传成功"); + + Ok(Json(json!({ + "code": 0, + "data": { "avatar": avatar_url }, + "message": "ok" + }))) +} diff --git a/software/api-server/src/routes/batteries.rs b/software/api-server/src/routes/batteries.rs new file mode 100644 index 0000000..0feece2 --- /dev/null +++ b/software/api-server/src/routes/batteries.rs @@ -0,0 +1,160 @@ +//! 电池台账 API — 列表查询 + 详情 + +use axum::extract::{Query, State}; +use axum::Json; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::error::AppError; +use crate::middleware::auth::{self, CurrentUser}; + +fn status_text(status: i8) -> &'static str { + match status { + 0 => "空闲", + 1 => "使用中", + 2 => "故障", + 3 => "报废", + _ => "未知", + } +} + +#[derive(Debug, Deserialize)] +pub struct BatteryQuery { + pub status: Option, + pub battery_id: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct BatteryItem { + pub id: i64, + pub battery_id: String, + pub soc: i64, + pub brand: Option, + pub model: Option, + pub sn: Option, + pub status: i8, + pub status_text: String, + pub created_at: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct BatteryRow { + id: i64, + battery_id: String, + soc: i64, + brand: Option, + model: Option, + sn: Option, + status: i8, + created_at: Option, +} + +/// GET /api/batteries — 电池台账列表 +pub async fn list_batteries( + user: CurrentUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + auth::check_permission(&user, "device:view")?; + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).min(100); + let offset = (page - 1) * page_size; + + let mut query = "SELECT * FROM batteries WHERE 1=1".to_string(); + let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string(); + + // 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定 + if params.status.is_some() { + query.push_str(" AND status = ?"); + count_query.push_str(" AND status = ?"); + } + if params.battery_id.is_some() { + query.push_str(" AND battery_id LIKE ?"); + count_query.push_str(" AND battery_id LIKE ?"); + } + + query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); + + let total: i64 = if let Some(s) = params.status { + if let Some(ref bid) = params.battery_id { + sqlx::query_scalar(&count_query) + .bind(s) + .bind(format!("%{}%", bid)) + .fetch_one(&state.mysql) + .await? + } else { + sqlx::query_scalar(&count_query) + .bind(s) + .fetch_one(&state.mysql) + .await? + } + } else if let Some(ref bid) = params.battery_id { + sqlx::query_scalar(&count_query) + .bind(format!("%{}%", bid)) + .fetch_one(&state.mysql) + .await? + } else { + sqlx::query_scalar(&count_query) + .fetch_one(&state.mysql) + .await? + }; + + let rows: Vec = if let Some(s) = params.status { + if let Some(ref bid) = params.battery_id { + sqlx::query_as(&query) + .bind(s) + .bind(format!("%{}%", bid)) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + } else { + sqlx::query_as(&query) + .bind(s) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + } + } else if let Some(ref bid) = params.battery_id { + sqlx::query_as(&query) + .bind(format!("%{}%", bid)) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + } else { + sqlx::query_as(&query) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + }; + + let items: Vec = rows.into_iter().map(|r| BatteryItem { + id: r.id, + battery_id: r.battery_id, + soc: r.soc, + brand: r.brand, + model: r.model, + sn: r.sn, + status: r.status, + status_text: status_text(r.status).to_string(), + created_at: r.created_at, + }).collect(); + + Ok(Json(json!({ + "code": 0, + "data": { + "list": items, + "total": total, + "page": page, + "page_size": page_size + }, + "message": "ok" + }))) +} diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index 44a919e..c7b83e5 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -24,6 +24,7 @@ struct CabinetSqlRow { iccid: Option, name: Option, address: Option, + project_address: Option, status: i8, board_count: Option, channel_count: Option, @@ -43,6 +44,7 @@ impl From for CabinetRow { iccid: row.iccid, name: row.name, address: row.address, + project_address: row.project_address, status: row.status, board_count: row.board_count, channel_count: row.channel_count, @@ -54,6 +56,7 @@ impl From for CabinetRow { pow_fail_dc: None, pow_fail_ac: None, is_online: None, + org_disabled: None, } } } @@ -69,14 +72,14 @@ pub async fn organization_tree( // 获取所有组织 let orgs: Vec = sqlx::query_as( - "SELECT id, name, parent_id FROM organizations ORDER BY id" + "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id" ) .fetch_all(db) .await?; // 获取所有项目 let projects: Vec = sqlx::query_as( - "SELECT id, organization_id, name FROM projects ORDER BY id" + "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?; @@ -116,6 +119,10 @@ pub async fn organization_tree( abstract_id: None, imei: None, status: None, + disabled: None, + address: p.address.clone(), + latitude: p.latitude.clone(), + longitude: p.longitude.clone(), } }) .collect(); @@ -143,6 +150,10 @@ pub async fn organization_tree( abstract_id: None, imei: None, status: None, + disabled: org.disabled, + address: None, + latitude: None, + longitude: None, } }) .collect() @@ -150,7 +161,7 @@ pub async fn organization_tree( let tree = build_tree(&orgs, &projects, &project_count_map, None); - Ok(Json(json!(tree))) + Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) } /// 设备列表筛选参数 @@ -172,7 +183,6 @@ pub async fn filter_cabinets( auth::check_permission(&user, "device:view")?; let db = &state.mysql; - let redis = &state.redis; let page = params.page.unwrap_or(1).max(1); let page_size = params.page_size.unwrap_or(12).clamp(1, 100); @@ -181,7 +191,9 @@ pub async fn filter_cabinets( // 基础SQL:联表查询获取通道状态统计 let base_sql = " SELECT - c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, c.status, + 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 @@ -217,21 +229,21 @@ pub async fn filter_cabinets( ("WHERE 1=0".into(), vec![]) }; - // 已绑定/未绑定过滤 + // 已绑定/未绑定过滤(绑定 = 分配到项目) if let Some(bound) = params.bound { match bound.as_str() { "1" => { if where_clause.is_empty() { - where_clause = "WHERE c.abstract_id IS NOT NULL".into(); + where_clause = "WHERE c.project_id IS NOT NULL".into(); } else { - where_clause.push_str(" AND c.abstract_id IS NOT NULL"); + where_clause.push_str(" AND c.project_id IS NOT NULL"); } } "0" => { if where_clause.is_empty() { - where_clause = "WHERE c.abstract_id IS NULL".into(); + where_clause = "WHERE c.project_id IS NULL".into(); } else { - where_clause.push_str(" AND c.abstract_id IS NULL"); + where_clause.push_str(" AND c.project_id IS NULL"); } } _ => {} @@ -247,7 +259,7 @@ pub async fn filter_cabinets( let total: i64 = count_query.fetch_one(db).await.unwrap_or(0); if total == 0 || offset >= total { - return Ok(Json(json!({"total": 0, "data": []}))); + return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" }))); } // 分页数据 @@ -269,7 +281,8 @@ pub async fn filter_cabinets( let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { AppError::Internal(format!("Redis客户端创建失败: {}", e)) })?; - let mut conn = client.get_async_connection().await.map_err(|e| { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { AppError::Internal(format!("Redis连接失败: {}", e)) })?; @@ -296,7 +309,34 @@ pub async fn filter_cabinets( } } - Ok(Json(json!({"total": total, "data": rows}))) + // 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标) + { + let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); + if !project_ids.is_empty() { + let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + r#"SELECT p.id, o.disabled FROM projects p + LEFT JOIN organizations o ON p.organization_id = o.id + WHERE p.id IN ({})"#, + placeholders.join(",") + ); + let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); + for pid in &project_ids { + q = q.bind(pid); + } + if let Ok(results) = q.fetch_all(db).await { + let disabled_map: std::collections::HashMap = results + .into_iter() + .map(|(pid, d)| (pid, d == 1)) + .collect(); + for row in rows.iter_mut() { + row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied(); + } + } + } + } + + Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" }))) } /// 设备实时状态参数 @@ -313,10 +353,32 @@ pub async fn device_realtime_status( ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; + // 校验设备归属 + if user.role_level < 2 { + if let Some(org_id) = user.organization_id { + let has_access: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM cabinets c \ + JOIN projects p ON c.project_id = p.id \ + WHERE c.imei = ? AND p.organization_id = ?)", + ) + .bind(¶ms.imei) + .bind(org_id) + .fetch_one(&state.mysql) + .await + .unwrap_or(false); + if !has_access { + return Err(AppError::Forbidden("无权访问该设备".into())); + } + } else { + return Err(AppError::Forbidden("无权访问该设备".into())); + } + } + let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { AppError::Internal(format!("Redis客户端创建失败: {}", e)) })?; - let mut conn = client.get_async_connection().await.map_err(|e| { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { AppError::Internal(format!("Redis连接失败: {}", e)) })?; @@ -382,7 +444,7 @@ pub async fn device_realtime_status( continue; } - let ch_fields = vec!["on", "full", "fault", "hex"]; + let ch_fields = vec!["on", "full", "fault", "hex", "temp"]; let ch_values: Vec> = redis::cmd("HMGET") .arg(ch_key) .arg(&ch_fields) @@ -411,6 +473,7 @@ pub async fn device_realtime_status( "full": full, "fault": fault, "hex": ch_values.get(3).and_then(|v| v.as_ref()).cloned().unwrap_or_default(), + "temp": ch_values.get(4).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()), }); } @@ -419,7 +482,11 @@ pub async fn device_realtime_status( } Ok(Json(json!({ - "device": device_status, - "boards": boards, + "code": 0, + "data": { + "device": device_status, + "boards": boards, + }, + "message": "ok" }))) } diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index 0712114..b5897ea 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -12,11 +12,11 @@ use sqlx::MySqlPool; use super::organizations::{ generate_auth_str, - CabinetDetail, CabinBoardRow, CabinBoardWithCompartments, - CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody, + CabinBoardRow, CompartmentRow, CreateCabinetBody, ReplaceImeiBody, UpdateCabinetBody, }; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; +use super::scope_guard; /// 基础柜子行(只包含数据库字段,用于简单查询) #[derive(Debug, Clone, Serialize, sqlx::FromRow)] @@ -28,7 +28,11 @@ struct CabinetBasicRow { iccid: Option, name: Option, address: Option, + latitude: Option, + longitude: Option, status: i8, + /// 充电工作模式:0=平台指令模式 1=自动充电工作模式 + work_mode: Option, } impl From for super::organizations::CabinetRow { @@ -52,6 +56,8 @@ impl From for super::organizations::CabinetRow { pow_fail_dc: None, pow_fail_ac: None, is_online: None, + org_disabled: None, + project_address: None, } } } @@ -109,7 +115,7 @@ pub async fn list_cabinets( let db = &state.mysql; let sql_rows = sqlx::query_as::<_, CabinetBasicRow>( - r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status + r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status, work_mode FROM cabinets WHERE project_id = ? ORDER BY id"#, ) .bind(project_id) @@ -117,7 +123,7 @@ pub async fn list_cabinets( .await?; let rows: Vec = sql_rows.into_iter().map(Into::into).collect(); - Ok(Json(json!(rows))) + Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// POST /api/cabinets — 批量添加柜子 @@ -129,6 +135,7 @@ pub async fn create_cabinet( Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:create")?; + scope_guard::verify_project_ownership(&state.mysql, &user, body.project_id).await?; let db = &state.mysql; if body.imeis.is_empty() { @@ -191,7 +198,7 @@ pub async fn create_cabinet( tracing::info!("创建柜子 id={} imei={}", cabinet_id, imei); } - Ok(Json(json!({ "ids": created_ids }))) + Ok(Json(json!({ "code": 0, "data": { "ids": created_ids }, "message": "ok" }))) } /// PUT /api/cabinets/:id — 更新柜子(划拨项目等) @@ -202,6 +209,7 @@ pub async fn update_cabinet( Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let rows = sqlx::query( @@ -224,7 +232,96 @@ pub async fn update_cabinet( } tracing::info!("更新柜子 id={}", id); - Ok(Json(json!({}))) + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) +} + +/// 柜子状态更新请求体 +#[derive(Debug, Deserialize)] +pub struct SetCabinetStatusBody { + pub status: i8, +} + +/// PUT /api/cabinets/:id/status — 设置柜子状态(启用/禁用) +/// +/// 注意:禁/启用操作仅总管理员(role_level >= 2)可以执行,客户管理员无此权限。 +pub async fn set_cabinet_status( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + auth::check_permission(&user, "device:edit")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; + + // 禁/启用操作仅总管理员可执行 + if user.role_level < 2 { + return Err(AppError::Forbidden("仅总管理员可禁用/启用柜子".into())); + } + + let db = &state.mysql; + let rows = sqlx::query("UPDATE cabinets SET status = ? WHERE id = ?") + .bind(body.status) + .bind(id) + .execute(db) + .await? + .rows_affected(); + + if rows == 0 { + return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); + } + + let action = if body.status == 2 { "禁用" } else { "启用" }; + tracing::info!("{}柜子 id={}", action, id); + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) +} + +/// 充电工作模式更新请求体(前端契约:字符串 "0" / "1") +#[derive(Debug, Deserialize)] +pub struct UpdateWorkModeBody { + pub work_mode: String, +} + +/// PUT /api/cabinets/:id/work-mode — 设置充电工作模式 +/// +/// 0=平台指令模式(默认,需平台下发指令才充电) +/// 1=自动充电工作模式(设备自主充电) +/// 权限:device:edit(编辑设备),并校验柜子存在 + 组织归属。 +pub async fn update_work_mode( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + auth::check_permission(&user, "device:edit")?; + // 组织隔离:非总管理员只能改本组织项目的柜子 + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; + + // 白名单取值,拒绝一切其它输入(含 "2"、"true" 等) + let mode: i8 = match body.work_mode.trim() { + "0" => 0, + "1" => 1, + other => { + return Err(AppError::BadRequest(format!( + "work_mode 只能为 \"0\"(平台指令模式) 或 \"1\"(自动充电模式),收到: {}", + other + ))) + } + }; + + let rows = sqlx::query("UPDATE cabinets SET work_mode = ? WHERE id = ?") + .bind(mode) + .bind(id) + .execute(&state.mysql) + .await? + .rows_affected(); + + // 总管理员路径下 verify_cabinet_ownership 直接放行,这里兜底不存在的情况 + if rows == 0 { + return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); + } + + tracing::info!("设置充电工作模式 id={} work_mode={}", id, mode); + Ok(Json(json!({ "code": 0, "data": { "success": true }, "message": "ok" }))) } /// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体) @@ -234,6 +331,7 @@ pub async fn delete_cabinet( Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:delete")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let mut tx = db.begin().await.map_err(AppError::Database)?; @@ -265,7 +363,7 @@ pub async fn delete_cabinet( } tracing::info!("删除柜子 id={}", id); - Ok(Json(json!({}))) + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } /// POST /api/cabinets/:id/bind — 绑定平台抽象ID @@ -275,6 +373,7 @@ pub async fn bind_cabinet( Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; @@ -314,7 +413,7 @@ pub async fn bind_cabinet( tx.commit().await.map_err(AppError::Database)?; tracing::info!("绑定柜子 id={} abstract_id={}", id, abstract_id); - Ok(Json(json!({ "abstract_id": abstract_id }))) + Ok(Json(json!({ "code": 0, "data": { "abstract_id": abstract_id }, "message": "ok" }))) } /// POST /api/cabinets/:id/replace-imei — 更换IMEI(换模组) @@ -325,6 +424,7 @@ pub async fn replace_imei( Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let imei = body.imei.trim().to_string(); if imei.len() != 15 || !imei.chars().all(|c| c.is_ascii_digit()) { @@ -366,7 +466,7 @@ pub async fn replace_imei( .await?; tracing::info!("更换IMEI id={} new_imei={}", id, imei); - Ok(Json(json!({}))) + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } /// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码 @@ -375,24 +475,55 @@ pub async fn regenerate_auth( State(state): State, Path(id): Path, ) -> Result, AppError> { - auth::check_permission(&user, "device:edit")?; + // 仅总管理员可重置安全码:device:auth_str:reset 不在企业管理员/操作员的权限码表中, + // 且 check_permission 对 role_level >= 2 直接放行,因此等价于「仅总管理员可用」 + auth::check_permission(&user, "device:auth_str:reset")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let auth_str = generate_auth_str(); - let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#) + let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ?, auth_str_issued = 0 WHERE id = ?"#) .bind(&auth_str) .bind(id) .execute(db) .await? .rows_affected(); + // 同步 Redis: + // 1. 用新码覆盖 auth_str:{imei} —— 旧安全码【立即失效】,设备继续用旧码 login 会收到「签名错误」, + // 从而触发设备重新调用 auth_str 申领新码,形成自愈闭环 + // 2. 清除 auth_str:issued:{imei} 标记 —— 允许设备重新申领 + let imei: Option = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") + .bind(id) + .fetch_optional(db) + .await?; + if let Some(imei) = imei { + let code_key = format!("auth_str:{}", imei); + let issued_key = format!("auth_str:issued:{}", imei); + let mut conn = state.redis.clone(); + + if let Err(e) = redis::cmd("SET") + .arg(&code_key) + .arg(&auth_str) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[regenerate_auth] SET {} 失败: {}", code_key, e); + } + + let _: Result<(), _> = redis::cmd("DEL") + .arg(&issued_key) + .query_async(&mut conn) + .await; + } + if rows == 0 { return Err(AppError::NotFound(format!("柜子 {} 不存在", id))); } tracing::info!("重新生成 auth_str id={}", id); - Ok(Json(json!({ "auth_str": auth_str }))) + Ok(Json(json!({ "code": 0, "data": { "auth_str": auth_str }, "message": "ok" }))) } // --------------------------------------------------------------------------- @@ -409,7 +540,7 @@ pub async fn get_cabinet_detail( let db = &state.mysql; let cabinet = sqlx::query_as::<_, CabinetBasicRow>( - r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, status + r#"SELECT id, project_id, abstract_id, imei, iccid, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude, status, work_mode FROM cabinets WHERE id = ?"#, ) .bind(id) @@ -422,17 +553,87 @@ pub async fn get_cabinet_detail( check_project_access(&user, db, pid).await?; } + // 仓控板 + 仓体(含实时字段占位),并收集通道 Redis key 所需的映射 + let (mut cabin_boards, comp_ids, comp_to_board) = load_boards_with_compartments(db, id).await?; + + // 一次 pipeline 读取柜级 + 通道级实时状态;Redis 异常一律降级,不让详情接口整体失败 + let realtime = + fetch_cabinet_realtime(&state.redis_url, &cabinet.imei, &comp_ids, &comp_to_board).await; + apply_realtime(&mut cabin_boards, &realtime); + + // 查询项目名称 + let project_name: Option = if let Some(pid) = cabinet.project_id { + sqlx::query_scalar("SELECT name FROM projects WHERE id = ?") + .bind(pid) + .fetch_optional(db) + .await + .ok() + .flatten() + } else { + None + }; + + Ok(Json(json!({ + "code": 0, + "data": { + "id": cabinet.id, + "project_id": cabinet.project_id, + "project_name": project_name, + "abstract_id": cabinet.abstract_id, + "imei": cabinet.imei, + "iccid": cabinet.iccid, + "name": cabinet.name, + "status": cabinet.status, + // 充电工作模式:字符串 "0"/"1"(前端契约),无值时为 null + "work_mode": cabinet.work_mode.map(|m| m.to_string()), + "rssi": realtime.rssi, + "pow_fail_dc": realtime.pow_fail_dc, + "pow_fail_ac": realtime.pow_fail_ac, + "address": cabinet.address, + "latitude": cabinet.latitude, + "longitude": cabinet.longitude, + "cabin_boards": cabin_boards, + }, + "message": "ok" + }))) +} + +// --------------------------------------------------------------------------- +// 设备详情 — Redis 实时状态(柜级 + 通道级) +// --------------------------------------------------------------------------- + +/// 详情页实时状态快照;Redis 不可用时整体为默认空值 +#[derive(Default)] +struct CabinetRealtime { + rssi: Option, + pow_fail_dc: Option, + pow_fail_ac: Option, + /// 仓体 id -> (SOC, 电池ID) + soc_map: std::collections::HashMap, Option)>, + /// 仓体 id -> (温度, batt_status, pow_status) + ch_map: std::collections::HashMap, Option, Option)>, +} + +/// 查询仓控板与仓体,构建带实时字段占位的 JSON +/// +/// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`, +/// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。 +async fn load_boards_with_compartments( + db: &MySqlPool, + cabinet_id: i64, +) -> Result<(Vec, Vec, Vec<(i64, (i8, i8))>), AppError> { let boards: Vec = sqlx::query_as( r#"SELECT id, cabinet_id, board_id, status FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#, ) - .bind(id) + .bind(cabinet_id) .fetch_all(db) .await?; - // 查询仓控板+仓体,同时构建 Value 以便注入 SOC let mut cabin_boards: Vec = Vec::new(); let mut comp_ids: Vec = Vec::new(); + let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new(); + for board in boards { let compartments: Vec = sqlx::query_as( r#"SELECT id, cabin_board_id, channel_id, status @@ -444,15 +645,22 @@ pub async fn get_cabinet_detail( for c in &compartments { comp_ids.push(c.id); + comp_to_board.push((c.id, (board.board_id, c.channel_id))); } - let comp_values: Vec = compartments.into_iter().map(|c| { - json!({ - "id": c.id, - "channel_id": c.channel_id, - "status": c.status, + // batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null + let comp_values: Vec = compartments + .into_iter() + .map(|c| { + json!({ + "id": c.id, + "channel_id": c.channel_id, + "status": c.status, + "batt_status": Value::Null, + "pow_status": Value::Null, + }) }) - }).collect(); + .collect(); cabin_boards.push(json!({ "id": board.id, @@ -462,71 +670,135 @@ pub async fn get_cabinet_detail( })); } - // 从 Redis 批量读取实时状态(RSSI/电源/SOC) - let (rssi, pow_fail_dc, pow_fail_ac, mut soc_map) = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - if let Ok(mut conn) = client.get_async_connection().await { - let key = format!("device:{}", cabinet.imei); - let mut pipe = redis::pipe(); - pipe.cmd("HGET").arg(&key).arg("rssi") - .cmd("HGET").arg(&key).arg("pow_fail_dc") - .cmd("HGET").arg(&key).arg("pow_fail_ac"); - for cid in &comp_ids { - pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); - } - let vals: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); - let rssi: Option = vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse().ok()); - let dc = vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"); - let ac = vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"); - let mut soc_map = std::collections::HashMap::new(); - for (i, cid) in comp_ids.iter().enumerate() { - if let Some(Some(data)) = vals.get(3 + i) { - if let Ok(v) = serde_json::from_str::(data) { - let soc = v.get("soc").and_then(|s| s.as_i64()); - let bid = v.get("battery_id").and_then(|s| s.as_str()).map(|s| s.to_string()); - soc_map.insert(*cid, (soc, bid)); - } - } - } - (rssi, dc, ac, soc_map) - } else { (None, None, None, std::collections::HashMap::new()) } - } else { (None, None, None, std::collections::HashMap::new()) }; + Ok((cabin_boards, comp_ids, comp_to_board)) +} - // 注入 SOC 到每个仓体 - for board in &mut cabin_boards { - if let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) { - for comp in comps { - if let Some(id) = comp.get("id").and_then(|i| i.as_i64()) { - if let Some((soc, bid)) = soc_map.get(&id) { - if let Some(s) = soc { - comp["soc"] = json!(s); - } - if let Some(b) = bid { - comp["battery_id"] = json!(b); - } - } - } +/// 一次 pipeline 拉取柜级(device:{imei}) + 通道级(device:{imei}:board:{b}:ch:{c}) 实时状态 +/// +/// 为什么放一起:详情接口要求低延迟,柜级 3 个字段 + 每通道 3 个字段共用一次网络往返。 +/// 任何一步失败都只记 WARN 并降级为空值 —— 详情主体数据来自 MySQL,不能因缓存故障整体失败。 +async fn fetch_cabinet_realtime( + redis_url: &str, + imei: &str, + comp_ids: &[i64], + comp_to_board: &[(i64, (i8, i8))], +) -> CabinetRealtime { + let client = match redis::Client::open(redis_url) { + Ok(c) => c, + Err(e) => { + tracing::warn!("[cabinet_detail] Redis 客户端创建失败,实时状态降级: {}", e); + return CabinetRealtime::default(); + } + }; + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = match client.get_multiplexed_async_connection().await { + Ok(c) => c, + Err(e) => { + tracing::warn!("[cabinet_detail] Redis 连接失败,实时状态降级: {}", e); + return CabinetRealtime::default(); + } + }; + + let key = format!("device:{}", imei); + let mut pipe = redis::pipe(); + pipe.cmd("HGET").arg(&key).arg("rssi") + .cmd("HGET").arg(&key).arg("pow_fail_dc") + .cmd("HGET").arg(&key).arg("pow_fail_ac"); + for cid in comp_ids { + pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); + } + // 通道级:板号/通道号为十进制不补零,与设备上报写入的 key 保持一致 + for (_, (board_id, channel_id)) in comp_to_board { + let rkey = format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id); + pipe.cmd("HGET").arg(&rkey).arg("temp") + .cmd("HGET").arg(&rkey).arg("batt_status") + .cmd("HGET").arg(&rkey).arg("pow_status"); + } + + // 用类型标注而非 turbofish:query_async 的泛型首参是连接类型 + let queried: Result>, redis::RedisError> = + pipe.query_async(&mut conn).await; + match queried { + Ok(vals) => parse_cabinet_realtime(&vals, comp_ids, comp_to_board), + Err(e) => { + tracing::warn!("[cabinet_detail] Redis pipeline 读取失败,实时状态降级 imei={} err={}", imei, e); + CabinetRealtime::default() + } + } +} + +/// 解析 pipeline 结果:柜级 3 项 + 每仓体 1 项(SOC JSON) + 每通道 3 项(temp/batt_status/pow_status) +fn parse_cabinet_realtime( + vals: &[Option], + comp_ids: &[i64], + comp_to_board: &[(i64, (i8, i8))], +) -> CabinetRealtime { + /// 取出第 i 项的非空字符串 + fn pick(vals: &[Option], i: usize) -> Option { + vals.get(i).and_then(|v| v.as_ref()).cloned() + } + + let mut rt = CabinetRealtime { + rssi: pick(vals, 0).and_then(|v| v.parse().ok()), + pow_fail_dc: pick(vals, 1).map(|v| v == "1"), + pow_fail_ac: pick(vals, 2).map(|v| v == "1"), + ..Default::default() + }; + + for (i, cid) in comp_ids.iter().enumerate() { + if let Some(data) = pick(vals, 3 + i) { + if let Ok(v) = serde_json::from_str::(&data) { + let soc = v.get("soc").and_then(|s| s.as_i64()); + let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string); + rt.soc_map.insert(*cid, (soc, bid)); } } } - Ok(Json(json!({ - "code": 0, - "data": { - "id": cabinet.id, - "project_id": cabinet.project_id, - "abstract_id": cabinet.abstract_id, - "imei": cabinet.imei, - "iccid": cabinet.iccid, - "name": cabinet.name, - "status": cabinet.status, - "rssi": rssi, - "pow_fail_dc": pow_fail_dc, - "pow_fail_ac": pow_fail_ac, - "address": cabinet.address, - "cabin_boards": cabin_boards, - }, - "message": "ok" - }))) + // 通道级字段紧随 SOC 段之后,每通道 3 项 + let base = 3 + comp_ids.len(); + for (i, (cid, _)) in comp_to_board.iter().enumerate() { + let off = base + i * 3; + let temp = pick(vals, off).and_then(|v| v.parse::().ok()); + let batt_status = pick(vals, off + 1); + let pow_status = pick(vals, off + 2); + rt.ch_map.insert(*cid, (temp, batt_status, pow_status)); + } + + rt +} + +/// 把实时状态注入仓体 JSON:缺失字段保持 null / 原值,绝不写空字符串 +fn apply_realtime(cabin_boards: &mut [Value], rt: &CabinetRealtime) { + for board in cabin_boards.iter_mut() { + let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) else { + continue; + }; + for comp in comps.iter_mut() { + let Some(cid) = comp.get("id").and_then(|i| i.as_i64()) else { + continue; + }; + if let Some((soc, bid)) = rt.soc_map.get(&cid) { + if let Some(s) = soc { + comp["soc"] = json!(s); + } + if let Some(b) = bid { + comp["battery_id"] = json!(b); + } + } + if let Some((temp, batt_status, pow_status)) = rt.ch_map.get(&cid) { + if let Some(t) = temp { + comp["temp"] = json!(t); + } + if let Some(bs) = batt_status { + comp["batt_status"] = json!(bs); + } + if let Some(ps) = pow_status { + comp["pow_status"] = json!(ps); + } + } + } + } } // --------------------------------------------------------------------------- @@ -638,3 +910,4 @@ pub async fn list_compartment_options( Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } + diff --git a/software/api-server/src/routes/charge_orders.rs b/software/api-server/src/routes/charge_orders.rs new file mode 100644 index 0000000..61fc4e2 --- /dev/null +++ b/software/api-server/src/routes/charge_orders.rs @@ -0,0 +1,230 @@ +//! 充电订单 API — 列表查询 + 详情 + +use axum::extract::{Query, State}; +use axum::Json; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::error::AppError; +use crate::middleware::auth::{self, CurrentUser}; + +/// 默认页码(预留 serde default 用) +#[allow(dead_code)] +fn default_page() -> i64 { 1 } +/// 默认每页大小(预留 serde default 用) +#[allow(dead_code)] +fn default_page_size() -> i64 { 20 } + +fn status_text(status: i8) -> &'static str { + match status { + 0 => "充电中", + 1 => "已完成", + 2 => "异常", + _ => "未知", + } +} + +fn reason_text(reason: Option<&str>) -> &'static str { + match reason { + Some("normal") => "正常充满", + Some("user_stop") => "手动停止", + Some("forced") => "强制结束", + Some("timeout") => "超时结束", + _ => "未知", + } +} + +#[derive(Debug, Deserialize)] +pub struct ChargeOrderQuery { + pub cabinet_id: Option, + pub compartment_id: Option, + pub battery_id: Option, + pub status: Option, + pub start_time: Option, + pub end_time: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct ChargeOrderItem { + pub id: i64, + pub cabinet_id: i64, + pub cabinet_name: Option, + pub compartment_id: i64, + pub battery_id: String, + pub start_time: Option, + pub end_time: Option, + pub start_soc: i64, + pub end_soc: Option, + pub energy: i64, + pub reason: Option, + pub reason_text: String, + pub status: i8, + pub status_text: String, + pub created_at: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct ChargeOrderRow { + id: i64, + cabinet_id: i64, + cabinet_name: Option, + compartment_id: i64, + battery_id: String, + start_time: Option, + end_time: Option, + start_soc: i64, + end_soc: Option, + energy: i64, + reason: Option, + status: i8, + created_at: Option, +} + +/// GET /api/charge-orders — 充电订单列表 +pub async fn list_charge_orders( + user: CurrentUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + auth::check_permission(&user, "charge_record:view")?; + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).min(100); + let offset = (page - 1) * page_size; + + let mut query = + "SELECT co.*, cab.name as cabinet_name FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1" + .to_string(); + let mut count_query = "SELECT COUNT(*) FROM charge_orders WHERE 1=1".to_string(); + + // 按固定顺序追加 `?` 占位符,绑定顺序必须与下方 build_count_query / build_list_query 一致 + if params.cabinet_id.is_some() { + query.push_str(" AND cabinet_id = ?"); + count_query.push_str(" AND cabinet_id = ?"); + } + if params.compartment_id.is_some() { + query.push_str(" AND compartment_id = ?"); + count_query.push_str(" AND compartment_id = ?"); + } + if params.battery_id.is_some() { + query.push_str(" AND battery_id LIKE ?"); + count_query.push_str(" AND battery_id LIKE ?"); + } + if params.status.is_some() { + query.push_str(" AND status = ?"); + count_query.push_str(" AND status = ?"); + } + if params.start_time.is_some() { + query.push_str(" AND start_time >= ?"); + count_query.push_str(" AND start_time >= ?"); + } + if params.end_time.is_some() { + query.push_str(" AND end_time <= ?"); + count_query.push_str(" AND end_time <= ?"); + } + + query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); + + let total: i64 = build_count_query(count_query, ¶ms, &state.mysql).await?; + let rows: Vec = build_list_query(query, params, page_size, offset, &state.mysql).await?; + + let items: Vec = rows.into_iter().map(|r| ChargeOrderItem { + id: r.id, + cabinet_id: r.cabinet_id, + cabinet_name: r.cabinet_name, + compartment_id: r.compartment_id, + battery_id: r.battery_id, + start_time: r.start_time.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()), + end_time: r.end_time.map(|t| t.format("%Y-%m-%d %H:%M:%S").to_string()), + start_soc: r.start_soc, + end_soc: r.end_soc, + energy: r.energy, + reason: r.reason.clone(), + reason_text: reason_text(r.reason.as_deref()).to_string(), + status: r.status, + status_text: status_text(r.status).to_string(), + created_at: r.created_at, + }).collect(); + + Ok(Json(json!({ + "code": 0, + "data": { + "list": items, + "total": total, + "page": page, + "page_size": page_size + }, + "message": "ok" + }))) +} + +/// 执行 COUNT 查询并返回总数。 +/// +/// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致: +/// cabinet_id → compartment_id → battery_id → status → start_time → end_time +async fn build_count_query( + query: String, + params: &ChargeOrderQuery, + mysql: &sqlx::MySqlPool, +) -> Result { + let mut q = sqlx::query_scalar::<_, i64>(&query); + if let Some(cid) = params.cabinet_id { + q = q.bind(cid); + } + if let Some(cid) = params.compartment_id { + q = q.bind(cid); + } + if let Some(ref bid) = params.battery_id { + // LIKE 值前后加 %,在 bind 值中拼接,不拼入 SQL 文本 + q = q.bind(format!("%{}%", bid)); + } + if let Some(s) = params.status { + q = q.bind(s); + } + if let Some(ref st) = params.start_time { + q = q.bind(st); + } + if let Some(ref et) = params.end_time { + q = q.bind(et); + } + q.fetch_one(mysql).await +} + +/// 执行列表查询并返回订单行。 +/// +/// 参数绑定顺序必须与 `list_charge_orders` 中向 `query` 追加 `?` 占位符的顺序一致: +/// cabinet_id → compartment_id → battery_id → status → start_time → end_time → LIMIT → OFFSET +async fn build_list_query( + query: String, + params: ChargeOrderQuery, + page_size: i64, + offset: i64, + mysql: &sqlx::MySqlPool, +) -> Result, sqlx::Error> { + let mut q = sqlx::query_as::<_, ChargeOrderRow>(&query); + if let Some(cid) = params.cabinet_id { + q = q.bind(cid); + } + if let Some(cid) = params.compartment_id { + q = q.bind(cid); + } + if let Some(ref bid) = params.battery_id { + // LIKE 值前后加 %,在 bind 值中拼接,不拼入 SQL 文本 + q = q.bind(format!("%{}%", bid)); + } + if let Some(s) = params.status { + q = q.bind(s); + } + if let Some(ref st) = params.start_time { + q = q.bind(st); + } + if let Some(ref et) = params.end_time { + q = q.bind(et); + } + // 分页参数始终存在,放最后 + q = q.bind(page_size).bind(offset); + q.fetch_all(mysql).await +} diff --git a/software/api-server/src/routes/charge_records.rs b/software/api-server/src/routes/charge_records.rs index cb6dc7d..a83bfb9 100644 --- a/software/api-server/src/routes/charge_records.rs +++ b/software/api-server/src/routes/charge_records.rs @@ -249,11 +249,13 @@ pub async fn export_charge_records( let user_id = user.user_id; let file_name = generate_export_filename(¶ms); - // 序列化查询参数 + // 序列化查询参数(含组织过滤信息) let query_json = serde_json::to_string(&json!({ "cabinet_id": params.cabinet_id, "start_time": params.start_time, "end_time": params.end_time, + "org_id": user.organization_id, + "role_level": user.role_level, })) .unwrap_or_default(); @@ -328,7 +330,7 @@ async fn generate_charge_records_excel( let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({})); - let (where_clause, 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()), None, None, @@ -336,6 +338,19 @@ async fn generate_charge_records_excel( params.get("end_time").and_then(|v| v.as_str()), ); + // 组织过滤(非总管理员) + let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0); + let org_id = params.get("org_id").and_then(|v| v.as_i64()); + if role_level < 2 { + let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " }; + if let Some(oid) = org_id { + where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix)); + bind_values.push(oid.to_string()); + } else { + where_clause.push_str(&format!("{}1=0", prefix)); + } + } + let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?; // 更新进度 50% @@ -351,7 +366,7 @@ async fn generate_charge_records_excel( let headers = [ "ID", "柜子", "仓板", "通道", "开始时间", "结束时间", - "充电量(Wh)", "最高温度(0.1°C)", "状态", + "充电量(Wh)", "最高温度(K×100)", "状态", ]; for (col, header) in headers.iter().enumerate() { sheet.write_string(0, col as u16, *header)?; diff --git a/software/api-server/src/routes/energy_stats.rs b/software/api-server/src/routes/energy_stats.rs index f19ca8a..2533783 100644 --- a/software/api-server/src/routes/energy_stats.rs +++ b/software/api-server/src/routes/energy_stats.rs @@ -11,8 +11,8 @@ use serde_json::{json, Value}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; -/// 电费单价(元/Wh),energy 单位为 Wh,0.80 元/kWh = 0.0008 元/Wh -const COST_PER_WH: f64 = 0.0008; +/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh +const COST_PER_WH_CENT: i64 = 80; /// 趋势查询参数 #[derive(Debug, Deserialize)] @@ -150,7 +150,7 @@ pub async fn get_summary( "data": { "today_energy": today_energy, "month_energy": month_energy, - "estimated_cost": ((month_energy as f64) * COST_PER_WH * 100.0).round() / 100.0, + "estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0, "cabinet_count": cabinet_count, "online_count": online_count, }, @@ -181,14 +181,17 @@ pub async fn get_cabinet_ranking( let sql = format!( "SELECT c.id, c.abstract_id, c.name, \ - COALESCE(es_today.total_energy, 0) AS today_energy, \ - COALESCE(es_month.total_energy, 0) AS month_energy \ + CAST(COALESCE(es_today.total_energy, 0) AS SIGNED) AS today_energy, \ + CAST(COALESCE(es_month.month_energy, 0) AS SIGNED) AS month_energy \ FROM cabinets c \ LEFT JOIN energy_stats es_today \ ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \ - LEFT JOIN energy_stats es_month \ - ON c.id = es_month.cabinet_id \ - AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ + LEFT JOIN ( \ + SELECT cabinet_id, SUM(total_energy) AS month_energy \ + FROM energy_stats \ + WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ + GROUP BY cabinet_id \ + ) es_month ON c.id = es_month.cabinet_id \ {} \ ORDER BY today_energy DESC, c.id ASC", org_join @@ -236,14 +239,17 @@ pub async fn get_project_stats( "SELECT p.id, p.name, \ COUNT(DISTINCT c.id) AS cabinet_count, \ CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \ - CAST(COALESCE(SUM(es_month.total_energy), 0) AS SIGNED) AS month_energy \ + CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \ FROM projects p \ LEFT JOIN cabinets c ON p.id = c.project_id \ LEFT JOIN energy_stats es_today \ ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \ - LEFT JOIN energy_stats es_month \ - ON c.id = es_month.cabinet_id \ - AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ + LEFT JOIN ( \ + SELECT cabinet_id, SUM(total_energy) AS month_energy \ + FROM energy_stats \ + WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \ + GROUP BY cabinet_id \ + ) es_month ON c.id = es_month.cabinet_id \ {} \ GROUP BY p.id, p.name \ ORDER BY today_energy DESC, p.id ASC", @@ -365,12 +371,14 @@ pub async fn export_energy_stats( let user_id = user.user_id; let file_name = generate_export_filename(¶ms); - // 序列化查询参数 + // 序列化查询参数(含组织过滤信息) let query_json = serde_json::to_string(&json!({ "cabinet_id": params.cabinet_id, "project_id": params.project_id, "start_date": params.start_date, "end_date": params.end_date, + "org_id": user.organization_id, + "role_level": user.role_level, })) .unwrap_or_default(); @@ -475,13 +483,27 @@ async fn generate_energy_excel( let (file_name, query_json) = row.ok_or("任务不存在")?; let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({})); - let (where_clause, 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("project_id").and_then(|v| v.as_i64()), params.get("start_date").and_then(|v| v.as_str()), params.get("end_date").and_then(|v| v.as_str()), ); + // 组织过滤(非总管理员) + let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0); + let org_id = params.get("org_id").and_then(|v| v.as_i64()); + if role_level < 2 { + if let Some(oid) = org_id { + let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " }; + where_clause.push_str(&format!("{}es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix)); + bind_values.push(oid.to_string()); + } else { + let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " }; + where_clause.push_str(&format!("{}1=0", prefix)); + } + } + // 查询能耗明细 let sql = format!( "SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \ diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index ef93d67..ff99bef 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -11,13 +11,15 @@ //! - POST /api/door/open — 开门 //! - GET /api/cabinets/scan/:abstract_id — 扫码验证 -use axum::extract::{Path, Query, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::Json; use redis; use serde::Deserialize; use serde_json::{json, Value}; +use std::io::Write; +use uuid::Uuid; -use crate::commands::{AppState, DeviceCommand}; +use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; @@ -31,6 +33,17 @@ pub(crate) struct CompartmentActionInput { compartment_id: i64, } +/// 问题反馈请求体 +#[derive(Debug, Deserialize)] +pub(crate) struct FeedbackInput { + /// 反馈内容 + content: String, + /// 联系方式(可选) + contact: Option, + /// 图片URL列表(可选) + images: Option>, +} + /// 设备列表查询参数 #[derive(Debug, Deserialize)] pub(crate) struct CabinetListQuery { @@ -64,6 +77,10 @@ struct H5CabinetRow { status: i8, project_id: Option, imei: Option, + iccid: Option, + address: Option, + latitude: Option, + longitude: Option, channel_count: i64, idle_count: i64, charging_count: i64, @@ -96,10 +113,13 @@ struct ChargeRecordBriefRow { energy: Option, } -/// 柜子 IMEI 查询行(用于指令下发) +/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发) #[derive(Debug, sqlx::FromRow)] struct CabinetImeiRow { + cabinet_id: i64, imei: String, + board_id: i8, + channel_id: i8, } // --------------------------------------------------------------------------- @@ -115,7 +135,7 @@ pub async fn get_dashboard( let pool = &state.mysql; // 根据角色确定组织过滤条件 - // 超级管理员可通过 org_id 参数切换组织 + // 总管理员可通过 org_id 参数切换组织 let is_super = user.role_level >= 2 && query.org_id.is_none(); let org_id = query.org_id.or(user.organization_id); @@ -151,8 +171,8 @@ pub async fn get_dashboard( // 故障告警(取最近10条故障仓体,返回项目名+柜名) let alerts = fetch_fault_alerts(pool, is_super, org_id).await?; - // 项目摘要 - let projects = fetch_project_summaries(pool, &user).await?; + // 项目摘要(带组织过滤) + let projects = fetch_project_summaries(pool, &user, org_id).await?; Ok(Json(json!({ "code": 0, @@ -414,12 +434,23 @@ async fn fetch_fault_alerts( async fn fetch_project_summaries( pool: &sqlx::MySqlPool, user: &CurrentUser, + org_id: Option, ) -> Result, AppError> { - // 获取用户可见的项目 + // 获取用户可见的项目(优先使用查询参数 org_id) + let filter_org = org_id.or(user.organization_id); let projects: Vec = if user.role_level >= 2 { - sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") + if let Some(oid) = filter_org { + sqlx::query_as::<_, ProjectRow>( + "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", + ) + .bind(oid) .fetch_all(pool) .await? + } else { + sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") + .fetch_all(pool) + .await? + } } else if let Some(org_id) = user.organization_id { sqlx::query_as::<_, ProjectRow>( "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", @@ -502,7 +533,7 @@ pub async fn get_cabinets( // 按项目过滤(同时验证组织权限) if user.role_level >= 2 { sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -515,7 +546,7 @@ pub async fn get_cabinets( .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.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -536,7 +567,7 @@ pub async fn get_cabinets( // 总管理员:按组织过滤或看全部 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.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -551,7 +582,7 @@ pub async fn get_cabinets( .await? } else { sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -565,7 +596,7 @@ pub async fn get_cabinets( } 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.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -582,23 +613,62 @@ pub async fn get_cabinets( vec![] }; - // 从Redis批量查RSSI + // 从Redis批量查RSSI + pow_fail let mut rssi_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut dc_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut ac_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut temp_map: std::collections::HashMap> = std::collections::HashMap::new(); if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - if let Ok(mut conn) = client.get_async_connection().await { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let mut pipe = redis::pipe(); for r in &rows { if let Some(ref imei) = r.imei { - pipe.cmd("HGET").arg(&format!("device:{imei}")).arg("rssi"); + let key = format!("device:{imei}"); + pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]); } } if !rows.is_empty() { - let results: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); - for (i, r) in rows.iter().enumerate() { - if r.imei.is_some() { - let val = results.get(i).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()); - rssi_map.insert(r.imei.clone().unwrap_or_default(), val); + let results: Vec>> = match pipe.query_async(&mut conn).await { + Ok(r) => r, + Err(e) => { + tracing::error!("[h5] Redis pipeline查询失败: {}", e); + Vec::new() } + }; + for (i, r) in rows.iter().enumerate() { + if let Some(ref imei) = r.imei { + if let Some(vals) = results.get(i) { + rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1")); + ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1")); + temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + } + } + } + } + } + } + + // 查询柜子所属组织是否禁用(用于前端徽标) + let mut org_disabled_map: std::collections::HashMap = std::collections::HashMap::new(); + { + let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); + if !project_ids.is_empty() { + let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p + LEFT JOIN organizations o ON p.organization_id = o.id + WHERE p.id IN ({})"#, + placeholders.join(",") + ); + let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); + for pid in &project_ids { + q = q.bind(pid); + } + if let Ok(rows) = q.fetch_all(pool).await { + for (pid, disabled) in rows { + org_disabled_map.insert(pid, disabled == 1); } } } @@ -607,19 +677,33 @@ pub async fn get_cabinets( let list: Vec = rows .into_iter() .map(|r| { - let rssi = r.imei.as_ref().and_then(|imei| rssi_map.get(imei).copied()).flatten(); + let imei = r.imei.clone(); + let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten(); + let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten(); + let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten(); + let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten(); + let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); json!({ "id": r.id, "abstract_id": r.abstract_id, "name": r.name, + "imei": r.imei, + "iccid": r.iccid, "status": r.status, "project_id": r.project_id, + "address": r.address, + "latitude": r.latitude, + "longitude": r.longitude, "channel_count": r.channel_count, "idle_count": r.idle_count, "charging_count": r.charging_count, "full_count": r.full_count, "fault_count": r.fault_count, "rssi": rssi, + "pow_fail_dc": pow_fail_dc, + "pow_fail_ac": pow_fail_ac, + "temp": temp, + "org_disabled": org_disabled, }) }) .collect(); @@ -640,14 +724,14 @@ pub async fn get_cabinet_by_abstract_id( ) -> Result, AppError> { let pool = &state.mysql; - let cab: Option<(i64, String, String, Option, Option, i8)> = sqlx::query_as( - "SELECT id, abstract_id, imei, iccid, name, status FROM cabinets WHERE abstract_id = ?", + let cab: Option<(i64, String, String, Option, i8)> = sqlx::query_as( + "SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?", ) .bind(&abstract_id) .fetch_optional(pool) .await?; - let (cab_id, abs_id, imei, iccid, name, status) = + let (cab_id, abs_id, _imei, name, status) = cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; // 查询仓控板+仓体 @@ -685,8 +769,6 @@ pub async fn get_cabinet_by_abstract_id( "data": { "id": cab_id, "abstract_id": abs_id, - "imei": imei, - "iccid": iccid, "name": name, "status": status, "cabin_boards": cabin_boards, @@ -735,7 +817,8 @@ pub async fn get_compartment_detail( // 从 Redis 获取实时数据 let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - if let Ok(mut conn) = client.get_async_connection().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 = 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!({})) @@ -775,10 +858,8 @@ pub async fn get_compartment_detail( "cabin_board_id": cabin_board_id, "voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0), "current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0), - "power": realtime.get("power").and_then(|v| v.as_i64()).unwrap_or(0), "energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0), "soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0), - "soh": realtime.get("soh").and_then(|v| v.as_i64()).unwrap_or(0), "cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), "cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])), "bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])), @@ -835,13 +916,82 @@ pub async fn scan_cabinet( // --------------------------------------------------------------------------- /// POST /api/h5/charge/start — 开始充电 +/// +/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`, +/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。 +/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。 pub async fn start_charge( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:start")?; - send_compartment_command(&state, body.compartment_id, "on").await + + // 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式 + // (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理) + let order_row: Result)>, sqlx::Error> = sqlx::query_as( + r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode + 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(body.compartment_id) + .fetch_optional(&state.mysql) + .await; + + match order_row { + // 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单; + // **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单 + Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => { + // 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`) + match crate::workers::report_worker::load_channel_battery( + &state.redis, &imei, board_id, channel_id, + ) + .await + { + Some(battery) => { + if let Err(e) = crate::workers::report_worker::ensure_charge_order( + &state.mysql, + &state.redis, + &imei, + cabinet_id, + board_id, + channel_id, + &battery.battery_id, + Some(battery.soc), + ) + .await + { + tracing::error!( + "[h5] 下发 on 前建单失败 compartment_id={} err={}", + body.compartment_id, + e + ); + } + } + None => tracing::warn!( + "[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}", + body.compartment_id + ), + } + } + Ok(Some(_)) => tracing::info!( + "[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}", + body.compartment_id + ), + Ok(None) => tracing::warn!( + "[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}", + body.compartment_id + ), + Err(e) => tracing::error!( + "[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}", + body.compartment_id, + e + ), + } + + send_compartment_command(&state, &user, body.compartment_id, "on").await } /// POST /api/h5/charge/stop — 停止充电 @@ -851,34 +1001,53 @@ pub async fn stop_charge( Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:stop")?; - send_compartment_command(&state, body.compartment_id, "off").await + send_compartment_command(&state, &user, body.compartment_id, "off").await } -/// POST /api/h5/door/open — 开门 +/// POST /api/h5/door/open — 开门(**柜级动作**) +/// +/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓), +/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。 pub async fn open_door( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { - auth::check_permission(&user, "device:door:unlock")?; - send_compartment_command(&state, body.compartment_id, "open").await + auth::check_permission(&user, "charge:open_door")?; + + let cabinet_id: Option = sqlx::query_scalar( + r#"SELECT cb.cabinet_id FROM compartments comp + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + WHERE comp.id = ?"#, + ) + .bind(body.compartment_id) + .fetch_optional(&state.mysql) + .await?; + let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; + + send_cabinet_command(&state, &user, cabinet_id, None, "open").await } // --------------------------------------------------------------------------- // 辅助函数 // --------------------------------------------------------------------------- -/// 根据仓体ID查找所属柜子的IMEI,并通过 Redis 下发指令 +/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。 +/// +/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。 +/// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。 async fn send_compartment_command( state: &AppState, + user: &CurrentUser, compartment_id: i64, action: &str, ) -> Result, AppError> { let pool = &state.mysql; - // 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id + // 一次查询拿到 cabinet_id + IMEI + board_id + channel_id let row: Option = sqlx::query_as( - r#"SELECT cab.imei FROM cabinets cab + 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 = ?"#, @@ -888,35 +1057,61 @@ async fn send_compartment_command( .await?; let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; - let imei = &cab_row.imei; - // 查询 sub_device_id(仓控板 ID) - let sub_row: Option<(i8,)> = sqlx::query_as( - r#"SELECT cb.board_id FROM cabin_boards cb - JOIN compartments comp ON comp.cabin_board_id = cb.id - WHERE comp.id = ?"#, + // 检查设备是否被禁用 + let disabled: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2", ) - .bind(compartment_id) - .fetch_optional(pool) - .await?; + .bind(cab_row.cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if disabled { + return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into())); + } - let sub_device_id = sub_row.map(|(bid,)| bid as u8); + // 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效) + 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?; + + let imei = &cab_row.imei; // 检查是否有设备节点在线 if !state.nodes.is_device_online(imei).await { return Err(AppError::BadRequest("设备不在线".into())); } - // 构建指令并通过 Redis 下发 + // 构造协议 id: + // 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零) + // 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定 + let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id); + let cmd = DeviceCommand { act: action.to_string(), msg_id: 0, - dev_id: Some(imei.clone()), - sub_device_id, + id: Some(id.clone()), params: Some(json!({})), }; - let json_str = cmd.to_json(); + // 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) + let mut json_val = + serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?; + json_val[ROUTE_DEV_ID_FIELD] = json!(imei); + let json_str = json_val.to_string(); let mut conn = state.redis.clone(); redis::cmd("LPUSH") .arg("device:commands") @@ -926,8 +1121,8 @@ async fn send_compartment_command( .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; tracing::info!( - "[h5] 指令已推入队列 imei={} action={} compartment_id={}", - imei, action, compartment_id + "[h5] 指令已推入队列 id={} action={} compartment_id={}", + id, action, compartment_id ); Ok(Json(json!({ @@ -937,6 +1132,405 @@ async fn send_compartment_command( }))) } +// --------------------------------------------------------------------------- +// 柜级 / 单仓控板级指令(pow_on / pow_off / reset) +// --------------------------------------------------------------------------- + +/// 柜级 / 单仓控板级指令下发。 +/// +/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段) +/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`) +/// +/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。 +async fn send_cabinet_command( + state: &AppState, + user: &CurrentUser, + cabinet_id: i64, + board_id: Option, + action: &str, +) -> Result, AppError> { + let pool = &state.mysql; + + let imei: Option = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") + .bind(cabinet_id) + .fetch_optional(pool) + .await?; + let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; + + // 设备是否被禁用 + let disabled: bool = + sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2") + .bind(cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if disabled { + return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into())); + } + + // 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效) + let org_disabled: bool = sqlx::query_scalar( + r#"SELECT COUNT(*) > 0 FROM cabinets c + INNER JOIN projects p ON c.project_id = p.id + INNER JOIN organizations o ON p.organization_id = o.id + WHERE c.id = ? AND o.disabled = 1"#, + ) + .bind(cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if org_disabled { + return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into())); + } + + // 组织数据隔离校验 + verify_cabinet_access(user, cabinet_id, pool).await?; + + // 设备必须在线:平台对不在线的设备一律不投递 + if !state.nodes.is_device_online(&imei).await { + return Err(AppError::BadRequest("设备不在线".into())); + } + + // 构造协议 id: + // - `board_id = None` → **柜级**:**不带 id**(省略该字段) + // - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01") + let id = board_id.map(|b| format!("{:02}", b)); + + let cmd = DeviceCommand { + act: action.to_string(), + msg_id: 0, + id: id.clone(), + params: Some(json!({})), + }; + + // 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) + let mut json_val = + serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?; + json_val[ROUTE_DEV_ID_FIELD] = json!(imei); + let json_str = json_val.to_string(); + let mut conn = state.redis.clone(); + redis::cmd("LPUSH") + .arg("device:commands") + .arg(&json_str) + .query_async::<()>(&mut conn) + .await + .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; + + tracing::info!( + "[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}", + id, + action, + cabinet_id, + board_id + ); + + Ok(Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "指令已下发" + }))) +} + +/// POST /api/cabinets/:id/open — **解锁柜门**(柜级) +/// +/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。 +/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。 +pub async fn cabinet_open( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "charge:open_door")?; + send_cabinet_command(&state, &user, id, None, "open").await +} + +/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级) +pub async fn cabinet_pow_on( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + send_cabinet_command(&state, &user, id, None, "pow_on").await +} + +/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级) +/// +/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`; +/// 恢复供电须再下发 `pow-on`。 +pub async fn cabinet_pow_off( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + send_cabinet_command(&state, &user, id, None, "pow_off").await +} + +/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`) +pub async fn cabinet_reset( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + send_cabinet_command(&state, &user, id, None, "reset").await +} + +/// POST /api/cabin-boards/:id/reset — **重启单块仓控板** +/// (协议 `id = IMEI-仓控板号`,两段) +pub async fn board_reset( + user: CurrentUser, + State(state): State, + Path(board_pk): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + + let row: Option<(i64, i8)> = + sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?") + .bind(board_pk) + .fetch_optional(&state.mysql) + .await?; + let (cabinet_id, board_id) = + row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?; + + send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await +} + +/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交) +pub async fn submit_feedback( + user: CurrentUser, + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let pool = &state.mysql; + + let content = body.content.trim(); + if content.is_empty() { + return Err(AppError::BadRequest("反馈内容不能为空".into())); + } + if content.chars().count() > 2000 { + return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into())); + } + let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty()); + if let Some(c) = &contact { + if c.chars().count() > 200 { + return Err(AppError::BadRequest("联系方式过长".into())); + } + } + + // 图片校验:最多 6 张 + let images = body.images.clone().unwrap_or_default(); + let images: Vec = images + .iter() + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .collect(); + if images.len() > 6 { + return Err(AppError::BadRequest("最多上传6张图片".into())); + } + let images_json = if images.is_empty() { + String::from("[]") + } else { + serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string()) + }; + + sqlx::query( + "INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)" + ) + .bind(user.user_id) + .bind(content) + .bind(contact) + .bind(&images_json) + .execute(pool) + .await + .map_err(AppError::Database)?; + + tracing::info!( + "[h5] 问题反馈已提交 user_id={} content_len={}", + user.user_id, content.chars().count() + ); + + Ok(Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "反馈已提交,感谢您的反馈" + }))) +} + +/// GET /api/feedback — 查询反馈列表(分页,管理员使用) +#[derive(Debug, Deserialize)] +pub(crate) struct FeedbackListQuery { + page: Option, + page_size: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct FeedbackRow { + id: i64, + user_id: i64, + content: String, + contact: Option, + images: Option, + status: i8, + created_at: chrono::NaiveDateTime, +} + +/// GET /api/h5/feedback — 反馈列表(管理端) +/// +/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。 +pub async fn list_feedback( + _user: CurrentUser, + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let pool = &state.mysql; + let page = query.page.unwrap_or(1).max(1); + let page_size = query.page_size.unwrap_or(20).clamp(1, 100); + let offset = (page - 1) * page_size; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback") + .fetch_one(pool) + .await?; + + let rows: Vec = sqlx::query_as::<_, FeedbackRow>( + r#"SELECT id, user_id, content, contact, images, status, created_at + FROM user_feedback + ORDER BY created_at DESC + LIMIT ? OFFSET ?"#, + ) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let list: Vec = rows.into_iter().map(|r| { + let images: Vec = r.images + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + json!({ + "id": r.id, + "user_id": r.user_id, + "content": r.content, + "contact": r.contact, + "images": images, + "status": r.status, + "status_text": if r.status == 0 { "待处理" } else { "已处理" }, + "created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + }) + }).collect(); + + Ok(Json(json!({ + "code": 0, + "data": { "list": list, "total": total, "page": page, "page_size": page_size }, + "message": "ok" + }))) +} + +/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理) +#[derive(Debug, Deserialize)] +pub(crate) struct FeedbackStatusInput { + status: i8, +} + +const FEEDBACK_IMG_DIR: &str = "uploads/feedback"; + +pub async fn upload_feedback_image( + user: CurrentUser, + mut multipart: Multipart, +) -> Result, AppError> { + let mut file_data: Option> = None; + let mut ext = "".to_string(); + + while let Some(field) = multipart.next_field().await + .map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? { + let filename = field.file_name().unwrap_or("").to_string(); + let data = field.bytes().await + .map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?; + if let Some(pos) = filename.rfind('.') { + ext = filename[pos..].to_string(); + } + file_data = Some(data.to_vec()); + } + + let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?; + if data.is_empty() { + return Err(AppError::BadRequest("上传内容为空".into())); + } + + if data.len() > 5 * 1024 * 1024 { + return Err(AppError::BadRequest("图片文件不能超过 5MB".into())); + } + + if ext.is_empty() { + ext = ".jpg".to_string(); + } + let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; + if !allowed.contains(&ext.as_str()) { + return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into())); + } + + let dir = std::path::Path::new(FEEDBACK_IMG_DIR); + std::fs::create_dir_all(dir) + .map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?; + + let filename = format!("{}{}", Uuid::new_v4().simple(), ext); + let filepath = dir.join(&filename); + let mut f = std::fs::File::create(&filepath) + .map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?; + f.write_all(&data) + .map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?; + + let url = format!("/uploads/feedback/{}", filename); + tracing::info!( + user_id = user.user_id, path = %url, + "反馈图片上传成功" + ); + + Ok(Json(json!({ + "code": 0, + "data": { "url": url }, + "message": "ok" + }))) +} + +pub async fn update_feedback_status( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let pool = &state.mysql; + + if body.status != 0 && body.status != 1 { + return Err(AppError::BadRequest("状态值不合法".into())); + } + + let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?") + .bind(body.status) + .bind(id) + .execute(pool) + .await + .map_err(AppError::Database)? + .rows_affected(); + + if affected == 0 { + return Err(AppError::NotFound("反馈记录不存在".into())); + } + + tracing::info!( + "[h5] 反馈状态已更新 operator={} feedback_id={} status={}", + user.user_id, id, body.status + ); + + Ok(Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "ok" + }))) +} + /// 验证用户是否有权访问指定柜子(组织数据隔离) /// /// 总管理员(role_level >= 2)可访问所有柜子; diff --git a/software/api-server/src/routes/maps.rs b/software/api-server/src/routes/maps.rs new file mode 100644 index 0000000..b72742c --- /dev/null +++ b/software/api-server/src/routes/maps.rs @@ -0,0 +1,155 @@ +//! 腾讯地图 API 代理(服务端签名,避免前端暴露 SK) +//! +//! 签名算法(MD5): +//! 1. 参数值用 RAW(不 URL 编码)拼成 query string +//! 2. 签名串 = path + "?" + query_string + secret +//! 3. sig = MD5(签名串) 32位小写 +//! 4. URL 请求中的参数值需 URL 编码 +//! 注意:签名用 RAW 值,URL 用 URL 编码值,两者不同! + +use axum::extract::Query; +use axum::Json; +use serde::Deserialize; +use serde_json::Value; +use std::collections::BTreeMap; + +use crate::error::AppError; + +const TMAP_KEY: &str = "A3YBZ-CIO6G-4F3QO-QEWPM-UQK3F-P7FXT"; +const TMAP_SECRET: &str = "iIk8Z6CwPKaTgMAppAvtqhVq42CUUvYC"; +const TMAP_BASE: &str = "https://apis.map.qq.com"; + +/// 计算腾讯地图签名(MD5) +/// 签名使用 RAW 值(不 URL 编码),URL 请求使用 URL 编码值 +fn sign(path: &str, params: &BTreeMap<&str, String>) -> String { + let qs = params + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&"); + let to_sign = format!("{}?{}{}", path, qs, TMAP_SECRET); + let digest = md5::compute(to_sign.as_bytes()); + format!("{:x}", digest) +} + +/// 构建完整请求 URL(含签名) +fn build_url(path: &str, params: BTreeMap<&str, String>) -> String { + let sig = sign(path, ¶ms); + let encoded_qs = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + format!("{}{}?{}&sig={}", TMAP_BASE, path, encoded_qs, sig) +} + +// --------------------------------------------------------------------------- +// 输入提示(地点搜索建议) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(crate) struct SuggestionQuery { + keyword: String, + region: Option, +} + +/// GET /api/maps/suggestion — 输入提示 +pub async fn suggestion( + Query(query): Query, +) -> Result, AppError> { + if query.keyword.trim().is_empty() { + return Err(AppError::BadRequest("keyword 不能为空".into())); + } + + let mut params = BTreeMap::new(); + params.insert("keyword", query.keyword.trim().to_string()); + params.insert("key", TMAP_KEY.to_string()); + if let Some(region) = &query.region { + params.insert("region", region.to_string()); + } + + let url = build_url("/ws/place/v1/suggestion", params); + let resp = reqwest::get(&url) + .await + .map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?; + let body: Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?; + + Ok(Json(body)) +} + +// --------------------------------------------------------------------------- +// 逆地址解析(坐标→地址) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(crate) struct GeocoderBody { + lat: f64, + lng: f64, +} + +/// POST /api/maps/geocoder — 逆地理编码 +pub async fn geocoder( + Json(body): Json, +) -> Result, AppError> { + let location = format!("{},{}", body.lat, body.lng); + + let mut params = BTreeMap::new(); + params.insert("key", TMAP_KEY.to_string()); + params.insert("location", location); + + let url = build_url("/ws/geocoder/v1/", params); + let resp = reqwest::get(&url) + .await + .map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?; + let body: Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?; + + Ok(Json(body)) +} + +// --------------------------------------------------------------------------- +// 地点搜索 +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(crate) struct PlaceSearchQuery { + keyword: String, + region: Option, + page_size: Option, +} + +/// GET /api/maps/place-search — 地点搜索 +pub async fn place_search( + Query(query): Query, +) -> Result, AppError> { + if query.keyword.trim().is_empty() { + return Err(AppError::BadRequest("keyword 不能为空".into())); + } + + let mut params = BTreeMap::new(); + params.insert("keyword", query.keyword.trim().to_string()); + params.insert("key", TMAP_KEY.to_string()); + params.insert("page_size", query.page_size.unwrap_or(10).to_string()); + + if let Some(region) = query.region.filter(|r| !r.is_empty()) { + params.insert("region", region); + } else { + params.insert("boundary", "rectangle(3.5,73.5,53.5,135.5)".to_string()); + } + + let url = build_url("/ws/place/v1/search", params); + let resp = reqwest::get(&url) + .await + .map_err(|e| AppError::Internal(format!("腾讯地图请求失败: {}", e)))?; + let body: Value = resp + .json() + .await + .map_err(|e| AppError::Internal(format!("响应解析失败: {}", e)))?; + + Ok(Json(body)) +} \ No newline at end of file diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index f302e37..b14d264 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -3,17 +3,22 @@ //! 认证中间件保护所有 /api/* 路由(除 /api/auth/login 和 /api/health 外)。 //! 权限校验在各路由处理器内部通过 `check_permission` 完成。 +use axum::http::Method; use axum::middleware; use axum::routing::{get, post, put}; use axum::Router; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::services::ServeDir; use crate::commands::AppState; use crate::middleware::auth::auth_middleware; mod auth; +mod batteries; mod cabinets; mod cabinet_tree; mod charge_records; +mod charge_orders; mod device_logs; mod downloads; mod energy_stats; @@ -24,7 +29,10 @@ mod organizations; mod projects; mod roles; mod users; +mod scope_guard; mod users_perm; +mod wechat; +mod maps; /// 构建完整路由树(需要传入 AppState 用于认证中间件) pub fn build(state: AppState) -> Router { @@ -32,6 +40,8 @@ pub fn build(state: AppState) -> Router { // 认证相关 .route("/api/auth/logout", post(auth::logout)) .route("/api/auth/me", get(auth::me)) + .route("/api/auth/avatar", put(auth::update_avatar)) + .route("/api/auth/avatar/upload", post(auth::upload_avatar)) // 设备指令下发 .route("/api/devices/{dev_id}/command", post(crate::commands::send_command)) .route("/api/devices/stats", get(crate::commands::device_stats)) @@ -53,6 +63,7 @@ pub fn build(state: AppState) -> Router { // 组织管理 .route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization)) .route("/api/organizations/{id}", put(organizations::update_organization).delete(organizations::delete_organization)) + .route("/api/organizations/{id}/status", put(organizations::set_organization_status)) // 项目管理 .route("/api/organizations/{org_id}/projects", get(projects::list_projects).post(projects::create_project)) .route("/api/projects/{id}", put(projects::update_project).delete(projects::delete_project)) @@ -60,7 +71,15 @@ pub fn build(state: AppState) -> Router { .route("/api/projects/{project_id}/cabinets", get(cabinets::list_cabinets)) .route("/api/cabinets", post(cabinets::create_cabinet)) .route("/api/cabinets/{id}", put(cabinets::update_cabinet).delete(cabinets::delete_cabinet).get(cabinets::get_cabinet_detail)) + .route("/api/cabinets/{id}/status", put(cabinets::set_cabinet_status)) + .route("/api/cabinets/{id}/work-mode", put(cabinets::update_work_mode)) .route("/api/cabinets/{id}/regenerate-auth", post(cabinets::regenerate_auth)) + // 柜级设备控制:开门解锁 / 接触器通断 / 重启整柜;仓控板单独重启 + .route("/api/cabinets/{id}/open", post(h5::cabinet_open)) + .route("/api/cabinets/{id}/pow-on", post(h5::cabinet_pow_on)) + .route("/api/cabinets/{id}/pow-off", post(h5::cabinet_pow_off)) + .route("/api/cabinets/{id}/reset", post(h5::cabinet_reset)) + .route("/api/cabin-boards/{id}/reset", post(h5::board_reset)) .route("/api/cabinets/{id}/bind", post(cabinets::bind_cabinet)) .route("/api/cabinets/{id}/replace-imei", post(cabinets::replace_imei)) // 组织树 @@ -70,6 +89,10 @@ pub fn build(state: AppState) -> Router { // 充电记录 .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)) // 下载中心 @@ -102,14 +125,33 @@ pub fn build(state: AppState) -> Router { .route("/api/charge/stop", post(h5::stop_charge)) .route("/api/door/open", post(h5::open_door)) .route("/api/cabinets/scan/{abstract_id}", get(h5::scan_cabinet)) + // 问题反馈 + .route("/api/feedback", post(h5::submit_feedback).get(h5::list_feedback)) + .route("/api/feedback/{id}", put(h5::update_feedback_status)) + .route("/api/feedback/upload", post(h5::upload_feedback_image)) // 认证中间件层 .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); Router::::new() .route("/api/health", get(health::health_check)) + .route("/api/auth/challenge", get(auth::get_challenge)) .route("/api/auth/login", post(auth::login)) + // 微信小程序手机号快速登录(免认证) + .route("/api/auth/wx-login", post(auth::wx_login)) // 扫码入口(免认证) .route("/api/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id)) + // 微信回调(免认证) + .route("/api/wechat/callback", get(wechat::verify).post(wechat::handle_message)) + // 腾讯地图代理(免认证,前端需要) + .route("/api/maps/suggestion", get(maps::suggestion)) + .route("/api/maps/geocoder", post(maps::geocoder)) + .route("/api/maps/place-search", get(maps::place_search)) + // 头像上传目录(免认证,仅用于公开访问静态文件) + .nest_service("/uploads", ServeDir::new("uploads")) .merge(protected) + .layer(CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]) + .allow_headers([axum::http::header::AUTHORIZATION, axum::http::header::CONTENT_TYPE])) .with_state(state) } diff --git a/software/api-server/src/routes/operation_logs.rs b/software/api-server/src/routes/operation_logs.rs index d2641a7..4de1cbf 100644 --- a/software/api-server/src/routes/operation_logs.rs +++ b/software/api-server/src/routes/operation_logs.rs @@ -107,7 +107,7 @@ pub async fn list_operation_logs( target_type: Option, target_id: Option, detail: Option, - created_at: String, + created_at: chrono::NaiveDateTime, } let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql); @@ -131,7 +131,7 @@ pub async fn list_operation_logs( "target_type": r.target_type, "target_id": r.target_id, "detail": r.detail, - "created_at": r.created_at, + "created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), }) }) .collect(); diff --git a/software/api-server/src/routes/organizations.rs b/software/api-server/src/routes/organizations.rs index 73efbad..c73a184 100644 --- a/software/api-server/src/routes/organizations.rs +++ b/software/api-server/src/routes/organizations.rs @@ -54,6 +54,13 @@ pub struct OrganizationRow { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option, + #[serde(skip_serializing_if = "is_zero")] + pub disabled: Option, +} + +/// 序列化辅助:0 时跳过 disabled 字段(保持旧前端兼容) +fn is_zero(v: &Option) -> bool { + matches!(v, Some(0) | None) } /// 项目表行 @@ -63,6 +70,12 @@ pub struct ProjectRow { #[serde(skip_serializing_if = "Option::is_none")] pub organization_id: Option, pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latitude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub longitude: Option, } /// 柜子表行 @@ -96,6 +109,10 @@ pub struct CabinetRow { pub pow_fail_dc: Option, pub pow_fail_ac: Option, pub is_online: Option, + /// 所属组织是否被禁用(甲方禁用客户组织) + pub org_disabled: Option, + /// 所属项目的地址(柜子地址为空时使用) + pub project_address: Option, } /// 树节点 @@ -111,6 +128,14 @@ pub struct TreeNode { pub imei: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latitude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub longitude: Option, } /// 仓控板行 @@ -131,47 +156,10 @@ pub struct CompartmentRow { pub status: i8, } -/// 仓控板 + 仓体详情 -#[derive(Debug, Serialize)] -pub struct CabinBoardWithCompartments { - pub id: i64, - pub board_id: i8, - pub status: i8, - pub compartments: Vec, -} - -/// 柜子详情(含仓控板 + 仓体) -#[derive(Debug, Serialize)] -pub struct CabinetDetail { - pub id: i64, - pub project_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub abstract_id: Option, - pub imei: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub iccid: Option, - pub name: Option, - pub status: i8, - pub rssi: Option, - pub pow_fail_dc: Option, - pub pow_fail_ac: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub address: Option, - pub cabin_boards: Vec, -} - // --------------------------------------------------------------------------- // 辅助函数 // --------------------------------------------------------------------------- -/// 生成平台抽象 ID: `10-` + IMEI 的 SHA256 前 4 字节的十六进制 -pub fn generate_abstract_id(imei: &str) -> String { - use sha2::Digest; - let hash = sha2::Sha256::digest(imei.as_bytes()); - let hex_str = hex::encode(&hash[..4]); - format!("10-{hex_str}") -} - /// 生成 8 位随机字母数字安全码 pub fn generate_auth_str() -> String { use rand::Rng; @@ -193,12 +181,16 @@ pub async fn list_organizations( let db = &state.mysql; let rows = sqlx::query_as::<_, OrganizationRow>( - r#"SELECT id, name, parent_id FROM organizations ORDER BY id"#, + r#"SELECT id, name, parent_id, disabled FROM organizations ORDER BY id"#, ) .fetch_all(db) .await?; - Ok(Json(json!(rows))) + Ok(Json(json!({ + "code": 0, + "data": rows, + "message": "ok" + }))) } /// POST /api/organizations — 创建组织 @@ -223,7 +215,7 @@ pub async fn create_organization( let id = result.last_insert_id() as i64; tracing::info!("创建组织 id={} name={}", id, name); - Ok(Json(json!({ "id": id, "name": name }))) + Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" }))) } /// PUT /api/organizations/:id — 更新组织 @@ -253,7 +245,48 @@ pub async fn update_organization( } tracing::info!("更新组织 id={} name={}", id, name); - Ok(Json(json!({ "id": id, "name": name }))) + Ok(Json(json!({ "code": 0, "data": { "id": id, "name": name }, "message": "ok" }))) +} + +/// 组织禁用请求体 +#[derive(Debug, Deserialize)] +pub struct OrgStatusBody { + pub disabled: i8, +} + +/// PUT /api/organizations/:id/status — 禁用/启用组织(仅总管理员) +/// +/// 禁用后该组织及其子组织的柜子全部不可用(指令链路拦截)。 +pub async fn set_organization_status( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + // 仅总管理员(甲方)可禁用组织 + if user.role_level < 2 { + return Err(AppError::Forbidden("仅总管理员可禁用组织".into())); + } + + if body.disabled != 0 && body.disabled != 1 { + return Err(AppError::BadRequest("disabled 必须为 0 或 1".into())); + } + + let db = &state.mysql; + let rows = sqlx::query("UPDATE organizations SET disabled = ? WHERE id = ?") + .bind(body.disabled) + .bind(id) + .execute(db) + .await? + .rows_affected(); + + if rows == 0 { + return Err(AppError::NotFound(format!("组织 {} 不存在", id))); + } + + let action = if body.disabled == 1 { "禁用" } else { "启用" }; + tracing::info!("{}组织 id={}", action, id); + Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", action) }))) } /// DELETE /api/organizations/:id — 删除组织 @@ -265,19 +298,48 @@ pub async fn delete_organization( auth::check_permission(&user, "org:delete")?; let db = &state.mysql; - let count: (i64,) = sqlx::query_as( + + // 组织下有项目,不允许删除 + let project_count: (i64,) = sqlx::query_as( r#"SELECT COUNT(*) FROM projects WHERE organization_id = ?"#, ) .bind(id) .fetch_one(db) .await?; - - if count.0 > 0 { + if project_count.0 > 0 { return Err(AppError::BadRequest( format!("组织 {} 下仍有项目,无法删除", id), )); } + // 组织下有绑定用户,不允许删除 + let user_count: (i64,) = sqlx::query_as( + r#"SELECT COUNT(*) FROM users WHERE organization_id = ?"#, + ) + .bind(id) + .fetch_one(db) + .await?; + if user_count.0 > 0 { + return Err(AppError::BadRequest( + format!("组织 {} 下仍有 {} 名用户,无法删除", id, user_count.0), + )); + } + + // 组织下有设备(通过 项目→柜子 链路),不允许删除 + let cabinet_count: (i64,) = sqlx::query_as( + r#"SELECT COUNT(*) FROM cabinets cab + INNER JOIN projects p ON cab.project_id = p.id + WHERE p.organization_id = ?"#, + ) + .bind(id) + .fetch_one(db) + .await?; + if cabinet_count.0 > 0 { + return Err(AppError::BadRequest( + format!("组织 {} 下仍有 {} 台设备,无法删除", id, cabinet_count.0), + )); + } + let rows = sqlx::query(r#"DELETE FROM organizations WHERE id = ?"#) .bind(id) .execute(db) @@ -289,5 +351,5 @@ pub async fn delete_organization( } tracing::info!("删除组织 id={}", id); - Ok(Json(json!({}))) + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } diff --git a/software/api-server/src/routes/projects.rs b/software/api-server/src/routes/projects.rs index a68718e..946d8a2 100644 --- a/software/api-server/src/routes/projects.rs +++ b/software/api-server/src/routes/projects.rs @@ -4,13 +4,26 @@ use axum::extract::{Path, State}; use axum::Json; +use serde::Deserialize; use serde_json::{json, Value}; -use super::organizations::{NameBody, ProjectRow}; +use super::organizations::ProjectRow; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use crate::commands::AppState; +/// 项目请求体(创建/更新共用) +#[derive(Debug, Deserialize)] +pub struct ProjectBody { + pub name: String, + #[serde(default)] + pub address: Option, + #[serde(default)] + pub latitude: Option, + #[serde(default)] + pub longitude: Option, +} + /// GET /api/organizations/:org_id/projects — 按组织查询项目列表 pub async fn list_projects( user: CurrentUser, @@ -18,16 +31,17 @@ pub async fn list_projects( Path(org_id): Path, ) -> Result, AppError> { auth::check_permission(&user, "project:view")?; + super::scope_guard::verify_org_ownership(&user, org_id)?; let db = &state.mysql; let rows = sqlx::query_as::<_, ProjectRow>( - r#"SELECT id, organization_id, name FROM projects WHERE organization_id = ? ORDER BY id"#, + r#"SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects WHERE organization_id = ? ORDER BY id"#, ) .bind(org_id) .fetch_all(db) .await?; - Ok(Json(json!(rows))) + Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// POST /api/organizations/:org_id/projects — 创建项目 @@ -35,9 +49,10 @@ pub async fn create_project( user: CurrentUser, State(state): State, Path(org_id): Path, - Json(body): Json, + Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "project:create")?; + super::scope_guard::verify_org_ownership(&user, org_id)?; let db = &state.mysql; let name = body.name.trim().to_string(); @@ -56,16 +71,32 @@ pub async fn create_project( return Err(AppError::NotFound(format!("组织 {} 不存在", org_id))); } - let result = sqlx::query(r#"INSERT INTO projects (organization_id, name) VALUES (?, ?)"#) - .bind(org_id) - .bind(&name) - .execute(db) - .await?; + let result = sqlx::query( + r#"INSERT INTO projects (organization_id, name, address, latitude, longitude) VALUES (?, ?, ?, ?, ?)"#, + ) + .bind(org_id) + .bind(&name) + .bind(&body.address) + .bind(&body.latitude) + .bind(&body.longitude) + .execute(db) + .await?; let id = result.last_insert_id() as i64; tracing::info!("创建项目 id={} name={} org_id={}", id, name, org_id); - Ok(Json(json!({ "id": id, "organization_id": org_id, "name": name }))) + Ok(Json(json!({ + "code": 0, + "data": { + "id": id, + "organization_id": org_id, + "name": name, + "address": body.address, + "latitude": body.latitude, + "longitude": body.longitude, + }, + "message": "ok" + }))) } /// PUT /api/projects/:id — 更新项目 @@ -73,9 +104,10 @@ pub async fn update_project( user: CurrentUser, State(state): State, Path(id): Path, - Json(body): Json, + Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "project:edit")?; + super::scope_guard::verify_project_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let name = body.name.trim().to_string(); @@ -83,19 +115,34 @@ pub async fn update_project( return Err(AppError::BadRequest("项目名称不能为空".into())); } - let rows = sqlx::query(r#"UPDATE projects SET name = ? WHERE id = ?"#) - .bind(&name) - .bind(id) - .execute(db) - .await? - .rows_affected(); + let rows = sqlx::query( + r#"UPDATE projects SET name = ?, address = ?, latitude = ?, longitude = ? WHERE id = ?"#, + ) + .bind(&name) + .bind(&body.address) + .bind(&body.latitude) + .bind(&body.longitude) + .bind(id) + .execute(db) + .await? + .rows_affected(); if rows == 0 { return Err(AppError::NotFound(format!("项目 {} 不存在", id))); } - tracing::info!("更新项目 id={} name={}", id, name); - Ok(Json(json!({ "id": id, "name": name }))) + tracing::info!("更新项目 id={} name={} address={:?}", id, name, body.address); + Ok(Json(json!({ + "code": 0, + "data": { + "id": id, + "name": name, + "address": body.address, + "latitude": body.latitude, + "longitude": body.longitude, + }, + "message": "ok" + }))) } /// DELETE /api/projects/:id — 删除项目 @@ -105,6 +152,7 @@ pub async fn delete_project( Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "project:delete")?; + super::scope_guard::verify_project_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; let count: (i64,) = sqlx::query_as( @@ -131,5 +179,5 @@ pub async fn delete_project( } tracing::info!("删除项目 id={}", id); - Ok(Json(json!({}))) + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } diff --git a/software/api-server/src/routes/scope_guard.rs b/software/api-server/src/routes/scope_guard.rs new file mode 100644 index 0000000..f3a1995 --- /dev/null +++ b/software/api-server/src/routes/scope_guard.rs @@ -0,0 +1,70 @@ +//! 组织级数据隔离校验 — 确保用户只能访问其所属组织的资源。 +//! +//! 总管理员(role_level=2)跳过所有组织归属校验。 +//! 企业管理员(role_level=1)和操作员(role_level=0)只能访问本组织资源。 + +use sqlx::MySqlPool; + +use crate::error::AppError; +use crate::middleware::auth::CurrentUser; + +/// 校验当前用户是否有权访问指定柜子。 +pub async fn verify_cabinet_ownership( + pool: &MySqlPool, + user: &CurrentUser, + cabinet_id: i64, +) -> Result<(), AppError> { + if user.role_level >= 2 { + return Ok(()); + } + let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?; + let has_access: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM cabinets c \ + JOIN projects p ON c.project_id = p.id \ + WHERE c.id = ? AND p.organization_id = ?)", + ) + .bind(cabinet_id) + .bind(org_id) + .fetch_one(pool) + .await + .map_err(|_| AppError::Forbidden("无权访问该资源".into()))?; + if !has_access { + return Err(AppError::Forbidden("无权访问该资源".into())); + } + Ok(()) +} + +/// 校验当前用户是否有权访问指定项目。 +pub async fn verify_project_ownership( + pool: &MySqlPool, + user: &CurrentUser, + project_id: i64, +) -> Result<(), AppError> { + if user.role_level >= 2 { + return Ok(()); + } + let org_id = user.organization_id.ok_or(AppError::Forbidden("无权访问该资源".into()))?; + let has_access: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM projects WHERE id = ? AND organization_id = ?)", + ) + .bind(project_id) + .bind(org_id) + .fetch_one(pool) + .await + .map_err(|_| AppError::Forbidden("无权访问该资源".into()))?; + if !has_access { + return Err(AppError::Forbidden("无权访问该资源".into())); + } + Ok(()) +} + +/// 校验当前用户是否有权访问指定组织。 +pub fn verify_org_ownership(user: &CurrentUser, org_id: i64) -> Result<(), AppError> { + if user.role_level >= 2 { + return Ok(()); + } + if user.organization_id != Some(org_id) { + return Err(AppError::Forbidden("无权访问该资源".into())); + } + Ok(()) +} \ No newline at end of file diff --git a/software/api-server/src/routes/users.rs b/software/api-server/src/routes/users.rs index 82bd3ec..3686384 100644 --- a/software/api-server/src/routes/users.rs +++ b/software/api-server/src/routes/users.rs @@ -17,7 +17,6 @@ use serde_json::{json, Value}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; use crate::commands::AppState; -use crate::config::DEFAULT_PASSWORD; /// 用户列表查询参数 #[derive(Debug, Deserialize)] @@ -74,6 +73,46 @@ pub(crate) struct StatusInput { // 辅助函数: 记录操作日志 // --------------------------------------------------------------------------- +/// 企业管理员操作校验:必须操作本组织的用户且不能操作自己 +/// +/// 返回目标用户的当前组织ID(用于后续业务判断) +async fn verify_org_admin_scope( + pool: &sqlx::MySqlPool, + actor: &CurrentUser, + target_user_id: i64, +) -> Result, AppError> { + // 总管理员不需要此校验 + if actor.role_level >= 2 { + return Ok(None); + } + + // 企业管理员必须有组织 + let actor_org = actor.organization_id + .ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?; + + if target_user_id == actor.user_id { + return Err(AppError::BadRequest("不能操作自己的账户".into())); + } + + // 查询目标用户所属组织 + let target_org: Option<(Option,)> = sqlx::query_as( + "SELECT organization_id FROM users WHERE id = ?" + ) + .bind(target_user_id) + .fetch_optional(pool) + .await?; + + let (target_org,) = target_org + .ok_or_else(|| AppError::NotFound("用户不存在".into()))?; + + // 目标必须属于同一组织 + if target_org != Some(actor_org) { + return Err(AppError::Forbidden("只能操作本组织的用户".into())); + } + + Ok(target_org) +} + /// 记录一条操作日志到 operation_logs 表 async fn log_operation( pool: &sqlx::MySqlPool, @@ -95,7 +134,7 @@ async fn log_operation( .await; if let Err(e) = result { - tracing::error!("记录操作日志失败: {}", e); + tracing::error!(err = %e, "记录操作日志失败"); } } @@ -140,85 +179,53 @@ pub async fn list_users( }))); } + // 构建通用 WHERE 子句(避免 count/list 重复维护3个分支) + let (where_clause, bind_org) = match (has_keyword, org_filter) { + (true, Some(org_id)) => ( + "WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(), + Some(org_id), + ), + (true, None) => ( + "WHERE phone LIKE ? OR name LIKE ?".to_string(), + None, + ), + (false, Some(org_id)) => ( + "WHERE organization_id = ?".to_string(), + Some(org_id), + ), + (false, None) => (String::new(), None), + }; + // 查询总数 - let total: (i64,) = if has_keyword { - let mut q = sqlx::query_as( - r#"SELECT COUNT(*) FROM users WHERE phone LIKE ? OR name LIKE ?"#, - ) - .bind(&like_pattern) - .bind(&like_pattern); - if let Some(org_id) = org_filter { - q = sqlx::query_as( - r#"SELECT COUNT(*) FROM users WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?"#, - ) - .bind(&like_pattern) - .bind(&like_pattern) - .bind(org_id); + let total: (i64,) = { + let sql = format!("SELECT COUNT(*) FROM users {}", where_clause); + let mut q = sqlx::query_as::<_, (i64,)>(&sql); + if has_keyword { + q = q.bind(&like_pattern).bind(&like_pattern); + } + if let Some(org_id) = bind_org { + q = q.bind(org_id); } q.fetch_one(pool).await? - } else if let Some(org_id) = org_filter { - sqlx::query_as("SELECT COUNT(*) FROM users WHERE organization_id = ?") - .bind(org_id) - .fetch_one(pool) - .await? - } else { - sqlx::query_as("SELECT COUNT(*) FROM users") - .fetch_one(pool) - .await? }; // 查询用户列表 - let rows: Vec = if has_keyword { - let mut q = sqlx::query_as::<_, UserRow>( + let rows: Vec = { + let sql = format!( r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at - FROM users u - WHERE u.phone LIKE ? OR u.name LIKE ? + FROM users u {} ORDER BY u.id DESC LIMIT ? OFFSET ?"#, - ) - .bind(&like_pattern) - .bind(&like_pattern) - .bind(page_size) - .bind(offset); - if let Some(org_id) = org_filter { - q = sqlx::query_as::<_, UserRow>( - r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at - FROM users u - WHERE (u.phone LIKE ? OR u.name LIKE ?) AND u.organization_id = ? - ORDER BY u.id DESC - LIMIT ? OFFSET ?"#, - ) - .bind(&like_pattern) - .bind(&like_pattern) - .bind(org_id) - .bind(page_size) - .bind(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); } - q.fetch_all(pool).await? - } else if let Some(org_id) = org_filter { - sqlx::query_as::<_, UserRow>( - r#"SELECT u.id, u.phone, u.name, u.role, u.organization_id, u.status, u.created_at, u.updated_at - FROM users u - WHERE u.organization_id = ? - ORDER BY u.id DESC - LIMIT ? OFFSET ?"#, - ) - .bind(org_id) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await? - } else { - sqlx::query_as::<_, UserRow>( - 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 ?"#, - ) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await? + if let Some(org_id) = bind_org { + q = q.bind(org_id); + } + q.bind(page_size).bind(offset).fetch_all(pool).await? }; // 收集组织ID并批量查询组织名称 @@ -236,12 +243,12 @@ pub async fn list_users( for id in &org_ids { q = q.bind(id); } - q.fetch_all(pool).await.unwrap_or_default() + q.fetch_all(pool).await? }; let org_map: std::collections::HashMap = org_names.into_iter().collect(); // 构建角色名称映射 - let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"]; + let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"]; let list: Vec = rows .into_iter() @@ -274,6 +281,11 @@ pub async fn list_users( // --------------------------------------------------------------------------- /// POST /api/users — 创建用户 +/// +/// 权限模型: +/// - 总管理管理员(role_level>=2):可创建任意角色;角色0/1必须指定组织,角色2强制无组织 +/// - 企业管理员(role_level=1):仅可创建操作员/企业管理员,组织强制为本组织 +/// - 操作员(role_level=0):无 user:create 权限,被拦截 pub async fn create_user( current_user: CurrentUser, State(state): State, @@ -292,15 +304,25 @@ pub async fn create_user( let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); let role = input.role.unwrap_or(0); if !(0..=2).contains(&role) { - return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into())); + return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into())); } - // 企业管理员必须指定所属组织 - if role == 1 && input.organization_id.is_none() { - return Err(AppError::BadRequest("企业管理员必须指定所属组织".into())); - } + // 确定最终组织归属(按角色和操作者身份) + let org_id = if current_user.role_level >= 2 { + // 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织 + if role == 2 { + None + } else { + Some(input.organization_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?) + } + } else { + // 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织 + if role == 2 { + return Err(AppError::Forbidden("企业管理员不能创建总管理员".into())); + } + Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?) + }; - let org_id = input.organization_id; // 校验组织存在 if let Some(oid) = org_id { let org_exists: (i64,) = sqlx::query_as( @@ -314,7 +336,7 @@ pub async fn create_user( } } - let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); + let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?; let password = auth::hash_password(&plain_password)?; // 检查手机号是否已存在 @@ -345,7 +367,7 @@ pub async fn create_user( let detail = format!("创建用户: 手机号={}, 角色={}, 姓名={}", phone, role, name.as_deref().unwrap_or("-")); log_operation(pool, current_user.user_id, "create", "user", Some(user_id), &detail).await; - tracing::info!("创建用户 id={} phone={} role={}", user_id, phone, role); + tracing::info!(user_id, phone = %phone, role, "创建用户"); Ok(Json(json!({ "code": 0, @@ -382,28 +404,47 @@ pub async fn update_user( let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); let new_role = input.role.unwrap_or(old_role); if !(0..=2).contains(&new_role) { - return Err(AppError::BadRequest("角色值必须为0(普通)/1(企业管理员)/2(总管理员)".into())); - } - let new_org_id = input.organization_id.or(old_org_id); - - // 企业管理员必须指定所属组织 - if new_role == 1 && new_org_id.is_none() { - return Err(AppError::BadRequest("企业管理员必须指定所属组织".into())); + return Err(AppError::BadRequest("角色值必须为0(操作员)/1(企业管理员)/2(总管理员)".into())); } - // 校验组织存在 - if let Some(oid) = new_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))); - } + // 企业管理员作用域校验:只能编辑本组织用户,不能改成总管理员,组织锁定为本组织 + let new_org_id: Option = if current_user.role_level < 2 { + let actor_org = current_user.organization_id + .ok_or_else(|| AppError::Forbidden("您未关联组织,无权操作".into()))?; + if user_id == current_user.user_id { + return Err(AppError::BadRequest("不能操作自己的账户".into())); } + // 目标必须属于同一组织 + if old_org_id != Some(actor_org) { + return Err(AppError::Forbidden("只能操作本组织的用户".into())); + } + // 不能改成总管理员 + if new_role == 2 { + return Err(AppError::Forbidden("企业管理员不能授予总管理员角色".into())); + } + Some(actor_org) + } else { + let org_id = if new_role == 2 { + None // 总管理员强制无组织 + } else { + Some(input.organization_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?) + }; + + // 校验组织存在 + if let Some(oid) = org_id + && Some(oid) != old_org_id { + let org_exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM organizations WHERE id = ?" + ) + .bind(oid) + .fetch_one(pool) + .await?; + if org_exists.0 == 0 { + return Err(AppError::BadRequest(format!("组织 {} 不存在", oid))); + } + } + org_id + }; let rows = sqlx::query( "UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?" @@ -430,7 +471,7 @@ pub async fn update_user( ); log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await; - tracing::info!("编辑用户 id={}", user_id); + tracing::info!(user_id, "编辑用户"); Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" }))) } @@ -449,7 +490,10 @@ pub async fn reset_password( auth::check_permission(¤t_user, "user:edit")?; let pool = &state.mysql; - let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); + // 企业管理员只能重置本组织用户密码 + verify_org_admin_scope(pool, ¤t_user, user_id).await?; + + let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?; let new_password = auth::hash_password(&plain_password)?; let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?") @@ -467,7 +511,7 @@ pub async fn reset_password( let detail = format!("重置密码: 用户ID={}", user_id); log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await; - tracing::info!("重置密码 user_id={}", user_id); + tracing::info!(user_id, "重置密码"); Ok(Json(json!({ "code": 0, "data": null, "message": "密码重置成功" }))) } @@ -487,10 +531,8 @@ pub async fn set_user_status( let pool = &state.mysql; - // 不能禁用自己 - if user_id == current_user.user_id { - return Err(AppError::BadRequest("不能修改自己的状态".into())); - } + // 企业管理员作用域校验(含不能操作自己+同组织限制) + verify_org_admin_scope(pool, ¤t_user, user_id).await?; if ![0, 1].contains(&input.status) { return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into())); @@ -513,7 +555,7 @@ pub async fn set_user_status( let detail = format!("{}用户: 用户ID={}", status_text, user_id); log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await; - tracing::info!("{}用户 user_id={}", status_text, user_id); + tracing::info!(user_id, action = %status_text, "{}用户", status_text); Ok(Json(json!({ "code": 0, "data": null, "message": format!("{}成功", status_text) }))) } @@ -532,10 +574,8 @@ pub async fn delete_user( let pool = &state.mysql; - // 不能删除自己 - if user_id == current_user.user_id { - return Err(AppError::BadRequest("不能删除自己的账户".into())); - } + // 企业管理员作用域校验(含不能操作自己+同组织限制) + verify_org_admin_scope(pool, ¤t_user, user_id).await?; // 查询用户信息用于日志 let user_info: Option<(String,)> = sqlx::query_as( @@ -560,7 +600,7 @@ pub async fn delete_user( let detail = format!("删除用户: ID={}, 手机号={}", user_id, phone); log_operation(pool, current_user.user_id, "delete", "user", Some(user_id), &detail).await; - tracing::info!("删除用户 id={} phone={}", user_id, phone); + tracing::info!(user_id, phone = %phone, "删除用户"); Ok(Json(json!({ "code": 0, "data": null, "message": "删除成功" }))) } diff --git a/software/api-server/src/routes/wechat.rs b/software/api-server/src/routes/wechat.rs new file mode 100644 index 0000000..0f28fbf --- /dev/null +++ b/software/api-server/src/routes/wechat.rs @@ -0,0 +1,63 @@ +//! 微信公众号回调 API +//! +//! - GET /api/wechat/callback — 服务器地址验证(echostr) +//! - POST /api/wechat/callback — 接收推送消息 +//! +//! 配置参数: +//! - Token: 7GW16XdnFyak0mE32rc8ZPij(与微信后台一致) +//! - EncodingAESKey: kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu +//! - 加解密方式: 明文模式 +//! - 数据格式: XML + +use axum::extract::Query; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use serde::Deserialize; +use sha1::{Digest, Sha1}; +use tracing::info; + +/// 微信验证请求参数 +#[derive(Debug, Deserialize)] +pub struct WechatVerifyQuery { + pub signature: String, + pub timestamp: String, + pub nonce: String, + pub echostr: String, +} + +/// 获取 WeChat Token,优先从环境变量读取 +fn get_wechat_token() -> String { + std::env::var("WECHAT_TOKEN") + .unwrap_or_else(|_| "7GW16XdnFyak0mE32rc8ZPij".to_string()) +} + +/// 验证签名:将 token、timestamp、nonce 字典序排序后拼接,做 SHA1 加密 +/// 与 signature 比较,一致则返回 true +fn verify_signature(signature: &str, timestamp: &str, nonce: &str) -> bool { + let token = get_wechat_token(); + let mut parts = vec![token, timestamp.to_string(), nonce.to_string()]; + parts.sort(); + let joined = parts.concat(); + let mut hasher = Sha1::new(); + hasher.update(joined.as_bytes()); + let digest = hex::encode(hasher.finalize()); + digest == signature +} + +/// GET /api/wechat/callback — 微信服务器地址验证 +pub async fn verify( + Query(query): Query, +) -> impl IntoResponse { + if verify_signature(&query.signature, &query.timestamp, &query.nonce) { + (StatusCode::OK, query.echostr) + } else { + (StatusCode::FORBIDDEN, "signature verification failed".to_string()) + } +} + +/// POST /api/wechat/callback — 接收微信推送的消息 +pub async fn handle_message(body: String) -> impl IntoResponse { + info!("微信消息推送: {}", body); + // 明文模式,返回 success 即表示收到 + (StatusCode::OK, "success".to_string()) +} \ No newline at end of file diff --git a/software/api-server/src/workers/energy.rs b/software/api-server/src/workers/energy.rs new file mode 100644 index 0000000..ca2b8ad --- /dev/null +++ b/software/api-server/src/workers/energy.rs @@ -0,0 +1,288 @@ +//! 订单电量积分(**待确认第 13 项的兜底方案**) +//! +//! 背景:交付文档把 `energy`(单订单累计充电量 Wh)列入「待确认事项第 13 项」—— +//! **协议暂不包含该字段**。文档同时写明兜底方案: +//! > **若无该数据源,订单电量由平台侧按 `电压 × 电流 × 时间` 积分计算** +//! +//! 本模块就是那个兜底: +//! - 每次 `status_post`(协议固定每 60 秒一次)拿到通道的**电压(mV)/电流(mA)/温度**, +//! 按 `Wh = mV × mA × Δt / 3.6e9` 累加到该通道的**订单电量累加器**; +//! - 订单结束时读取累加值 → 写入 `charge_orders.energy` 并生成 `charge_records`; +//! - 新订单创建时**清零**累加器。 +//! +//! 设计取舍: +//! - **累加器放在 Redis 且以"通道"为键**(不是订单)——避免每条 `status_post` 都去查库确认 +//! "该通道当前有没有活跃订单"(36 通道 × 每秒多柜会造成大量无谓查询)。新订单创建时清零、 +//! 订单结束时读取并清零,语义等价而开销恒定。 +//! - 用 `HINCRBY` 累加**毫瓦时(mWh)**而不是瓦时,避免 60 秒小增量被取整成 0;落库时再换算成 Wh。 +//! - 温度取**历史最大值**(`charge_records.max_temperature` 要的是"本次充电最高温度")。 +//! 用 **Lua 脚本**把「累加 + 更新最高温 + 记时间戳」做成**一次原子往返**;若环境禁用了 +//! `EVAL`,降级为普通命令(电量照常累加,仅放弃最高温跟踪),**不影响订单电量**。 +//! - 单次积分的时间间隔 Δt 取**协议约定的 60 秒**并夹取到 `[0, MAX_DT]`:`status_post` 是固定节奏, +//! 但断线/停电后首个采样不应把"离线期间"也算成充电(否则会虚增电量)。 + +use redis::aio::ConnectionManager; + +/// 协议约定的上报间隔(秒)——积分用的 Δt。 +/// +/// **为什么用固定 60 秒而不是"两次采样的真实间隔"**: +/// - 协议 §5.3 规定 `status_post` **固定每 60 秒一次**,正常运行时它就是真实间隔; +/// - 用真实间隔需要在每个通道上多读一次 Redis 存上次时间戳(36 通道 × 每分钟 × 每柜), +/// 为极小的精度收益付出可观的往返开销; +/// - 断线/停电后补报时,固定 60 秒**只会少算**(少算的那段本就没有可信采样), +/// 符合"宁可少算、不可虚增"的口径 —— 电量直接关系电费,虚增比少算更糟。 +pub const ASSUMED_INTERVAL_SECS: i64 = 60; + +/// 换算系数:`mV × mA × s` → **mWh**。 +/// +/// 推导:`W = (mV/1000) × (mA/1000)`;`Wh = W × s/3600`; +/// 换成 mWh 再乘 1000 → `mWh = mV × mA × s / 3_600_000`。 +const MV_MA_S_TO_MWH_DIVISOR: i128 = 3_600_000; + +/// 计算一次采样的电量增量(**mWh**)。 +/// +/// - `voltage_mv` / `current_ma`:协议换算后的平台单位(见交付文档 §5.3) +/// - `dt_secs`:本次采样覆盖的秒数(已由调用方夹取到 `[0, MAX_DT_SECS]`) +/// +/// 电流为 0(未充电)时增量为 0;任何非正输入都返回 0,不做无意义的负数累加。 +/// 用 `i128` 中间量避免 `mV × mA × s` 溢出(54000 × 8000 × 300 = 1.296e11 > i32,也接近 i64 无需担心, +/// 但统一用宽类型更稳妥)。 +pub fn mwh_delta(voltage_mv: i64, current_ma: i64, dt_secs: i64) -> i64 { + if voltage_mv <= 0 || current_ma <= 0 || dt_secs <= 0 { + return 0; + } + let product = voltage_mv as i128 * current_ma as i128 * dt_secs as i128; + (product / MV_MA_S_TO_MWH_DIVISOR) as i64 +} + +/// 把 mWh 换算成落库用的 **Wh**(四舍五入到整数,因为 `charge_orders.energy` 是 INT/BIGINT 的 Wh)。 +pub fn mwh_to_wh(mwh: i64) -> i64 { + if mwh <= 0 { + return 0; + } + (mwh + 500) / 1000 +} + +/// 通道订单累加器键:`device:{imei}:board:{板号}:ch:{通道号}:order` +/// +/// 字段:`mwh`(累计毫瓦时)、`maxtemp`(本次充电最高温度 K×100)、`ts`(上次采样秒级时间戳) +pub fn order_energy_key(imei: &str, board_id: i64, channel_id: i64) -> String { + format!("device:{}:board:{}:ch:{}:order", imei, board_id, channel_id) +} + +/// Lua:原子地「累加电量 + 取最高温 + 记时间戳 + 维护"连续无电池"计数」,避免多次往返与竞态。 +/// +/// KEYS[1] 累加器键;ARGV[1] mwh 增量;ARGV[2] 本次温度(K×100);ARGV[3] 当前时间戳(秒); +/// ARGV[4] `1`=本通道有电池 / `0`=无电池(用于"连续无电池"兜底判定,见 [`consecutive_no_battery`]) +const ACCUMULATE_LUA: &str = r#" +local cur = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0') +local t = tonumber(ARGV[2]) +if t > cur then redis.call('HSET', KEYS[1], 'maxtemp', ARGV[2]) end +redis.call('HINCRBY', KEYS[1], 'mwh', ARGV[1]) +redis.call('HSET', KEYS[1], 'ts', ARGV[3]) +if ARGV[4] == '1' then redis.call('HSET', KEYS[1], 'nobat', '0') +else redis.call('HINCRBY', KEYS[1], 'nobat', 1) end +return 1 +"#; + +/// 累加一次采样的电量与最高温(由 `status_post` 的通道写入路径调用)。 +/// +/// `battery_present` = 该通道 `batt_status` 的 **bit7(有电池)**,用于维护"连续无电池"计数: +/// 待确认第 18 项的兜底方案之一就是「**靠 `status_post` 的 bit7 归零判断电池已被取走**」, +/// 由 [`crate::workers::report_worker`] 的周期对账在连续 [`NO_BATTERY_STRIKES`] 次后提前结单。 +/// +/// 失败只记 WARN:**电量统计是增值功能,不能因为 Redis 抖动影响状态上报主流程**。 +pub async fn accumulate( + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + voltage_mv: i64, + current_ma: i64, + temp_k100: i64, + battery_present: bool, +) { + let delta = mwh_delta(voltage_mv, current_ma, ASSUMED_INTERVAL_SECS); + let key = order_energy_key(imei, board_id, channel_id); + let now = chrono::Utc::now().timestamp(); + let present = if battery_present { "1" } else { "0" }; + let mut conn = redis.clone(); + + // 优先用 Lua 一次往返完成全部更新 + let script = redis::Script::new(ACCUMULATE_LUA); + match script + .key(&key) + .arg(delta) + .arg(temp_k100.max(0)) + .arg(now) + .arg(present) + .invoke_async::(&mut conn) + .await + { + Ok(_) => {} + Err(e) => { + // 环境可能禁用 EVAL(或脚本被 RDS 策略拦):降级为普通命令,**电量照常累加**, + // 仅放弃"最高温"与"连续无电池"跟踪(缺最高温只是记录不完整,不影响电费口径)。 + tracing::warn!("[energy] Lua 累加失败,降级为普通命令 key={} err={}", key, e); + let mut conn2 = redis.clone(); + let mut pipe = redis::pipe(); + pipe.cmd("HINCRBY").arg(&key).arg("mwh").arg(delta).ignore(); + pipe.cmd("HSET").arg(&key).arg("ts").arg(now).ignore(); + if battery_present { + pipe.cmd("HSET").arg(&key).arg("nobat").arg(0).ignore(); + } else { + pipe.cmd("HINCRBY").arg(&key).arg("nobat").arg(1).ignore(); + } + if let Err(e2) = pipe.query_async::<()>(&mut conn2).await { + tracing::warn!("[energy] 电量累加失败 key={} err={}", key, e2); + } + } + } +} + +/// 「连续无电池」达到该次数即视为电池已被取走(协议 §5.6 状态兜底的抖动保护)。 +/// +/// `status_post` 固定每 60 秒一次,故 2 次 ≈ **连续 2 分钟**无电池才判定, +/// 避免单次瞬时读数为 0 就误结单(待确认第 16 项:板离线时的表现尚未约定, +/// 因此阈值宁高不低,且真正的兜底是 10 小时订单超时)。 +pub const NO_BATTERY_STRIKES: i64 = 2; + +/// 读取该通道"连续无电池"的采样次数(0 表示最近一次有电池)。 +pub async fn consecutive_no_battery( + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, +) -> i64 { + let key = order_energy_key(imei, board_id, channel_id); + let mut conn = redis.clone(); + let value: Option = redis::cmd("HGET") + .arg(&key) + .arg("nobat") + .query_async(&mut conn) + .await + .unwrap_or(None); + value.unwrap_or(0) +} + +/// 读取并**清零**通道累加器,返回 `(累计毫瓦时, 本次最高温 K×100)`。 +/// +/// 用于订单结束时结算:先取值再删除,保证下一单从 0 开始。 +/// 读取失败返回 `(0, 0)` 并记 WARN —— 结算拿不到电量时宁可记 0,也不能让订单结束流程失败。 +pub async fn take_and_reset( + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, +) -> (i64, i64) { + let key = order_energy_key(imei, board_id, channel_id); + + // 用 Lua 保证"读+删"原子(避免读到之后、删除之前又来一次累加造成跨单串账) + const TAKE_LUA: &str = r#" +local mwh = tonumber(redis.call('HGET', KEYS[1], 'mwh') or '0') +local temp = tonumber(redis.call('HGET', KEYS[1], 'maxtemp') or '0') +redis.call('DEL', KEYS[1]) +return {mwh, temp} +"#; + let mut conn = redis.clone(); + let script = redis::Script::new(TAKE_LUA); + match script + .key(&key) + .invoke_async::<(i64, i64)>(&mut conn) + .await + { + Ok(v) => v, + Err(e) => { + tracing::warn!("[energy] 读取累加器失败,按 0 结算 key={} err={}", key, e); + // 仍尝试清理,避免脏数据带到下一单 + let mut conn2 = redis.clone(); + let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn2).await; + (0, 0) + } + } +} + +/// 清零通道累加器(新订单创建时调用)。 +pub async fn reset(redis: &ConnectionManager, imei: &str, board_id: i64, channel_id: i64) { + let key = order_energy_key(imei, board_id, channel_id); + let mut conn = redis.clone(); + let _: Result<(), _> = redis::cmd("DEL").arg(&key).query_async(&mut conn).await; +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 换算自检:54.000V × 8.000A 持续 60 秒 = 432W × (1/60)h = **7.2 Wh = 7200 mWh** + #[test] + fn test_mwh_delta_real_world_case() { + assert_eq!(mwh_delta(54_000, 8_000, 60), 7_200); + } + + /// 未充电(电流 0)不累加 —— 空仓/停止充电时不该产生电量 + #[test] + fn test_mwh_delta_zero_current_no_accumulation() { + assert_eq!(mwh_delta(54_000, 0, 60), 0); + } + + /// 非法/异常输入一律返回 0,不做负数累加 + #[test] + fn test_mwh_delta_rejects_non_positive_inputs() { + assert_eq!(mwh_delta(0, 8_000, 60), 0); + assert_eq!(mwh_delta(-1, 8_000, 60), 0); + assert_eq!(mwh_delta(54_000, -1, 60), 0); + assert_eq!(mwh_delta(54_000, 8_000, 0), 0); + assert_eq!(mwh_delta(54_000, 8_000, -5), 0); + } + + /// 小电流长时间不因取整丢成 0(这正是用 mWh 而非 Wh 累加的原因) + /// 例:54V × 0.1A = 5.4W,60 秒 = 0.09 Wh = 90 mWh(若按 Wh 取整会变成 0) + #[test] + fn test_mwh_delta_small_current_not_lost_by_rounding() { + assert_eq!(mwh_delta(54_000, 100, 60), 90); + assert_eq!(mwh_to_wh(90), 0, "不足 0.5Wh 时落库为 0 是可接受的舍入"); + // 但多次累加后不会丢:10 次 = 900 mWh → 1 Wh + assert_eq!(mwh_to_wh(900), 1); + } + + /// mWh → Wh 四舍五入 + #[test] + fn test_mwh_to_wh_rounding() { + assert_eq!(mwh_to_wh(0), 0); + assert_eq!(mwh_to_wh(-5), 0); + assert_eq!(mwh_to_wh(499), 0); + assert_eq!(mwh_to_wh(500), 1); + assert_eq!(mwh_to_wh(1_499), 1); + assert_eq!(mwh_to_wh(1_500), 2); + assert_eq!(mwh_to_wh(7_200), 7); + } + + /// 一轮完整充电的量级检查:54V/8A 充 1 小时应为 432 Wh + #[test] + fn test_full_hour_charge_magnitude() { + let mwh: i64 = (0..60).map(|_| mwh_delta(54_000, 8_000, 60)).sum(); + assert_eq!(mwh, 432_000); + assert_eq!(mwh_to_wh(mwh), 432); + } + + /// 累加器键格式(跨模块契约:`report_worker` 写入、`order_timeout` 结算读取) + #[test] + fn test_order_energy_key_format() { + assert_eq!( + order_energy_key("864720562557021", 1, 3), + "device:864720562557021:board:1:ch:3:order" + ); + } + + /// 固定间隔的取向:断线补报时按 60 秒计(少算),而不是按真实空洞(虚增) + #[test] + fn test_fixed_interval_is_conservative() { + let as_fixed = mwh_delta(54_000, 8_000, ASSUMED_INTERVAL_SECS); + let as_hour_gap = mwh_delta(54_000, 8_000, 3_600); + assert_eq!(as_fixed, 7_200); + assert!( + as_fixed < as_hour_gap, + "用固定 60 秒时必须显著小于按真实空洞计算,避免补报虚增电量" + ); + } +} diff --git a/software/api-server/src/workers/log_retention.rs b/software/api-server/src/workers/log_retention.rs new file mode 100644 index 0000000..c916d2e --- /dev/null +++ b/software/api-server/src/workers/log_retention.rs @@ -0,0 +1,108 @@ +//! 设备日志保留策略协程 +//! +//! 业务背景:`device_logs` 是**只增不减**的表 —— `status_post` 每台柜子每 60 秒写一条, +//! 加上 `bat_in`/`bat_out`/停电/回执等事件,量级随时间线性增长且**原本没有任何清理策略**, +//! 长期会把 RDS 撑满(磁盘告警、备份变慢、查询变慢)。 +//! +//! 本协程按**保留天数**定期分批删除过期日志: +//! - **分批 `DELETE ... LIMIT`**:避免一条大删除造成长事务、锁表、主从延迟; +//! - 单轮有**最大批数上限**:防止积压过多时一轮跑太久; +//! - 所有异常只记日志,**绝不让循环退出**(清理是运维保障,不能反过来拖垮服务)。 +//! +//! 依赖索引 `device_logs(created_at)`(由 `db::migrate` 幂等创建)—— 否则每次删除都会全表扫描。 + +use std::time::Duration; + +use sqlx::MySqlPool; + +/// 日志保留天数:超过该天数的 `device_logs` 会被清理。 +/// +/// 取 90 天的理由:设备日志主要服务于"最近出了什么问题"的排障, +/// 3 个月足够回溯;再久的数据排障价值低,但存储成本持续累积。 +const RETENTION_DAYS: i64 = 90; + +/// 单批删除行数:控制单条 SQL 的影响行数,避免长事务 +const BATCH_LIMIT: i64 = 5000; + +/// 单轮最多删多少批:积压很多时一轮也不会跑太久,剩下的等下一轮 +const MAX_BATCHES_PER_ROUND: usize = 20; + +/// 执行间隔:6 小时一次即可(保留策略对时效性不敏感,低频更省资源) +const SCAN_INTERVAL_SECS: u64 = 6 * 3600; + +/// 启动设备日志保留协程(由 main.rs 以 tokio::spawn 启动,常驻运行) +pub async fn start(pool: MySqlPool) { + tracing::info!( + "[log_retention] 启动设备日志清理,保留 {} 天,执行间隔 {}s,单批 {} 行", + RETENTION_DAYS, + SCAN_INTERVAL_SECS, + BATCH_LIMIT + ); + + loop { + // 单轮失败不退出:记 error 后等下一个周期继续 + if let Err(e) = purge_once(&pool).await { + tracing::error!("[log_retention] 本轮清理失败,等待下轮重试: {}", e); + } + tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await; + } +} + +/// 单轮清理:分批删除 `created_at` 早于 `NOW() - RETENTION_DAYS` 的日志。 +/// +/// 返回删掉的总行数(便于日志与测试断言);SQL 失败向上抛,由调用方记录并继续循环。 +async fn purge_once(pool: &MySqlPool) -> Result { + let mut total: u64 = 0; + + for _ in 0..MAX_BATCHES_PER_ROUND { + // 阈值交给 MySQL 计算(created_at 也是 MySQL 写的),避免本地时区换算偏差; + // 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。 + let affected = sqlx::query( + "DELETE FROM device_logs \ + WHERE created_at < TIMESTAMPADD(DAY, ?, NOW()) \ + LIMIT ?", + ) + .bind(-RETENTION_DAYS) + .bind(BATCH_LIMIT) + .execute(pool) + .await? + .rows_affected(); + + total += affected; + + // 本批不足一批 → 已删完,提前结束 + if affected < BATCH_LIMIT as u64 { + break; + } + + // 批间短暂让出,避免长时间占满数据库连接 + tokio::time::sleep(Duration::from_millis(200)).await; + } + + if total > 0 { + tracing::info!( + "[log_retention] 已清理过期设备日志 {} 行(保留 {} 天)", + total, + RETENTION_DAYS + ); + } else { + tracing::debug!("[log_retention] 无过期设备日志需要清理"); + } + + Ok(total) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 保留策略的常量约束:必须是正数、批大小与批数上限在合理区间, + /// 避免误改成 0 天(清空全部)或超大值(一次锁表) + #[test] + fn test_retention_constants_are_sane() { + assert!(RETENTION_DAYS > 0, "保留天数必须为正,否则会清空全部日志"); + assert!(BATCH_LIMIT > 0 && BATCH_LIMIT <= 50_000, "单批行数需在合理区间"); + assert!(MAX_BATCHES_PER_ROUND > 0, "每轮至少允许一批"); + assert!(SCAN_INTERVAL_SECS >= 60, "清理间隔不应过于频繁"); + } +} diff --git a/software/api-server/src/workers/mod.rs b/software/api-server/src/workers/mod.rs index 460609d..6fa2090 100644 --- a/software/api-server/src/workers/mod.rs +++ b/software/api-server/src/workers/mod.rs @@ -1,5 +1,8 @@ //! 后台协程 — 处理 Redis 队列中的设备消息 +pub mod energy; +pub mod log_retention; pub mod node_checker; +pub mod order_timeout; pub mod report_worker; pub mod reply_worker; diff --git a/software/api-server/src/workers/order_timeout.rs b/software/api-server/src/workers/order_timeout.rs new file mode 100644 index 0000000..d5b372b --- /dev/null +++ b/software/api-server/src/workers/order_timeout.rs @@ -0,0 +1,92 @@ +//! 订单超时兜底协程 +//! +//! 业务背景:设备可能因断电/断网未上报充电结束,导致 `charge_orders` 卡在 +//! `status = 0`(充电中)。本协程周期性把**超过 10 小时**仍未结束的订单 +//! 自动收尾为已完成,避免"幽灵订单"永久占用仓体和统计口径。 +//! +//! 职责边界:本协程只负责「定时扫描 + 落库收尾」,不做停止充电的指令下发 +//! (设备侧指令由其它流程负责);所有异常只记日志,绝不让循环退出。 + +use std::time::Duration; + +use redis::aio::ConnectionManager; +use sqlx::MySqlPool; + +use crate::workers::report_worker; + +/// 超时阈值:订单持续充电超过该小时数即视为异常,自动结束 +const TIMEOUT_HOURS: i64 = 10; + +/// 扫描间隔:5 分钟扫一次,兼顾及时性与数据库压力 +const SCAN_INTERVAL_SECS: u64 = 300; + +/// 单轮最多处理订单数,避免一次扫描锁过多行 +const BATCH_LIMIT: i64 = 200; + +/// 启动订单超时兜底协程(由 main.rs 以 tokio::spawn 启动,常驻运行) +/// +/// 结单走 [`report_worker::finalize_timeout_order`](统一结算)—— 这样超时订单 +/// **同样会写回平台侧积分电量、并生成一条充电记录**,不会在统计口径里凭空消失。 +pub async fn start(pool: MySqlPool, redis: ConnectionManager) { + tracing::info!( + "[order_timeout] 启动订单超时兜底,扫描间隔={}s,超时阈值={}h", + SCAN_INTERVAL_SECS, + TIMEOUT_HOURS + ); + + loop { + // 单轮失败不退出:记 error 后等下一个周期继续 + if let Err(e) = scan_once(&pool, &redis).await { + tracing::error!("[order_timeout] 本轮扫描失败,等待下轮重试: {}", e); + } + tokio::time::sleep(Duration::from_secs(SCAN_INTERVAL_SECS)).await; + } +} + +/// 单轮扫描:找出 status=0 且 start_time 早于 NOW()-10h 的订单并结单 +/// +/// 返回 `Result` 而不是直接 panic,让调用方决定是否继续循环。 +async fn scan_once(pool: &MySqlPool, redis: &ConnectionManager) -> Result<(), sqlx::Error> { + // 阈值由 MySQL 计算:start_time 也是 MySQL 的 NOW() 写入的, + // 交给数据库算差值可避免本地时区换算偏差; + // 用 TIMESTAMPADD 而非 INTERVAL 字面量,确保阈值能以 ? 绑定(不拼 SQL)。 + let expired: Vec<(i64, i64, Option)> = sqlx::query_as( + "SELECT id, compartment_id, start_time FROM charge_orders \ + WHERE status = 0 AND start_time IS NOT NULL \ + AND start_time < TIMESTAMPADD(HOUR, ?, NOW()) \ + ORDER BY start_time ASC LIMIT ?", + ) + .bind(TIMEOUT_HOURS) + .bind(BATCH_LIMIT) + .fetch_all(pool) + .await?; + + if expired.is_empty() { + return Ok(()); + } + + for (order_id, compartment_id, start_time) in expired { + // 走统一结算:写回电量(平台侧积分)+ 生成充电记录(status=0 条件保证幂等, + // 避免与设备上报的正常结束竞争覆盖) + let affected = match report_worker::finalize_timeout_order(pool, redis, compartment_id).await { + Ok(n) => n, + Err(e) => { + // 单条失败不影响其余订单,下一轮扫描会再次命中 + tracing::error!("[order_timeout] 结算订单失败 order_id={} err={}", order_id, e); + continue; + } + }; + + if affected > 0 { + tracing::info!( + "[order_timeout] 订单超时自动结束 order_id={} compartment_id={} start_time={:?}", + order_id, + compartment_id, + start_time + ); + } + } + + tracing::info!("[order_timeout] 本轮超时结单处理完成"); + Ok(()) +} diff --git a/software/api-server/src/workers/reply_worker.rs b/software/api-server/src/workers/reply_worker.rs index 02214f8..be76395 100644 --- a/software/api-server/src/workers/reply_worker.rs +++ b/software/api-server/src/workers/reply_worker.rs @@ -7,22 +7,24 @@ use redis::aio::ConnectionManager; use crate::commands::PendingCommands; +/// 响应队列的轮询间隔(毫秒):**改用非阻塞轮询**,原因见 +/// [`crate::workers::report_worker::REDIS_POLL_INTERVAL_MS`](`BRPOP` 在本项目的 +/// 代理 + 多路复用连接组合下延迟不可控,实测 13~45 秒)。 +const REDIS_POLL_INTERVAL_MS: u64 = 200; + /// 启动指令响应处理协程 /// -/// 使用 BRPOP 阻塞等待所有 `device:reply:*` 队列的响应消息。 +/// 轮询 `device:replies`,把回执按 `msg_id` 派发给等待者。 pub async fn start(redis: ConnectionManager, pending: PendingCommands) { tracing::info!("[reply_worker] 启动"); loop { let mut conn = redis.clone(); - let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP") - .arg("device:replies") - .arg(0) - .query_async(&mut conn) - .await; + let result: Result, redis::RedisError> = + redis::cmd("RPOP").arg("device:replies").query_async(&mut conn).await; match result { - Ok((_key, reply_json)) => { + Ok(Some(reply_json)) => { // 解析 msg_id if let Ok(reply) = serde_json::from_str::(&reply_json) { let msg_id = reply["msg_id"].as_u64().unwrap_or(0) as u32; @@ -38,8 +40,12 @@ pub async fn start(redis: ConnectionManager, pending: PendingCommands) { }); } } + // 队列为空:短暂让出 + Ok(None) => { + tokio::time::sleep(std::time::Duration::from_millis(REDIS_POLL_INTERVAL_MS)).await; + } Err(e) => { - tracing::error!("[reply_worker] BRPOP 错误: {}", e); + tracing::error!("[reply_worker] RPOP 错误: {}", e); tokio::time::sleep(std::time::Duration::from_secs(1)).await; } } diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index c0afe0a..8304c6a 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -1,8 +1,29 @@ //! 设备上报处理协程 //! -//! 从 Redis LIST `device:reports` 读取设备上报消息, -//! 执行业务逻辑:签名验证、状态持久化、充电事件处理、告警判断等。 +//! 从 Redis LIST `device:reports` 读取设备上报消息(device-server 已注入 `dev_id`), +//! 执行业务逻辑:签名验证、状态持久化、充电事件处理、指令回执配对等。 //! 需要回复设备的消息通过 `device:commands` 队列发回。 +//! +//! 协议依据:`docs/柜控板固件对接文档.md`(v1.1) +//! - 上行报文**除 `auth_str` / `login` 外一律不带 `dev_id`**,设备身份由 device-server 注入; +//! - 报文性质按**有无 `suc`** 判定:带 = 回执(平台**不再回包**),不带 = 设备请求(平台要回包); +//! - 平台应答一律用 [`ServerResponse::success_for`] / [`ServerResponse::error_for`] 构造 +//! (自动取请求的 `act` + `msg_id`),`err` 只用协议 §3.3 的英文小写短语。 +//! +//! 本模块负责的 Redis 写入契约(其他模块按此读取): +//! - `device:{imei}`(Hash,TTL 300s):`online` / `rssi` / `cabinet_status1` / +//! `cabinet_status2` / `temp` / `meter` / `pow_fail_dc` / `pow_fail_ac` +//! - `device:{imei}:board:{板号}:ch:{通道号}`(Hash):`batt_status`(2 位 hex)/ `pow_status`(2 位 hex) +//! / `soc`(‰)/ `voltage`(mV)/ `current`(mA)/ `temp`(K×100);板号与通道号**十进制不补零** +//! - `device:{imei}:board:{板号}:ch:{通道号}:bat`(Hash,内部键,TTL 48h): +//! 该通道最近一次 `bat_in` 的电池信息(`on` 建单时取电池 id 与起始 SOC) +//! +//! 本模块消费 / 删除的 key(由 device-server 写入,见协议 §3.5 / §3.6): +//! - `pending:{imei}`(Hash):`field = msg_id`、`value = act`、TTL 120 秒; +//! 收到设备回执(带 `suc`)时按 `msg_id` + `act` 双条件命中并 `HDEL` +//! +//! 另:协议 §5.7 的"10 小时订单超时"结单由 `workers/order_timeout.rs` 负责, +//! 它**不复位**占用状态,故本模块额外启动周期性对账(见 [`reconcile_usage_loop`])。 use rand::Rng; use redis::aio::ConnectionManager; @@ -13,15 +34,44 @@ use std::sync::LazyLock; use std::time::{Duration, Instant}; use tokio::sync::Mutex; +use crate::error::AppError; use crate::protocol::ServerResponse; +use crate::workers::energy; + +/// 上报队列的轮询间隔(毫秒)。 +/// +/// **为什么用「`RPOP` 非阻塞轮询 + 短 sleep」而不是 `BRPOP`**(2026-09 实测结论): +/// Redis 是经 nginx stream 代理访问阿里云 RDS 的,且客户端用的是多路复用的 +/// `ConnectionManager`。在这种组合下 `BRPOP` 的阻塞语义**不可靠**,实测表现: +/// - `BRPOP key 0`(无限阻塞)→ 代理掐掉空闲连接后**整个消费永久卡死**,且无任何错误日志; +/// - `BRPOP key N`(有限超时)→ **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大, +/// 实测端到端延迟 13~45 秒(协议给的交互窗口才 60 秒,几乎不可用)。 +/// +/// 改为 `RPOP` 轮询后:**延迟稳定在轮询间隔内**,且完全不依赖阻塞命令语义与代理行为。 +/// 每 200ms 一次 `RPOP` 的开销对 Redis 可忽略(单机每秒 5 次)。 +const REDIS_POLL_INTERVAL_MS: u64 = 200; /// Redis key 前缀 const REDIS_KEY_AUTH_STR: &str = "auth_str:"; -/// 签名截取范围 +/// 签名截取范围(8 hex = 4 bytes,受 MCU 硬件协议约束不可改) +/// 审查编号:I5(已确认不修,硬件约束。设备一次只能获取一次 auth_str,除非显式标记重新生成) const SIGN_START: usize = 56; -const SIGN_END: usize = 63; +/// 取 hex[56..64],共 8 位(规范定稿值) +const SIGN_END: usize = 64; /// 签名校验窗口:±60 秒 const TIMESTAMP_TOLERANCE_SECS: i64 = 60; +/// 设备在线 TTL:300 秒内无任意上行即判离线(协议 §2.3) +const DEVICE_ONLINE_TTL_SECS: u32 = 300; +/// 通道状态 hex 长度:硬件 9 字节原样拼接 → **恰好 18 个 hex 字符**(协议 §5.3) +const CHANNEL_HEX_LEN: usize = 18; +/// 仓体级 `id` 的段数:`仓控板-通道`(协议 §3.7,段数必须恰好为 2) +const COMPARTMENT_ID_SEGMENTS: usize = 2; +/// 通道电池信息(`bat_in` 登记)的存活时间:覆盖 10 小时订单超时 + 余量 +const CHANNEL_BAT_TTL_SECS: u32 = 48 * 3600; +/// `cabinet.status1` bit0 = 断电报警位(0 正常 / 1 报警,协议 §3.8) +const CABINET_STATUS1_POWER_FAIL_MASK: u8 = 0x01; +/// 占用状态对账周期:与 `order_timeout` 的订单超时扫描同频(300 秒),避免额外数据库压力 +const RECONCILE_INTERVAL_SECS: u64 = 300; // --------------------------------------------------------------------------- // 频率限制器 @@ -61,16 +111,20 @@ static AUTH_STR_LIMITER: LazyLock = LazyLock::new(|| { pub async fn start(redis: ConnectionManager, mysql: MySqlPool) { tracing::info!("[report_worker] 启动"); + // 占用状态对账 + 「无电池」兜底:`workers/order_timeout.rs` 负责 10 小时超时结单,但**不复位** + // `compartments.status` / `batteries.status`,且停电/离线期间漏报 `bat_out` 的订单需要 + // 靠"通道连续报无电池"提前结单(待确认第 18 项的兜底)—— 按分工这都属于本 worker 的职责。 + tokio::spawn(reconcile_usage_loop(mysql.clone(), redis.clone())); + loop { let mut conn = redis.clone(); - let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP") - .arg("device:reports") - .arg(0) - .query_async(&mut conn) - .await; + // 非阻塞轮询取一条上报(原因见 [`REDIS_POLL_INTERVAL_MS`]): + // `LPUSH` 入队 + `RPOP` 出队 = FIFO,与原先 `BRPOP` 的取数顺序一致。 + let result: Result, redis::RedisError> = + redis::cmd("RPOP").arg("device:reports").query_async(&mut conn).await; match result { - Ok((_key, msg_json)) => { + Ok(Some(msg_json)) => { let mysql = mysql.clone(); let redis = redis.clone(); tokio::spawn(async move { @@ -79,8 +133,12 @@ pub async fn start(redis: ConnectionManager, mysql: MySqlPool) { } }); } + // 队列为空:短暂让出,避免空转打满 CPU + Ok(None) => { + tokio::time::sleep(Duration::from_millis(REDIS_POLL_INTERVAL_MS)).await; + } Err(e) => { - tracing::error!("[report_worker] BRPOP 错误: {}", e); + tracing::error!("[report_worker] RPOP 错误: {}", e); tokio::time::sleep(Duration::from_secs(1)).await; } } @@ -99,16 +157,34 @@ async fn process_report( tracing::debug!("[report] 处理 dev_id={} act={}", dev_id, act); + // 协议 §3.5:带 `suc` 的上行是**回执**(平台不再回包),不带 `suc` 才是设备请求 + let is_receipt = msg.get("suc").is_some(); + match act { - "auth_str" => handle_auth_str(&msg, dev_id, redis).await?, - "login" => handle_login(&msg, dev_id, redis).await?, + "auth_str" => handle_auth_str(&msg, dev_id, mysql, redis).await?, + "login" => handle_login(&msg, dev_id, mysql, redis).await?, "status_post" => handle_status_post(&msg, dev_id, mysql, redis).await?, - "pow_fail" => handle_pow_fail(&msg, dev_id, mysql, redis).await?, - "bat_in" => handle_bat_in(&msg, dev_id, mysql).await?, - "bat_out" => handle_bat_out(&msg, dev_id, mysql).await?, - "off" => handle_device_off(&msg, dev_id, mysql).await?, + // 协议定稿:停电是【两条独立指令】,各自对应同名 Redis 字段 + // pow_fail_dc —— 直流停电(接触器分闸 → 仓控板下电,柜控板仍有电) + // pow_fail_ac —— 交流停电(柜控板自身断电,靠板载电容余电抢发最后一条) + "pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(&msg, dev_id, mysql, redis).await?, + // `bat_in` 只登记电池台账,**绝不建单**(协议 §5.5) + "bat_in" => handle_bat_in(&msg, dev_id, mysql, redis).await?, + // `bat_out` 结束订单(`reason='user_stop'`)+ 台账置空闲(协议 §5.6) + "bat_out" => handle_bat_out(&msg, dev_id, mysql, redis).await?, + // `on` / `off` 是**双向 act**:带 `suc` = 对下行指令的回执(只配对、不回包); + // 不带 `suc` = 设备上报(`on` = 建单入口、`off` = 结束订单),平台需回包(协议 §5.7) + "on" | "off" if is_receipt => handle_cmd_result(&msg, dev_id, mysql, redis).await?, + "on" => handle_device_on(&msg, dev_id, mysql, redis).await?, + "off" => handle_device_off(&msg, dev_id, mysql, redis).await?, + // `open` / `pow_on` / `pow_off` / `reset` 的上行都是回执(平台不回包) + "open" | "pow_on" | "pow_off" | "reset" => { + handle_cmd_result(&msg, dev_id, mysql, redis).await?; + } other => { tracing::warn!("[report] 未知上报类型 dev_id={} act={}", dev_id, other); + let resp = ServerResponse::error_for(&msg, "unknown act"); + send_response_to_device(redis, dev_id, &resp).await?; } } @@ -121,7 +197,10 @@ async fn send_response_to_device( dev_id: &str, resp: &ServerResponse, ) -> Result<(), Box> { - let json_str = resp.to_json(); + let mut json_val = serde_json::from_str::(&resp.to_json())?; + // 内部路由字段 `_dev_id` 供 device-server 路由(下发前摘除,设备侧不可见) + json_val[crate::commands::ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id); + let json_str = json_val.to_string(); let mut conn = redis.clone(); redis::cmd("LPUSH") .arg("device:commands") @@ -132,80 +211,172 @@ async fn send_response_to_device( Ok(()) } -/// 处理 `auth_str`:生成 8 位随机安全码并存入 Redis +/// 发送 `login` 失败应答,并标记该连接在发送后主动断开。 +/// +/// 规范:**`login` 失败即断开 TCP** —— device-server 发出该报文后关闭连接, +/// 设备需重连并重新走握手流程(重连后先重新申领安全码)。 +/// 注意:`login` 失败**只返回 `invalid signature` 一种**(不区分时间戳/签名/无记录), +/// 属刻意安全设计,避免外部据此试探系统状态。 +/// +/// 内部标记 `_close` 仅用于服务端判断,device-server 下发前会摘除,**设备侧不可见**。 +async fn send_login_failure( + redis: &ConnectionManager, + dev_id: &str, + resp: &ServerResponse, +) -> Result<(), Box> { + let mut json_val = serde_json::from_str::(&resp.to_json())?; + // `_dev_id` 供 device-server 路由;`_close` 指示发送后断开连接 + json_val[crate::commands::ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id); + json_val["_close"] = serde_json::json!(true); + let json_str = json_val.to_string(); + let mut conn = redis.clone(); + redis::cmd("LPUSH") + .arg("device:commands") + .arg(&json_str) + .query_async::<()>(&mut conn) + .await?; + tracing::info!("[login] 失败应答已推入队列,平台将断开该连接 dev_id={}", dev_id); + Ok(()) +} + +/// 处理 `auth_str`:向设备下发安全码(权威存储为 DB `cabinets.auth_str`,Redis 仅作缓存)。 +/// +/// 两种情况会走到这里: +/// 1. 首次开机申领(DB 无码)→ 生成 8 位随机码 +/// 2. 管理员「重置安全码」后重新申领(`auth_str_issued = 0`)→ 复用 DB 中已登记的新码, +/// 保证接口返回值与设备实际使用值一致 async fn handle_auth_str( msg: &serde_json::Value, dev_id: &str, + mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let msg_id = msg["msg_id"].as_u64().map(|v| v as u32); if !AUTH_STR_LIMITER.check(dev_id).await { tracing::warn!("[auth_str] 频率超限 dev_id={}", dev_id); - let resp = ServerResponse::error(msg_id, "请求过于频繁,请稍后再试"); + let resp = ServerResponse::error_for(&msg, "rate limit exceeded"); send_response_to_device(redis, dev_id, &resp).await?; return Ok(()); } - let auth_str: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(8) - .map(char::from) - .collect(); + // 以 DB(cabinets.auth_str)为权威存储,Redis 仅作缓存:读取已登记的安全码与下发标记 + let db_row: Option<(Option, Option)> = + sqlx::query_as("SELECT auth_str, auth_str_issued FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await?; + // 安全:**设备必须已在 `cabinets` 中登记**。 + // 未登记的 IMEI 若也给它发码,会同时造成两个后果: + // ① DB 那条 `UPDATE ... WHERE imei = ?` 影响 0 行 → 码根本没落库(设备"拿到了却不认") + // ② 码会写进 Redis 缓存 → 后续 `login` 命中缓存直接通过 → **未登记设备可以连上平台** + // 因此这里**静默丢弃**(不回包、不发码、不写 Redis),只记 WARN 便于排查。 + // 合法设备必然已登记(建档时录入 IMEI),所以这条只影响未知设备/攻击流量。 + let (db_code, issued) = match db_row { + Some(row) => row, + None => { + tracing::warn!("[auth_str] 设备未登记,静默丢弃 dev_id={}", dev_id); + return Ok(()); + } + }; + + // 安全码只能下发一次:DB 已标记下发即拒绝,须管理员「重置安全码」后才能重新申领 + if issued == Some(1) { + tracing::warn!("[auth_str] 安全码已下发过,拒绝重复申领 dev_id={}", dev_id); + let resp = ServerResponse::error_for(&msg, "auth_str already issued"); + send_response_to_device(redis, dev_id, &resp).await?; + return Ok(()); + } + + // 复用 DB 中已登记的安全码(权威值):管理员「重置安全码」写入的就是它, + // 保证接口返回值与设备实际使用值一致;DB 无码(首次开机)才新生成。 + let auth_str: String = match db_code.filter(|s| !s.is_empty()) { + Some(code) => { + tracing::info!("[auth_str] 复用已登记安全码 dev_id={}", dev_id); + code + } + None => rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(8) + .map(char::from) + .collect(), + }; + + // 写入 Redis 缓存(权威值在 DB,随后一并写回 DB) let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}"); let mut conn = redis.clone(); - let _: Result<(), _> = redis::cmd("SET") + if let Err(e) = redis::cmd("SET") .arg(&key) .arg(&auth_str) .query_async::<()>(&mut conn) - .await; + .await + { + tracing::error!("[redis] SET auth_str失败 key={} err={}", key, e); + } + + // 标记 DB + Redis(发出后即不可再次申领,须管理员重置) + mark_auth_str_issued(mysql, redis, dev_id, &auth_str).await; tracing::info!("[auth_str] 已生成安全码 dev_id={}", dev_id); - let mut resp = ServerResponse::success(msg_id); + let mut resp = ServerResponse::success_for(&msg); resp.auth_str = Some(auth_str); send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } -/// 处理 `login`:验证签名 +/// 标记安全码已下发:DB(`auth_str_issued=1` + 写回新码)与 Redis 下发标记。 +/// 两者都失败也只记 ERROR —— 设备已收到码,重发请求会被 `auth_str already issued` 拦住。 +async fn mark_auth_str_issued(mysql: &MySqlPool, redis: &ConnectionManager, dev_id: &str, auth_str: &str) { + let db_result: Result = + sqlx::query("UPDATE cabinets SET auth_str_issued = 1, auth_str = ? WHERE imei = ?") + .bind(auth_str) + .bind(dev_id) + .execute(mysql) + .await; + if let Err(e) = db_result { + tracing::error!("[auth_str] 更新 DB 下发标记失败 dev_id={} err={}", dev_id, e); + } + + let issued_key = format!("{}issued:{}", REDIS_KEY_AUTH_STR, dev_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("SET") + .arg(&issued_key) + .arg("1") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] SET issued失败 key={} err={}", issued_key, e); + } +} + +/// 处理 `login`:验证签名,返回仓控板数量 async fn handle_login( msg: &serde_json::Value, dev_id: &str, + mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let msg_id = msg["msg_id"].as_u64().map(|v| v as u32); let timestamp = msg["timestamp"].as_u64().unwrap_or(0); let sign = msg["sign"].as_str().unwrap_or(""); // 校验时间窗口 ±60 秒 + // 安全设计:时间戳超窗【不单独告知】,对外统一回 invalid signature(见交付文档 §5.2) let now = chrono::Utc::now().timestamp(); let ts = timestamp as i64; if (now - ts).abs() > TIMESTAMP_TOLERANCE_SECS { tracing::warn!("[login] 时间戳超限 dev_id={} timestamp={}", dev_id, timestamp); - let resp = ServerResponse::error(msg_id, "时间戳无效"); - send_response_to_device(redis, dev_id, &resp).await?; + let resp = ServerResponse::error_for(&msg, "invalid signature"); + send_login_failure(redis, dev_id, &resp).await?; return Ok(()); } - // 从 Redis 获取安全码 - let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}"); - let mut conn = redis.clone(); - let auth_str: Option = redis::cmd("GET") - .arg(&key) - .query_async(&mut conn) - .await - .unwrap_or(None); - - let auth_str = match auth_str { - Some(s) => s, - None => { - let resp = ServerResponse::error(msg_id, "未获取安全码,请先调用 auth_str"); - send_response_to_device(redis, dev_id, &resp).await?; - return Ok(()); - } + // 取安全码(Redis 缓存 → DB 权威存储回源)。 + // 完全取不到时 fetch_auth_str 已回复 `invalid signature`,这里直接结束。 + let auth_str: String = match fetch_auth_str(mysql, redis, dev_id, msg).await? { + Some(code) => code, + None => return Ok(()), }; // 计算期望签名 @@ -215,129 +386,623 @@ async fn handle_login( let expected_sign = &hex_hash[SIGN_START..SIGN_END]; if sign == expected_sign { - // 标记设备在线(统一主key,5分钟 TTL) - let redis_key = format!("device:{}", dev_id); - let mut conn = redis.clone(); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&redis_key) - .arg("online").arg("1") - .query_async(&mut conn) - .await; - let _: Result<(), _> = redis::cmd("EXPIRE") - .arg(&redis_key) - .arg(300_u32) - .query_async(&mut conn) - .await; + mark_device_online(redis, dev_id).await; - tracing::info!("[login] 成功 dev_id={}", dev_id); - let resp = ServerResponse::success(msg_id); + // 柜子规格 spec:前 2 位仓控板数 + 后 2 位每板仓数(如 "0606" = 6 板 × 每板 6 仓) + let spec = fetch_spec(mysql, dev_id).await?; + + // 充电工作模式:"0"=平台指令模式(默认)/"1"=自动充电工作模式 + let work_mode = fetch_work_mode(mysql, dev_id).await; + + // ICCID 变更检测:更新 DB + 写 sim_change_logs + sync_iccid(mysql, dev_id, msg).await?; + + tracing::info!( + "[login] 成功 dev_id={} spec={} work_mode={}", + dev_id, + spec, + work_mode + ); + + let mut resp = ServerResponse::success_for(&msg); + resp.spec = Some(spec); + resp.work_mode = Some(work_mode); send_response_to_device(redis, dev_id, &resp).await?; } else { tracing::warn!("[login] 签名不匹配 dev_id={}", dev_id); - let resp = ServerResponse::error(msg_id, "签名错误"); - send_response_to_device(redis, dev_id, &resp).await?; + let resp = ServerResponse::error_for(&msg, "invalid signature"); + send_login_failure(redis, dev_id, &resp).await?; } Ok(()) } -/// 处理状态上报 — 解析status字典,按层级存入Redis -/// Redis结构: -/// device:{imei} — 设备级(online, rssi, pow_fail_dc, pow_fail_ac) -/// device:{imei}:board:{idx} — 板级(status_hex, fault) -/// device:{imei}:board:{idx}:ch:{0-5} — 通道级(on, load, fault, alarm) +/// 取设备安全码:Redis 为缓存,DB `cabinets.auth_str` 为权威存储。 +/// +/// 缓存未命中时回源 DB 并回填缓存,避免因 Redis 故障/清理导致设备永久无法登录。 +/// 完全取不到(该设备从未申领过)→ 回复 `invalid signature` 并返回 `None` +/// —— **不向设备暴露"平台无此设备记录"** 是刻意的安全设计(协议 §5.2)。 +async fn fetch_auth_str( + mysql: &MySqlPool, + redis: &ConnectionManager, + dev_id: &str, + msg: &serde_json::Value, +) -> Result, Box> { + let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}"); + let mut conn = redis.clone(); + let cached: Option = redis::cmd("GET") + .arg(&key) + .query_async(&mut conn) + .await + .unwrap_or(None); + + if let Some(code) = cached.filter(|s| !s.is_empty()) { + return Ok(Some(code)); + } + + let db_row: Option> = sqlx::query_scalar("SELECT auth_str FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await?; + + match db_row.flatten().filter(|s| !s.is_empty()) { + Some(code) => { + // 回填缓存,后续 login 直接命中 Redis + if let Err(e) = redis::cmd("SET") + .arg(&key) + .arg(&code) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[login] 回填安全码缓存失败 key={} err={}", key, e); + } + tracing::info!("[login] 安全码缓存未命中,已从 DB 回源 dev_id={}", dev_id); + Ok(Some(code)) + } + None => { + tracing::warn!("[login] DB 中无安全码 dev_id={}", dev_id); + let resp = ServerResponse::error_for(msg, "invalid signature"); + send_login_failure(redis, dev_id, &resp).await?; + Ok(None) + } + } +} + +/// 标记设备在线:`device:{imei}` 的 `online=1` + TTL 300 秒 +/// (平台以"300 秒内是否收到过该设备任意报文"判定在线,协议 §2.3) +async fn mark_device_online(redis: &ConnectionManager, dev_id: &str) { + let key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("HSET") + .arg(&key) + .arg("online").arg("1") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] HSET online失败 key={} err={}", key, e); + } + if let Err(e) = redis::cmd("EXPIRE") + .arg(&key) + .arg(DEVICE_ONLINE_TTL_SECS) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] EXPIRE失败 key={} err={}", key, e); + } +} + +/// 该柜规格 `spec`(固定 4 位):前 2 位仓控板数 + 后 2 位每板通道数, +/// 如 `"0606"` = 6 块仓控板 × 每板 6 仓(协议 §5.2 / §10 第 9 条) +async fn fetch_spec(mysql: &MySqlPool, dev_id: &str) -> Result { + let (board_count, compartment_count): (i64, i64) = sqlx::query_as( + "SELECT COUNT(DISTINCT cb.id), COUNT(comp.id) + FROM cabin_boards cb + JOIN cabinets cab ON cb.cabinet_id = cab.id + LEFT JOIN compartments comp ON comp.cabin_board_id = cb.id + WHERE cab.imei = ?", + ) + .bind(dev_id) + .fetch_one(mysql) + .await + .map_err(|e| { + tracing::error!("[login] 查询柜子规格失败 dev_id={} err={}", dev_id, e); + AppError::Internal("查询仓控板数量失败".into()) + })?; + + let per_board = if board_count > 0 { compartment_count / board_count } else { 0 }; + Ok(format!("{:02}{:02}", board_count, per_board)) +} + +/// 读取充电工作模式:`"0"`=平台指令模式(默认)/"1"=自动充电工作模式。 +/// +/// 库中 `cabinets.work_mode` 是 **TINYINT**(0/1/NULL),协议要求应答里是**字符串** `"0"`/`"1"`, +/// 故这里读 `Option` 再转换;NULL 或读取失败按默认 `"0"` 处理并记 WARN,**不阻断登录**。 +async fn fetch_work_mode(mysql: &MySqlPool, dev_id: &str) -> String { + match sqlx::query_scalar::<_, Option>("SELECT work_mode FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await + { + Ok(v) => match v.flatten() { + Some(1) => "1".to_string(), + _ => "0".to_string(), + }, + Err(e) => { + tracing::warn!( + "[login] 读取 work_mode 失败,按默认 0 处理 dev_id={} err={}", + dev_id, + e + ); + "0".to_string() + } + } +} + +/// ICCID 变更检测:写回 `cabinets.iccid`,变更时记 `sim_change_logs`(协议 §5.2:换 SIM 会被记录) +async fn sync_iccid( + mysql: &MySqlPool, + dev_id: &str, + msg: &serde_json::Value, +) -> Result<(), sqlx::Error> { + let new_iccid = match msg.get("iccid").and_then(|v| v.as_str()) { + Some(v) if !v.is_empty() => v, + _ => return Ok(()), + }; + + // ⚠️ `iccid` 列**可为 NULL**(设备首次上报前它一直是 NULL),所以类型必须是 + // `Option>`:**外层**表示"该 IMEI 的行是否存在",**内层**表示"列是否为 NULL"。 + // 曾写成 `Option`,结果遇到 NULL 时报 + // `decoding column 0: unexpected null; try decoding as an Option` + // 并让**整条 `login` 处理失败** —— 也就是"新建柜子还没上报过 ICCID 就永远登录不上"。 + let old_iccid_row: Option> = + sqlx::query_scalar("SELECT iccid FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await?; + let old_iccid = old_iccid_row.flatten(); + + match old_iccid { + Some(old) if old == new_iccid => Ok(()), + Some(old) => { + sqlx::query("UPDATE cabinets SET iccid = ? WHERE imei = ?") + .bind(new_iccid) + .bind(dev_id) + .execute(mysql) + .await?; + sqlx::query("INSERT INTO sim_change_logs (imei, old_iccid, new_iccid) VALUES (?, ?, ?)") + .bind(dev_id) + .bind(&old) + .bind(new_iccid) + .execute(mysql) + .await?; + tracing::info!("[login] SIM变更 dev_id={} {} -> {}", dev_id, old, new_iccid); + Ok(()) + } + None => { + sqlx::query("UPDATE cabinets SET iccid = ? WHERE imei = ?") + .bind(new_iccid) + .bind(dev_id) + .execute(mysql) + .await?; + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// 通用解析 / Redis key / 日志工具 +// --------------------------------------------------------------------------- + +/// 通道 18 字符 hex 的解析结果(协议 §5.3,多字节一律大端) +#[derive(Debug, Clone, PartialEq)] +struct ChannelStatus { + /// byte0 电池状态(2 位小写 hex,位定义见协议 §3.8) + batt_status: String, + /// byte1 电源状态(2 位小写 hex)—— **与电池状态相互独立**,空仓也照实上报 + pow_status: String, + /// SOC,平台单位 ‰(硬件 1% → ×10) + soc: i64, + /// 充电电压,平台单位 mV(硬件 0.01V → ×10) + voltage: i64, + /// 充电电流,平台单位 mA(硬件 0.01A → ×10) + current: i64, + /// 电芯最高温度,平台单位 K×100(硬件 1K → ×100) + temp: i64, +} + +/// 该通道最近一次 `bat_in` 登记的电池信息(Redis 内部键)。 +/// +/// 为什么需要它:协议里 `on` 报文**不带 `info`**,起始 SOC 与电池 id 只能回溯 `bat_in`(§5.7)。 +#[derive(Debug, Clone)] +pub struct ChannelBattery { + /// 电池序列号;**可能为空串**(BMS 读不到序列号,不影响充电) + pub battery_id: String, + /// 起始 SOC(‰) + pub soc: i64, +} + +/// 解析通道的 18 字符 hex(协议 §5.3 字节布局): +/// byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3-4 电压(0.01V, 大端) +/// / byte5-6 电流(0.01A, 大端) / byte7-8 电芯最高温度(1K, 大端)。 +/// +/// 长度不是 18、含非 hex 字符(大小写不敏感)或字段缺失 → 返回 `None`, +/// 由调用方记 WARN 并跳过该通道,**绝不 panic**。 +fn parse_channel_hex(raw: &str) -> Option { + if raw.len() != CHANNEL_HEX_LEN { + return None; + } + // 按字节取 2 个 hex 字符 → u8;非 hex 或越界(非 ASCII 边界)返回 None + let byte = |idx: usize| -> Option { + u8::from_str_radix(raw.get(idx * 2..idx * 2 + 2)?, 16).ok() + }; + let be16 = |idx: usize| -> Option { + Some(u16::from_be_bytes([byte(idx)?, byte(idx + 1)?])) + }; + + Some(ChannelStatus { + batt_status: format!("{:02x}", byte(0)?), + pow_status: format!("{:02x}", byte(1)?), + soc: i64::from(byte(2)?) * 10, + voltage: i64::from(be16(3)?) * 10, + current: i64::from(be16(5)?) * 10, + temp: i64::from(be16(7)?) * 100, + }) +} + +/// 校验并规范化 2 位 hex 状态字节(协议 §3.8);缺失或非法 → `None`(调用方记 WARN) +fn normalize_hex_byte(raw: Option<&str>) -> Option { + let raw = raw?; + if raw.len() != 2 { + return None; + } + u8::from_str_radix(raw, 16).ok().map(|b| format!("{:02x}", b)) +} + +/// 解析仓体级 `id`(`仓控板-通道`,**段数必须恰好为 2**,协议 §3.7)→ (仓控板号, 通道号)。 +/// +/// 仓控板 1–15、通道 1–8;`03` 这类补零写法兼容,段数/数字非法 → `None`(调用方回 `invalid id`)。 +fn parse_compartment_id(msg: &serde_json::Value) -> Option<(i8, i8)> { + let id = msg.get("id").and_then(|v| v.as_str())?; + let parts: Vec<&str> = id.split('-').collect(); + if parts.len() != COMPARTMENT_ID_SEGMENTS { + return None; + } + let board_id: i8 = parts.first()?.trim().parse().ok()?; + let channel_id: i8 = parts.get(1)?.trim().parse().ok()?; + if board_id < 1 || channel_id < 1 { + return None; + } + Some((board_id, channel_id)) +} + +/// 按(IMEI、仓控板号、通道号)查仓体 → (仓体 ID, 柜子 ID)。 +/// 查不到返回 `None`(协议非法目标,应答 `invalid id`);DB 故障返回 `Err`(由调用方记 ERROR 后上抛)。 +async fn resolve_compartment( + mysql: &MySqlPool, + dev_id: &str, + board_id: i8, + channel_id: i8, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT comp.id, cab.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 cab.imei = ? AND cb.board_id = ? AND comp.channel_id = ?", + ) + .bind(dev_id) + .bind(board_id) + .bind(channel_id) + .fetch_optional(mysql) + .await +} + +/// 解析 `id` 并定位仓体:成功 → `(仓控板号, 通道号, 仓体 ID, 柜子 ID)`。 +/// +/// **任一步失败都会回复 `invalid id` 并返回 `None`**(调用方直接 `return Ok(())`), +/// 避免四个仓体级处理函数各写一遍相同的校验分支。 +/// 注意:DB 故障**不**回 `invalid id`(会误导设备"修正格式后重发"),只记 ERROR 后上抛给外层日志兜底。 +async fn resolve_target( + msg: &serde_json::Value, + dev_id: &str, + mysql: &MySqlPool, + redis: &ConnectionManager, + tag: &str, +) -> Result, Box> { + let (board_id, channel_id) = match parse_compartment_id(msg) { + Some(v) => v, + None => { + tracing::warn!("[{}] id 段数非法 dev_id={} id={}", tag, dev_id, msg["id"]); + let resp = ServerResponse::error_for(msg, "invalid id"); + send_response_to_device(redis, dev_id, &resp).await?; + return Ok(None); + } + }; + + let resolved = resolve_compartment(mysql, dev_id, board_id, channel_id) + .await + .map_err(|e| { + tracing::error!("[{}] 查询仓体失败 dev_id={} err={}", tag, dev_id, e); + e + })?; + + match resolved { + Some((compartment_id, cabinet_id)) => { + Ok(Some((board_id, channel_id, compartment_id, cabinet_id))) + } + None => { + tracing::warn!( + "[{}] id 对应仓体不存在 dev_id={} board={} ch={}", + tag, + dev_id, + board_id, + channel_id + ); + let resp = ServerResponse::error_for(msg, "invalid id"); + send_response_to_device(redis, dev_id, &resp).await?; + Ok(None) + } + } +} + +/// 通道级实时状态键:`device:{imei}:board:{板号}:ch:{通道号}`(板号/通道号十进制不补零) +fn channel_status_key(imei: &str, board_id: i64, channel_id: i64) -> String { + format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id) +} + +/// 通道电池信息键(`bat_in` 登记,内部键) +fn channel_battery_key(imei: &str, board_id: i8, channel_id: i8) -> String { + format!("device:{}:board:{}:ch:{}:bat", imei, board_id, channel_id) +} + +/// 已下发指令的挂起项键(device-server 下发时写入,收到回执时 HDEL 配对) +fn pending_key(imei: &str) -> String { + format!("pending:{}", imei) +} + +/// 写 `device_logs`。失败只记 ERROR、不阻断业务: +/// **平台日志写失败不该让设备重发业务报文**(协议 §3.6:超时 ≠ 未执行)。 +async fn insert_device_log(mysql: &MySqlPool, dev_id: &str, log_type: i64, content: serde_json::Value) { + let result: Result = sqlx::query( + "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( + (SELECT id FROM cabinets WHERE imei = ?), ?, ? + )", + ) + .bind(dev_id) + .bind(log_type) + .bind(content.to_string()) + .execute(mysql) + .await; + if let Err(e) = result { + tracing::error!("[log] device_logs 写入失败 dev_id={} err={}", dev_id, e); + } +} + +// --------------------------------------------------------------------------- +// status_post +// --------------------------------------------------------------------------- + +/// 处理 `status_post` —— 周期全量状态上报(协议 §5.3)。 +/// +/// 写入契约见模块头注释;`boards` 缺失或非数组时记 WARN 但**仍回 `suc:1`** +/// (状态缺失是设备侧问题,不应因平台解析失败让设备重发)。 async fn handle_status_post( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let mut conn = redis.clone(); - let device_key = format!("device:{}", dev_id); - - // 设置设备在线 + RSSI - let rssi = msg["rssi"].as_i64().unwrap_or(0); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&device_key) - .arg("online").arg("1") - .arg("rssi").arg(rssi.to_string()) - .query_async(&mut conn) - .await; - - // 设置TTL 300秒 - let _: Result<(), _> = redis::cmd("EXPIRE") - .arg(&device_key) - .arg(300_u32) - .query_async(&mut conn) - .await; + let cabinet = msg.get("cabinet"); + let rssi = msg.get("rssi").and_then(|v| v.as_i64()).unwrap_or(0); + let temp = cabinet.and_then(|c| c.get("temp")).and_then(|v| v.as_i64()).unwrap_or(0); + let meter = cabinet.and_then(|c| c.get("meter")).and_then(|v| v.as_i64()).unwrap_or(0); - // 解析 status 字典,按层级存入 - if let Some(status_obj) = msg["status"].as_object() { - for (key, value) in status_obj { - // key格式: "IMEI-仓控板ID" → board_idx - let parts: Vec<&str> = key.split('-').collect(); - if parts.len() < 2 { - continue; - } - let board_idx = parts[parts.len() - 1]; // 取最后一段作为板号 - - // 存板级状态(原始hex) - let board_key = format!("device:{}:board:{}", dev_id, board_idx); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&board_key) - .arg("status_hex").arg(value.as_str().unwrap_or("")) - .query_async(&mut conn) - .await; - - // 解析6个通道,每个通道4个hex字符(2字节) - let hex_str = value.as_str().unwrap_or(""); - for ch in 0..6 { - let start = ch * 8; // 每通道8个hex字符 = 4字节 - if start + 8 > hex_str.len() { - break; - } - let ch_hex = &hex_str[start..start + 8]; - - // 每通道4字节,按协议解析 - // 字节1: bit0=在线, bit1=充电中, bit2=充满, bit3=故障 - // 字节2: 负载类型(预留) - // 字节3-4: 预留 - let byte1 = u8::from_str_radix(&ch_hex[0..2], 16).unwrap_or(0); - - let ch_key = format!("device:{}:board:{}:ch:{}", dev_id, board_idx, ch); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&ch_key) - .arg("on").arg(if byte1 & 0x02 != 0 { "1" } else { "0" }) - .arg("full").arg(if byte1 & 0x04 != 0 { "1" } else { "0" }) - .arg("fault").arg(if byte1 & 0x08 != 0 { "1" } else { "0" }) - .arg("hex").arg(ch_hex) - .query_async(&mut conn) - .await; - } - } + let raw_status1 = cabinet.and_then(|c| c.get("status1")).and_then(|v| v.as_str()); + let raw_status2 = cabinet.and_then(|c| c.get("status2")).and_then(|v| v.as_str()); + let status1 = normalize_hex_byte(raw_status1); + let status2 = normalize_hex_byte(raw_status2); + if cabinet.is_none() || status1.is_none() || status2.is_none() { + tracing::warn!( + "[status_post] 柜体状态缺失或非法 dev_id={} status1={:?} status2={:?}", + dev_id, + raw_status1, + raw_status2 + ); + } + + // 1) 设备级 Hash:在线标记 + 信号 + 柜体状态(TTL 300 秒) + write_device_state(redis, dev_id, rssi, status1.as_deref(), status2.as_deref(), temp, meter).await; + + // 2) 供电恢复即清除停电标记:`cabinet.status1` bit0 = 0(正常)→ pow_fail_dc/ac 置 0; + // bit0 = 1 时**不动**(停电事件由 pow_fail_dc / pow_fail_ac 单独置 1,协议 §5.4) + clear_power_fail_if_normal(redis, dev_id, raw_status1).await; + + // 3) 逐板逐通道写入(数组下标 = 通道号 - 1) + match msg.get("boards").and_then(|v| v.as_array()) { + Some(boards) => { + for board in boards { + write_board_channels(redis, dev_id, board).await; + } + } + None => tracing::warn!("[status_post] boards 缺失或非数组 dev_id={}", dev_id), } - // 持久化到 MySQL let content = serde_json::json!({ "dev_id": dev_id, - "rssi": msg["rssi"], - "status": msg["status"], - "timestamp": msg["timestamp"], + "act": "status_post", + "rssi": rssi, + "cabinet": cabinet, + "board_count": msg.get("boards").and_then(|v| v.as_array()).map(|b| b.len()).unwrap_or(0), }); - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 1, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + insert_device_log(mysql, dev_id, 1, content).await; + + tracing::debug!("[status_post] 已存储 dev_id={}", dev_id); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; - tracing::debug!("[status_post] 已解析并存储 dev_id={}", dev_id); Ok(()) } +/// 写设备级 Hash `device:{imei}`(TTL 300 秒):`online` / `rssi` / 柜体状态 / 温度 / 电表。 +async fn write_device_state( + redis: &ConnectionManager, + dev_id: &str, + rssi: i64, + status1: Option<&str>, + status2: Option<&str>, + temp: i64, + meter: i64, +) { + let key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + let mut cmd = redis::cmd("HSET"); + cmd.arg(&key) + .arg("online").arg("1") + .arg("rssi").arg(rssi.to_string()) + .arg("temp").arg(temp.to_string()) + .arg("meter").arg(meter.to_string()); + if let Some(s1) = status1 { + cmd.arg("cabinet_status1").arg(s1); + } + if let Some(s2) = status2 { + cmd.arg("cabinet_status2").arg(s2); + } + if let Err(e) = cmd.query_async::<()>(&mut conn).await { + tracing::error!("[redis] HSET 设备状态失败 key={} err={}", key, e); + } + // TTL 同时承担"在线判定":300 秒无上行即判离线(协议 §2.3) + if let Err(e) = redis::cmd("EXPIRE") + .arg(&key) + .arg(DEVICE_ONLINE_TTL_SECS) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] EXPIRE 设备状态失败 key={} err={}", key, e); + } +} + +/// 供电恢复检测:`cabinet.status1` bit0 = 0(正常)→ 清除 `pow_fail_dc` / `pow_fail_ac`。 +/// +/// 为什么只在 bit0=0 时清:bit0=1 表示仍在报警,此时若清标记会把刚置位的停电状态抹掉。 +async fn clear_power_fail_if_normal(redis: &ConnectionManager, dev_id: &str, raw_status1: Option<&str>) { + let byte = match raw_status1.and_then(|s| u8::from_str_radix(s, 16).ok()) { + Some(b) => b, + None => { + tracing::warn!("[status_post] 无有效 status1,跳过停电状态清除 dev_id={}", dev_id); + return; + } + }; + if byte & CABINET_STATUS1_POWER_FAIL_MASK != 0 { + return; // bit0 = 1:仍在断电报警,保持停电标记 + } + + let key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("HSET") + .arg(&key) + .arg("pow_fail_dc").arg("0") + .arg("pow_fail_ac").arg("0") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] 清除停电标记失败 key={} err={}", key, e); + } else { + tracing::debug!("[status_post] 供电正常,已清除停电标记 dev_id={}", dev_id); + } +} + +/// 写入一块仓控板的全部通道状态;单个通道 hex 非法只跳过该通道并记 WARN,不影响其余通道。 +async fn write_board_channels(redis: &ConnectionManager, dev_id: &str, board: &serde_json::Value) { + let board_no = match board.get("board").and_then(|v| v.as_i64()) { + Some(v) => v, + None => { + tracing::warn!("[status_post] 板号缺失或非法 dev_id={} board={}", dev_id, board); + return; + } + }; + let channels = match board.get("channels").and_then(|v| v.as_array()) { + Some(v) => v, + None => { + tracing::warn!("[status_post] channels 缺失或非数组 dev_id={} board={}", dev_id, board_no); + return; + } + }; + + for (idx, raw) in channels.iter().enumerate() { + let hex = match raw.as_str() { + Some(v) => v, + None => { + tracing::warn!("[status_post] 通道状态非字符串 dev_id={} board={}", dev_id, board_no); + continue; + } + }; + let status = match parse_channel_hex(hex) { + Some(v) => v, + None => { + tracing::warn!( + "[status_post] 通道 hex 非法(需 18 位 hex)dev_id={} board={} idx={} hex={}", + dev_id, + board_no, + idx, + hex + ); + continue; + } + }; + // 数组下标 = 通道号 - 1(协议 §5.3:第 1 个元素 = 通道 1) + let channel_no = idx as i64 + 1; + write_channel_state(redis, dev_id, board_no, channel_no, &status).await; + } +} + +/// 写通道级 Hash `device:{imei}:board:{板号}:ch:{通道号}`(字段契约见模块头注释) +async fn write_channel_state( + redis: &ConnectionManager, + dev_id: &str, + board_no: i64, + channel_no: i64, + status: &ChannelStatus, +) { + let key = channel_status_key(dev_id, board_no, channel_no); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("HSET") + .arg(&key) + .arg("batt_status").arg(&status.batt_status) + .arg("pow_status").arg(&status.pow_status) + .arg("soc").arg(status.soc.to_string()) + .arg("voltage").arg(status.voltage.to_string()) + .arg("current").arg(status.current.to_string()) + .arg("temp").arg(status.temp.to_string()) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] HSET 通道状态失败 key={} err={}", key, e); + } + + // 订单电量积分(**待确认第 13 项的兜底方案**):按 电压 × 电流 × 时间 累加到该通道的 + // 订单累加器;同时跟踪本次充电最高温与"连续无电池"采样次数 + // (后者供待确认第 18 项的状态兜底:bit7 连续归零 ⇒ 判定电池已被取走)。 + // 放在这里是因为这是"每次 status_post 都能拿到电压/电流/温度/电池位"的唯一入口。 + let battery_present = u8::from_str_radix(&status.batt_status, 16) + .map(|b| b & 0x80 != 0) + .unwrap_or(true); + energy::accumulate( + redis, + dev_id, + board_no, + channel_no, + status.voltage, + status.current, + status.temp, + battery_present, + ) + .await; +} + +// --------------------------------------------------------------------------- +// 停电上报 +// --------------------------------------------------------------------------- + /// 处理停电上报 async fn handle_pow_fail( msg: &serde_json::Value, @@ -345,129 +1010,1451 @@ async fn handle_pow_fail( mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let info = msg["info"].as_str().unwrap_or("0"); - + // 停电类型由 act 本身承载(两条独立指令),不再使用 info 字段 + let act = msg["act"].as_str().unwrap_or(""); + let field = match act { + "pow_fail_dc" => "pow_fail_dc", + "pow_fail_ac" => "pow_fail_ac", + other => { + tracing::warn!("[pow_fail] 未知停电类型 act={} dev_id={}", other, dev_id); + return Ok(()); + } + }; + // 更新Redis Hash(统一主key) let redis_key = format!("device:{}", dev_id); let mut conn = redis.clone(); - - if info == "0" { - // 直流停电 - let _: Result<(), _> = redis::cmd("HSET") - .arg(&redis_key) - .arg("pow_fail_dc").arg("1") - .query_async(&mut conn) - .await; - } else if info == "1" { - // 交流停电 - let _: Result<(), _> = redis::cmd("HSET") - .arg(&redis_key) - .arg("pow_fail_ac").arg("1") - .query_async(&mut conn) - .await; + + if let Err(e) = redis::cmd("HSET") + .arg(&redis_key) + .arg(field) + .arg("1") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] HSET {} 失败 key={} err={}", field, redis_key, e); } // 同时存入device_logs let content = serde_json::json!({ "dev_id": dev_id, - "info": info, + "act": act, "timestamp": msg["timestamp"], "sub_device_id": msg["sub_device_id"], }); + insert_device_log(mysql, dev_id, 4, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 4, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!("[pow_fail] 停电上报 dev_id={} act={}", dev_id, act); + + // 回复设备 + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; - tracing::info!("[pow_fail] 停电上报 dev_id={} info={}", dev_id, info); Ok(()) } -/// 处理电池插入 +// --------------------------------------------------------------------------- +// 通道电池信息(bat_in 登记,供 on 建单取电池 id / 起始 SOC) +// --------------------------------------------------------------------------- + +/// 写该通道最近一次 `bat_in` 的电池信息(内部键,TTL 48 小时)。 +/// +/// 为什么单独存:协议里 `on` 报文**不带 `info`**,起始 SOC 只能回溯 `bat_in`(§5.7); +/// 而 `status_post` 的通道状态是**周期性**的,无法区分"插入首帧"。 +async fn save_channel_battery( + redis: &ConnectionManager, + dev_id: &str, + board_id: i8, + channel_id: i8, + battery: Option<&serde_json::Value>, + battery_id: &str, + soc: i64, +) { + let text = |name: &str| -> String { + battery + .and_then(|b| b.get(name)) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + let number = |name: &str| -> String { + battery + .and_then(|b| b.get(name)) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string() + }; + + let key = channel_battery_key(dev_id, board_id, channel_id); + let mut conn = redis.clone(); + let result: Result<(), redis::RedisError> = redis::cmd("HSET") + .arg(&key) + .arg("battery_id").arg(battery_id) + .arg("soc").arg(soc.to_string()) + .arg("info").arg(text("info")) + .arg("brand_code").arg(text("brand_code")) + .arg("model_code").arg(text("model_code")) + .arg("material_code").arg(text("material_code")) + .arg("cell_count").arg(number("cell_count")) + .arg("rated_voltage").arg(number("rated_voltage")) + .arg("rated_capacity").arg(number("rated_capacity")) + .query_async(&mut conn) + .await; + + if let Err(e) = result { + tracing::error!("[redis] HSET 通道电池信息失败 key={} err={}", key, e); + return; + } + if let Err(e) = redis::cmd("EXPIRE") + .arg(&key) + .arg(CHANNEL_BAT_TTL_SECS) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] EXPIRE 通道电池信息失败 key={} err={}", key, e); + } +} + +/// 读取该通道最近一次 `bat_in` 登记的电池信息。 +/// +/// 供设备上报 `on`(自动充电模式)与平台下发 `on`(H5 建单)取电池 id 与起始 SOC。 +/// 该通道**从未有 `bat_in` 登记**(或 Redis 故障)→ 返回 `None`,由调用方决定兜底策略。 +pub async fn load_channel_battery( + redis: &ConnectionManager, + imei: &str, + board_id: i8, + channel_id: i8, +) -> Option { + let key = channel_battery_key(imei, board_id, channel_id); + let mut conn = redis.clone(); + let values: Result>, redis::RedisError> = redis::cmd("HMGET") + .arg(&key) + .arg(&["battery_id", "soc"]) + .query_async(&mut conn) + .await; + + match values { + Ok(values) => { + // 键不存在 → 两个字段都是 None → 视为"无登记"; + // 注意 battery_id 可能是**存在的空串**(BMS 读不到序列号),那是有效登记 + let battery_id = values.first().cloned().flatten()?; + let soc = values + .get(1) + .cloned() + .flatten() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + Some(ChannelBattery { battery_id, soc }) + } + Err(e) => { + tracing::warn!("[redis] 读取通道电池信息失败 key={} err={}", key, e); + None + } + } +} + +/// 清除通道电池登记(电池已拔出) +async fn delete_channel_battery(redis: &ConnectionManager, dev_id: &str, board_id: i8, channel_id: i8) { + let key = channel_battery_key(dev_id, board_id, channel_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("DEL").arg(&key).query_async::(&mut conn).await { + tracing::error!("[redis] DEL 通道电池信息失败 key={} err={}", key, e); + } +} + +// --------------------------------------------------------------------------- +// 充电订单(建单唯一入口 = on,协议 §5.7) +// --------------------------------------------------------------------------- + +/// 建单判定的结论(纯逻辑,便于单测覆盖"一个通道只可能有一块电池"这条核心规则) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderDecision { + /// 无活跃订单 → 新建 + Create, + /// 已有活跃订单且电池未变(含任一方 id 为空串)→ 不新建 + Skip, + /// 已有活跃订单但换电池了 → 老订单强制结束后再新建 + Replace, +} + +/// 纯函数:根据「当前活跃订单的电池 id」与「本次要充电的电池 id」决定建单动作。 +/// +/// 规则依据交付文档 §5.5 / §5.7: +/// - **一个通道只可能有一块电池** —— 所以 id 变了必然是换了新电池,老订单必须先结束; +/// - **任一方为空串**(BMS 读不到序列号)→ **视为未变**,不新建(否则每次重复上报都会多一笔订单); +/// - 双方 id 相同 → 同一次充电的重复上报(设备重发 `on`)→ 不新建。 +/// +/// 抽成纯函数的原因:这段判定是**订单生命周期里最容易出错、又最需要被证明正确**的一环, +/// 而它原本内嵌在"查库 + 结单 + 建单"的异步流程里,无法单测。 +pub fn decide_order_action(active_battery: Option<&str>, new_battery: &str) -> OrderDecision { + let Some(old) = active_battery else { + return OrderDecision::Create; + }; + // 任一方未知(空串)⇒ 无法证明换了电池 ⇒ 保守视为未变 + if old.is_empty() || new_battery.is_empty() { + return OrderDecision::Skip; + } + if old == new_battery { + OrderDecision::Skip + } else { + OrderDecision::Replace + } +} + +/// 确保该通道有一笔活跃充电订单(**协议里建单的唯一入口是 `on`**,见 §5.7)。 +/// 建单判定见 [`decide_order_action`](依据"一个通道只可能有一块电池")。 +/// +/// `start_soc` 为 `None` 时回读该通道 `bat_in` 登记的 SOC 兜底,仍拿不到则记 0; +/// 平台下发 `on`(`routes/h5.rs`)与设备上报 `on`(自动充电模式)**共用本函数**。 +pub async fn ensure_charge_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + cabinet_id: i64, + board_id: i8, + channel_id: i8, + battery_id: &str, + start_soc: Option, +) -> Result<(), Box> { + let compartment_id = match find_compartment_id(pool, cabinet_id, board_id, channel_id).await? { + Some(id) => id, + None => { + tracing::warn!("[order] 仓体不存在,跳过建单 cabinet_id={} board={} ch={}", cabinet_id, board_id, channel_id); + return Ok(()); + } + }; + + let active: Option<(i64, String)> = sqlx::query_as( + "SELECT id, battery_id FROM charge_orders + WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + + // 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变) + let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id); + + match &active { + None => {} + Some(_) if decision == OrderDecision::Skip => { + tracing::debug!( + "[order] 通道已有活跃订单且电池未变,不新建 compartment_id={}", + compartment_id + ); + return Ok(()); + } + Some((order_id, old_battery)) => { + // 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。 + // 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。 + 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 + ); + } + } + + let start_soc = match start_soc { + Some(v) => v, + None => resolve_start_soc(pool, redis, cabinet_id, board_id, channel_id).await, + }; + + insert_charge_order( + pool, + redis, + imei, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + cabinet_id, + battery_id, + start_soc, + ) + .await?; + + tracing::info!( + "[order] 已建充电订单 compartment_id={} battery_id={} start_soc={}", + compartment_id, + battery_id, + start_soc + ); + + Ok(()) +} + +/// 插入一笔进行中的充电订单(`status=0`),并把该仓体/电池标记为**占用**: +/// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。 +/// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。 +/// +/// 同时**清零该通道的电量累加器**(`energy::reset`)—— 保证本单积分从 0 开始, +/// 不会把上一单的余量算进来(累加器是"通道维度"的,靠这里划清每单边界)。 +async fn insert_charge_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + compartment_id: i64, + cabinet_id: i64, + battery_id: &str, + start_soc: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_time, start_soc, status) + VALUES (?, ?, ?, NOW(), ?, 0)", + ) + .bind(compartment_id) + .bind(cabinet_id) + .bind(battery_id) + .bind(start_soc) + .execute(pool) + .await?; + energy::reset(redis, imei, board_id, channel_id).await; + mark_compartment_status(pool, compartment_id, 1).await; + if !battery_id.is_empty() { + mark_battery_status(pool, battery_id, 1).await; + } + Ok(()) +} + +/// 充电记录 `status` 取值(与 `charge_records.status` 注释一致) +pub const RECORD_STATUS_NORMAL: i8 = 0; +/// 充电记录:异常结束(换电池强制结束 / 定超时兜底) +pub const RECORD_STATUS_ABNORMAL: i8 = 1; +/// 充电记录:手动停止(用户主动拔电池) +pub const RECORD_STATUS_MANUAL_STOP: i8 = 2; + +/// **统一结算订单**:写回电量、生成充电记录、复位占用状态。**所有"订单结束"的路径都必须走它**。 +/// +/// 为什么统一:`energy` 只能在这里落库 —— 平台侧按 `电压 × 电流 × 时间` 积分 +/// (**待确认第 13 项的兜底方案**,见 [`crate::workers::energy`])。若某条结束路径漏了它, +/// 那条订单的电量就永远是 0,且充电记录页/小程序统计会缺一条。 +/// +/// 步骤: +/// 1. 从 Redis 累加器**取出并清零**本次充电的电量与最高温(原子,见 `energy::take_and_reset`) +/// 2. `UPDATE charge_orders` 收尾(带 `status = 0` 条件,避免与其它路径重复结算) +/// 3. 仅当本次真的改动了行(`rows_affected > 0`)才 `INSERT charge_records`, +/// 从而**天然幂等**:重复调用不会产生重复记录 +/// +/// `end_soc` 为 `None` 时保持库中原值不变(用 `COALESCE` 语义写在 SQL 里)。 +async fn finalize_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + compartment_id: i64, + end_soc: Option, + order_status: i8, + reason: &str, + record_status: i8, +) -> Result { + // 1) 结算电量(平台侧积分结果) + let (mwh, max_temp) = energy::take_and_reset(redis, imei, board_id, channel_id).await; + let energy_wh = energy::mwh_to_wh(mwh); + + // 2) 找到该仓体当前活跃订单(若无则说明已被别的路径结算,直接返回) + let order_id: Option = sqlx::query_scalar( + "SELECT id FROM charge_orders WHERE compartment_id = ? AND status = 0 \ + ORDER BY id DESC LIMIT 1", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + let Some(order_id) = order_id else { + return Ok(0); + }; + + let affected = sqlx::query( + "UPDATE charge_orders SET status = ?, end_time = NOW(), + end_soc = COALESCE(?, end_soc), energy = ?, reason = ? + WHERE id = ? AND status = 0", + ) + .bind(order_status) + .bind(end_soc) + .bind(energy_wh) + .bind(reason) + .bind(order_id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + return Ok(0); + } + + // 3) 生成充电记录(供后台「充电记录」页 / 导出 / 小程序今日统计使用) + // 字段从订单行直接 SELECT 过来,避免再查一次仓体与柜子 + if let Err(e) = sqlx::query( + "INSERT INTO charge_records + (compartment_id, cabinet_id, start_time, end_time, energy, max_temperature, status) + SELECT compartment_id, cabinet_id, start_time, end_time, ?, ?, ? + FROM charge_orders WHERE id = ?", + ) + .bind(energy_wh) + .bind(max_temp) + .bind(record_status) + .bind(order_id) + .execute(pool) + .await + { + // 记录缺失只记 ERROR:订单已经正确收尾,不能因为记录写失败而回滚/重试整笔业务 + tracing::error!( + "[order] 充电记录写入失败 order_id={} err={}(订单已正常收尾)", + order_id, + e + ); + } + + tracing::info!( + "[order] 订单结算 order_id={} reason={} energy={}Wh(积分 {}mWh)最高温={}", + order_id, + reason, + energy_wh, + mwh, + max_temp + ); + + Ok(affected) +} + +/// 复位 / 置位仓体占用状态(0 空闲 / 1 充电中)。失败只记 ERROR,不影响设备流程。 +async fn mark_compartment_status(pool: &MySqlPool, compartment_id: i64, status: i8) { + let result: Result = + sqlx::query("UPDATE compartments SET status = ? WHERE id = ?") + .bind(status) + .bind(compartment_id) + .execute(pool) + .await; + if let Err(e) = result { + tracing::error!( + "[order] 仓体占用状态更新失败 compartment_id={} status={} err={}", + compartment_id, + status, + e + ); + } +} + +/// 复位 / 置位电池台账占用状态(0 空闲 / 1 使用中)。失败只记 ERROR,不影响设备流程。 +async fn mark_battery_status(pool: &MySqlPool, battery_id: &str, status: i8) { + let result: Result = + sqlx::query("UPDATE batteries SET status = ? WHERE battery_id = ?") + .bind(status) + .bind(battery_id) + .execute(pool) + .await; + if let Err(e) = result { + tracing::error!( + "[order] 电池台账状态更新失败 battery_id={} status={} err={}", + battery_id, + status, + e + ); + } +} + +/// 周期性对账:把**有已结束订单、且当前没有活跃订单**的仓体/电池复位为空闲。 +/// +/// 为什么需要它:10 小时超时结单(`order_timeout`)与停电/离线期间漏报的结束事件都不会 +/// 走到 `bat_out` / `off` 的复位分支,若不兜底,仓体会永远显示"充电中"。 +/// 为什么加 `EXISTS(已结束订单)` 条件:**刚 `bat_in` 还未开始充电**的仓体/电池 +/// (`status=1` 但一笔订单都没有)不应被误复位,那属于正常的"已插入、待充电"状态。 +async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) { + let mut ticker = tokio::time::interval(Duration::from_secs(RECONCILE_INTERVAL_SECS)); + loop { + ticker.tick().await; + if let Err(e) = reconcile_usage_once(&mysql, &redis).await { + tracing::error!("[reconcile] 占用状态对账失败 err={}", e); + } + } +} + +/// 单轮对账(见 [`reconcile_usage_loop`] 的说明) +/// +/// 两件事: +/// 1. **占用状态复位**:订单已全部结束但 `compartments`/`batteries` 还标着"占用"的,置回空闲 +/// 2. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续 +/// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §5.8 位定义:0=无电池)却还挂着活跃订单 +/// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。 +/// +/// 为什么放在这里而不是 `status_post` 处理路径:这里只需扫**活跃订单**(数量少), +/// 而 `status_post` 每条要对 36 个通道都判一次"有没有活跃订单"(每柜每分钟 36 次查询,代价大)。 +async fn reconcile_usage_once( + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), sqlx::Error> { + reconcile_no_battery_orders(mysql, redis).await?; + + let compartments = sqlx::query( + "UPDATE compartments comp SET comp.status = 0 + WHERE comp.status = 1 + AND EXISTS (SELECT 1 FROM charge_orders co + WHERE co.compartment_id = comp.id AND co.status <> 0) + AND NOT EXISTS (SELECT 1 FROM charge_orders co2 + WHERE co2.compartment_id = comp.id AND co2.status = 0)", + ) + .execute(mysql) + .await?; + + let batteries = sqlx::query( + "UPDATE batteries b SET b.status = 0 + WHERE b.status = 1 + AND EXISTS (SELECT 1 FROM charge_orders co + WHERE co.battery_id = b.battery_id AND co.status <> 0) + AND NOT EXISTS (SELECT 1 FROM charge_orders co2 + WHERE co2.battery_id = b.battery_id AND co2.status = 0)", + ) + .execute(mysql) + .await?; + + if compartments.rows_affected() > 0 || batteries.rows_affected() > 0 { + tracing::info!( + "[reconcile] 占用状态已复位 compartments={} batteries={}", + compartments.rows_affected(), + batteries.rows_affected() + ); + } + Ok(()) +} + +/// **待确认第 18 项的兜底**:对"通道连续多次报无电池但订单还挂着"的订单提前结单。 +/// +/// 场景:停电/离线期间人把电池取走 —— 设备**根本没机会上报 `bat_out`**, +/// 于是订单会一直挂到 10 小时超时。这里用 `status_post` 的 `batt_status` **bit7**(有无电池) +/// 作为兜底信号,配合 [`energy::NO_BATTERY_STRIKES`] 次连续采样过滤瞬时抖动。 +/// +/// ⚠️ **已知局限(待确认第 16 项)**:若某块仓控板整体离线且 4G 层"照报全 0 的板", +/// 这里会把"板离线"误判成"电池被拔"。因此: +/// - 判定阈值取"连续多次"而非一次; +/// - 结单原因记为 `forced`(异常结束)而非 `normal`,人工可从充电记录里识别出这批异常单; +/// - 该分支只对**已有活跃订单**的通道生效(不会凭空影响空仓)。 +async fn reconcile_no_battery_orders( + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), sqlx::Error> { + // 只扫活跃订单(数量级远小于全量通道),带出通道定位所需信息 + let active: Vec<(i64, i64, String, i8, i8)> = sqlx::query_as( + "SELECT co.id, co.compartment_id, cab.imei, cb.board_id, comp.channel_id + FROM charge_orders co + JOIN compartments comp ON comp.id = co.compartment_id + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + JOIN cabinets cab ON cab.id = co.cabinet_id + WHERE co.status = 0 + LIMIT 500", + ) + .fetch_all(mysql) + .await?; + + for (order_id, compartment_id, imei, board_id, channel_id) in active { + let strikes = energy::consecutive_no_battery(redis, &imei, board_id.into(), channel_id.into()).await; + if strikes < energy::NO_BATTERY_STRIKES { + continue; + } + + // 连续无电池 ⇒ 电池已被取走,提前结单(电量取回累加器后再清) + let affected = finalize_order( + mysql, + redis, + &imei, + board_id.into(), + channel_id.into(), + compartment_id, + None, + 2, + "forced", + RECORD_STATUS_ABNORMAL, + ) + .await?; + + if affected > 0 { + mark_compartment_status(mysql, compartment_id, 0).await; + tracing::warn!( + "[reconcile] 通道连续 {} 次无电池,判定电池已被取走并提前结单 \ + order_id={} dev_id={} board={} ch={}(未收到 bat_out 的兜底)", + strikes, + order_id, + imei, + board_id, + channel_id + ); + } + } + + Ok(()) +} + +/// 供 `workers/order_timeout.rs` 调用:**按仓体结算一笔超时订单**(10 小时兜底)。 +/// +/// 存在的意义:超时结单也必须走 [`finalize_order`],否则那条订单的电量永远是 0、 +/// 充电记录也会缺一条(`order_timeout` 模块只有 `compartment_id`, +/// 所以在这里补一次"仓体 → 设备/板/通道"的定位)。 +/// +/// 返回实际结算的订单数(0 = 该仓体已无活跃订单,无需处理)。 +pub async fn finalize_timeout_order( + pool: &MySqlPool, + redis: &ConnectionManager, + compartment_id: i64, +) -> Result { + let location: Option<(String, i8, i8)> = sqlx::query_as( + "SELECT cab.imei, cb.board_id, comp.channel_id + FROM compartments comp + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + JOIN cabinets cab ON cab.id = cb.cabinet_id + WHERE comp.id = ?", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + + let Some((imei, board_id, channel_id)) = location else { + tracing::warn!( + "[order_timeout] 仓体定位失败,跳过结算 compartment_id={}", + compartment_id + ); + return Ok(0); + }; + + finalize_order( + pool, + redis, + &imei, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + None, + 1, + "timeout", + RECORD_STATUS_ABNORMAL, + ) + .await +} + +/// 按(柜子 ID、仓控板号、通道号)查仓体 ID +async fn find_compartment_id( + pool: &MySqlPool, + cabinet_id: i64, + board_id: i8, + channel_id: i8, +) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT comp.id FROM compartments comp + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + WHERE cb.cabinet_id = ? AND cb.board_id = ? AND comp.channel_id = ?", + ) + .bind(cabinet_id) + .bind(board_id) + .bind(channel_id) + .fetch_optional(pool) + .await +} + +/// 起始 SOC 兜底:查该柜 IMEI 后读通道 `bat_in` 登记值;拿不到记 0(协议 §5.7:"若拿不到则记 0") +async fn resolve_start_soc( + pool: &MySqlPool, + redis: &ConnectionManager, + cabinet_id: i64, + board_id: i8, + channel_id: i8, +) -> i64 { + let imei: Result, sqlx::Error> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") + .bind(cabinet_id) + .fetch_optional(pool) + .await; + + match imei { + Ok(Some(imei)) => match load_channel_battery(redis, &imei, board_id, channel_id).await { + Some(battery) => battery.soc, + None => 0, + }, + Ok(None) => 0, + Err(e) => { + tracing::error!("[order] 查询 IMEI 失败 cabinet_id={} err={}", cabinet_id, e); + 0 + } + } +} + +/// 登记 / 更新电池台账(`status=1 使用中`)。 +/// +/// 新协议的 `brand_code` / `model_code` 是**原值透传**的代码,此处落到台账既有的 +/// `brand` / `model` 列("代码 → 名称"的映射由平台侧维护,协议 §10 第 7 条); +/// `material_code` / `cell_count` / 额定值等新字段随 `device_logs` 全量留痕。 +/// 写入失败只记 ERROR:设备不该因平台台账写失败而重发 `bat_in`。 +async fn upsert_battery_ledger( + mysql: &MySqlPool, + battery_id: &str, + soc: i64, + battery: &serde_json::Value, +) { + let brand_code = battery.get("brand_code").and_then(|v| v.as_str()).unwrap_or(""); + let model_code = battery.get("model_code").and_then(|v| v.as_str()).unwrap_or(""); + let material_code = battery.get("material_code").and_then(|v| v.as_str()).unwrap_or(""); + let cell_count = battery.get("cell_count").and_then(|v| v.as_i64()); + let rated_voltage = battery.get("rated_voltage").and_then(|v| v.as_i64()); + let rated_capacity = battery.get("rated_capacity").and_then(|v| v.as_i64()); + + // 协议 §5.5 的电池属性按【原值】入库:`brand_code` 等是硬件代码, + // 映射出的中文名称留给 `brand` / `model`(待代码表确认后回填)—— + // 依据交付文档 §10 定稿第 7 条:4G 层透传原值、映射在平台侧完成。 + let result: Result = sqlx::query( + "INSERT INTO batteries + (battery_id, soc, brand_code, model_code, material_code, cell_count, + rated_voltage, rated_capacity, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE soc = VALUES(soc), brand_code = VALUES(brand_code), + model_code = VALUES(model_code), material_code = VALUES(material_code), + cell_count = VALUES(cell_count), rated_voltage = VALUES(rated_voltage), + rated_capacity = VALUES(rated_capacity), status = 1", + ) + .bind(battery_id) + .bind(soc) + .bind(brand_code) + .bind(model_code) + .bind(material_code) + .bind(cell_count) + .bind(rated_voltage) + .bind(rated_capacity) + .execute(mysql) + .await; + + if let Err(e) = result { + tracing::error!("[bat_in] 电池台账写入失败 battery_id={} err={}", battery_id, e); + } +} + +// --------------------------------------------------------------------------- +// bat_in / bat_out +// --------------------------------------------------------------------------- + +/// 处理 `bat_in` —— 电池插入(协议 §5.5):解析 `id` → 登记/更新电池台账 → 记 `device_logs` → 回 `suc:1`。 +/// ⛔ **`bat_in` 与订单无关,这里绝不建单**(订单唯一入口是 `on`,见 §5.7);`battery_id` 为空串时跳过台账。 async fn handle_bat_in( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { - tracing::info!("[bat_in] 电池插入 dev_id={}", dev_id); + let (board_id, channel_id, compartment_id, _cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "bat_in").await? { + Some(v) => v, + None => return Ok(()), + }; + + let battery = msg.get("battery").filter(|b| b.is_object()); + if battery.is_none() { + tracing::warn!("[bat_in] 缺少 battery 块 dev_id={} id={}", dev_id, msg["id"]); + } + let battery_id = battery + .and_then(|b| b.get("battery_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let info = battery + .and_then(|b| b.get("info")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + // `info` 与 `status_post` 的通道 hex 同格式:byte2 = SOC(1%) → 平台 ‰ + let soc = parse_channel_hex(info).map(|c| c.soc); + if !info.is_empty() && soc.is_none() { + tracing::warn!("[bat_in] info 非 18 位 hex,SOC 按 0 处理 dev_id={} info={}", dev_id, info); + } + + // 台账登记:battery_id 非空才登记(空串 = BMS 读不到序列号,不影响充电) + if battery_id.is_empty() { + tracing::info!("[bat_in] 电池序列号为空,跳过台账登记 dev_id={} board={} ch={}", dev_id, board_id, channel_id); + } else if let Some(b) = battery { + upsert_battery_ledger(mysql, battery_id, soc.unwrap_or(0), b).await; + } + + // 记录通道最近一次 bat_in:`on` 建单时按此取电池 id 与起始 SOC(§5.7) + save_channel_battery( + redis, + dev_id, + board_id, + channel_id, + battery, + battery_id, + soc.unwrap_or(0), + ) + .await; let content = serde_json::json!({ "dev_id": dev_id, - "id": msg["id"], - "timestamp": msg["timestamp"], - "bms": msg.get("bms"), + "act": "bat_in", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "compartment_id": compartment_id, + "board_id": board_id, + "channel_id": channel_id, + "battery_id": battery_id, + "battery": battery, }); + insert_device_log(mysql, dev_id, 2, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 2, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!("[bat_in] 已登记 dev_id={} board={} ch={} battery_id={} start_soc={:?}", dev_id, board_id, channel_id, battery_id, soc); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } -/// 处理电池拔出 +/// 处理 `bat_out` —— 电池拔出(协议 §5.6): +/// 结束该通道活跃订单(`reason='user_stop'`)+ 台账置空闲 + 清理通道电池登记。 async fn handle_bat_out( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { - tracing::info!("[bat_out] 电池拔出 dev_id={}", dev_id); + let (board_id, channel_id, compartment_id, _cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "bat_out").await? { + Some(v) => v, + None => return Ok(()), + }; + + let battery = msg.get("battery").filter(|b| b.is_object()); + let battery_id = battery + .and_then(|b| b.get("battery_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + // 末帧 SOC:取 `battery.info`(与通道 hex 同格式) + let end_soc = battery + .and_then(|b| b.get("info")) + .and_then(|v| v.as_str()) + .and_then(parse_channel_hex) + .map(|c| c.soc); + + // 结束该通道活跃订单(统一结算:写回平台侧积分电量 + 生成充电记录) + let updated = finalize_order( + mysql, + redis, + dev_id, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + end_soc, + 1, + "user_stop", + RECORD_STATUS_MANUAL_STOP, + ) + .await + .map_err(|e| { + tracing::error!("[bat_out] 结束订单失败 dev_id={} err={}", dev_id, e); + e + })?; + if updated == 0 { + tracing::warn!( + "[bat_out] 无活跃订单可结束 dev_id={} compartment_id={}", + dev_id, + compartment_id + ); + } + + // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) + mark_compartment_status(mysql, compartment_id, 0).await; + if !battery_id.is_empty() { + mark_battery_status(mysql, battery_id, 0).await; + } + + // 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id + delete_channel_battery(redis, dev_id, board_id, channel_id).await; let content = serde_json::json!({ "dev_id": dev_id, - "id": msg["id"], - "timestamp": msg["timestamp"], + "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; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 2, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!( + "[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}", + dev_id, + compartment_id, + battery_id, + end_soc + ); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } -/// 处理设备主动上报 off(充满自动停止) +// --------------------------------------------------------------------------- +// 设备上报 on / off(不带 suc 的形态) +// --------------------------------------------------------------------------- + +/// 处理设备上报 `off`(不带 `suc`,充满/故障自动停止)—— 结束活跃订单(`reason='normal'`)。 +/// +/// **该通道没有活跃订单时不处理,但照常回 `suc:1`**(协议 §5.7:老订单可能已被 10 小时超时结束)。 async fn handle_device_off( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { - tracing::info!("[off] 设备上报停止充电 dev_id={}", dev_id); + let (board_id, channel_id, compartment_id, _cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "off").await? { + Some(v) => v, + None => return Ok(()), + }; + + let end_soc = msg + .get("battery") + .and_then(|b| b.get("info")) + .and_then(|v| v.as_str()) + .and_then(parse_channel_hex) + .map(|c| c.soc); + + // 结束订单(统一结算:写回平台侧积分电量 + 生成充电记录) + let updated = finalize_order( + mysql, + redis, + dev_id, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + end_soc, + 1, + "normal", + RECORD_STATUS_NORMAL, + ) + .await + .map_err(|e| { + tracing::error!("[off] 结束订单失败 dev_id={} err={}", dev_id, e); + e + })?; + if updated == 0 { + tracing::info!( + "[off] 无活跃订单,不做处理 dev_id={} compartment_id={}", + dev_id, + compartment_id + ); + } else { + // 订单结束 ⇒ 占用状态复位:仓体不再"充电中",电池台账置空闲 + // (`off` 后电池仍可能留在柜内,但已不在使用中;残留由 reconcile 对账兜底) + mark_compartment_status(mysql, compartment_id, 0).await; + let battery_id = msg + .get("battery") + .and_then(|b| b.get("battery_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !battery_id.is_empty() { + mark_battery_status(mysql, battery_id, 0).await; + } + } let content = serde_json::json!({ "dev_id": dev_id, - "id": msg["id"], - "msg_id": msg["msg_id"], - "timestamp": msg["timestamp"], + "act": "off", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "compartment_id": compartment_id, + "end_soc": end_soc, }); + insert_device_log(mysql, dev_id, 2, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 2, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!( + "[off] 充电停止 dev_id={} compartment_id={} end_soc={:?}", + dev_id, + compartment_id, + end_soc + ); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } + +/// 处理设备上报 `on`(不带 `suc`,自动充电模式下设备自行开始充电)—— **这是建单入口之一**。 +/// +/// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`,协议 §5.7)。 +/// 建单失败只记 ERROR 仍回 `suc:1`:设备**已经实际开始充电**,让设备重发不会改变事实, +/// 订单登记属平台内部账务问题(协议 §3.6:超时 ≠ 未执行)。 +async fn handle_device_on( + msg: &serde_json::Value, + dev_id: &str, + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), Box> { + let (board_id, channel_id, _compartment_id, cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "on").await? { + Some(v) => v, + None => return Ok(()), + }; + + // 电池 id / 起始 SOC:回溯该通道最近一次 bat_in(拿不到则按未知电池建单) + let (battery_id, start_soc) = match load_channel_battery(redis, dev_id, board_id, channel_id).await { + Some(battery) => (battery.battery_id, Some(battery.soc)), + None => { + tracing::warn!( + "[on] 该通道无 bat_in 登记,按未知电池建单 dev_id={} board={} ch={}", + dev_id, + board_id, + channel_id + ); + (String::new(), None) + } + }; + + if let Err(e) = ensure_charge_order( + mysql, + redis, + dev_id, + cabinet_id, + board_id, + channel_id, + &battery_id, + start_soc, + ) + .await + { + tracing::error!( + "[on] 建单失败 dev_id={} board={} ch={} err={}", + dev_id, + board_id, + channel_id, + e + ); + } + + let content = serde_json::json!({ + "dev_id": dev_id, + "act": "on", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "battery_id": battery_id, + "start_soc": start_soc, + }); + insert_device_log(mysql, dev_id, 2, content).await; + + tracing::info!( + "[on] 设备上报开始充电 dev_id={} board={} ch={} battery_id={}", + dev_id, + board_id, + channel_id, + battery_id + ); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 指令回执(带 suc 的上行) +// --------------------------------------------------------------------------- + +/// 处理指令执行回执(`open` / `pow_on` / `pow_off` / `reset` / 回执形态的 `on` / `off`)。 +/// +/// 协议 §3.5:**回执是上行应答,平台一律不再回包**(双方都对"应答"回包会形成回环)。 +/// - **A4 配对**:device-server 下发时写挂起项 `pending:{dev_id}` → field = `msg_id`、value = `act`。 +/// 这里先 `HGET` 取挂起项的 `act`,与回执的 `act` **双条件命中**才 `HDEL` +/// (协议 §3.5:配对必须同时校验 `msg_id` 与 `act`;仅凭 `msg_id` 可能错配另一个交互)。 +/// 字段不存在 → 记 WARN「迟到的回执,已作废」,仍然不回包。 +/// - **60 秒语义**:挂起项没有入队时间,且 device-server 已完工(挂起项 TTL 120 秒兜底自动清理), +/// 因此这里**只按"字段是否存在"判定**:命中即视为 60 秒内到达;字段不存在即视为已作废。 +/// 120 秒内到达的"真迟到"回执会被记为已配对——回执本就只用于记录与配对,不触发任何业务动作, +/// 所以不影响正确性(这也正是协议 §3.6「超时后不重做、不补记业务动作」的要求)。 +/// - **A6 记录**:设备回执里的 `err`(`suc=0` 时的失败原因)写入 `device_logs.content`。 +async fn handle_cmd_result( + msg: &serde_json::Value, + dev_id: &str, + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), Box> { + let act = msg["act"].as_str().unwrap_or("unknown"); + let suc = msg["suc"].as_i64().unwrap_or(0); + let id = msg["id"].as_str().unwrap_or(""); + let err = msg.get("err").and_then(|v| v.as_str()).unwrap_or(""); + let msg_id = msg.get("msg_id").and_then(|v| v.as_u64()); + + match msg_id { + Some(msg_id) => pair_pending_command(redis, dev_id, act, msg_id).await, + None => tracing::warn!("[cmd_result] 回执缺少 msg_id,无法配对 dev_id={} act={}", dev_id, act), + } + + // A6:`suc=0` 的设备侧原因必须留痕(协议只要求 err 为英文短语,不限制取值) + if suc == 0 { + tracing::warn!( + "[cmd_result] 设备执行失败 dev_id={} act={} id={} err={}", + dev_id, + act, + id, + err + ); + } else { + tracing::info!("[cmd_result] dev_id={} act={} suc=1 id={}", dev_id, act, id); + } + + let content = serde_json::json!({ + "dev_id": dev_id, + "act": act, + "suc": suc, + "err": err, + "id": id, + "msg_id": msg_id, + "timestamp": msg["timestamp"], + }); + insert_device_log(mysql, dev_id, 2, content).await; + + // 协议 §3.5:收到回执【不回包】—— 这里刻意什么都不发 + Ok(()) +} + +/// A4 配对:拿回执的 `(msg_id, act)` 去挂起表 `pending:{dev_id}` 命中并删除。 +/// +/// 挂起表由 device-server 在**下发时**写入:field = `msg_id`(十进制字符串)、 +/// value = `act`、整个 hash TTL 120 秒(超时自动清理,平台侧不重传、协议 §3.6)。 +async fn pair_pending_command(redis: &ConnectionManager, dev_id: &str, act: &str, msg_id: u64) { + let key = pending_key(dev_id); + let mut conn = redis.clone(); + + // 先查 value(下发的 act),实现 §3.5 要求的 `msg_id` + `act` 双条件配对 + let pending_act: Result, redis::RedisError> = redis::cmd("HGET") + .arg(&key) + .arg(msg_id) + .query_async(&mut conn) + .await; + + let pending_act = match pending_act { + Ok(v) => v, + Err(e) => { + tracing::error!("[cmd_result] 读取挂起项失败 key={} msg_id={} err={}", key, msg_id, e); + return; + } + }; + + let matched = match pending_act.as_deref() { + None => false, + Some(pending) => pending == act, + }; + + if !matched { + match pending_act.as_deref() { + // 字段不存在:已超时作废(或无对应下发)→ 不改动挂起表,留给真正的回执 + None => tracing::warn!( + "[cmd_result] 迟到的回执,已作废 dev_id={} act={} msg_id={}", + dev_id, + act, + msg_id + ), + Some(pending) => tracing::warn!( + "[cmd_result] 回执 act 与挂起项不一致,忽略 dev_id={} recv_act={} pending_act={} msg_id={}", + dev_id, + act, + pending, + msg_id + ), + } + return; + } + + let deleted: Result = redis::cmd("HDEL") + .arg(&key) + .arg(msg_id) + .query_async(&mut conn) + .await; + match deleted { + Ok(_) => tracing::debug!( + "[cmd_result] 回执已配对 dev_id={} act={} msg_id={}", + dev_id, + act, + msg_id + ), + Err(e) => tracing::error!( + "[cmd_result] 删除挂起项失败 key={} msg_id={} err={}", + key, + msg_id, + e + ), + } +} + +// --------------------------------------------------------------------------- +// 单元测试:协议解析与跨模块契约 +// +// 覆盖本轮新增的**纯函数**(协议 §3.7 / §3.8 / §5.3 的解析规则)。 +// 依赖 DB/Redis 的业务逻辑(订单、台账、挂起配对)不在此处测 —— 属集成测试范畴。 +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 按协议 §5.3 的字节布局构造 ChannelStatus,便于断言语义正确 + fn cs(batt: &str, pow: &str, soc: i64, voltage: i64, current: i64, temp: i64) -> ChannelStatus { + ChannelStatus { + batt_status: batt.to_string(), + pow_status: pow.to_string(), + soc, + voltage, + current, + temp, + } + } + + // ---- parse_channel_hex:18 字符 hex(9 字节,大端)---------------------- + + /// 交付文档 §5.3 的示例:有电池充电中 + 电源打开 + #[test] + fn test_parse_channel_hex_doc_example_full() { + assert_eq!( + parse_channel_hex("828255152c01f40143"), + Some(cs("82", "82", 850, 54200, 5000, 32300)) + ); + } + + /// 交付文档 §5.5 `bat_in` 的 info 示例:有电池未充电 + #[test] + fn test_parse_channel_hex_doc_example_bat_in() { + assert_eq!( + parse_channel_hex("80825515180320012a"), + Some(cs("80", "82", 850, 54000, 8000, 29800)) + ); + } + + /// 交付文档 §5.6 `bat_out` 末帧示例 + #[test] + fn test_parse_channel_hex_doc_example_bat_out() { + assert_eq!( + parse_channel_hex("80005f15180000012a"), + Some(cs("80", "00", 950, 54000, 0, 29800)) + ); + } + + /// 交付文档 §5.7 `off` 末帧示例:充满 + #[test] + fn test_parse_channel_hex_doc_example_off() { + assert_eq!( + parse_channel_hex("81026415540000012a"), + Some(cs("81", "02", 1000, 54600, 0, 29800)) + ); + } + + /// **空仓**:byte0 = 00(无电池),但 byte1 照实报电源状态 —— 两个维度相互独立 + /// (这是协议定稿的明确要求,不能因为"没电池"就把电源写成 0) + #[test] + fn test_parse_channel_hex_empty_channel_keeps_power_status() { + assert_eq!( + parse_channel_hex("008200000000000000"), + Some(cs("00", "82", 0, 0, 0, 0)) + ); + } + + /// hex 大小写不敏感,且状态字节统一输出小写(与 §3.8 示例一致) + #[test] + fn test_parse_channel_hex_case_insensitive_and_lowercased() { + assert_eq!( + parse_channel_hex("828255152C01F40143"), + Some(cs("82", "82", 850, 54200, 5000, 32300)) + ); + } + + /// 长度必须恰好 18(9 字节),多/少一个字符都拒绝 + #[test] + fn test_parse_channel_hex_rejects_wrong_length() { + assert_eq!(parse_channel_hex(""), None); + assert_eq!(parse_channel_hex("828255152c01f4014"), None); // 17 + assert_eq!(parse_channel_hex("828255152c01f401433"), None); // 19 + } + + /// 非 hex 字符拒绝(且不能 panic) + #[test] + fn test_parse_channel_hex_rejects_non_hex() { + assert_eq!(parse_channel_hex("zz8255152c01f40143"), None); + assert_eq!(parse_channel_hex("82 255152c01f40143"), None); + } + + /// 多字节 UTF-8(按字节切分会落到字符中间)必须安全返回 None,不得 panic + #[test] + fn test_parse_channel_hex_handles_multibyte_without_panic() { + // 9 个 2 字节汉字 = 18 字节,长度校验能过,但按字符边界取子串会失败 + assert_eq!(parse_channel_hex("状态状态状态状态状态状态状态状态状态"), None); + } + + // ---- normalize_hex_byte:柜体状态 2 位 hex(§3.8)--------------------- + + #[test] + fn test_normalize_hex_byte_valid_and_lowercased() { + assert_eq!(normalize_hex_byte(Some("82")), Some("82".to_string())); + assert_eq!(normalize_hex_byte(Some("8A")), Some("8a".to_string())); + assert_eq!(normalize_hex_byte(Some("00")), Some("00".to_string())); + } + + #[test] + fn test_normalize_hex_byte_rejects_invalid() { + assert_eq!(normalize_hex_byte(None), None); + assert_eq!(normalize_hex_byte(Some("")), None); + assert_eq!(normalize_hex_byte(Some("8")), None); + assert_eq!(normalize_hex_byte(Some("882")), None); + assert_eq!(normalize_hex_byte(Some("ZZ")), None); + } + + // ---- parse_compartment_id:`仓控板-通道` 两段(§3.7)------------------- + + #[test] + fn test_parse_compartment_id_accepts_two_segments() { + assert_eq!(parse_compartment_id(&json!({"id": "01-3"})), Some((1, 3))); + assert_eq!(parse_compartment_id(&json!({"id": "1-3"})), Some((1, 3))); + // 通道补零也兼容 + assert_eq!(parse_compartment_id(&json!({"id": "01-03"})), Some((1, 3))); + // 量程上界:仓控板 15、通道 8 + assert_eq!(parse_compartment_id(&json!({"id": "15-8"})), Some((15, 8))); + } + + /// **段数必须恰好为 2**:柜级(0 段)与旧的含 IMEI 三段写法都必须拒绝 + #[test] + fn test_parse_compartment_id_rejects_other_segment_counts() { + assert_eq!(parse_compartment_id(&json!({"id": "1"})), None); + assert_eq!(parse_compartment_id(&json!({"id": "864720562557021"})), None); + // 旧协议写法(`IMEI-仓控板-通道`)现在是非法的,必须报 invalid id + assert_eq!(parse_compartment_id(&json!({"id": "864720562557021-01-3"})), None); + } + + #[test] + fn test_parse_compartment_id_rejects_invalid_values() { + assert_eq!(parse_compartment_id(&json!({})), None); // 缺 id + assert_eq!(parse_compartment_id(&json!({"id": 3})), None); // 非字符串 + assert_eq!(parse_compartment_id(&json!({"id": "0-3"})), None); // 仓控板 < 1 + assert_eq!(parse_compartment_id(&json!({"id": "01-0"})), None); // 通道 < 1 + assert_eq!(parse_compartment_id(&json!({"id": "a-3"})), None); // 非数字 + assert_eq!(parse_compartment_id(&json!({"id": "-3"})), None); // 空首段 + } + + // ---- Redis key 契约(跨模块:device-server 写入 / cabinets.rs 读取)----- + + /// 通道实时状态键:板号/通道号**十进制不补零**,与 `routes/cabinets.rs` 读取端一致 + #[test] + fn test_channel_status_key_format_contract() { + assert_eq!( + channel_status_key("864720562557021", 1, 3), + "device:864720562557021:board:1:ch:3" + ); + assert_eq!( + channel_status_key("864720562557021", 15, 8), + "device:864720562557021:board:15:ch:8" + ); + } + + /// 电池信息内部键(`bat_in` 写入、建单回溯 SOC 读取) + #[test] + fn test_channel_battery_key_format_contract() { + assert_eq!( + channel_battery_key("864720562557021", 1, 3), + "device:864720562557021:board:1:ch:3:bat" + ); + } + + /// 挂起项键必须与 device-server 的 `HSET pending:{dev_id}` 完全一致(否则回执配不上对) + #[test] + fn test_pending_key_format_matches_device_server() { + assert_eq!(pending_key("864720562557021"), "pending:864720562557021"); + } + + // ---- 停电标记:cabinet.status1 bit0(§3.8 / §5.4)--------------------- + + /// bit0 = 0(供电正常)→ 清除停电标记;bit0 = 1(仍在报警)→ 保留标记。 + /// 这里只校验判定所用的掩码语义(真正的 HSET 在集成测试覆盖) + #[test] + fn test_power_fail_mask_semantics() { + assert_eq!(CABINET_STATUS1_POWER_FAIL_MASK, 0x01); + // 0x00 / 0x10 / 0x82 的 bit0 均为 0 → 视为供电正常 + for byte in [0x00u8, 0x10, 0x82, 0xfe] { + assert_eq!(byte & CABINET_STATUS1_POWER_FAIL_MASK, 0, "byte={byte:#04x}"); + } + // bit0 = 1 → 仍在断电报警 + for byte in [0x01u8, 0x11, 0x83, 0xff] { + assert_ne!(byte & CABINET_STATUS1_POWER_FAIL_MASK, 0, "byte={byte:#04x}"); + } + } + + /// 通道 hex 长度常量必须与协议 `cell_count × 4 + 16` 无关的固定 18 一致 + #[test] + fn test_channel_hex_len_constant() { + assert_eq!(CHANNEL_HEX_LEN, 18); + assert_eq!(COMPARTMENT_ID_SEGMENTS, 2); + } + + // ---- 建单判定(协议 §5.5 / §5.7 的核心业务规则)--------------------- + + /// 无活跃订单 → 新建(正常路径:上一轮已由 bat_out/off 结束) + #[test] + fn test_decide_order_action_create_when_no_active_order() { + assert_eq!(decide_order_action(None, "SN123456"), OrderDecision::Create); + // 电池 id 为空也照常新建(BMS 读不到序列号不影响充电) + assert_eq!(decide_order_action(None, ""), OrderDecision::Create); + } + + /// 同一块电池重复上报 `on` → 不新建(设备重发同一次充电) + #[test] + fn test_decide_order_action_skip_same_battery() { + assert_eq!( + decide_order_action(Some("SN123456"), "SN123456"), + OrderDecision::Skip + ); + } + + /// **换电池** → 老订单必须先结束(依据:一个通道只可能有一块电池) + #[test] + fn test_decide_order_action_replace_on_different_battery() { + assert_eq!( + decide_order_action(Some("SN_OLD"), "SN_NEW"), + OrderDecision::Replace + ); + } + + /// **任一方 id 为空串 → 视为未变**(无法证明换了电池时保守处理,避免重复建单) + #[test] + fn test_decide_order_action_skip_when_either_battery_id_empty() { + assert_eq!(decide_order_action(Some(""), "SN_NEW"), OrderDecision::Skip); + assert_eq!(decide_order_action(Some("SN_OLD"), ""), OrderDecision::Skip); + assert_eq!(decide_order_action(Some(""), ""), OrderDecision::Skip); + } + + /// 仅"双方都已知且不同"才判为换电池 —— 这条边界决定会不会多建订单 + #[test] + fn test_decide_order_action_boundary_is_strict() { + let cases = [ + (None, "", OrderDecision::Create), + (Some("A"), "A", OrderDecision::Skip), + (Some("A"), "B", OrderDecision::Replace), + (Some("A"), "", OrderDecision::Skip), + (Some(""), "B", OrderDecision::Skip), + ]; + for (active, new, expect) in cases { + assert_eq!( + decide_order_action(active, new), + expect, + "active={active:?} new={new:?}" + ); + } + } +} diff --git a/software/device-server/.env.example b/software/device-server/.env.example new file mode 100644 index 0000000..0d60022 --- /dev/null +++ b/software/device-server/.env.example @@ -0,0 +1,20 @@ +# 设备 TCP 服务环境变量示例(复制为 .env.production / .env.test 后填入真实值) +# ⚠️ 真实 .env* 文件已被 gitignore,禁止提交到仓库 + +# Redis 连接(阿里云 Redis,与 api-server 同库) +REDIS_URL=redis://:CHANGE_ME@HOST:6379/5 + +# TCP 监听端口(测试 3001 / 正式 9001) +TCP_PORT=9001 + +# 本节点 ID(多节点部署时区分) +NODE_ID=device-node-1 + +# API 服务地址(节点注册/心跳用) +API_SERVER_URL=http://127.0.0.1:9000 + +# 心跳间隔(秒) +HEARTBEAT_INTERVAL=30 + +# 设备在线注册 TTL(秒) +DEVICE_REGISTER_TTL=300 \ No newline at end of file diff --git a/software/device-server/.env.production b/software/device-server/.env.production deleted file mode 100644 index dbd5a3c..0000000 --- a/software/device-server/.env.production +++ /dev/null @@ -1,17 +0,0 @@ -# Redis 连接(生产 db5) -REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5 - -# TCP 设备通讯 -TCP_PORT=9001 # 正式环境TCP - -# 节点ID -NODE_ID=node-1 - -# API服务地址 -API_SERVER_URL=http://127.0.0.1:3000 - -# 心跳间隔(秒) -HEARTBEAT_INTERVAL=60 - -# 设备注册TTL(秒) -DEVICE_REGISTER_TTL=180 diff --git a/software/device-server/.env.test b/software/device-server/.env.test deleted file mode 100644 index 681ebc6..0000000 --- a/software/device-server/.env.test +++ /dev/null @@ -1,17 +0,0 @@ -# Redis 连接(测试 db6) -REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6 - -# TCP 设备通讯 -TCP_PORT=3001 # 测试环境TCP - -# 节点ID -NODE_ID=node-1 - -# API服务地址 -API_SERVER_URL=http://127.0.0.1:3000 - -# 心跳间隔(秒) -HEARTBEAT_INTERVAL=60 - -# 设备注册TTL(秒) -DEVICE_REGISTER_TTL=180 diff --git a/software/device-server/Cargo.lock b/software/device-server/Cargo.lock index 39281a1..9f3ac83 100644 --- a/software/device-server/Cargo.lock +++ b/software/device-server/Cargo.lock @@ -540,6 +540,7 @@ dependencies = [ "redis", "serde", "serde_json", + "socket2 0.5.10", "thiserror", "tokio", "tokio-util", diff --git a/software/device-server/Cargo.toml b/software/device-server/Cargo.toml index 0298b1a..e9daf11 100644 --- a/software/device-server/Cargo.toml +++ b/software/device-server/Cargo.toml @@ -16,3 +16,4 @@ bytes = "1" futures = "0.3" dotenvy = "0.15" chrono = { version = "0.4", features = ["serde"] } +socket2 = "0.5" diff --git a/software/device-server/src/config.rs b/software/device-server/src/config.rs index 2a2459a..82b2652 100644 --- a/software/device-server/src/config.rs +++ b/software/device-server/src/config.rs @@ -32,10 +32,10 @@ impl Config { .unwrap_or_else(|_| "60".to_string()) .parse() .expect("HEARTBEAT_INTERVAL must be a valid number"), - register_ttl: std::env::var("REGISTER_TTL") - .unwrap_or_else(|_| "300".to_string()) - .parse() - .expect("REGISTER_TTL must be a valid number"), +register_ttl: std::env::var("DEVICE_REGISTER_TTL") + .unwrap_or_else(|_| "300".to_string()) + .parse() + .expect("DEVICE_REGISTER_TTL must be a valid number"), } } } diff --git a/software/device-server/src/main.rs b/software/device-server/src/main.rs index c5ecd9a..5d17224 100644 --- a/software/device-server/src/main.rs +++ b/software/device-server/src/main.rs @@ -18,6 +18,11 @@ use std::time::Duration; use config::Config; +/// 内部路由字段名:api-server 在推入 `device:commands` 时附加, +/// 指明"这条指令发给哪台设备"(值 = IMEI)。device-server 读取后**下发前摘除**, +/// 设备侧不可见(与 `_close` 同类)。 +const ROUTE_DEV_ID_FIELD: &str = "_dev_id"; + #[tokio::main] async fn main() { tracing_subscriber::fmt() @@ -37,8 +42,8 @@ async fn main() { .expect("Redis 连接失败"); tracing::info!("Redis 连接成功"); - // 初始化设备连接池 - let device_pool = tcp::connection::ConnectionPool::new(); + // 初始化设备连接池(注入 Redis:下发指令时写挂起登记 `pending:{dev_id}`) + let device_pool = tcp::connection::ConnectionPool::new(redis_conn.clone()); // 通过 Redis 注册节点信息 register_node(&redis_conn, &cfg).await; @@ -59,14 +64,15 @@ async fn main() { // 启动指令消费协程(从 Redis 读取平台指令并发送到设备) let cmd_redis = redis_conn.clone(); let cmd_pool = device_pool.clone(); + let cmd_node_id = cfg.node_id.clone(); tokio::spawn(async move { - consume_commands(cmd_redis, cmd_pool).await; + consume_commands(cmd_redis, cmd_pool, cmd_node_id).await; }); // 启动 TCP 监听 let tcp_addr = format!("{}:{}", cfg.host, cfg.tcp_port); tracing::info!("TCP 设备服务启动于 {}", tcp_addr); - tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn).await; + tcp::server::run_tcp_server(tcp_addr, device_pool, redis_conn, cfg.node_id).await; } /// 通过 Redis 注册节点信息(HSET hash 结构,与 heartbeat 保持一致) @@ -128,48 +134,124 @@ async fn heartbeat(redis: &redis::aio::ConnectionManager, node_id: &str, registe } } -/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 读取平台指令并发送到设备 +/// 消费 Redis 指令队列 — 从统一队列 `device:commands` 和本节点队列读取指令, +/// 查询路由后发送到正确节点。 async fn consume_commands( redis: redis::aio::ConnectionManager, pool: tcp::connection::ConnectionPool, + node_id: String, ) { - tracing::info!("[cmd_consumer] 启动"); + tracing::info!("[cmd_consumer] 启动 node_id={}", node_id); + let my_queue = format!("device:commands:{}", node_id); loop { let mut conn = redis.clone(); - let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP") - .arg("device:commands") - .arg(0) - .query_async(&mut conn) - .await; + // **非阻塞轮询**取一条待下发指令,先取本节点专属队列、再取统一队列。 + // + // 为什么不用 `BRPOP`(2026-09 实测结论):Redis 经 nginx stream 代理访问阿里云 RDS, + // 客户端又是多路复用的 `ConnectionManager`,这种组合下 `BRPOP` 的阻塞语义**不可靠**: + // - `BRPOP key 0`(无限阻塞)→ 代理掐掉空闲连接后**消费永久卡死**,且无任何错误日志; + // - `BRPOP key N`(有限超时)→ **"超时才醒"而非"数据到达即醒"**,实测下行指令 + // (含 `login` 应答)延迟 15~45 秒,超过设备的等待窗口后会断开,平台侧表现为 + // 「发送失败 设备不在线 → 入死信」。 + // + // 轮询把投递延迟稳定在 200ms 内,且不依赖阻塞命令语义。 + // (生产者用 `LPUSH`,这里用 `RPOP`,FIFO 顺序与原先一致。) + const POLL_INTERVAL_MS: u64 = 200; + let picked: Result, redis::RedisError> = match redis::cmd("RPOP") + .arg(&my_queue) + .query_async::>(&mut conn) + .await + { + Ok(Some(v)) => Ok(Some(v)), + // 本节点队列为空时再取统一队列(多节点场景下的广播队列) + Ok(None) => { + let mut conn2 = redis.clone(); + redis::cmd("RPOP") + .arg("device:commands") + .query_async::>(&mut conn2) + .await + } + Err(e) => Err(e), + }; - match result { - Ok((_key, cmd_json)) => { - // 从 JSON 中提取 dev_id 用于路由 - let dev_id = serde_json::from_str::(&cmd_json) - .ok() - .and_then(|v| v.get("dev_id").and_then(|d| d.as_str()).map(String::from)); + match picked { + Ok(Some(cmd_json)) => { + // 内部路由字段 `_dev_id`(由 api-server 附加):它才是"这条指令发给哪台设备"。 + // ⚠️ 不能再用协议 `id` 推导 —— 协议定稿后 `id` 只表示**动作目标** + // (柜级省略 / 一段=单板 / 两段=仓体级),**不含 IMEI**。 + let parsed: Option = serde_json::from_str(&cmd_json).ok(); + let dev_id = parsed + .as_ref() + .and_then(|v| v.get(ROUTE_DEV_ID_FIELD).and_then(|d| d.as_str())) + .map(String::from); let dev_id = match dev_id { - Some(id) if !id.is_empty() => id, + Some(s) if !s.is_empty() => s, _ => { - tracing::warn!("[cmd_consumer] 指令缺少 dev_id,丢弃"); + tracing::warn!( + "[cmd_consumer] 指令缺少内部路由字段 {},丢弃:{}", + ROUTE_DEV_ID_FIELD, + cmd_json + ); continue; } }; - // 发送到设备 - match pool.send_command(&dev_id, &cmd_json).await { - Ok(()) => { - tracing::debug!("[cmd] 已发送到设备 dev_id={}", dev_id); + // 协议 `id`(动作目标)—— 仅用于日志;柜级指令不带此字段 + let proto_id = parsed + .as_ref() + .and_then(|v| v.get("id").and_then(|d| d.as_str())) + .unwrap_or("-") + .to_string(); + + // 查询路由:HGET device:{dev_id} node_id + let mut route_conn = redis.clone(); + let target_node: Option = redis::cmd("HGET") + .arg(format!("device:{}", dev_id)) + .arg("node_id") + .query_async(&mut route_conn) + .await + .unwrap_or(None); + + match target_node { + Some(ref nid) if nid != &node_id => { + // 设备在其他节点,转发到该节点专属队列 + tracing::debug!("[cmd] 转发 dev_id={} 到节点 {}", dev_id, nid); + let mut reroute_conn = redis.clone(); + let _: Result<(), _> = redis::cmd("LPUSH") + .arg(format!("device:commands:{}", nid)) + .arg(&cmd_json) + .query_async(&mut reroute_conn) + .await; } - Err(e) => { - tracing::warn!("[cmd] 发送失败 dev_id={} err={}", dev_id, e); + _ => { + // 本节点设备或无路由(路由不存在时也尝试本地发送,兼容单节点场景) + match pool.send_with_msgid(&dev_id, &cmd_json).await { + Ok(()) => { + tracing::debug!("[cmd] 已发送到设备 dev_id={} id={}", dev_id, proto_id); + } + Err(e) => { + tracing::error!("[cmd] 发送失败 dev_id={} err={} 已入死信", dev_id, e); + // ponytail: 丢入死信队列,不重试(重试语义和次数待业务明确) + let mut dl_conn = redis.clone(); + let _: Result<(), _> = redis::cmd("LPUSH") + .arg("device:commands:dead") + .arg(&cmd_json) + .query_async(&mut dl_conn) + .await; + } + } } } } + Ok(None) => { + // 两个队列都为空:短暂让出,避免空转打满 CPU(延迟上限 = 本间隔) + tracing::trace!("[cmd_consumer] 队列为空,继续等待"); + tokio::time::sleep(Duration::from_millis(POLL_INTERVAL_MS)).await; + } Err(e) => { - tracing::error!("[cmd_consumer] BRPOP 错误: {}", e); + tracing::error!("[cmd_consumer] RPOP 错误: {}", e); tokio::time::sleep(Duration::from_secs(1)).await; } } diff --git a/software/device-server/src/tcp/connection.rs b/software/device-server/src/tcp/connection.rs index 02d1c09..8e292cb 100644 --- a/software/device-server/src/tcp/connection.rs +++ b/software/device-server/src/tcp/connection.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] //! 设备连接池管理。 //! //! 每台设备登录成功后分配一个 `DeviceConnection`,包含命令发送通道。 @@ -7,43 +6,65 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::RwLock; +use tokio::sync::mpsc; + +/// 敏感指令列表(未认证设备不得执行) +const SENSITIVE_ACTS: &[&str] = &["open", "pow_on", "pow_off", "reset", "auth_str"]; + +/// 挂起登记(`pending:{dev_id}`)的 TTL(秒)。 +/// +/// 取值 120 = 协议 60 秒等待窗口的 2 倍:既保证超时后自动作废, +/// 又留出余量避免登记项在回执到达前先过期(api-server 命中时会 `HDEL`)。 +const PENDING_TTL_SECS: u32 = 120; /// 单台设备的连接状态。 -#[allow(dead_code)] pub struct DeviceConnection { - pub dev_id: String, /// 向设备发送指令的通道。 - pub tx: mpsc::UnboundedSender, - /// 连接建立时间。 - pub connected_at: Instant, + pub tx: mpsc::Sender, /// 最后活跃时间(收到任意消息时更新)。 pub last_seen: Instant, /// 是否已完成登录验证。 pub authenticated: bool, + /// 下行指令 msg_id 自增计数(**每连接独立**,按协议 §3.4 从 `1` 起、`0` 不使用; + /// 连接重建后重新计数) + next_msg_id: u32, } impl DeviceConnection { - pub fn new(dev_id: String, tx: mpsc::UnboundedSender) -> Self { + /// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。 + pub fn new(tx: mpsc::Sender) -> Self { let now = Instant::now(); - Self { dev_id, tx, connected_at: now, last_seen: now, authenticated: false } + // 初值 1:协议定稿「两个方向各自独立计数,都每连接从 1 起自增,0 不使用」; + // 且 api-server 多处用 `unwrap_or(0)` 兜底,用 0 起会与"缺号"混淆 + Self { tx, last_seen: now, authenticated: false, next_msg_id: 1 } } /// 标记活跃时间。 pub fn touch(&mut self) { self.last_seen = Instant::now(); } + + /// 获取下一个 msg_id(从 `1` 起自增,`0` 不使用)。 + fn alloc_msg_id(&mut self) -> u32 { + let id = self.next_msg_id; + self.next_msg_id += 1; + id + } } /// 线程安全的设备连接池。 #[derive(Clone)] pub struct ConnectionPool { inner: Arc>>, + /// 挂起登记用的 Redis 连接(`pending:{dev_id}`)。 + /// 仅用于辅助登记,**任何 Redis 失败都不得影响指令下发**。 + redis: redis::aio::ConnectionManager, } impl ConnectionPool { - pub fn new() -> Self { - Self { inner: Arc::new(RwLock::new(HashMap::new())) } + pub fn new(redis: redis::aio::ConnectionManager) -> Self { + Self { inner: Arc::new(RwLock::new(HashMap::new())), redis } } /// 注册设备连接。如果已存在则替换旧连接。 @@ -58,22 +79,89 @@ impl ConnectionPool { map.remove(dev_id); } - /// 向指定设备发送 JSON 指令。 - /// 返回 `Ok(())` 表示已投递到通道;设备可能因缓冲区满而断开。 - pub async fn send_command(&self, dev_id: &str, json: &str) -> Result<(), &'static str> { - let map = self.inner.read().await; - match map.get(dev_id) { - Some(conn) => conn.tx.send(json.to_string()).map_err(|_| "通道已关闭"), - None => Err("设备不在线"), - } - } - - /// 查询设备是否在线且已验证。 - pub async fn is_online(&self, dev_id: &str) -> bool { + /// 查询该设备连接是否已通过 `login` 认证。 + /// 连接不存在或尚未认证时返回 `false`。 + pub async fn is_authenticated(&self, dev_id: &str) -> bool { let map = self.inner.read().await; map.get(dev_id).map(|c| c.authenticated).unwrap_or(false) } + /// 向指定设备发送指令并注入自增 msg_id。 + /// 每连接独立计数(从 `1` 起),重连后重新计数。 + /// 未认证连接只能接收非敏感指令(如 status 查询)。 + /// + /// 区分两类出站消息(判据 = **有没有 `suc`**,见交付文档 §3.5): + /// - **应答**(ServerResponse,**带 `suc`**,同时也带 `act`):设备请求的回复, + /// **透传原始 `msg_id`**(协议要求"应答的 msg_id 必须等于请求的 msg_id") + /// - **指令**(DeviceCommand,**不带 `suc`**):平台主动下发,**注入自增 `msg_id`** + /// + /// 指令在注入 `msg_id` 后写入 Redis 挂起表 `pending:{dev_id}`, + /// 供 api-server 把设备回执配对回具体指令(见 `register_pending`)。 + pub async fn send_with_msgid(&self, dev_id: &str, json: &str) -> Result<(), &'static str> { + let pending = self.prepare_outbound(dev_id, json).await?; + + // 挂起登记放在连接池锁**之外**:Redis 往返不应阻塞其他连接的读写。 + // 指令已投递到写通道后才登记;失败只记 WARN(登记是辅助功能,不能拖累下发)。 + if let Some((msg_id, act)) = pending { + register_pending(&self.redis, dev_id, msg_id, &act).await; + } + Ok(()) + } + + /// 出站报文的校验与投递:跑安全门控、按需注入 `msg_id`,最后写入设备写通道。 + /// + /// 返回 `Some((msg_id, act))` 表示这是**指令**(需登记挂起项); + /// 返回 `None` 表示这是**应答**(无平台挂起项,不登记)。 + async fn prepare_outbound( + &self, + dev_id: &str, + json: &str, + ) -> Result, &'static str> { + let mut map = self.inner.write().await; + let conn = map.get_mut(dev_id).ok_or("设备不在线")?; + let mut cmd: serde_json::Value = + serde_json::from_str(json).map_err(|_| "JSON 解析失败")?; + + // 「是否应答」的判据:**只有应答带 `suc`**(协议 §3.5)。 + // 不能再用 `act` 判断 —— 协议定稿后应答也必带 `act`(§3.1)。 + let is_response = cmd.get("suc").is_some(); + + // 检查敏感指令门控(**只拦指令**,应答不拦) + // 否则 `auth_str` 的应答会被自己拦掉 → 首次配网永久死锁 + if !conn.authenticated && !is_response { + if let Some(act) = cmd.get("act").and_then(|a| a.as_str()) { + if SENSITIVE_ACTS.contains(&act) { + tracing::warn!("[安全] 拦截敏感指令 act={} dev_id={} 连接未认证", act, dev_id); + return Err("连接未认证,拒绝敏感指令"); + } + } + } + + // 认证触发:login 成功响应的唯一特征是 spec 字段存在 + suc==1 + // (2026-09 协议定稿:login 应答的柜子规格字段由 cell 改为 spec,此处必须同步) + if cmd.get("spec").is_some() { + if let Some(suc) = cmd.get("suc").and_then(|s| s.as_u64()) { + if suc == 1 { + conn.authenticated = true; + tracing::info!("[安全] 设备 {} 登录认证通过", dev_id); + } + } + } + + let mut pending = None; + if !is_response { + // 平台主动下发的指令,注入自增 msg_id,并登记挂起项 + let msg_id = conn.alloc_msg_id(); + cmd["msg_id"] = serde_json::json!(msg_id); + let act = cmd.get("act").and_then(|a| a.as_str()).unwrap_or("").to_string(); + pending = Some((msg_id, act)); + } + // else: 应答,保持原始 msg_id 不动(透传设备发起的请求的编号) + + conn.tx.try_send(cmd.to_string()).map_err(|_| "通道已关闭或已满")?; + Ok(pending) + } + /// 更新设备活跃时间。 pub async fn touch(&self, dev_id: &str) { let mut map = self.inner.write().await; @@ -81,33 +169,86 @@ impl ConnectionPool { conn.touch(); } } +} - /// 标记设备已验证。 - pub async fn set_authenticated(&self, dev_id: &str) { - let mut map = self.inner.write().await; - if let Some(conn) = map.get_mut(dev_id) { - conn.authenticated = true; +/// 写入挂起登记:`HSET pending:{dev_id} {msg_id} {act}` + `EXPIRE pending:{dev_id} 120`。 +/// +/// **为什么由 device-server 登记**:`msg_id` 是本服务分配的,api-server 只有拿到 +/// 「`msg_id` → `act`」映射,才能把设备回执配对回具体的下发指令(§3.5 要求 +/// `msg_id` + `act` 双条件配对);平台对每条指令只等 60 秒,超时即作废。 +/// +/// 失败处理:Redis 异常只记 WARN —— 挂起登记是**辅助功能**, +/// 绝不能让一条已成功投递到设备的指令因为登记失败而报错(那会误导上层重发)。 +async fn register_pending( + redis: &redis::aio::ConnectionManager, + dev_id: &str, + msg_id: u32, + act: &str, +) { + if act.is_empty() { + tracing::warn!("[pending] 指令缺少 act,跳过挂起登记 dev_id={} msg_id={}", dev_id, msg_id); + return; + } + + let key = format!("pending:{}", dev_id); + let mut conn = redis.clone(); + let result: Result<(), _> = redis::cmd("HSET") + .arg(&key) + .arg(msg_id) + .arg(act) + .query_async(&mut conn) + .await; + + match result { + Ok(_) => tracing::debug!("[pending] 已登记 dev_id={} msg_id={} act={}", dev_id, msg_id, act), + Err(e) => { + tracing::warn!( + "[pending] 挂起登记失败 dev_id={} msg_id={} err={}(指令已下发,仅影响配对)", + dev_id, + msg_id, + e + ); + return; } } - /// 清理超时未活跃的连接(心跳检测)。 - pub async fn cleanup_timeout(&self, timeout: std::time::Duration) -> Vec { - let mut map = self.inner.write().await; - let now = Instant::now(); - let mut removed = Vec::new(); - map.retain(|dev_id, conn| { - if now.duration_since(conn.last_seen) > timeout { - removed.push(dev_id.clone()); - false - } else { - true - } - }); - removed - } - - /// 返回当前在线设备数量。 - pub async fn online_count(&self) -> usize { - self.inner.read().await.len() + // ponytail: HSET 不支持同时携带 TTL,需单独 EXPIRE(与 main.rs 的节点注册写法一致) + let mut ttl_conn = redis.clone(); + let ttl_result: Result<(), _> = redis::cmd("EXPIRE") + .arg(&key) + .arg(PENDING_TTL_SECS) + .query_async(&mut ttl_conn) + .await; + if let Err(e) = ttl_result { + tracing::warn!("[pending] 挂起登记 TTL 设置失败 dev_id={} err={}", dev_id, e); + } +} + +// --------------------------------------------------------------------------- +// 单元测试:下行编号与内部字段(协议 §3.4 / §3.5) +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc; + + fn conn() -> DeviceConnection { + let (tx, _rx) = mpsc::channel(4); + DeviceConnection::new(tx) + } + + /// 协议定稿:两个方向各自独立计数,**都每连接从 `1` 起自增**(`0` 不使用) + #[test] + fn test_alloc_msg_id_starts_at_one_and_increments() { + let mut c = conn(); + assert_eq!(c.alloc_msg_id(), 1); + assert_eq!(c.alloc_msg_id(), 2); + assert_eq!(c.alloc_msg_id(), 3); + } + + /// 新建连接默认未认证 —— 未认证时敏感指令会被拦、业务报文会被丢弃 + #[test] + fn test_new_connection_is_unauthenticated() { + assert!(!conn().authenticated); } } diff --git a/software/device-server/src/tcp/handler.rs b/software/device-server/src/tcp/handler.rs index 536e4e5..a232304 100644 --- a/software/device-server/src/tcp/handler.rs +++ b/software/device-server/src/tcp/handler.rs @@ -1,6 +1,8 @@ //! 设备消息处理逻辑(轻量化)。 //! -//! 设备服务只处理通信和指令解析,不处理业务逻辑: +//! 设备服务只做**协议透传**,不处理业务逻辑: +//! - `status_post` 的通道数据在设备侧已是 **18 字符 hex 明文**,本服务**原样透传**, +//! 不再做任何拆解/位域解析(解析归 api-server 的业务层) //! - 所有设备上报消息统一转发到 Redis `device:reports` 队列 //! - 不写 DB、不验证签名、不处理业务规则 //! - auth_str/login 等协议级消息也由 API 服务处理后通过 `device:commands` 回复 @@ -8,34 +10,57 @@ use super::connection::ConnectionPool; use super::protocol::DeviceMessage; -/// 分发消息:统一转发到 Redis 上报队列。 +/// 分发消息:补写设备身份后转发到 Redis 上报队列。 /// -/// 设备服务不做任何业务处理,仅负责解析 JSON 并转发。 +/// `current_dev_id` = 本 TCP 连接绑定的设备身份(由 `auth_str`/`login` 建立)。 +/// 为什么需要它:新协议里业务上行**不带 `dev_id`**(设备身份由连接确定), +/// 而 api-server 依赖 `dev_id` 识别设备,故转发前必须补上。 +/// +/// 本服务**不直接回复设备**:应答由 api-server 经 Redis `device:commands` 下发, +/// 因此这里没有返回值(历史上曾返回 `Option` 但恒为 `None`, +/// 那个结构体副本已删除,避免与 api-server 的真实定义漂移)。 pub async fn handle_message( - msg: DeviceMessage, + mut msg: DeviceMessage, + current_dev_id: Option<&str>, _pool: &ConnectionPool, redis: &redis::aio::ConnectionManager, -) -> Option { +) { + attach_dev_id(&mut msg, current_dev_id); + // 所有消息统一转发到 Redis,由 API 服务的 report_worker 处理 forward_to_redis(redis, &msg).await; // 不直接回复设备,等待 API 服务通过 device:commands 队列回复 - None +} + +/// 把连接绑定的设备身份写入待转发报文。 +/// +/// 以**连接**为准(而非报文自带值):设备身份由 TCP 连接确定, +/// 这样即使设备漏带/错带 `dev_id`,api-server 侧也不会认错设备。 +fn attach_dev_id(msg: &mut DeviceMessage, current_dev_id: Option<&str>) { + match current_dev_id { + Some(dev_id) if !dev_id.is_empty() => msg.dev_id = Some(dev_id.to_string()), + // 理论上不会出现:未登录连接的业务报文已被 server.rs 的门控丢弃。 + // 此时仍照常转发(不丢数据),仅记 WARN 便于排查。 + _ => tracing::warn!( + "[forward] 连接未绑定 dev_id,转发报文将不含设备身份, act={}", + msg.act + ), + } } /// 将设备消息转发到 Redis 统一上报队列 `device:reports` -async fn forward_to_redis( - redis: &redis::aio::ConnectionManager, - msg: &DeviceMessage, -) { - if msg.dev_id.is_none() { - tracing::warn!("[forward] 消息缺少 dev_id, act={}", msg.act); - return; - } +async fn forward_to_redis(redis: &redis::aio::ConnectionManager, msg: &DeviceMessage) { + let json_str = match serde_json::to_string(msg) { + Ok(json) => json, + Err(e) => { + // 序列化失败说明报文结构异常,记 ERROR 并丢弃(不推送半截数据) + tracing::error!("[forward] 消息序列化失败 act={} err={}", msg.act, e); + return; + } + }; - let json_str = serde_json::to_string(msg).unwrap_or_default(); let mut conn = redis.clone(); - let result: Result<(), _> = redis::cmd("LPUSH") .arg("device:reports") .arg(&json_str) diff --git a/software/device-server/src/tcp/protocol.rs b/software/device-server/src/tcp/protocol.rs index 9e441a1..37d2632 100644 --- a/software/device-server/src/tcp/protocol.rs +++ b/software/device-server/src/tcp/protocol.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] //! 设备通讯协议消息类型定义与序列化。 //! //! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。 @@ -12,8 +11,15 @@ use serde_json::Value; /// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。 #[derive(Debug, Deserialize, Serialize)] pub struct DeviceMessage { - /// 动作类型:login / auth_str / status_post / pow_fail / off + /// 动作类型(上行全集,见交付文档 §4.1): + /// auth_str / login / status_post / pow_fail_dc / pow_fail_ac / bat_in / bat_out / on / off, + /// 以及下行指令的执行回执 open / pow_on / pow_off / reset pub act: String, + /// 设备身份(柜控板 IMEI)。 + /// + /// **新协议里只有 `auth_str` / `login` 携带 `dev_id`**(用于把 TCP 连接与设备绑定,§3.1); + /// **其余上行一律不带** —— 连接建立后设备身份即由该 TCP 连接确定。 + /// 内部转发到 Redis `device:reports` 时由 handler 补写该字段(api-server 依赖它识别设备)。 pub dev_id: Option, pub msg_id: Option, /// 登录签名时的时间戳(秒级) @@ -22,63 +28,120 @@ pub struct DeviceMessage { pub sign: Option, /// 485 子设备 ID(仓控板 ID 1-6) pub sub_device_id: Option, - /// 状态上报时的 HEX 数据 - pub status: Option, /// 4G 信号强度 pub rssi: Option, + /// `status_post` 的**柜体块**:`{status1, status2, temp, meter}`(见交付文档 §5.3) + #[serde(skip_serializing_if = "Option::is_none")] + pub cabinet: Option, + /// `status_post` 的**仓控板数组**:`[{board, channels: ["<18hex>", ...]}]`(见交付文档 §5.3)。 + /// + /// 通道数据在设备侧已是 **18 字符 hex 明文**,本服务**只透传、不拆解位域**。 + #[serde(skip_serializing_if = "Option::is_none")] + pub boards: Option, + /// `bat_in` / `bat_out` / `off` 的**电池块**(见交付文档 §5.5 / §5.6 / §5.7): + /// `{battery_id, brand_code, model_code, material_code, cell_count, + /// rated_voltage, rated_capacity, info, cells}` + #[serde(skip_serializing_if = "Option::is_none")] + pub battery: Option, /// 未在顶层定义的扩展字段 #[serde(flatten)] pub extra: Option, } -/// 平台回复设备的消息。 -#[derive(Debug, Serialize)] -pub struct ServerResponse { - /// 1=成功,0=失败 - pub suc: u8, - pub msg_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub err: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_str: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} +// ───────────────────────────────────────────────────────────────────────────── +// 关于「平台回复设备」与「平台下发指令」两类报文: +// +// 本服务**只做透传** —— 回复走 Redis `device:commands`(由 api-server 用 +// `api-server/src/protocol.rs::ServerResponse` 构造),指令同样由 api-server 用 +// `api-server/src/commands.rs::DeviceCommand` 构造,本服务按 `serde_json::Value` +// 读写、只负责"摘内部字段 + 注入 msg_id/登记挂起 + 投递"。 +// +// 因此这里**不再保留** `ServerResponse` / `DeviceCommand` 的结构体副本: +// 有了副本反而会与 api-server 的真实定义漂移(历史上 api-server 里那份 +// `DeviceMessage` 副本就是这样带着废弃字段误导了很久)。 +// ───────────────────────────────────────────────────────────────────────────── -impl ServerResponse { - /// 创建成功响应。 - pub fn success(msg_id: Option) -> Self { - Self { suc: 1, msg_id, err: None, auth_str: None, data: None } +// --------------------------------------------------------------------------- +// 单元测试:上行报文反序列化(协议 §3.1 / §5.3 / §5.5 / §3.5) +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::DeviceMessage; + + /// `status_post` 新结构:`cabinet` + `boards[].channels[]`(18 字符 hex 透传) + #[test] + fn test_device_message_status_post_new_shape() { + let raw = r#"{"act":"status_post","msg_id":3,"rssi":85, + "cabinet":{"status1":"00","status2":"00","temp":29800,"meter":125500}, + "boards":[{"board":1,"channels":["828255152c01f40143","008200000000000000"]}]}"#; + let msg: DeviceMessage = serde_json::from_str(raw).unwrap(); + assert_eq!(msg.act, "status_post"); + assert_eq!(msg.rssi, Some(85)); + assert_eq!(msg.cabinet.as_ref().unwrap()["status1"], "00"); + let boards = msg.boards.as_ref().unwrap().as_array().unwrap(); + assert_eq!(boards.len(), 1); + assert_eq!(boards[0]["board"], 1); + assert_eq!(boards[0]["channels"][1], "008200000000000000"); + // 新协议业务上行不带 dev_id + assert!(msg.dev_id.is_none()); } - /// 创建失败响应。 - pub fn error(msg_id: Option, err: impl Into) -> Self { - Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None, data: None } + /// `bat_in` 的 `battery` 块(属性 + 18hex info + cells) + #[test] + fn test_device_message_bat_in_battery_block() { + let raw = r#"{"act":"bat_in","msg_id":5,"id":"01-3","battery":{ + "battery_id":"SN123456","brand_code":"1","cell_count":15, + "info":"80825515180320012a","cells":"0e100e15"}}"#; + let msg: DeviceMessage = serde_json::from_str(raw).unwrap(); + let battery = msg.battery.as_ref().unwrap(); + assert_eq!(battery["battery_id"], "SN123456"); + assert_eq!(battery["cell_count"], 15); + assert_eq!(battery["info"], "80825515180320012a"); + // `id` 未在结构体声明 → 必须落进 `extra`,否则 api-server 拿不到动作目标 + let extra = msg.extra.as_ref().unwrap(); + assert_eq!(extra["id"], "01-3"); } - /// 序列化为 JSON 字符串(紧凑格式,无多余空格)。 - pub fn to_json(&self) -> String { - serde_json::to_string(self).expect("ServerResponse 序列化不应失败") - } -} - -/// 平台下发至设备的指令。 -#[derive(Debug, Serialize, Deserialize)] -pub struct DeviceCommand { - pub act: String, - pub msg_id: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub dev_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sub_device_id: Option, - /// 扩展参数(开门指令、充电参数等) - #[serde(flatten)] - pub params: Option, -} - -impl DeviceCommand { - /// 序列化为 JSON 字符串(紧凑格式)。 - pub fn to_json(&self) -> String { - serde_json::to_string(self).expect("DeviceCommand 序列化不应失败") + /// **关键契约**:设备回执的 `suc` 必须原样落到 `extra`。 + /// api-server 用 `msg.get("suc").is_some()` 判定"回执 vs 设备上报"(协议 §3.5), + /// 若这里把 `suc` 丢掉,回执会被误当成新请求处理。 + #[test] + fn test_device_message_receipt_suc_preserved_in_extra() { + let raw = r#"{"act":"on","msg_id":1,"id":"01-3","suc":1}"#; + let msg: DeviceMessage = serde_json::from_str(raw).unwrap(); + let extra = msg.extra.as_ref().unwrap(); + assert_eq!(extra["suc"], 1); + assert_eq!(extra["id"], "01-3"); + } + + /// 有 `suc=0` + `err` 的失败回执:`err` 也必须透传(api-server 要落库,见 A6) + #[test] + fn test_device_message_failed_receipt_error_preserved() { + let raw = r#"{"act":"open","msg_id":1,"suc":0,"err":"door lock not engaged"}"#; + let msg: DeviceMessage = serde_json::from_str(raw).unwrap(); + let extra = msg.extra.as_ref().unwrap(); + assert_eq!(extra["suc"], 0); + assert_eq!(extra["err"], "door lock not engaged"); + } + + /// `auth_str` / `login` 是仅有的两条带 `dev_id` 的上行 + #[test] + fn test_device_message_auth_str_carries_dev_id() { + let raw = r#"{"act":"auth_str","msg_id":1,"dev_id":"864720562557021"}"#; + let msg: DeviceMessage = serde_json::from_str(raw).unwrap(); + assert_eq!(msg.dev_id.as_deref(), Some("864720562557021")); + } + + /// 停电上报无附加字段(协议定稿:`pow_fail_dc` / `pow_fail_ac` 均不带 payload) + #[test] + fn test_device_message_pow_fail_has_no_payload() { + for act in ["pow_fail_dc", "pow_fail_ac"] { + let raw = format!(r#"{{"act":"{act}","msg_id":4}}"#); + let msg: DeviceMessage = serde_json::from_str(&raw).unwrap(); + assert_eq!(msg.act, act); + assert!(msg.cabinet.is_none()); + assert!(msg.boards.is_none()); + assert!(msg.battery.is_none()); + } } } diff --git a/software/device-server/src/tcp/server.rs b/software/device-server/src/tcp/server.rs index e2a1747..fc4b9e4 100644 --- a/software/device-server/src/tcp/server.rs +++ b/software/device-server/src/tcp/server.rs @@ -1,52 +1,46 @@ //! TCP 服务端:监听端口、接收设备连接、按 LF 分割 JSON 消息。 use futures::StreamExt; -use std::time::Duration; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::mpsc; -use tokio::time; use tokio_util::codec::{FramedRead, LinesCodec}; use super::connection::{ConnectionPool, DeviceConnection}; use super::handler::handle_message; use super::protocol::DeviceMessage; -/// 心跳超时:5000ms 未收到消息则断开连接。 -const HEARTBEAT_TIMEOUT: Duration = Duration::from_millis(5000); -/// 清理超时连接的间隔。 -const CLEANUP_INTERVAL: Duration = Duration::from_secs(10); +/// 未登录连接**允许**发送的唯一两个 `act`(协议定稿:其余指令必须先 `login` 成功)。 +const UNAUTH_ALLOWED_ACTS: &[&str] = &["auth_str", "login"]; + +/// 连接建立后必须在此秒数内完成 `login` 认证,否则平台主动断开。 +/// 依据:协议规定"login 成功这个 TCP 才可以保持"。 +const UNAUTH_TIMEOUT_SECS: u64 = 30; + +/// 未登录超时的巡检间隔(秒)。 +const UNAUTH_CHECK_INTERVAL_SECS: u64 = 5; /// 启动 TCP 监听循环。 pub async fn run_tcp_server( addr: String, pool: ConnectionPool, redis: redis::aio::ConnectionManager, + node_id: String, ) { let listener = TcpListener::bind(&addr) .await .expect("TCP 端口绑定失败"); tracing::info!("TCP 设备服务启动: {}", addr); - // 后台清理过期连接 - let cleanup_pool = pool.clone(); - tokio::spawn(async move { - let mut interval = time::interval(CLEANUP_INTERVAL); - loop { - interval.tick().await; - let removed = cleanup_pool.cleanup_timeout(HEARTBEAT_TIMEOUT).await; - for dev_id in &removed { - tracing::warn!("[心跳超时] 断开 dev_id={}", dev_id); - } - } - }); - loop { match listener.accept().await { Ok((stream, peer)) => { tracing::info!("[新连接] peer={}", peer); + // ponytail: 不需要设 TCP keepalive,我们是 server,设备才是 client。 + // 设备每 60s 发 status_post 就是应用层保活。 let pool = pool.clone(); let redis = redis.clone(); - tokio::spawn(handle_connection(stream, peer, pool, redis)); + let nid = node_id.clone(); + tokio::spawn(handle_connection(stream, peer, pool, redis, nid)); } Err(e) => { tracing::error!("accept 失败: {}", e); @@ -61,15 +55,19 @@ async fn handle_connection( peer: std::net::SocketAddr, pool: ConnectionPool, redis: redis::aio::ConnectionManager, + node_id: String, ) { let (reader, writer) = tokio::io::split(stream); - let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(4096)); + // 单行上限 64 KB:status_post 为「每 60 秒全量上报」 + // (当前 6板×6组 ≈ 8 KB;设计上限 15板×8组 ≈ 25 KB,含电芯数据约 40 KB),留足余量 + let mut lines = FramedRead::new(reader, LinesCodec::new_with_max_length(65536)); - // 写通道:HTTP 命令下发通过此通道写入 TCP - let (tx, mut rx) = mpsc::unbounded_channel::(); + // 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积 + let (tx, mut rx) = mpsc::channel::(100); let mut current_dev_id: Option = None; let mut writer = writer; + let connected_at = std::time::Instant::now(); loop { tokio::select! { @@ -90,29 +88,47 @@ async fn handle_connection( }; // 如果是 login/auth_str,需要 dev_id 来关联连接 + // 注意:注册在签名验证之前,因为 login 响应(auth_str/cell)必须通过 + // 连接池下发。先注册才能回复。纯内网 TCP + 真实设备重连快,攻击面有限。 + // 审查编号:I3(已确认不修,协议约束) if (msg.act == "auth_str" || msg.act == "login") && let Some(ref dev_id) = msg.dev_id && current_dev_id.is_none() { - let conn = DeviceConnection::new(dev_id.clone(), tx.clone()); + let conn = DeviceConnection::new(tx.clone()); pool.register(dev_id.clone(), conn).await; current_dev_id = Some(dev_id.clone()); + // 首次识别设备时写入 route + update_device_route(&redis, dev_id, &node_id).await; } - // 更新活跃时间 + // 更新活跃时间,同时刷新 route(处理重连切换到其他节点的情况) if let Some(ref dev_id) = current_dev_id { pool.touch(dev_id).await; + update_device_route(&redis, dev_id, &node_id).await; } - // 处理消息 - let resp = handle_message(msg, &pool, &redis).await; - - if let Some(resp) = resp { - let json = resp.to_json(); - if let Err(e) = tx.send(json) { - tracing::error!("[{}] 响应发送失败: {}", peer, e); - break; + // 安全规则(协议定稿):除 auth_str / login 外,其余指令必须先 login 成功 + // 未登录连接发来的业务报文一律丢弃(连接尚未与 dev_id 建立可信绑定, + // 平台侧也无法路由应答) + if !UNAUTH_ALLOWED_ACTS.contains(&msg.act.as_str()) { + let authed = match current_dev_id.as_deref() { + Some(dev_id) => pool.is_authenticated(dev_id).await, + None => false, + }; + if !authed { + tracing::warn!( + "[安全] 连接未登录,丢弃报文 act={} peer={}", + msg.act, + peer + ); + continue; } } + + // 处理消息(传入连接绑定的设备身份:新协议业务上行不带 dev_id, + // 转发到 Redis 前由 handler 补写,api-server 依赖它识别设备)。 + // 无返回值:本服务不直接回复设备,应答由 api-server 经 device:commands 下发。 + handle_message(msg, current_dev_id.as_deref(), &pool, &redis).await; } Some(Err(e)) => { tracing::warn!("[{}] 读取错误: {}", peer, e); @@ -124,14 +140,49 @@ async fn handle_connection( } } } + // 未登录超时巡检:连接建立后 UNAUTH_TIMEOUT_SECS 秒内未通过 login 即断开 + // (协议规定:login 成功这条 TCP 才可以保持) + _ = tokio::time::sleep(std::time::Duration::from_secs(UNAUTH_CHECK_INTERVAL_SECS)) => { + use tokio::io::AsyncWriteExt; + + let authed = match current_dev_id.as_deref() { + Some(dev_id) => pool.is_authenticated(dev_id).await, + None => false, + }; + if !authed && connected_at.elapsed().as_secs() >= UNAUTH_TIMEOUT_SECS { + tracing::warn!( + "[{}] 未在 {} 秒内完成 login,平台主动断开连接", + peer, + UNAUTH_TIMEOUT_SECS + ); + let _ = writer.shutdown().await; + break; + } + } // 向设备写指令 Some(json) = rx.recv() => { use tokio::io::AsyncWriteExt; - let framed = format!("{}\n", json); + + // api-server 会附加内部标记 `_close`(login 失败后断开)与 + // `_dev_id`(路由目标):两者仅供服务端使用,下发前摘除,设备侧不可见 + let (payload, close_after) = split_internal_flags(&json); + + let framed = format!("{}\n", payload); if let Err(e) = writer.write_all(framed.as_bytes()).await { tracing::error!("[{}] 写入失败: {}", peer, e); break; } + + // 规范:login 失败即断开 TCP,强制设备重连并重新走握手流程 + if close_after { + tracing::warn!( + "[{}] login 失败,主动断开连接 dev_id={:?}", + peer, + current_dev_id + ); + let _ = writer.shutdown().await; + break; + } } } } @@ -142,3 +193,119 @@ async fn handle_connection( tracing::info!("[断连] dev_id={}", dev_id); } } + +/// 更新设备路由:在 `device:{imei}` hash 中写入 `node_id`,并刷新 TTL。 +/// 路由信息用于多节点时指令消费方判断设备连接在哪个节点上。 +async fn update_device_route( + redis: &redis::aio::ConnectionManager, + dev_id: &str, + node_id: &str, +) { + let route_key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + let _: Result<(), _> = redis::cmd("HSET") + .arg(&route_key) + .arg("node_id") + .arg(node_id) + .query_async(&mut conn) + .await; + // ponytail: 与设备在线 TTL 保持一致(300s),HSET 不支持同时设置 TTL + let _: Result<(), _> = redis::cmd("EXPIRE") + .arg(&route_key) + .arg(300_u32) + .query_async(&mut conn) + .await; +} + +/// 摘除 api-server 附加的**内部标记**(`_close` / `_dev_id`)。 +/// +/// 返回 `(实际下发给设备的报文, 是否在发送后断开连接)`。 +/// 这两个字段只用于服务端内部: +/// - `_close`:`login` 失败后主动断开连接 +/// - `_dev_id`:指令的路由目标(设备身份),下发前必须摘除 —— +/// 否则设备会把它当成协议字段,且协议定稿后 `dev_id` 只在 `auth_str`/`login` 携带 +/// +/// 两者**都不会下发到设备**。 +fn split_internal_flags(json: &str) -> (String, bool) { + match serde_json::from_str::(json) { + Ok(mut value) => { + let close_after = value + .get("_close") + .and_then(|flag| flag.as_bool()) + .unwrap_or(false); + if let Some(obj) = value.as_object_mut() { + obj.remove("_close"); + obj.remove("_dev_id"); + } + (value.to_string(), close_after) + } + // 非 JSON(理论上不会出现):原样下发,不断开 + Err(_) => (json.to_string(), false), + } +} + +// --------------------------------------------------------------------------- +// 单元测试:内部字段摘除(`_close` / `_dev_id` 绝不能下发到设备) +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::split_internal_flags; + + /// 普通指令:无内部字段时原样返回、不断开 + #[test] + fn test_split_internal_flags_plain_command_untouched() { + let raw = r#"{"act":"on","msg_id":1,"id":"01-3"}"#; + let (payload, close) = split_internal_flags(raw); + assert!(!close); + let v: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(v["act"], "on"); + assert_eq!(v["msg_id"], 1); + assert_eq!(v["id"], "01-3"); + assert!(v.get("_dev_id").is_none()); + assert!(v.get("_close").is_none()); + } + + /// `_dev_id`(路由目标)必须被摘除 —— 它是服务端内部字段, + /// 且协议定稿后 `dev_id` 只在 auth_str/login 携带,泄漏会让设备误解报文语义 + #[test] + fn test_split_internal_flags_removes_route_dev_id() { + let raw = r#"{"act":"open","msg_id":2,"_dev_id":"864720562557021"}"#; + let (payload, close) = split_internal_flags(raw); + assert!(!close); + let v: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(v["act"], "open"); + assert_eq!(v["msg_id"], 2); + assert!(v.get("_dev_id").is_none()); + } + + /// `_close` 被摘除并回报"发送后断开"(login 失败即断连) + #[test] + fn test_split_internal_flags_reports_and_removes_close() { + let raw = r#"{"act":"login","msg_id":2,"suc":0,"err":"invalid signature","_close":true}"#; + let (payload, close) = split_internal_flags(raw); + assert!(close); + let v: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert_eq!(v["act"], "login"); + assert_eq!(v["err"], "invalid signature"); + assert!(v.get("_close").is_none()); + } + + /// 两个内部字段同时出现时都要摘掉 + #[test] + fn test_split_internal_flags_removes_both() { + let raw = r#"{"act":"login","suc":0,"_dev_id":"864720562557021","_close":true}"#; + let (payload, close) = split_internal_flags(raw); + assert!(close); + let v: serde_json::Value = serde_json::from_str(&payload).unwrap(); + assert!(v.get("_dev_id").is_none()); + assert!(v.get("_close").is_none()); + } + + /// 非 JSON 原样下发且不断开(理论上不会出现,但不能因此丢失报文) + #[test] + fn test_split_internal_flags_non_json_passthrough() { + let (payload, close) = split_internal_flags("not-json"); + assert_eq!(payload, "not-json"); + assert!(!close); + } +} diff --git a/software/h5-taro/babel.config.js b/software/h5-taro/babel.config.js deleted file mode 100644 index a49260e..0000000 --- a/software/h5-taro/babel.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config -module.exports = { - presets: [ - ['taro', { - framework: 'react', - ts: true, - }], - ], -} diff --git a/software/h5-taro/config/dev.js b/software/h5-taro/config/dev.js deleted file mode 100644 index 73170a7..0000000 --- a/software/h5-taro/config/dev.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = { - env: { - NODE_ENV: '"development"', - }, - h5: { - devServer: { - port: 5174, - proxy: { - '/h5/api': { - target: 'http://10.8.0.252:3000', - changeOrigin: true, - pathRewrite: { '^/h5': '' }, - }, - }, - }, - }, -} diff --git a/software/h5-taro/config/index.js b/software/h5-taro/config/index.js deleted file mode 100644 index 19a301e..0000000 --- a/software/h5-taro/config/index.js +++ /dev/null @@ -1,58 +0,0 @@ -const path = require('path') - -const config = { - projectName: 'h5-taro', - date: '2026-07-03', - designWidth: 750, - deviceRatio: { - 640: 2.34 / 2, - 750: 1, - 828: 1.81 / 2, - 375: 2 / 1, - }, - sourceRoot: 'src', - outputRoot: 'dist', - plugins: [], - defineConstants: {}, - copy: { - patterns: [], - options: {}, - }, - framework: 'react', - // compiler: 'webpack4', (default) - sass: { - data: '', - }, - mini: { - postcss: { - pxtransform: { enable: true, config: {} }, - url: { enable: true, config: { limit: 1024 } }, - cssModules: { - enable: false, - config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' }, - }, - }, - }, - h5: { - publicPath: '/h5/', - staticDirectory: 'static', - router: { - mode: 'browser', - basename: '/h5/', - }, - postcss: { - autoprefixer: { enable: true, config: {} }, - cssModules: { - enable: false, - config: { namingPattern: 'module', generateScopedName: '[name]__[local]___[hash:base64:5]' }, - }, - }, - }, -} - -module.exports = function (merge) { - if (process.env.NODE_ENV === 'development') { - return merge({}, config, require('./dev')) - } - return merge({}, config, require('./prod')) -} diff --git a/software/h5-taro/config/prod.js b/software/h5-taro/config/prod.js deleted file mode 100644 index c7746a5..0000000 --- a/software/h5-taro/config/prod.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - env: { - NODE_ENV: '"production"', - }, - outputRoot: 'dist', -} diff --git a/software/h5-taro/package.json b/software/h5-taro/package.json deleted file mode 100644 index 7a8b688..0000000 --- a/software/h5-taro/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "h5-taro", - "version": "1.0.0", - "private": true, - "description": "安知充 H5 用户端 (Taro)", - "scripts": { - "dev:h5": "taro build --type h5 --watch", - "build:h5": "taro build --type h5", - "dev:weapp": "taro build --type weapp --watch", - "build:weapp": "taro build --type weapp" - }, - "dependencies": { - "@babel/runtime": "^7.23.0", - "@tarojs/components": "3.6.32", - "@tarojs/plugin-framework-react": "^3.6.32", - "@tarojs/plugin-platform-h5": "3.6.32", - "@tarojs/react": "3.6.32", - "@tarojs/runtime": "3.6.32", - "@tarojs/taro": "3.6.32", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "zustand": "^4.5.0" - }, - "devDependencies": { - "@babel/core": "^7.23.0", - "@tarojs/cli": "3.6.32", - "@tarojs/webpack-runner": "3.6.32", - "@types/react": "^18.2.0", - "babel-preset-taro": "3.6.32", - "typescript": "^5.3.0" - } -} diff --git a/software/h5-taro/project.config.json b/software/h5-taro/project.config.json deleted file mode 100644 index 3eaac50..0000000 --- a/software/h5-taro/project.config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "miniprogramRoot": "./dist", - "projectname": "h5-taro", - "description": "安知充 H5 用户端", - "appid": "touristappid", - "setting": { - "urlCheck": true, - "es6": false, - "enhance": false, - "postcss": false, - "preloadBackgroundData": false, - "minified": false, - "newFeature": true, - "coverView": true, - "nodeModules": false, - "autoAudits": false, - "showShadowRootInWxmlPanel": true, - "scopeDataCheck": false, - "uglifyFileName": false, - "checkInvalidKey": true, - "checkSiteMap": true, - "uploadWithSourceMap": true, - "compileHotReLoad": false, - "lazyloadPlaceholderEnable": false, - "useMultiFrameRuntime": true, - "useApiHook": true, - "useApiHostProcess": true - }, - "compileType": "miniprogram" -} diff --git a/software/h5-taro/src/api/index.ts b/software/h5-taro/src/api/index.ts deleted file mode 100644 index c3d91e7..0000000 --- a/software/h5-taro/src/api/index.ts +++ /dev/null @@ -1,161 +0,0 @@ -import Taro from '@tarojs/taro' - -const BASE_URL = '/h5/api' - -async function request( - path: string, - options: { method?: string; body?: unknown; params?: Record } = {}, -): Promise { - const token = Taro.getStorageSync('h5_token') - const headers: Record = { - 'Content-Type': 'application/json', - } - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - - let url = `${BASE_URL}${path}` - if (options.params) { - const qs = new URLSearchParams(options.params).toString() - url += `?${qs}` - } - - const res = await Taro.request({ - url, - method: (options.method as 'GET' | 'POST' | 'PUT' | 'DELETE') || 'GET', - header: headers, - data: options.body, - }) - - // ponytail: single error shape, add wrapper if backend uses inconsistent envelopes - if (res.statusCode >= 400) { - const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败' - throw new Error(msg) - } - - return res.data as T -} - -// ---- Types ---- - -export interface LoginParams { - phone: string - password: string -} - -export interface LoginResult { - token: string - user: H5User -} - -export interface H5User { - id: number - phone: string - name?: string -} - -export interface DashboardData { - online_cabinets: number - charging_count: number - idle_channels: number - fault_channels: number - today_charge_count: number - today_energy_kwh: number - today_charge_hours: number - alerts: { project: string; cabinet: string; channel: number }[] - projects: { id: number; name: string; cabinet_count: number; charging: number; idle: number; fault: number }[] -} - -export interface CabinetItem { - id: number - abstract_id: string - name?: string - status: number // 1=在线 0=离线 - project_id?: number -} - -export interface CompartmentInfo { - id: number - channel_id: number - status: number // 0=空闲 1=充电中 2=故障 3=离线 - voltage?: number - current?: number -} - -export interface CabinBoard { - id: number - board_id: number - status: number - compartments: CompartmentInfo[] -} - -export interface CabinetDetail { - id: number - abstract_id: string - name?: string - imei?: string - status: number - cabin_boards: CabinBoard[] -} - -export interface CompartmentDetail { - id: number - channel_id: number - status: number - voltage?: number - current?: number - power?: number - energy?: number - soc?: number - soh?: number - cell_voltages?: number[] - cell_temps?: number[] - bms_alerts?: string[] - charge_records?: ChargeRecord[] -} - -export interface ChargeRecord { - id: number - start_time: string - end_time?: string - energy_kwh: number - cost: number -} - -// ---- API ---- - -export const h5Api = { - // Auth - login: (params: LoginParams) => - request<{ data: LoginResult }>('/h5/auth/login', { method: 'POST', body: params }), - - // Dashboard - getDashboard: () => - request<{ data: DashboardData }>('/h5/dashboard'), - - // Cabinets - getCabinets: (params?: { project_id?: number }) => - request<{ data: CabinetItem[] }>('/h5/cabinets', { params: params as Record }), - - // Cabinet detail (by abstract_id for QR scan — public endpoint) - getCabinetByAbstract: (abstractId: string) => - request<{ data: CabinetDetail }>(`/h5/cabinets/abstract/${abstractId}`), - - // Cabinet detail by DB id - getCabinetDetail: (id: number) => - request<{ data: CabinetDetail }>(`/h5/cabinets/${id}`), - - // Compartment detail - getCompartmentDetail: (id: number) => - request<{ data: CompartmentDetail }>(`/h5/compartments/${id}`), - - // Actions — match backend POST /api/h5/charge/start, /stop, /door/open with {compartment_id} - startCharge: (compartmentId: number) => - request<{ code: number }>('/h5/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }), - - stopCharge: (compartmentId: number) => - request<{ code: number }>('/h5/charge/stop', { method: 'POST', body: { compartment_id: compartmentId } }), - - openDoor: (compartmentId: number) => - request<{ code: number }>('/h5/door/open', { method: 'POST', body: { compartment_id: compartmentId } }), -} diff --git a/software/h5-taro/src/app.config.ts b/software/h5-taro/src/app.config.ts deleted file mode 100644 index bfeda26..0000000 --- a/software/h5-taro/src/app.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -export default { - pages: [ - 'pages/home/index', - 'pages/devices/index', - 'pages/me/index', - 'pages/login/index', - 'pages/device-detail/index', - 'pages/compartment-detail/index', - ], - window: { - backgroundTextStyle: 'light', - navigationBarBackgroundColor: '#ffffff', - navigationBarTitleText: '安知充', - navigationBarTextStyle: 'black', - navigationStyle: 'custom', - }, - tabBar: { - color: '#999999', - selectedColor: '#2563eb', - backgroundColor: '#ffffff', - borderStyle: 'black', - list: [ - { - pagePath: 'pages/home/index', - text: '首页', - iconPath: 'assets/home.svg', - selectedIconPath: 'assets/home-active.svg', - }, - { - pagePath: 'pages/devices/index', - text: '设备', - iconPath: 'assets/device.svg', - selectedIconPath: 'assets/device-active.svg', - }, - { - pagePath: 'pages/me/index', - text: '我的', - iconPath: 'assets/me.svg', - selectedIconPath: 'assets/me-active.svg', - }, - ], - }, -} diff --git a/software/h5-taro/src/app.css b/software/h5-taro/src/app.css deleted file mode 100644 index cad1512..0000000 --- a/software/h5-taro/src/app.css +++ /dev/null @@ -1,66 +0,0 @@ -/* 全局样式 */ -page { - background-color: #f5f5f5; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - font-size: 28px; - color: #333; - box-sizing: border-box; -} - -/* 通用重置 */ -view, -text, -image, -button, -input { - box-sizing: border-box; -} - -/* 灰色卡片容器 */ -.card { - background: #ffffff; - border-radius: 16px; - padding: 24px; - margin-bottom: 24px; -} - -/* 状态标签 */ -.status-badge { - display: inline-block; - padding: 4px 16px; - border-radius: 100px; - font-size: 24px; - font-weight: 500; -} - -.status-badge.online { - background-color: #dcfce7; - color: #16a34a; -} - -.status-badge.offline { - background-color: #f3f4f6; - color: #6b7280; -} - -.status-badge.charging { - background-color: #dcfce7; - color: #16a34a; -} - -.status-badge.idle { - background-color: #f3f4f6; - color: #6b7280; -} - -.status-badge.fault { - background-color: #fee2e2; - color: #dc2626; -} - -/* 占位图标(用纯CSS画简单的tab图标) */ -.tab-icon { - width: 48px; - height: 48px; - display: block; -} diff --git a/software/h5-taro/src/app.tsx b/software/h5-taro/src/app.tsx deleted file mode 100644 index ddcd522..0000000 --- a/software/h5-taro/src/app.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useEffect } from 'react' -import { useDidShow, useDidHide } from '@tarojs/taro' -import Taro from '@tarojs/taro' -import { useH5AuthStore } from './store/auth' -import './app.css' - -function App(props: { children: React.ReactNode }) { - const restore = useH5AuthStore((s) => s.restore) - const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated) - - useEffect(() => { - restore() - }, [restore]) - - // Handle QR scan entry: URL rewritten by index.html script - useEffect(() => { - const abstractId: string | undefined = (window as any).__SCAN_ABSTRACT_ID__ - if (!abstractId) return - - if (isAuthenticated) { - import('./api').then(({ h5Api }) => { - h5Api.getCabinetByAbstract(abstractId) - .then((res) => Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` })) - .catch(() => Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` })) - }) - } else { - Taro.redirectTo({ url: `/pages/login/index?abstract_id=${abstractId}` }) - } - // ponytail: runs once on mount, auth changes handled by redirect - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAuthenticated]) - - useDidShow(() => { - // ponytail: analytics hook, add when needed - }) - - useDidHide(() => { - // ponytail: cleanup hook, add when needed - }) - - return <>{props.children} -} - -export default App diff --git a/software/h5-taro/src/assets/device-active.svg b/software/h5-taro/src/assets/device-active.svg deleted file mode 100644 index 24af383..0000000 --- a/software/h5-taro/src/assets/device-active.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/device.svg b/software/h5-taro/src/assets/device.svg deleted file mode 100644 index b4989b3..0000000 --- a/software/h5-taro/src/assets/device.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/home-active.svg b/software/h5-taro/src/assets/home-active.svg deleted file mode 100644 index 6fcc4b7..0000000 --- a/software/h5-taro/src/assets/home-active.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/home.svg b/software/h5-taro/src/assets/home.svg deleted file mode 100644 index 1c33fc6..0000000 --- a/software/h5-taro/src/assets/home.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/me-active.svg b/software/h5-taro/src/assets/me-active.svg deleted file mode 100644 index fc62f1f..0000000 --- a/software/h5-taro/src/assets/me-active.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/me.svg b/software/h5-taro/src/assets/me.svg deleted file mode 100644 index d73cfb6..0000000 --- a/software/h5-taro/src/assets/me.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/components/ChannelCard.tsx b/software/h5-taro/src/components/ChannelCard.tsx deleted file mode 100644 index 0ae802e..0000000 --- a/software/h5-taro/src/components/ChannelCard.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' -import { ChannelStatusBadge } from './StatusBadge' - -interface Props { - channelId: number - status: number - voltage?: number - current?: number - onClick?: () => void -} - -const COLORS: Record = { - 0: '#9ca3af', // idle - 1: '#16a34a', // charging - 2: '#dc2626', // fault - 3: '#d1d5db', // offline -} - -export default function ChannelCard({ channelId, status, voltage, current, onClick }: Props) { - return ( - - - #{channelId} - - - {voltage !== undefined && ( - {voltage.toFixed(1)}V - )} - {current !== undefined && ( - {current.toFixed(1)}A - )} - - ) -} diff --git a/software/h5-taro/src/components/ConfirmDialog.tsx b/software/h5-taro/src/components/ConfirmDialog.tsx deleted file mode 100644 index 402796b..0000000 --- a/software/h5-taro/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' - -interface Props { - open: boolean - title: string - message: string - onConfirm: () => void - onCancel: () => void -} - -export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) { - if (!open) return null - return ( - - - - {title} - {message} - - - - 取消 - - - 确定 - - - - - ) -} diff --git a/software/h5-taro/src/components/ErrorState.tsx b/software/h5-taro/src/components/ErrorState.tsx deleted file mode 100644 index f29c927..0000000 --- a/software/h5-taro/src/components/ErrorState.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' -import { View, Text, Button } from '@tarojs/components' - -interface Props { - message: string - onRetry?: () => void -} - -export default function ErrorState({ message, onRetry }: Props) { - return ( - - 😵 - {message} - {onRetry && ( - - )} - - ) -} diff --git a/software/h5-taro/src/components/Loading.tsx b/software/h5-taro/src/components/Loading.tsx deleted file mode 100644 index a88a213..0000000 --- a/software/h5-taro/src/components/Loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' - -export default function Loading({ text = '加载中...' }: { text?: string }) { - return ( - - - {text} - - - ) -} diff --git a/software/h5-taro/src/components/StatusBadge.tsx b/software/h5-taro/src/components/StatusBadge.tsx deleted file mode 100644 index 47c467c..0000000 --- a/software/h5-taro/src/components/StatusBadge.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' - -const STATUS_MAP: Record = { - 0: { label: '空闲', cls: 'idle' }, - 1: { label: '充电中', cls: 'charging' }, - 2: { label: '故障', cls: 'fault' }, - 3: { label: '离线', cls: 'offline' }, -} - -export function CabinetStatusBadge({ status }: { status: number }) { - const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' } - return ( - - {s.label} - - ) -} - -export function ChannelStatusBadge({ status }: { status: number }) { - const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' } - return ( - - {s.label} - - ) -} diff --git a/software/h5-taro/src/index.html b/software/h5-taro/src/index.html deleted file mode 100644 index 02751dd..0000000 --- a/software/h5-taro/src/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - 安知充 - - - -

- - diff --git a/software/h5-taro/src/pages/compartment-detail/index.config.ts b/software/h5-taro/src/pages/compartment-detail/index.config.ts deleted file mode 100644 index 990f839..0000000 --- a/software/h5-taro/src/pages/compartment-detail/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '通道详情', -} diff --git a/software/h5-taro/src/pages/compartment-detail/index.tsx b/software/h5-taro/src/pages/compartment-detail/index.tsx deleted file mode 100644 index c6683dc..0000000 --- a/software/h5-taro/src/pages/compartment-detail/index.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro, { useRouter } from '@tarojs/taro' -import { h5Api, type CompartmentDetail } from '../../api' -import Loading from '../../components/Loading' -import ErrorState from '../../components/ErrorState' -import ConfirmDialog from '../../components/ConfirmDialog' -import { ChannelStatusBadge } from '../../components/StatusBadge' - -export default function CompartmentDetailPage() { - const { params } = useRouter() - const id = params.id - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [actionLoading, setActionLoading] = useState('') - const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null) - - // 隐藏 tabbar(非 tab 页) - useEffect(() => { - Taro.hideTabBar() - }, []) - - const fetchDetail = async () => { - if (!id) return - setLoading(true) - setError('') - try { - const res = await h5Api.getCompartmentDetail(Number(id)) - setData(res.data) - } catch { - setError('加载通道详情失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchDetail() - // ponytail: 15s auto-refresh during charging, add WebSocket for real-time - const timer = setInterval(fetchDetail, 15000) - return () => clearInterval(timer) - }, [id]) - - const handleAction = async (action: 'start' | 'stop' | 'door') => { - if (!id || actionLoading) return - setActionLoading(action) - try { - if (action === 'start') { - await h5Api.startCharge(Number(id)) - } else if (action === 'stop') { - await h5Api.stopCharge(Number(id)) - } else if (action === 'door') { - await h5Api.openDoor(Number(id)) - } - setConfirmAction(null) - fetchDetail() - } catch { - Taro.showToast({ title: '操作失败', icon: 'none' }) - } finally { - setActionLoading('') - } - } - - if (loading) return - if (error && !data) return - if (!data) return null - - const isCharging = data.status === 1 - const isIdle = data.status === 0 - - return ( - - {/* Real-time status */} - - - 实时状态 - - - - - {data.soc ?? '-'}% - SOC - - - {data.soh ?? '-'}% - SOH - - - {data.energy?.toFixed(1) ?? '-'} - 能耗(kWh) - - - - - {/* Electrical params */} - - 电气参数 - - - 电压 - {data.voltage?.toFixed(1) ?? '-'} V - - - 电流 - {data.current?.toFixed(1) ?? '-'} A - - - 功率 - {data.power?.toFixed(1) ?? '-'} W - - - 能耗 - {data.energy?.toFixed(3) ?? '-'} kWh - - - - - {/* BMS data */} - {data.cell_voltages && data.cell_voltages.length > 0 && ( - - BMS 数据 - - 单体电压 (V) - - {data.cell_voltages.map((v, i) => ( - - #{i + 1}: {v.toFixed(3)} - - ))} - - - {data.cell_temps && data.cell_temps.length > 0 && ( - - 温度 (°C) - - {data.cell_temps.map((t, i) => ( - 50 ? '#fef2f2' : '#fff7ed', - padding: '4px 12px', borderRadius: 8, fontSize: 24, - color: t > 50 ? '#dc2626' : '#f97316', - }} - > - #{i + 1}: {t.toFixed(1)} - - ))} - - - )} - {data.bms_alerts && data.bms_alerts.length > 0 && ( - - 告警 - {data.bms_alerts.map((alert, i) => ( - • {alert} - ))} - - )} - - )} - - {/* Charge records */} - {data.charge_records && data.charge_records.length > 0 && ( - - 充电记录 - {data.charge_records.slice(0, 5).map((record) => ( - - - {record.start_time.slice(0, 16)} - {record.end_time && {record.end_time.slice(0, 16)}} - - - {record.energy_kwh.toFixed(2)} kWh - ¥{record.cost.toFixed(2)} - - - ))} - - )} - - {/* Action buttons */} - - {isIdle && ( - handleAction('start')} - > - - {actionLoading === 'start' ? '操作中...' : '开始充电'} - - - )} - {isCharging && ( - setConfirmAction('stop')} - > - - {actionLoading === 'stop' ? '操作中...' : '停止充电'} - - - )} - setConfirmAction('door')} - > - - {actionLoading === 'door' ? '操作中...' : '开门'} - - - - - {/* Confirm dialogs */} - handleAction('stop')} - onCancel={() => setConfirmAction(null)} - /> - handleAction('door')} - onCancel={() => setConfirmAction(null)} - /> - - ) -} diff --git a/software/h5-taro/src/pages/device-detail/index.config.ts b/software/h5-taro/src/pages/device-detail/index.config.ts deleted file mode 100644 index b9f400f..0000000 --- a/software/h5-taro/src/pages/device-detail/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '设备详情', -} diff --git a/software/h5-taro/src/pages/device-detail/index.tsx b/software/h5-taro/src/pages/device-detail/index.tsx deleted file mode 100644 index 61fea70..0000000 --- a/software/h5-taro/src/pages/device-detail/index.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro, { useRouter } from '@tarojs/taro' -import { h5Api, type CabinetDetail } from '../../api' -import Loading from '../../components/Loading' -import ErrorState from '../../components/ErrorState' -import ChannelCard from '../../components/ChannelCard' -import { CabinetStatusBadge } from '../../components/StatusBadge' - -export default function DeviceDetailPage() { - const { params } = useRouter() - const id = params.id - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - // 隐藏 tabbar(非 tab 页) - useEffect(() => { - Taro.hideTabBar() - }, []) - - const fetchDetail = async () => { - if (!id) return - setLoading(true) - setError('') - try { - const res = await h5Api.getCabinetDetail(Number(id)) - setData(res.data) - } catch { - setError('加载设备详情失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchDetail() - }, [id]) - - if (loading) return - if (error) return - if (!data) return null - - return ( - - {/* Basic info */} - - - - {data.name || data.abstract_id} - ID: {data.abstract_id} - {data.imei && IMEI: {data.imei}} - - - - - - {/* Cabin boards */} - {data.cabin_boards.map((board) => ( - - - 仓板 {board.board_id} - - ({board.status === 1 ? '在线' : '离线'}) - - - - {board.compartments.map((comp) => ( - - Taro.navigateTo({ url: `/pages/compartment-detail/index?id=${comp.id}` })} - /> - - ))} - - - ))} - - ) -} diff --git a/software/h5-taro/src/pages/devices/index.config.ts b/software/h5-taro/src/pages/devices/index.config.ts deleted file mode 100644 index 9d2def3..0000000 --- a/software/h5-taro/src/pages/devices/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '设备', -} diff --git a/software/h5-taro/src/pages/devices/index.tsx b/software/h5-taro/src/pages/devices/index.tsx deleted file mode 100644 index 7fe1027..0000000 --- a/software/h5-taro/src/pages/devices/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { h5Api, type CabinetItem } from '../../api' -import Loading from '../../components/Loading' -import ErrorState from '../../components/ErrorState' -import { CabinetStatusBadge } from '../../components/StatusBadge' - -export default function DevicesPage() { - const [list, setList] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - const fetchList = async () => { - setLoading(true) - setError('') - try { - const res = await h5Api.getCabinets() - setList(res.data || []) - } catch { - setError('加载设备列表失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchList() - }, []) - - if (loading) return - if (error) return fetchList()} /> - - return ( - - {/* List */} - {list.length === 0 ? ( - - 暂无设备 - - ) : ( - - {list.map((item) => ( - Taro.navigateTo({ url: `/pages/device-detail/index?id=${item.id}` })} - > - - - {item.name || item.abstract_id} - - - - ID: {item.abstract_id} - - ))} - - )} - - ) -} diff --git a/software/h5-taro/src/pages/home/index.config.ts b/software/h5-taro/src/pages/home/index.config.ts deleted file mode 100644 index 86e3b2e..0000000 --- a/software/h5-taro/src/pages/home/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '首页', -} diff --git a/software/h5-taro/src/pages/home/index.tsx b/software/h5-taro/src/pages/home/index.tsx deleted file mode 100644 index cb87795..0000000 --- a/software/h5-taro/src/pages/home/index.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { h5Api, type DashboardData } from '../../api' -import { useH5AuthStore } from '../../store/auth' -import Loading from '../../components/Loading' - -export default function HomePage() { - const user = useH5AuthStore((s) => s.user) - const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated) - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - - // 未登录跳转到登录页 - useEffect(() => { - if (!isAuthenticated) { - Taro.navigateTo({ url: '/pages/login/index' }) - } - }, [isAuthenticated]) - - const fetchDashboard = async () => { - setLoading(true) - try { - const res = await h5Api.getDashboard() - setData(res.data) - } catch { - // ponytail: silent fail on dashboard, page shows zeros - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchDashboard() - }, []) - - if (loading) return - - return ( - - {/* Welcome */} - - - {user?.name ? `${user.name},你好` : '你好'} - - 欢迎使用安知充充电柜 - - - {/* Stats Grid — flex 2x2 */} - - - {data?.online_cabinets ?? 0} - 在线设备 - - - {data?.charging_count ?? 0} - 充电中 - - - {data?.idle_channels ?? 0} - 空闲仓体 - - - - {data?.fault_channels ?? 0} - - 故障 - - - - {/* Today summary */} - - 今日概况 - - - {data?.today_charge_count ?? 0} - 充电次数 - - - {data?.today_energy_kwh?.toFixed(1) ?? '0.0'} - 充电量(kWh) - - - {data?.today_charge_hours?.toFixed(1) ?? '0.0'} - 时长(h) - - - - - {/* Fault alerts */} - {data?.alerts && data.alerts.length > 0 && ( - - 故障告警 - {data.alerts.slice(0, 5).map((alert, i) => ( - - {alert.cabinet} — 通道 {alert.channel} - {alert.project} - - ))} - - )} - - {/* Quick Actions */} - - 快捷操作 - Taro.switchTab({ url: '/pages/devices/index' })} - > - 查看设备 - - Taro.switchTab({ url: '/pages/me/index' })} - > - 个人中心 - - - - ) -} diff --git a/software/h5-taro/src/pages/login/index.config.ts b/software/h5-taro/src/pages/login/index.config.ts deleted file mode 100644 index a11f33e..0000000 --- a/software/h5-taro/src/pages/login/index.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - navigationBarTitleText: '登录', - navigationStyle: 'custom', -} diff --git a/software/h5-taro/src/pages/login/index.tsx b/software/h5-taro/src/pages/login/index.tsx deleted file mode 100644 index b742550..0000000 --- a/software/h5-taro/src/pages/login/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text, Input, Button } from '@tarojs/components' -import Taro, { useRouter } from '@tarojs/taro' -import { useH5AuthStore } from '../../store/auth' -import { h5Api } from '../../api' - -export default function LoginPage() { - const { params } = useRouter() - const abstractId = params.abstract_id - const [phone, setPhone] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const { login, loading } = useH5AuthStore() - - // 隐藏 tabbar(login 页不是 tab 页) - useEffect(() => { - Taro.hideTabBar() - }, []) - - const handleLogin = async () => { - if (!phone || !password) { - setError('请输入手机号和密码') - return - } - setError('') - try { - await login(phone, password) - if (abstractId) { - // 扫码进来的,登录后跳转设备详情 - try { - const res = await h5Api.getCabinetByAbstract(abstractId) - Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` }) - } catch { - Taro.switchTab({ url: '/pages/home/index' }) - } - } else { - Taro.switchTab({ url: '/pages/home/index' }) - } - } catch (e: any) { - setError(e.message || '登录失败') - } - } - - return ( - - {/* Header */} - - 安知充 - 智能充电柜管理系统 - - - {/* Form */} - - - 手机号 - setPhone(e.detail.value)} - /> - - - 密码 - setPassword(e.detail.value)} - /> - - - {error && ( - {error} - )} - - - - - ) -} diff --git a/software/h5-taro/src/pages/me/index.config.ts b/software/h5-taro/src/pages/me/index.config.ts deleted file mode 100644 index c4f5af2..0000000 --- a/software/h5-taro/src/pages/me/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '我的', -} diff --git a/software/h5-taro/src/pages/me/index.tsx b/software/h5-taro/src/pages/me/index.tsx deleted file mode 100644 index 4f76f9d..0000000 --- a/software/h5-taro/src/pages/me/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { useH5AuthStore } from '../../store/auth' -import ConfirmDialog from '../../components/ConfirmDialog' - -export default function MePage() { - const { user, logout } = useH5AuthStore() - const [showLogout, setShowLogout] = useState(false) - - const handleLogout = () => { - logout() - Taro.reLaunch({ url: '/pages/login/index' }) - } - - return ( - - {/* User info */} - - - - {(user?.name || user?.phone || '?').charAt(0)} - - - - {user?.name || '用户'} - {user?.phone} - - - - {/* Menu items */} - - Taro.showToast({ title: '功能开发中', icon: 'none' })} - > - 修改密码 - › - - - - {/* Logout */} - - setShowLogout(true)} - > - 退出登录 - - - - setShowLogout(false)} - /> - - ) -} diff --git a/software/h5-taro/src/store/auth.ts b/software/h5-taro/src/store/auth.ts deleted file mode 100644 index 951746e..0000000 --- a/software/h5-taro/src/store/auth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { create } from 'zustand' -import Taro from '@tarojs/taro' -import { h5Api, type H5User } from '../api' - -interface AuthState { - token: string | null - user: H5User | null - loading: boolean - isAuthenticated: boolean - login: (phone: string, password: string) => Promise - logout: () => void - restore: () => void -} - -export const useH5AuthStore = create((set) => ({ - token: null, - user: null, - loading: false, - isAuthenticated: false, - - login: async (phone: string, password: string) => { - set({ loading: true }) - try { - const res = await h5Api.login({ phone, password }) - const { token, user } = res.data - Taro.setStorageSync('h5_token', token) - Taro.setStorageSync('h5_user', JSON.stringify(user)) - set({ token, user, isAuthenticated: true, loading: false }) - } catch { - set({ loading: false }) - throw new Error('登录失败,请检查手机号和密码') - } - }, - - logout: () => { - Taro.removeStorageSync('h5_token') - Taro.removeStorageSync('h5_user') - set({ token: null, user: null, isAuthenticated: false }) - }, - - restore: () => { - try { - const token = Taro.getStorageSync('h5_token') - const raw = Taro.getStorageSync('h5_user') - if (token) { - const user = raw ? (JSON.parse(raw) as H5User) : null - set({ token, user, isAuthenticated: true }) - } - } catch { - // ponytail: storage read failure, not worth crashing over - } - }, -})) diff --git a/software/h5-taro/tsconfig.json b/software/h5-taro/tsconfig.json deleted file mode 100644 index 8e150c2..0000000 --- a/software/h5-taro/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "es2017", - "module": "commonjs", - "lib": ["es2017", "dom"], - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/software/h5-uniapp/index.html b/software/h5-uniapp/index.html deleted file mode 100644 index c1fc8e1..0000000 --- a/software/h5-uniapp/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - 安知充 - - - -
- - - diff --git a/software/h5-uniapp/package.json b/software/h5-uniapp/package.json index ff96a3a..b128ecf 100644 --- a/software/h5-uniapp/package.json +++ b/software/h5-uniapp/package.json @@ -1,16 +1,14 @@ { - "name": "h5-uniapp", + "name": "mp-uniapp", "version": "1.0.0", "private": true, "scripts": { - "dev:h5": "uni", - "build:h5": "uni build" + "build:mp-weixin": "uni build --platform mp-weixin" }, "dependencies": { "@dcloudio/uni-app": "3.0.0-4060620250520001", "@dcloudio/uni-app-plus": "3.0.0-4060620250520001", "@dcloudio/uni-components": "3.0.0-4060620250520001", - "@dcloudio/uni-h5": "3.0.0-4060620250520001", "@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001", "jsqr": "^1.4.0", "pinia": "^2.1.7", diff --git a/software/h5-uniapp/src/App.vue b/software/h5-uniapp/src/App.vue index 7cbc2fd..b0100c6 100644 --- a/software/h5-uniapp/src/App.vue +++ b/software/h5-uniapp/src/App.vue @@ -5,18 +5,54 @@ import { useAuthStore } from './store/auth' onLaunch(() => { const authStore = useAuthStore() authStore.restore() - // 全局接收扫码参数 - const scanId = (window as any).__SCAN_ABSTRACT_ID__ - if (scanId) { - authStore.setScanId(scanId) - ;(window as any).__SCAN_ABSTRACT_ID__ = undefined + if (typeof window !== 'undefined') { + const scanId = (window as any).__SCAN_ABSTRACT_ID__ + if (scanId) { + authStore.setScanId(scanId) + ;(window as any).__SCAN_ABSTRACT_ID__ = undefined + } } }) diff --git a/software/h5-uniapp/src/api/index.ts b/software/h5-uniapp/src/api/index.ts index 57da8eb..9471735 100644 --- a/software/h5-uniapp/src/api/index.ts +++ b/software/h5-uniapp/src/api/index.ts @@ -1,6 +1,6 @@ -// API 层 — 复用 Taro 项目的逻辑,改用 uni.request +// API 层 — 微信小程序专用 -const BASE_URL = '/api/pms' +const BASE_URL = 'https://app.anzhizhichong.com/api/pms' interface RequestOptions { method?: string @@ -8,8 +8,19 @@ interface RequestOptions { params?: Record } +/** 登录态失效:清除本地凭证并回到登录页(带防抖,避免并发请求重复跳转) */ +let redirectingToLogin = false +function redirectToLogin() { + if (redirectingToLogin) return + redirectingToLogin = true + uni.removeStorageSync('mp_token') + uni.removeStorageSync('mp_user') + uni.reLaunch({ url: '/pages/login/index' }) + setTimeout(() => { redirectingToLogin = false }, 1500) +} + async function request(path: string, options: RequestOptions = {}): Promise { - const token = uni.getStorageSync('h5_token') + const token = uni.getStorageSync('mp_token') const header: Record = { 'Content-Type': 'application/json', } @@ -30,14 +41,25 @@ async function request(path: string, options: RequestOptions = {}): Promise, + timeout: 15000, success: (res) => { - if (res.statusCode >= 400) { - const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败' - reject(new Error(msg)) + const body = res.data as any + // token 过期/无效:清除登录态并跳回登录页(登录接口本身除外,避免死循环) + if (res.statusCode === 401 && !path.includes('/auth/')) { + redirectToLogin() + reject(new Error('登录已过期,请重新登录')) return } - resolve(res.data as T) + if (res.statusCode >= 400) { + reject(new Error(body?.message || body?.error || '请求失败')) + return + } + if (body && typeof body.code !== 'undefined' && body.code !== 0) { + reject(new Error(body.message || '请求失败')) + return + } + resolve((body?.data ?? body) as T) }, fail: (err) => { reject(new Error(err.errMsg || '网络错误')) @@ -48,11 +70,6 @@ async function request(path: string, options: RequestOptions = {}): Promise - request<{ data: LoginResult }>('/auth/login', { method: 'POST', body: params }), + wxLogin: (code: string, phoneCode: string) => + request('/auth/wx-login', { method: 'POST', body: { code, phone_code: phoneCode } }), + + updateAvatar: (avatar: string) => + request<{ avatar: string }>('/auth/avatar', { method: 'PUT', body: { avatar } }), getDashboard: (params?: { org_id?: string }) => - request<{ data: DashboardData }>('/dashboard', { params: params as Record }), + request('/dashboard', { params: params as Record }), getCabinets: (params?: { project_id?: number; org_id?: number }) => - request<{ data: CabinetItem[] }>('/cabinets', { params: params as Record }), + request('/cabinets', { params: params as Record }), getCabinetByAbstract: (abstractId: string) => - request<{ data: CabinetDetail }>(`/cabinets/abstract/${abstractId}`), + request(`/cabinets/abstract/${abstractId}`), getCabinetDetail: (id: number) => - request<{ data: CabinetDetail }>(`/cabinets/${id}`), + request(`/cabinets/${id}`), getCompartmentDetail: (id: number) => - request<{ data: CompartmentDetail }>(`/compartments/${id}`), + request(`/compartments/${id}`), startCharge: (compartmentId: number) => request<{ code: number }>('/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }), @@ -203,11 +253,33 @@ export const h5Api = { request<{ code: number }>('/door/open', { method: 'POST', body: { compartment_id: compartmentId } }), getChargeRecords: (params?: { page?: number; page_size?: number }) => - request<{ data: { list: ChargeRecordOrder[]; total: number } }>('/charge-records', { params: params as Record }), + request<{ list: ChargeRecordOrder[]; total: number }>('/charge-records', { params: params as Record }), getOrganizations: () => request('/organizations'), scanCabinet: (abstractId: string) => - request<{ data: ScanResult }>(`/cabinets/scan/${abstractId}`), + request(`/cabinets/scan/${abstractId}`), + + submitFeedback: (content: string, contact?: string, images?: string[]) => + request<{ success: boolean }>('/feedback', { method: 'POST', body: { content, contact, images } }), + + uploadFeedbackImage: (tempPath: string) => + new Promise((resolve, reject) => { + const token = uni.getStorageSync('mp_token') + uni.uploadFile({ + url: `${BASE_URL}/feedback/upload`, + filePath: tempPath, + name: 'file', + header: token ? { Authorization: `Bearer ${token}` } : {}, + timeout: 30000, + success: (res) => { + let body: any + try { body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data } catch { body = null } + if (body && body.code === 0 && body.data?.url) resolve(body.data.url) + else reject(new Error(body?.message || '图片上传失败')) + }, + fail: (err) => reject(new Error(err.errMsg || '图片上传失败')) + }) + }), } diff --git a/software/h5-uniapp/src/components/AppIcon.vue b/software/h5-uniapp/src/components/AppIcon.vue new file mode 100644 index 0000000..c3de9e3 --- /dev/null +++ b/software/h5-uniapp/src/components/AppIcon.vue @@ -0,0 +1,48 @@ + + + + + \ No newline at end of file diff --git a/software/h5-uniapp/src/manifest.json b/software/h5-uniapp/src/manifest.json index 121edbf..cec3310 100644 --- a/software/h5-uniapp/src/manifest.json +++ b/software/h5-uniapp/src/manifest.json @@ -1,24 +1,18 @@ { - "name": "安知充", + "name": "艾动智能", "appid": "", "description": "智能充电柜管理系统", "versionName": "1.0.0", "versionCode": "100", "transformPx": false, - "h5": { - "title": "安知充", - "router": { - "mode": "history", - "base": "/h5/" + "mp-weixin": { + "libVersion": "3.4.10", + "appid": "wx226061c550c52e85", + "setting": { + "urlCheck": false, + "es6": true, + "minified": true }, - "devServer": { - "port": 5175, - "proxy": { - "/api": { - "target": "http://10.8.0.252:3000", - "changeOrigin": true - } - } - } + "usingComponents": true } } diff --git a/software/h5-uniapp/src/pages.json b/software/h5-uniapp/src/pages.json index d817df3..1129d60 100644 --- a/software/h5-uniapp/src/pages.json +++ b/software/h5-uniapp/src/pages.json @@ -1,19 +1,19 @@ { "pages": [ { "path": "pages/home/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } }, - { "path": "pages/devices/index", "style": { "navigationStyle": "custom" } }, { "path": "pages/orders/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } }, { "path": "pages/me/index", "style": { "navigationStyle": "custom" } }, { "path": "pages/login/index", "style": { "navigationStyle": "custom" } }, { "path": "pages/device-detail/index", "style": { "navigationStyle": "custom" } }, - { "path": "pages/compartment-detail/index", "style": { "navigationStyle": "custom" } } + { "path": "pages/compartment-detail/index", "style": { "navigationStyle": "custom" } }, + { "path": "pages/feedback/index", "style": { "navigationStyle": "custom" } } ], "tabBar": { - "color": "#999999", - "selectedColor": "#2563eb", + "color": "#94a3b8", + "selectedColor": "#0d9488", "backgroundColor": "#ffffff", "borderStyle": "black", - "iconWidth": "28px", + "iconWidth": "24px", "list": [ { "pagePath": "pages/home/index", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" }, { "pagePath": "pages/orders/index", "iconPath": "static/tabbar/orders.png", "selectedIconPath": "static/tabbar/orders-active.png" }, @@ -22,8 +22,8 @@ }, "globalStyle": { "navigationBarTextStyle": "black", - "navigationBarTitleText": "安知充", + "navigationBarTitleText": "艾动智能", "navigationBarBackgroundColor": "#ffffff", - "backgroundColor": "#f5f5f5" + "backgroundColor": "#f5f7f8" } } diff --git a/software/h5-uniapp/src/pages/compartment-detail/index.vue b/software/h5-uniapp/src/pages/compartment-detail/index.vue index 2ac2e2c..975e2a4 100644 --- a/software/h5-uniapp/src/pages/compartment-detail/index.vue +++ b/software/h5-uniapp/src/pages/compartment-detail/index.vue @@ -1,91 +1,76 @@ diff --git a/software/h5-uniapp/src/pages/device-detail/index.vue b/software/h5-uniapp/src/pages/device-detail/index.vue index ad8bfa0..7a3fb1c 100644 --- a/software/h5-uniapp/src/pages/device-detail/index.vue +++ b/software/h5-uniapp/src/pages/device-detail/index.vue @@ -1,342 +1,208 @@ diff --git a/software/h5-uniapp/src/pages/feedback/index.vue b/software/h5-uniapp/src/pages/feedback/index.vue new file mode 100644 index 0000000..269ef2a --- /dev/null +++ b/software/h5-uniapp/src/pages/feedback/index.vue @@ -0,0 +1,144 @@ +