Compare commits

...

178 Commits

Author SHA1 Message Date
Codex ee7b16c932 feat(M10-B): 完成可复算经营报表与日汇总 2026-08-11 09:50:05 +08:00
Codex 5d70114bab feat(M10-A): 完成统一通知中心与投递闭环 2026-08-11 09:17:18 +08:00
Codex 41b9cf7349 fix(M09-REGRESSION): 完成商品逐单对账与权限收口 2026-08-11 08:25:12 +08:00
Codex e91b979128 feat(M09-D4): 完成商品选购与运营工作台 2026-08-11 08:06:13 +08:00
Codex 60264cbd11 feat(M09-D3): 完成商品寄存与安全取出闭环 2026-08-11 07:39:46 +08:00
Codex 1a3ea7bdf0 feat(M09-D2): 完成商品订单与库存占用生命周期 2026-08-11 07:04:23 +08:00
Codex 93af128002 feat(M09-D1): 完成商品目录与库存流水底座 2026-08-11 03:51:18 +08:00
Codex 2de796143f chore(V5.7): 增加中文提交信息校验门禁 2026-08-11 03:07:45 +08:00
Codex fd6be4f9b9 docs(M09-C): record settlement closure and advance cursor 2026-08-11 02:13:17 +08:00
Codex 1f1071501f feat(cleaning): close settlement integrity and reconciliation 2026-08-11 02:07:59 +08:00
Codex 5e0f5e39ee docs(M09-B): 回填清洁规则验收与游标 2026-08-10 21:52:09 +08:00
Codex bc7cb8fab9 feat(M09-B): 完成清洁规则与照片验收闭环 2026-08-10 21:49:07 +08:00
Codex b507e87999 docs(M08-D-R1): 回填后台架构返工与游标 2026-08-10 20:59:17 +08:00
Codex 3faf86872d refactor(M08-D-R1): 后台改用权限路由与懒加载 2026-08-10 20:57:30 +08:00
Codex bb89061671 docs(M06-R): 回填协议全量回归与游标 2026-08-10 20:32:06 +08:00
Codex da5751f191 fix(M06-R): 修复静态检查路径兼容 2026-08-10 19:20:47 +08:00
Codex 95cc5ea444 docs(M06-E-R1): 回填插座协议与游标 2026-08-10 19:15:15 +08:00
Codex 36b3b2fd25 fix(M06-E-R1): 通过危险口令秘密扫描 2026-08-10 19:13:32 +08:00
Codex aed36f0f82 fix(M06-E-R1): 校正智慧插座协议 2026-08-10 19:10:25 +08:00
Codex 3ff68d103b docs(M06-C-R1): 回填协议纠偏与游标 2026-08-10 16:28:11 +08:00
Codex dabc656f63 fix(M06-C-R1): 校正控制箱与门锁协议 2026-08-10 16:01:38 +08:00
Codex b71b48b3d0 docs(M09-A): 回填事务收口与V5.6游标 2026-08-10 14:58:50 +08:00
Codex 9b2f055ecd fix(M09-A): 收口保洁任务事务与并发 2026-08-10 14:48:04 +08:00
Codex b35af9037f docs(M08-D): 回填后台登录与会话入口 2026-08-10 13:58:49 +08:00
Codex 05110087d1 feat(M08-D): 补后台登录与可撤销会话 2026-08-10 13:54:03 +08:00
Codex 93d83e81b9 docs(M08-D): 回填审计日志与系统配置 2026-08-10 13:30:50 +08:00
Codex 7e170e8743 feat(M08-D): 补审计日志与系统配置 2026-08-10 13:28:54 +08:00
Codex bd00aa6275 docs(M08-D): 回填加盟申请与跟进运营 2026-08-10 13:18:51 +08:00
Codex aa4a6bd014 feat(M08-D): 补加盟申请与跟进运营 2026-08-10 13:17:19 +08:00
Codex e0e17159a7 docs(M08-D): 回填广告与装修运营 2026-08-10 12:59:22 +08:00
Codex c1c02421a1 feat(M08-D): 补广告与装修管理 2026-08-10 12:57:05 +08:00
Codex ec4219ae31 docs(M08-D): 回填多应用租户配置 2026-08-10 12:39:33 +08:00
Codex c7f27bc724 feat(M08-D): 补多小程序与租户配置 2026-08-10 12:37:53 +08:00
Codex b3fba4b815 docs(M08-D): 回填设备运营后台 2026-08-10 12:28:39 +08:00
Codex 5c9fd697f3 feat(M08-D): 补设备运营后台 2026-08-10 12:27:05 +08:00
Codex 7c5f55cccf docs(M08-D): 回填会员与员工运营 2026-08-10 12:20:56 +08:00
Codex 93f06d2ed6 feat(M08-D): 补会员与员工用户管理 2026-08-10 12:19:24 +08:00
Codex c71e130c5c docs(M08-D): 回填团购与第三方运营 2026-08-10 12:12:06 +08:00
Codex 7b978089d5 feat(M08-D): 补团购与第三方平台运营 2026-08-10 12:10:15 +08:00
Codex e8bd176914 docs(M08-D): 回填支付退款与分账运营 2026-08-10 12:01:37 +08:00
Codex 4eac99dd87 feat(M08-D): 补支付退款与分账运营 2026-08-10 12:00:02 +08:00
Codex 041146460d docs(M08-D): 回填后台订单运营 2026-08-10 11:53:50 +08:00
Codex 20a292e4ce feat(M08-D): 补后台订单运营 2026-08-10 11:52:25 +08:00
Codex 3502f5b5f7 docs(M08-D): 回填门店与房间管理 2026-08-10 11:42:36 +08:00
Codex 1ca783e91b feat(M08-D): 补门店与房间管理 2026-08-10 11:40:09 +08:00
Codex 04afce4417 docs(M08-D): 回填后台运营总览 2026-08-10 11:23:58 +08:00
Codex 65f2e7c916 feat(M08-D): 建立后台运营总览 2026-08-10 11:22:13 +08:00
Codex b08a2cca43 docs(M08-C): 完成管理员小程序模块 2026-08-10 11:14:06 +08:00
Codex ce52f07588 feat(M08-C): 补管理员验券与经营统计 2026-08-10 11:08:55 +08:00
Codex 3bfccb8513 docs(M08-C): 回填保洁与设备运营 2026-08-10 10:53:26 +08:00
Codex 65932a8000 feat(M08-C): 补保洁与设备运营 2026-08-10 10:52:06 +08:00
Codex a95e8bf8c8 docs(M08-C): 回填会员检索与员工概况 2026-08-10 10:38:10 +08:00
Codex 1b0839e881 feat(M08-C): 补会员检索与员工概况 2026-08-10 10:35:44 +08:00
Codex 8b46863cd8 docs(M08-C): 回填订单处置与代下单 2026-08-10 10:21:53 +08:00
Codex 566e61b5d9 feat(M08-C): 补管理员订单处置与代下单 2026-08-10 10:20:32 +08:00
Codex 8b2156cdd5 docs(M08-C): 回填管理员房态运营首段 2026-08-10 10:08:26 +08:00
Codex b70ada06a8 feat(M08-C): 建立管理员房态运营入口 2026-08-10 10:06:12 +08:00
Codex 1160f16e8d docs(M08-B): 回填现场记录与M08-C游标 2026-08-10 09:48:55 +08:00
Codex 90e230c153 feat(M08-B): 补现场执行记录追溯 2026-08-10 09:46:11 +08:00
Codex 01e86e6856 docs(M08-B): 回填联调筛选清除远端校验 2026-07-15 00:30:09 +08:00
Codex 3bee3c47b6 docs(M08-B): 回填联调筛选清除工程提交 2026-07-15 00:29:23 +08:00
Codex 96c5fa4c90 feat(M08-B): 补联调筛选清除 2026-07-15 00:25:30 +08:00
Codex 1a1892cfb4 docs(M08-B): 回填联调筛选导出远端校验 2026-07-08 10:49:42 +08:00
Codex 06ea496eb7 docs(M08-B): 回填联调筛选导出工程提交 2026-07-08 10:48:38 +08:00
Codex de0013e329 feat(M08-B): 补联调筛选导出 2026-07-08 10:43:44 +08:00
Codex dabb71d8ab docs(M08-B): 回填联调搜索远端校验 2026-07-08 10:38:18 +08:00
Codex 0f85422e82 docs(M08-B): 回填联调搜索工程提交 2026-07-08 10:36:55 +08:00
Codex 053e27fd40 feat(M08-B): 补联调检查项搜索 2026-07-08 10:31:19 +08:00
Codex a4c2d14c41 docs(M08-B): 回填批量免清洁远端校验 2026-07-08 10:26:11 +08:00
Codex c40cbcac11 docs(M08-B): 回填批量免清洁工程提交 2026-07-08 10:25:17 +08:00
Codex eb838f5a7e feat(M08-B): 补批量免清洁操作 2026-07-08 10:19:23 +08:00
Codex 0bdcb220e5 docs(M08-B): 回填免清洁统计展示工程提交 2026-07-08 10:13:33 +08:00
Codex 642659ce3e feat(M08-B): 补免清洁统计展示 2026-07-08 10:11:35 +08:00
Codex 19d6734b68 docs(M08-B): 回填任务免清洁工程提交 2026-07-07 15:10:37 +08:00
Codex 8ffb940bdb feat(M08-B): 补任务免清洁 2026-07-07 15:08:32 +08:00
Codex 2a4db54b94 docs(M08-B): 回填保洁员端统计看板工程提交 2026-07-07 14:49:02 +08:00
Codex 6979614aeb feat(M08-B): 补保洁员端统计看板 2026-07-07 14:48:19 +08:00
Codex eb0f58912e docs(M08-B): 回填保洁员绩效排行工程提交 2026-07-07 14:42:07 +08:00
Codex 38410b1185 feat(M08-B): 补保洁员绩效排行 2026-07-07 14:41:35 +08:00
Codex ee39e98418 docs(M08-B): 回填联调执行阶段看板工程提交 2026-07-07 14:33:46 +08:00
Codex 3713ddd1aa feat(M08-B): 补联调执行阶段看板 2026-07-07 14:33:15 +08:00
Codex 31052625ba docs(M08-B): 回填联调负责人看板工程提交 2026-07-07 14:27:35 +08:00
Codex b66b8d4d78 feat(M08-B): 补联调负责人看板 2026-07-07 14:26:54 +08:00
Codex 650b9b917a docs(M08-B): 回填联调逾期清单工程提交 2026-07-07 09:53:06 +08:00
Codex 85d65068e7 feat(M08-B): 补联调逾期清单 2026-07-07 09:52:28 +08:00
Codex e490637179 docs(M08-B): 回填联调期限记录工程提交 2026-07-07 09:45:53 +08:00
Codex bcc89addba feat(M08-B): 补联调期限记录 2026-07-07 09:45:15 +08:00
Codex 7e8fa275d5 docs(M08-B): 回填联调负责人筛选工程提交 2026-07-07 09:38:45 +08:00
Codex 8a17659bdf feat(M08-B): 补联调负责人筛选 2026-07-07 09:38:07 +08:00
Codex 98783714a3 docs(M08-B): 回填联调归档包导出工程提交 2026-07-07 09:33:12 +08:00
Codex 9d8fb3db6b feat(M08-B): 补联调归档包导出 2026-07-07 09:32:25 +08:00
Codex c026ef7ebe docs(M08-B): 回填联调风险清单导出工程提交 2026-07-07 09:27:03 +08:00
Codex dc27897ea4 feat(M08-B): 补联调风险清单导出 2026-07-07 09:26:27 +08:00
Codex 654f41fa40 docs(M08-B): 回填保洁员待补资料导出工程提交 2026-07-07 09:19:38 +08:00
Codex 05cf27f112 feat(M08-B): 补保洁员待补资料导出 2026-07-07 09:18:59 +08:00
Codex b643fda99c docs(M08-B): 回填运营待处理导出工程提交 2026-07-06 15:10:59 +08:00
Codex 1c1039ae0e feat(M08-B): 补运营待处理导出 2026-07-06 15:10:17 +08:00
Codex 0d0bdf20d5 docs(M08-B): 回填结算详情导出工程提交 2026-07-06 15:04:04 +08:00
Codex 6961193857 feat(M08-B): 补结算详情导出 2026-07-06 15:03:17 +08:00
Codex 8c60c35444 docs(M08-B): 回填任务成员导出工程提交 2026-07-06 14:57:51 +08:00
Codex a720c05c9d feat(M08-B): 补任务成员导出 2026-07-06 14:57:09 +08:00
Codex d0cf00309f docs(M08-B): 回填任务流水导出工程提交 2026-07-06 14:51:00 +08:00
Codex 2d07c92a88 feat(M08-B): 补任务流水导出 2026-07-06 14:50:19 +08:00
Codex 38ef98b06b docs(M08-B): 回填保洁统计导出工程提交 2026-07-06 14:43:58 +08:00
Codex a067c1d020 feat(M08-B): 补保洁统计导出 2026-07-06 14:43:19 +08:00
Codex 6c977632c0 docs(M08-B): 回填保洁员列表导出工程提交 2026-07-06 14:34:23 +08:00
Codex a09683b82d feat(M08-B): 补保洁员列表导出 2026-07-06 14:33:46 +08:00
Codex 7a4797021b docs(M08-B): 回填联调收口门禁工程提交 2026-07-06 08:56:32 +08:00
Codex a1adf4d6ab feat(M08-B): 补联调收口门禁 2026-07-06 08:55:49 +08:00
Codex 300e23c93c docs(M08-B): 回填联调收口签署工程提交 2026-07-06 08:49:13 +08:00
Codex 2e200842c9 feat(M08-B): 补联调收口签署 2026-07-06 08:48:51 +08:00
Codex 31aed4835e docs(M08-B): 回填联调交接报告工程提交 2026-07-06 08:43:48 +08:00
Codex 464566a5e0 feat(M08-B): 补联调交接报告 2026-07-06 08:43:19 +08:00
Codex 99d9d862b7 docs(M08-B): 回填联调待补证据工程提交 2026-07-06 08:38:18 +08:00
Codex 9f718ab154 feat(M08-B): 补联调待补证据清单 2026-07-06 08:37:53 +08:00
Codex 1e930597b1 docs(M08-B): 回填联调证据记录工程提交 2026-07-06 08:32:53 +08:00
Codex a9bb06f732 feat(M08-B): 补联调证据记录 2026-07-06 08:32:33 +08:00
Codex a8c3fde95a docs(M08-B): 回填联调更新时间工程提交 2026-07-06 08:27:25 +08:00
Codex 14fcfc2578 feat(M08-B): 补联调更新时间 2026-07-06 08:26:46 +08:00
Codex 89b5263352 docs(M08-B): 回填联调验收结论工程提交 2026-07-06 08:21:27 +08:00
Codex 0eb6349021 feat(M08-B): 补联调验收结论 2026-07-06 08:20:25 +08:00
Codex a95fdcbb93 docs(M08-B): 回填联调分类进度工程提交 2026-07-06 08:17:49 +08:00
Codex 1543a7df99 feat(M08-B): 补联调分类进度 2026-07-06 08:16:56 +08:00
Codex 86106459c4 docs(M08-B): 回填联调快照工程提交 2026-07-05 16:11:48 +08:00
Codex 06f905e792 feat(M08-B): 补联调快照导入导出 2026-07-05 16:10:54 +08:00
Codex 1f758cc29f docs(M08-B): 回填联调场次信息工程提交 2026-07-05 16:08:15 +08:00
Codex b5e7121c35 feat(M08-B): 补联调场次信息 2026-07-05 16:07:29 +08:00
Codex fda768d4ce docs(M08-B): 回填联调阻断摘要工程提交 2026-07-05 16:03:50 +08:00
Codex a44864309c feat(M08-B): 补联调阻断摘要 2026-07-05 16:02:51 +08:00
Codex 8e4264589d feat(M08-B): 补联调批量标记 2026-07-05 16:00:17 +08:00
Codex 273e429d9e feat(M08-B): 补联调状态筛选 2026-07-05 15:57:03 +08:00
Codex 435c9f3f97 feat(M08-B): 补现场联调清单 2026-07-05 15:54:57 +08:00
Codex 9ab3f5ff46 docs(M08-B): 回填任务详情导出工程提交 2026-07-05 15:44:37 +08:00
Codex 9346e7a18a feat(M08-B): 补任务详情导出 2026-07-05 15:43:45 +08:00
Codex 0b3246ae8a docs(M08-B): 回填任务导出工程提交 2026-07-05 15:40:04 +08:00
Codex a50c6afdbe feat(M08-B): 补任务列表导出 2026-07-05 15:39:02 +08:00
Codex d6bb9ccb1e docs(M08-B): 回填预检拦截工程提交 2026-07-05 15:28:21 +08:00
Codex 44af0ee155 feat(M08-B): 补微信转账预检拦截 2026-07-05 15:27:50 +08:00
Codex 52fb508e03 docs(M08-B): 回填预检导出工程提交 2026-07-05 15:15:24 +08:00
Codex 8fae70ef8e feat(M08-B): 补微信预检导出 2026-07-05 15:14:54 +08:00
Codex aa4f74360d docs(M08-B): 回填预检资料工程提交 2026-07-05 15:08:48 +08:00
Codex a264b37bd0 feat(M08-B): 补微信预检资料展示 2026-07-05 15:08:21 +08:00
Codex c1165ccfd8 docs(M08-B): 回填确认参数工程提交 2026-07-05 15:00:53 +08:00
Codex b091c7caf1 feat(M08-B): 补微信确认参数展示 2026-07-05 15:00:27 +08:00
Codex 8f7a914e7d docs(M08-B): 回填转账筛选工程提交 2026-07-05 14:55:54 +08:00
Codex 08c152aecc feat(M08-B): 补结算转账状态筛选 2026-07-05 14:55:22 +08:00
Codex 983966561f docs(M08-B): 回填运营入口工程提交 2026-07-05 14:43:51 +08:00
Codex 2fa1f079df feat(M08-B): 补运营待处理入口 2026-07-05 14:43:04 +08:00
Codex 9f6e267d43 feat(M08-B): 补结算批量同步 2026-07-02 08:10:37 +08:00
Codex 2ce59eee3b feat(M08-B): 补任务详情协作分账 2026-07-02 08:06:29 +08:00
Codex 5eff0a4a18 feat(M08-B): 补批量验收驳回 2026-07-02 08:01:40 +08:00
Codex 986a90806b feat(M08-B): 补任务事件流水 2026-06-30 18:26:53 +08:00
Codex 957a29b11e feat(M08-B): 补结算候选导出 2026-06-30 18:19:57 +08:00
Codex 67e7796652 feat(M08-B): 补待结算候选预览 2026-06-30 18:15:36 +08:00
Codex 78fb6ed2d0 feat(M08-B): 补统计保洁员筛选 2026-06-30 17:56:35 +08:00
Codex e6bb5e6dc6 feat(M08-B): 补任务详情照片预览 2026-06-30 17:48:29 +08:00
Codex e46d8c0f0a feat(M08-B): 补任务结算筛选 2026-06-30 17:30:33 +08:00
Codex dac353d184 feat(M08-B): 补保洁员选择器 2026-06-30 17:22:31 +08:00
Codex 335adcdc22 feat(M08-B): 补保洁员资料细节 2026-06-30 11:02:43 +08:00
Codex d35c39e33f feat(M08-B): 补保洁统计趋势 2026-06-30 10:52:38 +08:00
Codex 326c534ba7 feat(M08-B): 补保洁员资料管理 2026-06-30 10:40:45 +08:00
Codex e48d1f0301 feat(M08-B): 补保洁统计明细 2026-06-30 10:24:39 +08:00
Codex f59beb0e75 feat(M08-B): 补后台协作与结算详情 2026-06-30 10:02:17 +08:00
Codex 9506021b29 feat(M08-B): 补微信转账预检 2026-06-29 11:23:49 +08:00
Codex 1fada09249 feat(M08-B): 建立保洁管理后台 2026-06-29 11:06:54 +08:00
Codex a1bace2dac feat(M08-B): 补保洁微信转账回调与轮询 2026-06-29 10:47:54 +08:00
Codex b07c451700 feat(M08-B): 接入保洁微信转账适配器 2026-06-27 16:07:41 +08:00
Codex 05907a6bea feat(M08-B): 补保洁结算发放记录 2026-06-27 15:44:32 +08:00
Codex 5d224693ae docs(M08-B): 记录保洁多人协作进度 2026-06-26 17:47:39 +08:00
Codex 03496d74ab feat(M08-B): 增加保洁多人协作策略 2026-06-26 17:46:00 +08:00
Codex 1dc36d716a docs(M08-B): 记录保洁结算单进度 2026-06-26 15:52:07 +08:00
Codex 2ec4376b1c feat(M08-B): 增加保洁结算单和超时回收 2026-06-26 15:51:50 +08:00
Codex 704dca7bcf docs(M08-B): 记录保洁验收管理进度 2026-06-26 15:42:41 +08:00
Codex 59ce3eca53 feat(M08-B): 增加保洁验收和指派管理 2026-06-26 15:42:30 +08:00
Codex c8ddc8dd00 docs(M08-B): 记录保洁照片上传进度 2026-06-26 15:36:57 +08:00
Codex fe1a08880e feat(M08-B): 补保洁照片上传和自动建单 2026-06-26 15:36:22 +08:00
Codex f7e0c8d063 docs(M08-B): 记录保洁端基础流程进度 2026-06-25 23:22:03 +08:00
Codex 2a4dcd0fd8 feat(M08-B): 接入保洁任务端基础流程 2026-06-25 23:19:04 +08:00
304 changed files with 65561 additions and 589 deletions
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env sh
HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(git -C "$HOOK_DIR" rev-parse --show-toplevel 2>/dev/null)
if [ -z "$REPO_ROOT" ]; then
echo "FAIL: 无法确定 Git 仓库根目录,中文提交门禁未执行。" >&2
exit 1
fi
if ! command -v node >/dev/null 2>&1; then
echo "FAIL: 未找到 Node.js,无法执行中文提交门禁。" >&2
exit 1
fi
exec node "$REPO_ROOT/scripts/check-commit-message.mjs" --file "$1"
+35 -10
View File
@@ -1,10 +1,10 @@
# 自助棋牌室系统
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.4.md`](./V5.4.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.7.md`](./V5.7.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
## 固定约束
- 当前权威总纲:`V5.4.md`
- 当前权威总纲:`V5.7.md`
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
- 默认分支:`main`
- 生产 API`https://api.txyundm.cn`
@@ -19,8 +19,8 @@
## 目录
- `backend/`Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
- `admin/`:Vue3 后台管理端,必须适配桌面、平板和手机
- `miniapp/`:微信原生小程序
- `admin/`Vue3 后台管理端,已完成租户账号登录、短期访问令牌、轮换刷新令牌、当前会话、注销和 401 自动清理,并接入 M08-B 保洁运营工作台及 M08-D 计划内运营工作区;必须适配桌面、平板和手机
- `miniapp/`:微信原生小程序,已接入顾客端主链路与保洁端任务大厅、我的任务、照片上传、驳回补做和保洁员统计看板
- `database/`:迁移、种子和兼容 SQL
- `deploy/`Nginx、PM2、EMQX 模板与部署版本
- `scripts/`Windows、WSL 和 Ubuntu 辅助脚本
@@ -35,7 +35,7 @@
| 用途 | 位置 |
|---|---|
| 项目入口 | `D:\qipai\README.md` |
| 当前总纲 | `D:\qipai\V5.4.md` |
| 当前总纲 | `D:\qipai\V5.7.md` |
| 完整配置索引 | `D:\qipai\docs\configuration.md` |
| 仓库目录映射 | `D:\qipai\docs\repository-map.md` |
| 后端开发配置 | `D:\qipai\backend\.env.development` |
@@ -108,21 +108,37 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
## 持续开发规则
### 一次性启用 Git 中文提交门禁
每次新克隆仓库后必须执行一次安装脚本;Git hook 不会仅因克隆仓库自动启用。Windows 在仓库内执行:
```powershell
powershell -ExecutionPolicy Bypass -File .\scripts\setup-git-hooks.ps1
```
WSL/Ubuntu 在仓库内执行:
```bash
sh scripts/setup-git-hooks.sh
```
脚本会把当前克隆的 `core.hooksPath` 配置为 `.githooks`。之后每次提交都会校验首行符合 `<type>(<scope>): 中文摘要`;可单独运行 `node scripts/tests/check-commit-message.test.mjs` 验证门禁。
- 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`
- `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
- 每个子阶段必须完成工程编码、真实测试、增量文档、commit、push,并验证 `HEAD == origin/main`
- 完成一个子阶段后,在会话资源允许时自动继续下一子阶段,不询问“是否继续”。
- 纯 Markdown 变更不计业务进度;V5.4 文档提交后的下一次普通开发必须产生工程增量
- 纯 Markdown 变更不计业务进度;状态文档只随对应工程阶段伴随更新
- 当前游标、最近工程提交和下一工程目标以 `docs/module-status.md``docs/current-baseline.md` 为准,不在 README 中猜测。
## 当前进度
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M08-A`PARTIAL
- 最近工程提交:`4701674`完成顾客端权益可视化选择器和时长券折价
- 下一工程目标:继续 M08-A,补真机合法域名、真实微信支付和实物开门验证
- 当前执行游标:`M10-C`(TODO);M10-A 统一通知中心与 M10-B 统计报表已完成内部工程,真实通知渠道凭据仍保持 `BLOCKED_EXTERNAL`
- 最近工程结果:已推送 `5d70114` 完成 M10-A;当前 M10-B 随本次中文工程提交固化门店时区报表、收入减退款、房间实际时长/利用率、商品收入、保洁成本、支付渠道、日汇总 checksum 复算、CSV 与后台工作台
- 下一工程目标:执行 `M10-C`,完成多 AppID/租户隔离回归、加盟开通工作流与受控扩展适配器骨架
## 版本递进
@@ -130,10 +146,19 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
- **V5.2**:增加工程编码优先、反文档循环、增量审计和工程完成证据。
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
- **V5.7**:增加中文 Git 提交门禁,完成 M09 商品域、M10-A 通知中心与 M10-B 统计报表内部闭环,游标推进至 M10-C。
## Codex 入口
```text
请阅读 V5.4.md,按当前进度继续开发。
请阅读 V5.7.md,按当前进度继续开发。
```
+6734
View File
File diff suppressed because it is too large Load Diff
+7244
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
VITE_ADMIN_API_BASE_URL=https://api.txyundm.cn/admin-api
-1
View File
@@ -1 +0,0 @@
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>自助棋牌室后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2030
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@qipai/admin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vue-tsc --noEmit && vite build",
"test": "node tests/navigation.test.mjs && node ../scripts/check-admin-m08-d-r1.mjs --source-only",
"verify:r1": "npm run build && npm test && node ../scripts/check-admin-m08-d-r1.mjs --dist-only",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@lucide/vue": "^1.22.0",
"element-plus": "^2.10.4",
"vue": "^3.5.17",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "^5.6.3",
"unplugin-vue-components": "^28.8.0",
"vite": "^6.4.3",
"vue-tsc": "^2.2.12"
}
}
+262
View File
@@ -0,0 +1,262 @@
<template>
<el-config-provider>
<AdminLoginPanel
v-if="!adminSessionState.token"
@authenticated="handleAuthenticated"
/>
<main v-else-if="adminSessionState.ready && !restoreError" class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"></div>
<div>
<h1>自助棋牌室</h1>
<p>运营后台</p>
</div>
</div>
<nav class="nav-list" aria-label="后台模块">
<RouterLink
v-for="item in visibleNavigation"
:key="item.menuKey"
:to="item.path"
class="nav-item"
active-class="active"
>
<component :is="navigationIcons[item.icon]" :size="18" />
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">{{ route.meta.stage || 'M08-D' }}</p>
<h2>{{ route.meta.title || '运营后台' }}</h2>
</div>
<div class="session-box">
<div class="session-user">
<span>{{ adminSessionState.user?.nickname?.slice(0, 1) || '管' }}</span>
<div>
<strong>{{ adminSessionState.user?.nickname || '管理员' }}</strong>
<small>{{ adminSessionState.access.roles.join(' · ') || '已登录' }}</small>
</div>
</div>
<el-tooltip content="安全退出" placement="bottom">
<el-button :icon="LogOut" circle @click="handleLogout" />
</el-tooltip>
</div>
</header>
<el-alert
v-if="routeLoadError"
class="route-alert"
type="error"
show-icon
:closable="false"
title="页面资源加载失败"
:description="routeLoadError"
>
<template #default>
<el-button type="primary" plain @click="retryLazyRoute">重新加载</el-button>
</template>
</el-alert>
<RouterView v-else v-slot="{ Component }">
<Suspense>
<component
:is="Component"
:session="session"
@open-cleaning="openCleaning"
/>
<template #fallback>
<section class="route-state" aria-live="polite">
<el-skeleton :rows="8" animated />
</section>
</template>
</Suspense>
</RouterView>
</section>
</main>
<main v-else class="session-recovery" aria-live="polite">
<section v-if="restoreError" class="session-recovery-card">
<h1>暂时无法恢复后台会话</h1>
<p>{{ restoreError }}</p>
<div>
<el-button type="primary" :loading="restoring" @click="restoreSession">重试</el-button>
<el-button @click="clearSession">返回登录</el-button>
</div>
</section>
<section v-else class="session-recovery-card">
<el-skeleton :rows="4" animated />
<p>正在安全恢复会话与权限菜单</p>
</section>
</main>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import { ElMessage } from 'element-plus';
import {
ChartNoAxesCombined,
ClipboardList,
BellRing,
Handshake,
Images,
LayoutDashboard,
LogOut,
PanelsTopLeft,
RadioTower,
ScrollText,
Sparkles,
Store,
ShoppingBasket,
TicketCheck,
UsersRound,
WalletCards
} from '@lucide/vue';
import { useRoute } from 'vue-router';
import AdminLoginPanel from './components/AdminLoginPanel.vue';
import {
clearStoredSession,
getAdminMe,
logoutAdmin,
storeAdminSession,
type ApiSession
} from './api';
import {
ADMIN_NAVIGATION,
firstAuthorizedPath,
isNavigationAllowed,
resolveAuthorizedPath,
type AdminMenuKey
} from './navigation.mjs';
import { router } from './router';
import {
adminSessionState,
applyAdminIdentity,
applyAdminSession,
clearAdminSessionState
} from './session';
import type { AdminSessionPayload } from './types';
const route = useRoute();
const restoring = ref(false);
const restoreError = ref('');
const routeLoadError = ref('');
const session = computed<ApiSession>(() => ({ token: adminSessionState.token }));
const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
(item) => isNavigationAllowed(adminSessionState.access, item)
));
const navigationIcons: Record<string, Component> = {
ChartNoAxesCombined,
BellRing,
ClipboardList,
Handshake,
Images,
LayoutDashboard,
PanelsTopLeft,
RadioTower,
ScrollText,
Sparkles,
Store,
ShoppingBasket,
TicketCheck,
UsersRound,
WalletCards
};
function handleAuthenticated(payload: AdminSessionPayload) {
storeAdminSession(payload);
applyAdminSession(payload);
restoreError.value = '';
void ensureAllowedRoute();
ElMessage.success('登录成功');
}
async function handleLogout() {
try {
await logoutAdmin(session.value);
} catch {
// 本地令牌清理是退出动作的最终安全边界。
}
clearSession();
}
function clearSession() {
clearStoredSession();
clearAdminSessionState();
restoreError.value = '';
routeLoadError.value = '';
void router.replace('/overview');
}
function handleRefreshed(event: Event) {
applyAdminSession((event as CustomEvent<AdminSessionPayload>).detail);
void ensureAllowedRoute();
}
async function restoreSession() {
if (!adminSessionState.token || restoring.value) return;
restoring.value = true;
restoreError.value = '';
adminSessionState.ready = false;
try {
const result = await getAdminMe(session.value);
applyAdminIdentity(result.user, result.access);
await ensureAllowedRoute();
} catch (error) {
if (!localStorage.getItem('qipai.admin.token')) {
clearAdminSessionState();
} else {
restoreError.value = error instanceof Error
? `${error.message}。请检查网络后重试,或返回登录。`
: '会话恢复失败,请检查网络后重试。';
adminSessionState.ready = true;
}
} finally {
restoring.value = false;
}
}
async function ensureAllowedRoute() {
const menuKey = route.meta.menuKey as AdminMenuKey | undefined;
const target = menuKey
? resolveAuthorizedPath(adminSessionState.access, menuKey)
: firstAuthorizedPath(adminSessionState.access);
if (target && target !== route.path) await router.replace(target);
}
function openCleaning() {
void router.push('/cleaning');
}
function retryLazyRoute() {
window.location.reload();
}
const removeRouterErrorHandler = router.onError((error) => {
routeLoadError.value = error instanceof Error
? `${error.message}。可重新加载页面恢复。`
: '路由资源加载失败,可重新加载页面恢复。';
});
const removeAfterEach = router.afterEach((_to, _from, failure) => {
if (!failure) routeLoadError.value = '';
});
onMounted(() => {
window.addEventListener('qipai:admin-refreshed', handleRefreshed);
window.addEventListener('qipai:admin-unauthorized', clearSession);
if (adminSessionState.token) void restoreSession();
});
onBeforeUnmount(() => {
window.removeEventListener('qipai:admin-refreshed', handleRefreshed);
window.removeEventListener('qipai:admin-unauthorized', clearSession);
removeRouterErrorHandler();
removeAfterEach();
});
</script>
+1140
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
<template>
<main class="login-shell">
<section class="login-story" aria-label="平台介绍">
<div class="login-brand"><span></span><strong>自助棋牌室运营平台</strong></div>
<div><p class="section-kicker">QIPAI OPERATIONS</p><h1>一处掌握门店订单设备与资金</h1><p>统一租户权限可撤销会话与全链路审计为日常运营提供可信工作台</p></div>
<ul><li><ShieldCheck :size="18" />短期访问令牌与轮换刷新令牌</li><li><Fingerprint :size="18" />scrypt 密码哈希与失败锁定</li><li><ScrollText :size="18" />关键操作按租户完整留痕</li></ul>
</section>
<section class="login-form-wrap">
<div class="login-card">
<header><p class="section-kicker">管理后台</p><h2>欢迎回来</h2><p>使用租户代码和后台账号登录</p></header>
<el-alert v-if="lastError" :title="lastError" type="error" show-icon :closable="false" />
<el-form label-position="top" @submit.prevent="login">
<el-form-item label="租户代码"><el-input v-model="form.tenantCode" size="large" autocomplete="organization" placeholder="例如 demo" @keyup.enter="login" /></el-form-item>
<el-form-item label="登录账号"><el-input v-model="form.loginName" size="large" autocomplete="username" placeholder="请输入后台账号" @keyup.enter="login" /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" size="large" type="password" show-password autocomplete="current-password" placeholder="请输入密码" @keyup.enter="login" /></el-form-item>
<el-button type="primary" size="large" :loading="loading" :disabled="!canSubmit" @click="login">安全登录</el-button>
</el-form>
<footer><LockKeyhole :size="15" /><span>连续失败 5 次将锁定账号 15 分钟</span></footer>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { Fingerprint, LockKeyhole, ScrollText, ShieldCheck } from '@lucide/vue';
import { adminPasswordLogin, ApiError } from '../api';
import type { AdminSessionPayload } from '../types';
const emit = defineEmits<{ authenticated: [payload: AdminSessionPayload] }>();
const loading = ref(false); const lastError = ref('');
const form = reactive({ tenantCode: localStorage.getItem('qipai.admin.tenantCode') || '', loginName: '', password: '' });
const canSubmit = computed(() => form.tenantCode.trim().length >= 2 && form.loginName.trim().length >= 3 && form.password.length > 0);
async function login() { if (!canSubmit.value || loading.value) return; loading.value = true; lastError.value = ''; try { const payload = await adminPasswordLogin({ tenantCode: form.tenantCode.trim(), loginName: form.loginName.trim(), ['password']: form.password }); localStorage.setItem('qipai.admin.tenantCode', form.tenantCode.trim()); form.password = ''; emit('authenticated', payload); } catch (error) { lastError.value = error instanceof ApiError ? loginMessage(error) : error instanceof Error ? error.message : '登录失败'; } finally { loading.value = false; } }
function loginMessage(error: ApiError) { if (error.code === 'ADMIN_LOGIN_LOCKED') return '登录失败次数过多,账号已暂时锁定,请 15 分钟后重试。'; if (error.code === 'ADMIN_LOGIN_INVALID') return '租户代码、登录账号或密码不正确。'; return `${error.code}${error.traceId ? ` · 追踪号 ${error.traceId}` : ''}`; }
</script>
@@ -0,0 +1,177 @@
<template>
<section class="report-page">
<header class="panel report-toolbar">
<div>
<p class="section-kicker">M10-B · 可复算口径</p>
<h3>经营统计报表</h3>
<p>所有日期按各门店时区切分净收入等于成功实收减成功退款</p>
</div>
<div class="report-actions">
<el-select v-model="storeId" placeholder="全部授权门店" clearable filterable>
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-date-picker v-model="range" type="daterange" unlink-panels range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" />
<el-button-group>
<el-button @click="setPreset(1)">今日</el-button>
<el-button @click="setPreset(7)"> 7 </el-button>
</el-button-group>
<el-button type="primary" :icon="RefreshCw" :loading="loading" @click="load">查询</el-button>
<el-button :icon="Download" :loading="exporting" @click="exportCsv">导出</el-button>
<el-button v-if="canRebuild" :icon="RotateCcw" :loading="rebuilding" @click="rebuild">重算日汇总</el-button>
</div>
</header>
<el-alert v-if="errorMessage" type="error" :title="errorMessage" show-icon :closable="false" />
<template v-if="report">
<section class="report-metrics">
<article><span>总净收入</span><strong>{{ money(report.summary.totalNetCents) }}</strong><small>房费 {{ money(report.summary.roomNetCents) }} · 商品 {{ money(report.summary.productNetCents) }}</small></article>
<article><span>成功退款</span><strong>{{ money(report.summary.roomRefundCents + report.summary.productRefundCents) }}</strong><small>收入均已扣减退款</small></article>
<article><span>订单 / 下单人数</span><strong>{{ report.summary.orderCount }} / {{ report.summary.customerCount }}</strong><small>{{ report.scope.storeCount }} 家门店</small></article>
<article><span>实际使用时长</span><strong>{{ duration(report.summary.usedMinutes) }}</strong><small>利用率 {{ percent(report.summary.utilizationBasisPoints) }}</small></article>
<article><span>保洁成本</span><strong>{{ money(report.summary.cleaningCostCents) }}</strong><small>贡献额 {{ money(report.summary.contributionCents) }}</small></article>
</section>
<section class="panel reconciliation-card">
<div>
<p class="section-kicker">日汇总与明细复算</p>
<h3>{{ reconciliationText }}</h3>
<p>明细复算 {{ report.reconciliation.expectedRows }} 日汇总 {{ report.reconciliation.persistedRows }} 差异 {{ report.reconciliation.mismatchRows }} </p>
</div>
<el-tag :type="report.reconciliation.status === 'MATCHED' ? 'success' : report.reconciliation.status === 'MISMATCH' ? 'danger' : 'warning'">
{{ report.reconciliation.status }}
</el-tag>
</section>
<section class="report-grid">
<article class="panel">
<div class="section-heading"><div><p class="section-kicker">支付渠道</p><h3>净收入拆分</h3></div></div>
<div class="channel-grid">
<div v-for="channel in channels" :key="channel.key"><span>{{ channel.label }}</span><strong>{{ money(report.summary.channels[channel.key]) }}</strong></div>
</div>
</article>
<article class="panel">
<div class="section-heading"><div><p class="section-kicker">明细来源</p><h3>复算记录数</h3></div></div>
<div class="channel-grid">
<div v-for="(value, key) in report.detailCounts" :key="key"><span>{{ detailLabel(key) }}</span><strong>{{ value }}</strong></div>
</div>
</article>
</section>
<section class="panel report-table">
<div class="section-heading"><div><p class="section-kicker">逐店逐日</p><h3>经营明细</h3></div><span>{{ report.metricVersion }}</span></div>
<el-table :data="report.daily" stripe max-height="560">
<el-table-column prop="date" label="日期" width="112" fixed />
<el-table-column prop="storeName" label="门店" min-width="150" fixed />
<el-table-column label="净收入" width="120" align="right"><template #default="{ row }">{{ money(row.totalNetCents) }}</template></el-table-column>
<el-table-column label="商品收入" width="120" align="right"><template #default="{ row }">{{ money(row.productNetCents) }}</template></el-table-column>
<el-table-column label="保洁成本" width="120" align="right"><template #default="{ row }">{{ money(row.cleaningCostCents) }}</template></el-table-column>
<el-table-column prop="orderCount" label="订单" width="78" align="right" />
<el-table-column prop="customerCount" label="人数" width="78" align="right" />
<el-table-column label="使用时长" width="110" align="right"><template #default="{ row }">{{ duration(row.usedMinutes) }}</template></el-table-column>
<el-table-column label="利用率" width="100" align="right"><template #default="{ row }">{{ percent(row.utilizationBasisPoints) }}</template></el-table-column>
<el-table-column prop="timezone" label="时区" width="140" />
</el-table>
</section>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Download, RefreshCw, RotateCcw } from '@lucide/vue';
import { ElMessage } from 'element-plus';
import {
ApiError, downloadBusinessReport, getBusinessReport, listManagedStores,
rebuildBusinessReport, type ApiSession
} from '../api';
import { money } from '../format';
import { adminSessionState } from '../session';
import type { BusinessReport, BusinessReportChannel, ManagedStore } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const storeId = ref('');
const range = ref<[Date, Date]>(presetRange(7));
const report = ref<BusinessReport | null>(null);
const loading = ref(false);
const exporting = ref(false);
const rebuilding = ref(false);
const errorMessage = ref('');
const canRebuild = computed(() => adminSessionState.access.roles.includes('PLATFORM_ADMIN')
|| adminSessionState.access.capabilities.includes('tenant.manage')
|| adminSessionState.access.capabilities.includes('report.manage'));
const channels: Array<{ key: BusinessReportChannel; label: string }> = [
{ key: 'WECHAT', label: '微信' }, { key: 'BALANCE', label: '余额' },
{ key: 'PACKAGE', label: '套餐' }, { key: 'GROUP_BUY', label: '团购平台' },
{ key: 'OTHER', label: '其他' }
];
const reconciliationText = computed(() => ({
MATCHED: '日汇总与明细一致', NOT_AGGREGATED: '尚未生成日汇总', MISMATCH: '日汇总需要重算'
})[report.value?.reconciliation.status ?? 'NOT_AGGREGATED']);
function requestInput() {
return { storeId: storeId.value || undefined, from: dateKey(range.value[0]), to: dateKey(range.value[1]) };
}
async function load() {
loading.value = true; errorMessage.value = '';
try { report.value = await getBusinessReport(props.session, requestInput()); }
catch (error) { report.value = null; capture(error); }
finally { loading.value = false; }
}
async function rebuild() {
rebuilding.value = true;
try {
const result = await rebuildBusinessReport(props.session, requestInput());
ElMessage.success(`已加入 ${result.queued} 个日汇总任务;Worker 完成后刷新即可复核。`);
} catch (error) { capture(error); }
finally { rebuilding.value = false; }
}
async function exportCsv() {
exporting.value = true;
try {
const blob = await downloadBusinessReport(props.session, requestInput());
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
anchor.href = url; anchor.download = `business-report-${dateKey(range.value[0])}-${dateKey(range.value[1])}.csv`;
anchor.click(); URL.revokeObjectURL(url);
} catch (error) { capture(error); }
finally { exporting.value = false; }
}
function setPreset(days: number) { range.value = presetRange(days); void load(); }
function presetRange(days: number): [Date, Date] { const to = new Date(); const from = new Date(to); from.setDate(from.getDate() - days + 1); return [from, to]; }
function dateKey(value: Date) { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, '0'); const day = String(value.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }
function duration(minutes: number) { return minutes >= 60 ? `${Math.floor(minutes / 60)} 小时 ${minutes % 60}` : `${minutes} 分钟`; }
function percent(basisPoints: number) { return `${(basisPoints / 100).toFixed(2)}%`; }
function detailLabel(key: string) { return ({ payments: '房费支付', refunds: '房费退款', productPayments: '商品支付', productRefunds: '商品退款', orders: '房间订单', cleaningEntries: '保洁成本/冲正' } as Record<string, string>)[key] ?? key; }
function capture(error: unknown) { errorMessage.value = error instanceof ApiError ? `${error.message}${error.code}` : error instanceof Error ? error.message : '报表请求失败'; }
onMounted(async () => {
try { stores.value = await listManagedStores(props.session); await load(); }
catch (error) { capture(error); }
});
</script>
<style scoped>
.report-page { display: grid; gap: 18px; }
.report-toolbar { display: flex; justify-content: space-between; gap: 20px; align-items: flex-end; padding: 22px; }
.report-toolbar h3, .reconciliation-card h3 { margin: 4px 0; }
.report-toolbar p, .reconciliation-card p { margin: 0; color: var(--text-muted); }
.report-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }
.report-actions > .el-select { width: 190px; }
.report-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.report-metrics article { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 18px; display: grid; gap: 7px; }
.report-metrics span, .report-metrics small { color: var(--text-muted); }
.report-metrics strong { font-size: 24px; }
.reconciliation-card { display: flex; justify-content: space-between; align-items: center; padding: 20px; }
.report-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.report-grid > article, .report-table { padding: 20px; }
.channel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.channel-grid > div { background: var(--surface-soft); border-radius: 12px; padding: 14px; display: grid; gap: 6px; }
.channel-grid span { color: var(--text-muted); font-size: 13px; }
@media (max-width: 1180px) { .report-toolbar { align-items: stretch; flex-direction: column; } .report-actions { justify-content: flex-start; } .report-metrics { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 720px) { .report-metrics, .report-grid { grid-template-columns: 1fr; } .report-actions > * { width: 100% !important; } }
</style>
+310
View File
@@ -0,0 +1,310 @@
<template>
<section class="panel">
<div class="panel-toolbar">
<div class="toolbar-actions">
<el-input
v-model="searchDraft"
class="search-input"
placeholder="姓名/手机号/ID"
clearable
@keyup.enter="emitSearch"
/>
<el-segmented
:model-value="status"
:options="statusOptions"
@update:model-value="$emit('status-change', $event as UserStatus | '')"
/>
</div>
<div class="toolbar-actions">
<el-button :icon="Download" :disabled="items.length === 0" @click="exportCleaners">
导出
</el-button>
<el-button :icon="FileWarning" :disabled="incompleteCleaners.length === 0" @click="exportCleanerProfileIssues">
导出待补
</el-button>
<el-button type="primary" :icon="UserPlus" @click="createDialog.open = true">
新增
</el-button>
<el-tooltip content="刷新保洁员">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
</div>
<el-table :data="items" :loading="loading" class="data-table" row-key="id">
<el-table-column prop="nickname" label="保洁员" min-width="160" fixed>
<template #default="{ row }">
<div class="stack">
<strong>{{ row.nickname || compactText(row.id) }}</strong>
<span>ID {{ row.id }} · {{ row.maskedPhone }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">
{{ row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="资料" width="150">
<template #default="{ row }">
<div class="profile-badges">
<el-tag :type="row.wechatMiniappBound ? 'success' : 'warning'" effect="light">
{{ row.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
</el-tag>
<el-tag :type="profileComplete(row) ? 'success' : 'info'" effect="plain">
{{ profileComplete(row) ? '资料完整' : '待补资料' }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="门店范围" min-width="150">
<template #default="{ row }">{{ row.storeIds.join(', ') || '未配置' }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="180">
<template #default="{ row }">{{ compactText(row.note) }}</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最近登录" width="130">
<template #default="{ row }">{{ shortDate(row.lastLoginAt) }}</template>
</el-table-column>
<el-table-column prop="maskedLastIp" label="最近 IP" width="120" />
<el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button>
<el-button
size="small"
:type="row.status === 'ACTIVE' ? 'warning' : 'success'"
:icon="row.status === 'ACTIVE' ? UserX : UserCheck"
@click="$emit('status-toggle', { userId: row.id, status: row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE' })"
>
{{ row.status === 'ACTIVE' ? '停用' : '启用' }}
</el-button>
<el-button size="small" :icon="ShieldX" @click="$emit('reset-sessions', row.id)">
会话
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</div>
</section>
<el-dialog v-model="createDialog.open" title="新增保洁员" width="460px">
<el-form label-position="top">
<el-form-item label="姓名">
<el-input v-model="createDialog.nickname" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="createDialog.phone" inputmode="tel" />
</el-form-item>
<el-form-item label="门店 ID,逗号分隔">
<el-input v-model="createDialog.storeIdsText" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="createDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitCreate">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="editDialog.open" title="编辑保洁员" width="460px">
<el-form label-position="top">
<el-form-item label="姓名">
<el-input v-model="editDialog.nickname" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="editDialog.phone" inputmode="tel" placeholder="留空则不修改" />
</el-form-item>
<el-form-item label="门店 ID,逗号分隔">
<el-input v-model="editDialog.storeIdsText" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="editDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitEdit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import {
Download,
FileWarning,
Pencil,
RefreshCw,
ShieldX,
UserCheck,
UserPlus,
UserX
} from '@lucide/vue';
import { compactText, shortDate } from '../format';
import type { ManagedUser, PageResult, UserStatus } from '../types';
const props = defineProps<{
loading: boolean;
items: PageResult<ManagedUser>['items'];
total: number;
page: number;
pageSize: number;
status: UserStatus | '';
search: string;
}>();
const emit = defineEmits<{
refresh: [];
'status-change': [UserStatus | ''];
'search-change': [string];
'page-change': [number];
create: [{ nickname: string; phone: string; storeIds: string[]; note?: string }];
update: [{ userId: string; nickname: string; phone?: string; storeIds: string[]; note?: string }];
'status-toggle': [{ userId: string; status: UserStatus }];
'reset-sessions': [string];
}>();
const statusOptions = [
{ label: '全部', value: '' },
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'DISABLED' }
];
const searchDraft = ref(props.search);
const createDialog = reactive({
open: false,
nickname: '',
phone: '',
storeIdsText: '',
note: ''
});
const editDialog = reactive({
open: false,
userId: '',
nickname: '',
phone: '',
storeIdsText: '',
note: ''
});
const incompleteCleaners = computed(() => props.items.filter((row) => !profileComplete(row)));
watch(
() => props.search,
(value) => { searchDraft.value = value; }
);
function parseStoreIds(value: string) {
return value.split(/[,\s]+/).map((item) => item.trim()).filter(Boolean);
}
function emitSearch() {
emit('search-change', searchDraft.value.trim());
}
function submitCreate() {
emit('create', {
nickname: createDialog.nickname,
phone: createDialog.phone,
storeIds: parseStoreIds(createDialog.storeIdsText),
note: createDialog.note
});
Object.assign(createDialog, { open: false, nickname: '', phone: '', storeIdsText: '', note: '' });
}
function openEdit(row: ManagedUser) {
editDialog.open = true;
editDialog.userId = row.id;
editDialog.nickname = row.nickname;
editDialog.phone = '';
editDialog.storeIdsText = row.storeIds.join(', ');
editDialog.note = row.note;
}
function submitEdit() {
emit('update', {
userId: editDialog.userId,
nickname: editDialog.nickname,
phone: editDialog.phone.trim() || undefined,
storeIds: parseStoreIds(editDialog.storeIdsText),
note: editDialog.note
});
editDialog.open = false;
}
function profileComplete(row: ManagedUser) {
return row.wechatMiniappBound && row.storeIds.length > 0 && Boolean(row.nickname);
}
function profileIssues(row: ManagedUser) {
const issues: string[] = [];
if (!row.wechatMiniappBound) issues.push('未绑定微信');
if (!row.storeIds.length) issues.push('未配置门店范围');
if (!row.nickname) issues.push('缺姓名');
return issues;
}
function exportCleaners() {
downloadCsv(`cleaning-cleaners-${Date.now()}.csv`, [
['ID', '姓名', '状态', '微信绑定', '资料完整度', '门店范围', '手机号', '最近登录', '最近 IP', '注册时间', '备注'],
...props.items.map((row) => [
row.id,
row.nickname || '',
row.status,
row.wechatMiniappBound ? '已绑定微信' : '未绑定微信',
profileComplete(row) ? '资料完整' : '待补资料',
row.storeIds.join(' / '),
row.maskedPhone,
row.lastLoginAt || '',
row.maskedLastIp,
row.registeredAt,
row.note
])
]);
}
function exportCleanerProfileIssues() {
downloadCsv(`cleaning-cleaner-profile-issues-${Date.now()}.csv`, [
['ID', '姓名', '状态', '待补项目', '微信绑定', '门店范围', '手机号', '最近登录', '备注'],
...incompleteCleaners.value.map((row) => [
row.id,
row.nickname || '',
row.status,
profileIssues(row).join(' / '),
row.wechatMiniappBound ? '已绑定微信' : '未绑定微信',
row.storeIds.join(' / '),
row.maskedPhone,
row.lastLoginAt || '',
row.note
])
]);
}
function downloadCsv(filename: string, rows: string[][]) {
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string) {
return `"${String(value ?? '').replace(/"/g, '""')}"`;
}
</script>
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
<template>
<section class="rules-panel">
<header class="panel-heading">
<div>
<p class="eyebrow">M09-B · 清洁规则</p>
<h3>门店与房间清洁模板</h3>
<p>模板按房间门店租户依次匹配保存后只影响新任务既有任务继续使用创建时快照</p>
</div>
<el-button :loading="loading" @click="loadTemplates">刷新</el-button>
</header>
<el-alert
title="配置变更不会静默改写在途任务;每次保存都会记录审计。"
type="info"
show-icon
:closable="false"
/>
<el-form class="rule-form" label-position="top" @submit.prevent>
<el-form-item label="作用范围">
<el-select v-model="draft.scopeType" @change="handleScopeChange">
<el-option label="租户默认" value="TENANT" />
<el-option label="指定门店" value="STORE" />
<el-option label="指定房间" value="ROOM" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'STORE'" label="门店">
<el-select v-model="draft.scopeId" filterable placeholder="请选择门店">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'ROOM'" label="所属门店">
<el-select v-model="draft.roomStoreId" filterable placeholder="先选择门店" @change="handleRoomStoreChange">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
</el-form-item>
<el-form-item v-if="draft.scopeType === 'ROOM'" label="房间">
<el-select v-model="draft.scopeId" filterable placeholder="请选择房间" :disabled="!draft.roomStoreId">
<el-option
v-for="room in rooms"
:key="room.id"
:label="`${room.roomNo} · ${room.name}`"
:value="room.id"
/>
</el-select>
</el-form-item>
<el-form-item label="模板名称">
<el-input v-model.trim="draft.name" maxlength="128" show-word-limit />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="draft.status">
<el-option label="启用" value="ACTIVE" />
<el-option label="停用" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item class="wide-field" label="清洁要求">
<el-input v-model.trim="draft.requirement" type="textarea" :rows="3" maxlength="512" show-word-limit />
</el-form-item>
<el-form-item label="必须上传照片">
<el-switch v-model="draft.photoRequired" @change="normalizePhotoRule" />
</el-form-item>
<el-form-item label="最少照片">
<el-input-number v-model="draft.minPhotoCount" :min="draft.photoRequired ? 1 : 0" :max="draft.maxPhotoCount" />
</el-form-item>
<el-form-item label="最多照片">
<el-input-number v-model="draft.maxPhotoCount" :min="Math.max(1, draft.minPhotoCount)" :max="9" />
</el-form-item>
<el-form-item label="免清洁策略">
<el-select v-model="draft.exemptPolicy">
<el-option label="不允许免清洁" value="DISABLED" />
<el-option label="开始前允许" value="BEFORE_START" />
<el-option label="活动阶段均允许" value="ANY_ACTIVE" />
</el-select>
</el-form-item>
<div class="wide-field form-actions">
<el-button type="primary" :loading="saving" @click="saveTemplate">保存模板</el-button>
<el-button @click="resetDraft">新建配置</el-button>
</div>
</el-form>
<el-table v-loading="loading" :data="templates" class="data-table" empty-text="暂无清洁模板">
<el-table-column prop="scopeKey" label="范围" min-width="130" />
<el-table-column prop="name" label="模板" min-width="150" />
<el-table-column label="照片" min-width="130">
<template #default="{ row }">
{{ row.photoRequired ? `${row.minPhotoCount}${row.maxPhotoCount}` : `可选,最多 ${row.maxPhotoCount}` }}
</template>
</el-table-column>
<el-table-column label="免清洁" min-width="130">
<template #default="{ row }">{{ exemptPolicyLabel(row.exemptPolicy) }}</template>
</el-table-column>
<el-table-column label="版本" width="88">
<template #default="{ row }">v{{ row.version }}</template>
</el-table-column>
<el-table-column label="状态" width="88">
<template #default="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
</el-table-column>
<el-table-column label="操作" width="88" fixed="right">
<template #default="{ row }"><el-button link type="primary" @click="editTemplate(row)">编辑</el-button></template>
</el-table-column>
</el-table>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import {
listCleaningTemplates,
listManagedRooms,
listManagedStores,
upsertCleaningTemplate,
type ApiSession
} from '../api';
import type {
CleaningExemptPolicy,
CleaningTemplate,
CleaningTemplateScope,
ManagedRoom,
ManagedStore
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const templates = ref<CleaningTemplate[]>([]);
const stores = ref<ManagedStore[]>([]);
const rooms = ref<ManagedRoom[]>([]);
const loading = ref(false);
const saving = ref(false);
const draft = reactive({
scopeType: 'TENANT' as CleaningTemplateScope,
scopeId: '',
roomStoreId: '',
name: '默认清洁模板',
requirement: '完成桌面、地面与设备周边清洁',
photoRequired: true,
minPhotoCount: 1,
maxPhotoCount: 9,
exemptPolicy: 'ANY_ACTIVE' as CleaningExemptPolicy,
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED'
});
async function loadTemplates() {
if (!props.session.token || loading.value) return;
loading.value = true;
try {
templates.value = await listCleaningTemplates(props.session);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清洁模板加载失败');
} finally {
loading.value = false;
}
}
async function loadStores() {
try {
stores.value = await listManagedStores(props.session);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '门店列表加载失败');
}
}
async function loadRooms(storeId = draft.roomStoreId) {
if (!storeId) {
rooms.value = [];
return;
}
try {
rooms.value = await listManagedRooms(props.session, storeId);
} catch (error) {
rooms.value = [];
ElMessage.error(error instanceof Error ? error.message : '房间列表加载失败');
}
}
async function saveTemplate() {
if (!draft.name || (draft.scopeType !== 'TENANT' && !/^[1-9]\d{0,19}$/.test(draft.scopeId))) {
return ElMessage.warning('请填写模板名称和有效作用范围');
}
saving.value = true;
try {
await upsertCleaningTemplate(props.session, {
scopeType: draft.scopeType,
scopeId: draft.scopeType === 'TENANT' ? undefined : draft.scopeId,
name: draft.name,
requirement: draft.requirement,
photoRequired: draft.photoRequired,
minPhotoCount: draft.minPhotoCount,
maxPhotoCount: draft.maxPhotoCount,
exemptPolicy: draft.exemptPolicy,
status: draft.status
});
ElMessage.success('清洁模板已保存,仅影响后续新任务');
await loadTemplates();
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '清洁模板保存失败');
} finally {
saving.value = false;
}
}
async function editTemplate(template: CleaningTemplate) {
draft.scopeType = template.scopeType;
draft.scopeId = template.storeId || template.roomId || '';
draft.roomStoreId = template.scopeType === 'ROOM' ? template.storeId || '' : '';
if (draft.roomStoreId) await loadRooms();
draft.name = template.name;
draft.requirement = template.requirement;
draft.photoRequired = template.photoRequired;
draft.minPhotoCount = template.minPhotoCount;
draft.maxPhotoCount = template.maxPhotoCount;
draft.exemptPolicy = template.exemptPolicy;
draft.status = template.status;
}
function resetDraft() {
Object.assign(draft, {
scopeType: 'TENANT', scopeId: '', roomStoreId: '', name: '默认清洁模板',
requirement: '完成桌面、地面与设备周边清洁', photoRequired: true,
minPhotoCount: 1, maxPhotoCount: 9, exemptPolicy: 'ANY_ACTIVE', status: 'ACTIVE'
});
}
function handleScopeChange() { draft.scopeId = ''; draft.roomStoreId = ''; rooms.value = []; }
async function handleRoomStoreChange() { draft.scopeId = ''; await loadRooms(); }
function normalizePhotoRule() { draft.minPhotoCount = draft.photoRequired ? Math.max(1, draft.minPhotoCount) : 0; }
function exemptPolicyLabel(policy: CleaningExemptPolicy) {
return { DISABLED: '不允许', BEFORE_START: '开始前', ANY_ACTIVE: '活动阶段' }[policy];
}
watch(() => props.session.token, (token) => {
if (token) void Promise.all([loadTemplates(), loadStores()]);
}, { immediate: true });
</script>
<style scoped>
.rules-panel { display: grid; gap: 18px; }
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.panel-heading h3 { margin: 4px 0 6px; color: var(--ink); }
.panel-heading p:last-child { margin: 0; color: var(--muted); }
.rule-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 14px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
.wide-field { grid-column: 1 / -1; }
.form-actions { display: flex; justify-content: flex-end; gap: 10px; }
@media (max-width: 980px) { .rule-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .panel-heading { align-items: stretch; flex-direction: column; } .rule-form { grid-template-columns: 1fr; padding: 14px; } .wide-field { grid-column: auto; } .form-actions { justify-content: stretch; } .form-actions :deep(.el-button) { flex: 1; } }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,387 @@
<template>
<section class="panel">
<div class="panel-toolbar">
<el-form class="stats-filter" label-position="top">
<el-form-item label="周期">
<el-segmented :options="quickOptions" @update:model-value="applyQuickRange" />
</el-form-item>
<el-form-item label="开始">
<el-input v-model="filterDraft.from" placeholder="2026-06-01" />
</el-form-item>
<el-form-item label="结束">
<el-input v-model="filterDraft.to" placeholder="2026-07-01" />
</el-form-item>
<el-form-item label="门店 ID">
<el-input v-model="filterDraft.storeId" inputmode="numeric" />
</el-form-item>
<el-form-item label="保洁员">
<el-select
v-model="filterDraft.cleanerUserId"
class="filter-select"
clearable
filterable
placeholder="保洁员"
>
<el-option
v-for="cleaner in cleaners"
:key="cleaner.id"
:label="cleanerLabel(cleaner)"
:value="cleaner.id"
>
<div class="option-row">
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
<span>{{ cleaner.maskedPhone || '未留手机' }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-button type="primary" :icon="Search" @click="emitFilter">查询</el-button>
</el-form>
<el-button :icon="Download" @click="exportStatistics">导出</el-button>
<el-tooltip content="刷新统计">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
<div class="stats-body" v-loading="loading">
<section class="metric-grid compact" aria-label="统计概览">
<div class="metric">
<span>任务总数</span>
<strong>{{ statistics.summary.taskTotal }}</strong>
</div>
<div class="metric">
<span>待验收</span>
<strong>{{ statistics.summary.pendingReview }}</strong>
</div>
<div class="metric">
<span>待结算</span>
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
</div>
<div class="metric">
<span>{{ '\u514d\u6e05\u6d01' }}</span>
<strong>{{ statistics.summary.exempted }}</strong>
</div>
<div class="metric">
<span>已发放</span>
<strong>{{ money(statistics.summary.paidSettlementCents) }}</strong>
</div>
</section>
<section class="stat-block cleaner-performance-board">
<header>
<div>
<h3>保洁员绩效排行</h3>
<span>任务 / 完成率 / 驳回 / 结算</span>
</div>
<el-button :icon="Download" :disabled="cleanerPerformanceRows.length === 0" @click="exportCleanerPerformance">
导出排行
</el-button>
</header>
<div class="cleaner-performance-grid">
<button
v-for="row in cleanerPerformanceRows"
:key="row.cleanerUserId"
type="button"
class="cleaner-performance-card"
@click="filterByCleaner(row.cleanerUserId)"
>
<span> {{ row.rank }} </span>
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
<em>任务 {{ row.taskCount }} · 完成 {{ row.completedTaskCount }} · 驳回 {{ row.rejectedTaskCount }}</em>
<small>完成率 {{ row.completionRate }}% · 待结算 {{ money(row.pendingSettlementCents) }} · 已结算 {{ money(row.settledRewardCents) }}</small>
</button>
</div>
<el-empty v-if="cleanerPerformanceRows.length === 0" description="暂无保洁员绩效数据" :image-size="72" />
</section>
<div class="stats-grid">
<section class="stat-block">
<header>
<h3>任务状态</h3>
<span>{{ money(statistics.summary.rewardCents) }}</span>
</header>
<el-table :data="statistics.byStatus" size="small">
<el-table-column prop="status" label="状态" />
<el-table-column prop="total" label="数量" width="90" />
<el-table-column prop="rewardCents" label="奖励" width="120">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
</el-table>
</section>
<section class="stat-block">
<header>
<h3>结算口径</h3>
<span>待发 {{ money(statistics.summary.confirmedSettlementCents) }}</span>
</header>
<el-table :data="statistics.settlements" size="small">
<el-table-column prop="status" label="状态" />
<el-table-column prop="total" label="单数" width="90" />
<el-table-column prop="rewardCents" label="金额" width="120">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
</el-table>
</section>
</div>
<section class="stat-block">
<header>
<h3>每日趋势</h3>
<span>任务 / 待验 / 发放</span>
</header>
<div class="trend-list">
<div v-for="row in statistics.trend" :key="row.date" class="trend-row">
<div class="trend-date">{{ row.date }}</div>
<div class="trend-track" aria-label="每日任务趋势">
<span class="trend-bar" :style="{ width: trendWidth(row.taskTotal) }" />
</div>
<div class="trend-meta">
<strong>{{ row.taskTotal }}</strong>
<span>待验 {{ row.pendingReview }}</span>
<span>完成 {{ row.completed }}</span>
<span>驳回 {{ row.rejected }}</span>
<span>{{ '\u514d\u6e05\u6d01' }} {{ row.exempted }}</span>
<span>奖励 {{ money(row.rewardCents) }}</span>
<span>发放 {{ money(row.paidSettlementCents) }}</span>
</div>
</div>
<el-empty v-if="statistics.trend.length === 0" description="暂无趋势数据" :image-size="72" />
</div>
</section>
<section class="stat-block">
<header>
<h3>门店明细</h3>
<span>按待验收优先</span>
</header>
<el-table :data="statistics.byStore" size="small">
<el-table-column prop="storeName" label="门店" min-width="150" />
<el-table-column prop="taskTotal" label="任务" width="90" />
<el-table-column prop="pendingReview" label="待验" width="90" />
<el-table-column prop="completed" label="完成" width="90" />
<el-table-column prop="rejected" label="驳回" width="90" />
<el-table-column prop="exempted" :label="'\u514d\u6e05\u6d01'" width="90" />
<el-table-column prop="rewardCents" label="奖励" width="120">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
</el-table>
</section>
<section class="stat-block">
<header>
<h3>成员分账</h3>
<span>按待结算金额排序</span>
</header>
<el-table :data="statistics.members" size="small">
<el-table-column prop="cleanerName" label="保洁员" min-width="150">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
<span>ID {{ row.cleanerUserId }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="taskCount" label="任务" width="90" />
<el-table-column prop="completedTaskCount" label="完成" width="90" />
<el-table-column prop="rejectedTaskCount" label="驳回" width="90" />
<el-table-column prop="pendingSettlementCents" label="待结算" width="130">
<template #default="{ row }">{{ money(row.pendingSettlementCents) }}</template>
</el-table-column>
<el-table-column prop="settledRewardCents" label="已结算" width="130">
<template #default="{ row }">{{ money(row.settledRewardCents) }}</template>
</el-table-column>
</el-table>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { Download, RefreshCw, Search } from '@lucide/vue';
import { compactText, money } from '../format';
import type { CleaningStatistics, ManagedUser } from '../types';
const props = defineProps<{
loading: boolean;
statistics: CleaningStatistics;
filters: { from: string; to: string; storeId: string; cleanerUserId: string };
cleaners: ManagedUser[];
}>();
const emit = defineEmits<{
refresh: [];
filter: [{ from: string; to: string; storeId: string; cleanerUserId: string }];
}>();
const filterDraft = reactive({ ...props.filters });
const quickOptions = [
{ label: '今日', value: 'today' },
{ label: '近7天', value: '7d' },
{ label: '本月', value: 'month' }
];
const maxTrendTotal = computed(() => Math.max(1, ...props.statistics.trend.map((row) => row.taskTotal)));
const cleanerPerformanceRows = computed(() => props.statistics.members
.map((row) => ({
...row,
completionRate: row.taskCount ? Math.round((row.completedTaskCount / row.taskCount) * 100) : 0,
settlementTotalCents: row.pendingSettlementCents + row.settledRewardCents
}))
.sort((a, b) => (
b.taskCount - a.taskCount
|| b.completedTaskCount - a.completedTaskCount
|| a.rejectedTaskCount - b.rejectedTaskCount
|| b.settlementTotalCents - a.settlementTotalCents
))
.slice(0, 8)
.map((row, index) => ({ ...row, rank: index + 1 })));
watch(
() => props.filters,
(value) => Object.assign(filterDraft, value),
{ deep: true }
);
function emitFilter() {
emit('filter', {
from: filterDraft.from.trim(),
to: filterDraft.to.trim(),
storeId: filterDraft.storeId.trim(),
cleanerUserId: filterDraft.cleanerUserId.trim()
});
}
function cleanerLabel(cleaner: ManagedUser) {
return `${cleaner.nickname || compactText(cleaner.id)} / ${cleaner.maskedPhone || '未留手机'}`;
}
function applyQuickRange(value: string | number | boolean) {
const now = new Date();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
let start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
if (value === '7d') {
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
}
if (value === 'month') {
start = new Date(now.getFullYear(), now.getMonth(), 1);
}
filterDraft.from = formatDate(start);
filterDraft.to = formatDate(end);
emitFilter();
}
function formatDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function trendWidth(total: number) {
return `${Math.max(6, Math.round((total / maxTrendTotal.value) * 100))}%`;
}
function filterByCleaner(cleanerUserId: string) {
filterDraft.cleanerUserId = cleanerUserId;
emitFilter();
}
function exportCleanerPerformance() {
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算(分)', '已结算(分)'],
...cleanerPerformanceRows.value.map((row) => [
String(row.rank),
row.cleanerName || '',
row.cleanerUserId,
String(row.taskCount),
String(row.completedTaskCount),
String(row.rejectedTaskCount),
`${row.completionRate}%`,
String(row.pendingSettlementCents),
String(row.settledRewardCents)
])
]);
}
function exportStatistics() {
downloadCsv(`cleaning-statistics-${Date.now()}.csv`, [
['section', 'key', 'value', 'extra1', 'extra2', 'extra3', 'extra4'],
['filter', 'from', filterDraft.from || '\u672a\u8bbe\u7f6e', '', '', '', ''],
['filter', 'to', filterDraft.to || '\u672a\u8bbe\u7f6e', '', '', '', ''],
['filter', 'storeId', filterDraft.storeId || '\u5168\u90e8\u95e8\u5e97', '', '', '', ''],
['filter', 'cleanerUserId', filterDraft.cleanerUserId || '\u5168\u90e8\u4fdd\u6d01\u5458', '', '', '', ''],
['summary', '\u4efb\u52a1\u603b\u6570', String(props.statistics.summary.taskTotal), '', '', '', ''],
['summary', '\u5f85\u9a8c\u6536', String(props.statistics.summary.pendingReview), '', '', '', ''],
['summary', '\u8fdb\u884c\u4e2d', String(props.statistics.summary.active), '', '', '', ''],
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
['summary', '\u5956\u52b1\u5408\u8ba1(\u5206)', String(props.statistics.summary.rewardCents), '', '', '', ''],
['summary', '\u5f85\u7ed3\u7b97(\u5206)', String(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
['summary', '\u5f85\u53d1\u653e(\u5206)', String(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
['summary', '\u5df2\u53d1\u653e(\u5206)', String(props.statistics.summary.paidSettlementCents), '', '', '', ''],
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1(\u5206)', '', '', ''],
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), String(row.rewardCents), '', '', '']),
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d(\u5206)', '', '', ''],
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), String(row.rewardCents), '', '', '']),
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
...props.statistics.trend.map((row) => [
'\u6bcf\u65e5\u8d8b\u52bf',
row.date,
String(row.taskTotal),
String(row.pendingReview),
String(row.completed),
String(row.rejected),
String(row.exempted)
]),
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e(\u5206)', '', '', '', ''],
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, String(row.paidSettlementCents), '', '', '', '']),
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
...props.statistics.byStore.map((row) => [
'\u95e8\u5e97\u660e\u7ec6',
`${row.storeName || ''}(${row.storeId})`,
String(row.taskTotal),
String(row.pendingReview),
String(row.completed),
String(row.rejected),
String(row.exempted)
]),
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1(\u5206)', '', '', '', ''],
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, String(row.rewardCents), '', '', '', '']),
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97(\u5206)', '\u5df2\u7ed3\u7b97(\u5206)'],
...props.statistics.members.map((row) => [
'\u6210\u5458\u5206\u8d26',
`${row.cleanerName || ''}(${row.cleanerUserId})`,
String(row.taskCount),
String(row.completedTaskCount),
String(row.rejectedTaskCount),
String(row.pendingSettlementCents),
String(row.settledRewardCents)
]),
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1(\u5206)'],
...cleanerPerformanceRows.value.map((row) => [
'\u7ee9\u6548\u6392\u884c',
String(row.rank),
`${row.cleanerName || ''}(${row.cleanerUserId})`,
String(row.taskCount),
`${row.completionRate}%`,
String(row.rejectedTaskCount),
String(row.settlementTotalCents)
])
]);
}
function downloadCsv(filename: string, rows: string[][]) {
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string) {
return `"${String(value ?? '').replace(/"/g, '""')}"`;
}
</script>
+903
View File
@@ -0,0 +1,903 @@
<template>
<section class="panel">
<div class="panel-toolbar">
<div class="toolbar-actions">
<el-segmented
:model-value="status"
:options="statusOptions"
@update:model-value="$emit('status-change', $event as TaskStatus | '')"
/>
<el-input
v-model="filterDraft.storeId"
class="narrow-input"
placeholder="门店 ID"
clearable
@keyup.enter="submitFilters"
@clear="submitFilters"
/>
<el-select
v-model="filterDraft.cleanerUserId"
class="filter-select"
filterable
clearable
placeholder="保洁员"
@change="submitFilters"
@clear="submitFilters"
>
<el-option
v-for="cleaner in cleaners"
:key="cleaner.id"
:label="cleanerLabel(cleaner)"
:value="cleaner.id"
/>
</el-select>
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
</div>
<div class="toolbar-actions">
<el-button :icon="Download" :disabled="items.length === 0" @click="exportTasks">
导出
</el-button>
<el-button
type="success"
:icon="BadgeCheck"
:disabled="selectedSubmittedCount === 0"
@click="submitBatchComplete"
>
批量验收
</el-button>
<el-button
type="danger"
:icon="Undo2"
:disabled="selectedSubmittedCount === 0"
@click="openBatchReject"
>
批量驳回
</el-button>
<el-button
type="warning"
:icon="Ban"
:disabled="selectedExemptableCount === 0"
@click="submitBatchExempt"
>
{{ '\u6279\u91cf\u514d\u6e05\u6d01' }}
</el-button>
<el-popover trigger="click" width="280">
<template #reference>
<el-button :icon="RotateCcw">回收</el-button>
</template>
<el-form label-position="top" class="compact-form">
<el-form-item label="分钟">
<el-input-number v-model="reclaimForm.olderThanMinutes" :min="5" :max="1440" />
</el-form-item>
<el-form-item label="数量">
<el-input-number v-model="reclaimForm.limit" :min="1" :max="100" />
</el-form-item>
<el-button type="primary" :icon="Check" @click="$emit('reclaim', reclaimForm)">
执行
</el-button>
</el-form>
</el-popover>
<el-tooltip content="刷新任务">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
</div>
<el-table
:data="items"
:loading="loading"
class="data-table"
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="canSelectTask" />
<el-table-column prop="taskNo" label="任务" min-width="180" fixed>
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ row.storeName }} · {{ row.roomName || row.roomNo }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="{ row }">
<el-tag :type="taskTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
</el-table-column>
<el-table-column prop="memberCount" label="成员" width="90" />
<el-table-column prop="rewardCents" label="奖励" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="submittedAt" label="提交" width="130">
<template #default="{ row }">{{ shortDate(row.submittedAt) }}</template>
</el-table-column>
<el-table-column label="照片" width="90">
<template #default="{ row }">
<el-badge :value="row.photoUrls?.length || 0" :hidden="!row.photoUrls?.length">
<Image :size="18" />
</el-badge>
</template>
</el-table-column>
<el-table-column label="操作" width="340" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
<el-button size="small" :icon="Users" @click="openMembers(row)">成员</el-button>
<el-button
size="small"
type="success"
:icon="BadgeCheck"
:disabled="row.status !== 'SUBMITTED'"
@click="$emit('complete', { taskId: row.id, note: '后台验收通过' })"
>
验收
</el-button>
<el-button
size="small"
type="danger"
:icon="Undo2"
:disabled="row.status !== 'SUBMITTED'"
@click="openReject(row)"
>
驳回
</el-button>
<el-button
size="small"
type="warning"
:icon="Ban"
:disabled="!canExempt(row)"
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
>
{{ '\u514d\u6e05\u6d01' }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</div>
</section>
<el-dialog v-model="assignDialog.open" title="指派保洁员" width="420px">
<el-form label-position="top">
<el-form-item label="保洁员">
<el-select
v-model="assignDialog.cleanerUserId"
class="cleaner-select"
filterable
placeholder="选择保洁员"
:disabled="!cleaners.length"
>
<el-option
v-for="cleaner in cleaners"
:key="cleaner.id"
:label="cleanerLabel(cleaner)"
:value="cleaner.id"
>
<div class="option-row">
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
</div>
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="assignDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitAssign">确认</el-button>
</template>
</el-dialog>
<el-dialog v-model="rejectDialog.open" title="驳回任务" width="420px">
<el-input v-model="rejectDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit />
<template #footer>
<el-button @click="rejectDialog.open = false">取消</el-button>
<el-button type="danger" @click="submitReject">驳回</el-button>
</template>
</el-dialog>
<el-dialog v-model="batchRejectDialog.open" title="批量驳回待验收任务" width="460px">
<div class="dialog-stack">
<el-alert
:title="`将驳回 ${selectedSubmittedCount} 个已选择的待验收任务`"
type="warning"
show-icon
:closable="false"
/>
<el-input
v-model="batchRejectDialog.reason"
type="textarea"
:rows="4"
maxlength="512"
show-word-limit
placeholder="请输入统一驳回原因"
/>
</div>
<template #footer>
<el-button @click="batchRejectDialog.open = false">取消</el-button>
<el-button type="danger" @click="submitBatchReject">批量驳回</el-button>
</template>
</el-dialog>
<el-dialog v-model="memberDialog.open" title="协作者管理" width="640px">
<div class="dialog-stack">
<div class="detail-header">
<strong>{{ memberDialog.taskNo }}</strong>
<span>任务奖励 {{ money(memberDialog.taskRewardCents) }}</span>
</div>
<div class="row-actions">
<el-button size="small" :icon="Download" :disabled="!memberDialog.members.length" @click="exportTaskMembers">
导出成员
</el-button>
</div>
<el-table :data="memberDialog.members" size="small" v-loading="memberDialog.loading">
<el-table-column prop="nickname" label="成员" min-width="140">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
<span>ID {{ row.userId }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="memberRole" label="角色" width="90">
<template #default="{ row }">
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
{{ row.memberRole }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rewardCents" label="分账" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="settledAt" label="结算" width="130">
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button
size="small"
type="danger"
:icon="UserMinus"
:disabled="row.memberRole === 'LEAD' || !!row.settledAt"
@click="removeMember(row.userId)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<el-form label-position="top" class="member-form">
<el-form-item label="协作者">
<el-select
v-model="memberDialog.cleanerUserId"
class="cleaner-select"
filterable
placeholder="选择协作者"
:disabled="!cleaners.length"
>
<el-option
v-for="cleaner in cleaners"
:key="cleaner.id"
:label="cleanerLabel(cleaner)"
:value="cleaner.id"
>
<div class="option-row">
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
</div>
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="分账金额">
<el-input-number v-model="memberDialog.rewardCents" :min="0" :max="1000000" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="memberDialog.note" />
</el-form-item>
<el-button type="primary" :icon="UserPlus" @click="submitMember">添加/更新</el-button>
</el-form>
</div>
<template #footer>
<el-button @click="memberDialog.open = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="880px">
<div v-if="detailDialog.task" class="dialog-stack">
<div class="detail-grid">
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
<span>状态<strong>{{ detailDialog.task.status }}</strong></span>
<span>奖励<strong>{{ money(detailDialog.task.rewardCents) }}</strong></span>
<span>保洁员<strong>{{ compactText(detailDialog.task.cleanerUserId) }}</strong></span>
</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="门店房间">
{{ detailDialog.task.storeName }} · {{ detailDialog.task.roomName || detailDialog.task.roomNo }}
</el-descriptions-item>
<el-descriptions-item label="订单">
{{ compactText(detailDialog.task.orderNo) }}
</el-descriptions-item>
<el-descriptions-item label="要求" :span="2">
{{ compactText(detailDialog.task.requirement) }}
</el-descriptions-item>
<el-descriptions-item label="照片规则">
{{ detailDialog.task.photoRequired ? `${detailDialog.task.minPhotoCount}${detailDialog.task.maxPhotoCount}` : `可选,最多 ${detailDialog.task.maxPhotoCount}` }}
</el-descriptions-item>
<el-descriptions-item label="任务快照">
模板 v{{ detailDialog.task.cleaningTemplateVersion }} · 照片修订 {{ detailDialog.task.photoRevision }} · 补做 {{ detailDialog.task.reworkCount }}
</el-descriptions-item>
<el-descriptions-item label="驳回原因" :span="2">
{{ compactText(detailDialog.task.rejectReason) }}
</el-descriptions-item>
</el-descriptions>
<div class="timeline-grid">
<span>领取<strong>{{ shortDate(detailDialog.task.claimedAt) }}</strong></span>
<span>开始<strong>{{ shortDate(detailDialog.task.startedAt) }}</strong></span>
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
</div>
<section class="stat-block" v-loading="detailDialog.loadingSubmissions">
<header>
<h3>验收照片版本</h3>
<span>{{ detailDialog.submissions.length }} 次提交</span>
</header>
<div v-if="detailDialog.submissions.length" class="submission-list">
<article
v-for="submission in detailDialog.submissions"
:key="submission.id"
class="submission-card"
>
<header>
<strong> {{ submission.revision }} </strong>
<el-tag
:type="submission.status === 'ACCEPTED' ? 'success' : submission.status === 'REJECTED' ? 'danger' : 'warning'"
effect="light"
>
{{ submission.status }}
</el-tag>
<span>{{ shortDate(submission.submittedAt) }}</span>
</header>
<p v-if="submission.rejectReason" class="submission-reason">
驳回原因{{ submission.rejectReason }}
</p>
<p v-if="submission.note" class="submission-note">提交备注{{ submission.note }}</p>
<div v-if="submission.photoUrls.length" class="photo-grid">
<el-image
v-for="url in submission.photoUrls"
:key="`${submission.id}-${url}`"
:src="url"
fit="cover"
:preview-src-list="submission.photoUrls"
preview-teleported
/>
</div>
<el-empty v-else description="本版无照片" :image-size="48" />
</article>
</div>
<el-empty v-else description="暂无提交版本" :image-size="64" />
</section>
<section class="stat-block">
<header>
<h3>操作流水</h3>
<span>最近 {{ detailDialog.events.length }} </span>
</header>
<div class="row-actions">
<el-button size="small" :icon="Download" :disabled="!detailDialog.events.length" @click="exportTaskEvents">
导出流水
</el-button>
</div>
<el-table :data="detailDialog.events" size="small" v-loading="detailDialog.loadingEvents">
<el-table-column prop="action" label="动作" width="130" />
<el-table-column label="状态" width="150">
<template #default="{ row }">
{{ compactText(row.fromStatus) }} {{ row.toStatus }}
</template>
</el-table-column>
<el-table-column prop="actorId" label="操作人" width="100">
<template #default="{ row }">{{ compactText(row.actorId) }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="160">
<template #default="{ row }">{{ compactText(row.note) }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="时间" width="130">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
</section>
<section class="stat-block">
<header>
<h3>协作分账</h3>
<span>{{ detailDialog.members.length }} </span>
</header>
<el-table :data="detailDialog.members" size="small" v-loading="detailDialog.loadingMembers">
<el-table-column prop="nickname" label="成员" min-width="140">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
<span>ID {{ row.userId }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="memberRole" label="角色" width="90">
<template #default="{ row }">
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
{{ row.memberRole }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rewardCents" label="分账" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="settledAt" label="结算" width="130">
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
</el-table-column>
</el-table>
</section>
<div class="photo-grid" v-if="detailDialog.task.photoUrls.length">
<el-image
v-for="url in detailDialog.task.photoUrls"
:key="url"
:src="url"
fit="cover"
:preview-src-list="detailDialog.task.photoUrls"
preview-teleported
/>
</div>
<el-empty v-else description="暂无保洁照片" :image-size="80" />
</div>
<template #footer>
<el-button :icon="Download" :disabled="!detailDialog.task" @click="exportTaskDetail">
导出详情
</el-button>
<el-button @click="detailDialog.open = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import {
BadgeCheck,
Ban,
Check,
Download,
Image,
ListChecks,
RefreshCw,
RotateCcw,
Search,
Undo2,
UserMinus,
UserPlus,
Users
} from '@lucide/vue';
import { ElMessage } from 'element-plus';
import {
addCleaningTaskMember,
listCleaningTaskEvents,
listCleaningTaskMembers,
listCleaningTaskSubmissions,
removeCleaningTaskMember
} from '../api';
import { compactText, money, shortDate } from '../format';
import type {
CleaningTask,
CleaningTaskEvent,
CleaningTaskMember,
CleaningTaskSubmission,
ManagedUser,
PageResult,
TaskStatus
} from '../types';
const props = defineProps<{
session: { token: string };
loading: boolean;
items: PageResult<CleaningTask>['items'];
cleaners: ManagedUser[];
total: number;
page: number;
pageSize: number;
status: TaskStatus | '';
filters: { storeId: string; cleanerUserId: string };
}>();
const emit = defineEmits<{
refresh: [];
'status-change': [TaskStatus | ''];
filter: [{ storeId: string; cleanerUserId: string }];
'page-change': [number];
assign: [{ taskId: string; cleanerUserId: string; note?: string }];
complete: [{ taskId: string; note?: string }];
'complete-many': [{ taskIds: string[]; note?: string }];
reject: [{ taskId: string; reason: string }];
'reject-many': [{ taskIds: string[]; reason: string }];
exempt: [{ taskId: string; note?: string }];
'exempt-many': [{ taskIds: string[]; note?: string }];
reclaim: [{ olderThanMinutes: number; limit: number }];
}>();
const statusOptions = [
{ label: '全部', value: '' },
{ label: '待抢', value: 'WAITING' },
{ label: '已抢', value: 'CLAIMED' },
{ label: '进行', value: 'STARTED' },
{ label: '待验', value: 'SUBMITTED' },
{ label: '完成', value: 'COMPLETED' },
{ label: '\u9a73\u56de', value: 'REJECTED' },
{ label: '\u514d\u6e05\u6d01', value: 'EXEMPT' }
];
const filterDraft = reactive({ storeId: props.filters.storeId, cleanerUserId: props.filters.cleanerUserId });
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
const selectedTasks = ref<CleaningTask[]>([]);
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
const batchRejectDialog = reactive({ open: false, reason: '' });
const detailDialog = reactive<{
open: boolean;
loadingEvents: boolean;
loadingMembers: boolean;
loadingSubmissions: boolean;
task: CleaningTask | null;
events: CleaningTaskEvent[];
members: CleaningTaskMember[];
submissions: CleaningTaskSubmission[];
}>({
open: false,
loadingEvents: false,
loadingMembers: false,
loadingSubmissions: false,
task: null,
events: [],
members: [],
submissions: []
});
const memberDialog = reactive({
open: false,
loading: false,
taskId: '',
taskNo: '',
taskRewardCents: 0,
cleanerUserId: '',
rewardCents: 0,
note: '',
members: [] as CleaningTaskMember[]
});
const selectedSubmittedTasks = computed(() => selectedTasks.value
.filter((task) => task.status === 'SUBMITTED'));
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
const selectedExemptableTasks = computed(() => selectedTasks.value
.filter((task) => canExempt(task)));
const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length);
watch(
() => props.filters,
(value) => Object.assign(filterDraft, value),
{ deep: true }
);
function taskTag(status: TaskStatus) {
if (status === 'SUBMITTED') return 'warning';
if (status === 'COMPLETED' || status === 'SETTLED') return 'success';
if (status === 'REJECTED') return 'danger';
return 'info';
}
function canSelectTask(row: CleaningTask) {
return row.status === 'SUBMITTED' || canExempt(row);
}
function canExempt(task: CleaningTask) {
if (task.exemptPolicy === 'DISABLED') return false;
if (task.exemptPolicy === 'BEFORE_START') return ['WAITING', 'CLAIMED'].includes(task.status);
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(task.status);
}
function handleSelectionChange(rows: CleaningTask[]) {
selectedTasks.value = rows;
}
function cleanerLabel(cleaner: ManagedUser) {
const name = cleaner.nickname || `ID ${cleaner.id}`;
const phone = cleaner.maskedPhone ? ` · ${cleaner.maskedPhone}` : '';
const stores = cleaner.storeIds.length ? ` · 门店 ${cleaner.storeIds.join('/')}` : '';
return `${name}${phone}${stores}`;
}
function submitFilters() {
emit('filter', {
storeId: filterDraft.storeId.trim(),
cleanerUserId: filterDraft.cleanerUserId
});
}
async function openDetail(row: CleaningTask) {
detailDialog.open = true;
detailDialog.task = row;
detailDialog.events = [];
detailDialog.members = [];
detailDialog.submissions = [];
detailDialog.loadingEvents = true;
detailDialog.loadingMembers = true;
detailDialog.loadingSubmissions = true;
try {
const [events, members, submissions] = await Promise.all([
listCleaningTaskEvents(props.session, row.id),
listCleaningTaskMembers(props.session, row.id),
listCleaningTaskSubmissions(props.session, row.id)
]);
detailDialog.events = events;
detailDialog.members = members;
detailDialog.submissions = submissions;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
} finally {
detailDialog.loadingEvents = false;
detailDialog.loadingMembers = false;
detailDialog.loadingSubmissions = false;
}
}
function openAssign(row: CleaningTask) {
assignDialog.open = true;
assignDialog.taskId = row.id;
assignDialog.cleanerUserId = row.cleanerUserId || '';
assignDialog.note = '';
}
function submitAssign() {
if (!assignDialog.cleanerUserId) {
ElMessage.warning('请选择保洁员');
return;
}
emit('assign', {
taskId: assignDialog.taskId,
cleanerUserId: assignDialog.cleanerUserId,
note: assignDialog.note
});
assignDialog.open = false;
}
function openReject(row: CleaningTask) {
rejectDialog.open = true;
rejectDialog.taskId = row.id;
rejectDialog.reason = row.rejectReason || '';
}
function submitReject() {
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
rejectDialog.open = false;
}
function submitBatchComplete() {
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
if (!taskIds.length) {
ElMessage.warning('请选择待验收任务');
return;
}
emit('complete-many', { taskIds, note: '后台批量验收通过' });
}
function openBatchReject() {
if (!selectedSubmittedCount.value) {
ElMessage.warning('请选择待验收任务');
return;
}
batchRejectDialog.open = true;
batchRejectDialog.reason = '';
}
function submitBatchReject() {
const reason = batchRejectDialog.reason.trim();
if (!reason) {
ElMessage.warning('请输入驳回原因');
return;
}
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
emit('reject-many', { taskIds, reason });
batchRejectDialog.open = false;
}
function submitBatchExempt() {
const taskIds = selectedExemptableTasks.value.map((task) => task.id);
if (!taskIds.length) {
ElMessage.warning('\u8bf7\u9009\u62e9\u53ef\u514d\u6e05\u6d01\u4efb\u52a1');
return;
}
emit('exempt-many', {
taskIds,
note: '\u540e\u53f0\u6279\u91cf\u6807\u8bb0\u514d\u6e05\u6d01'
});
}
async function loadMembers() {
memberDialog.loading = true;
try {
memberDialog.members = await listCleaningTaskMembers(props.session, memberDialog.taskId);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '成员加载失败');
} finally {
memberDialog.loading = false;
}
}
async function openMembers(row: CleaningTask) {
memberDialog.open = true;
memberDialog.taskId = row.id;
memberDialog.taskNo = row.taskNo;
memberDialog.taskRewardCents = row.rewardCents;
memberDialog.cleanerUserId = '';
memberDialog.rewardCents = 0;
memberDialog.note = '';
await loadMembers();
}
async function submitMember() {
if (!memberDialog.cleanerUserId) {
ElMessage.warning('请选择协作者');
return;
}
try {
memberDialog.members = await addCleaningTaskMember(props.session, memberDialog.taskId, {
cleanerUserId: memberDialog.cleanerUserId,
rewardCents: memberDialog.rewardCents,
note: memberDialog.note
});
memberDialog.cleanerUserId = '';
memberDialog.rewardCents = 0;
memberDialog.note = '';
ElMessage.success('协作者已更新');
emit('refresh');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '协作者更新失败');
}
}
async function removeMember(cleanerUserId: string) {
try {
memberDialog.members = await removeCleaningTaskMember(
props.session,
memberDialog.taskId,
cleanerUserId,
'后台移除协作者'
);
ElMessage.success('协作者已移除');
emit('refresh');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '协作者移除失败');
}
}
function exportTasks() {
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额(分)', '照片数', '提交时间', '完成时间', '驳回原因'],
...props.items.map((item) => [
item.taskNo,
item.status,
item.storeName,
item.roomName || item.roomNo,
item.orderNo || '',
item.cleanerUserId || '',
String(item.memberCount || 0),
String(item.rewardCents),
String(item.photoUrls?.length || 0),
shortDate(item.submittedAt),
shortDate(item.completedAt),
item.rejectReason || ''
])
]);
}
function exportTaskDetail() {
const task = detailDialog.task;
if (!task) return;
downloadCsv(`cleaning-task-detail-${task.taskNo}-${Date.now()}.csv`, [
['类别', '字段', '值'],
['task', '任务号', task.taskNo],
['task', '状态', task.status],
['task', '门店', task.storeName],
['task', '房间', task.roomName || task.roomNo],
['task', '订单号', task.orderNo || ''],
['task', '保洁员ID', task.cleanerUserId || ''],
['task', '奖励金额(分)', String(task.rewardCents)],
['task', '照片数', String(task.photoUrls?.length || 0)],
['task', '领取时间', shortDate(task.claimedAt)],
['task', '开始时间', shortDate(task.startedAt)],
['task', '提交时间', shortDate(task.submittedAt)],
['task', '完成时间', shortDate(task.completedAt)],
['task', '保洁要求', task.requirement || ''],
['task', '驳回原因', task.rejectReason || ''],
...detailDialog.members.map((member) => [
'member',
`${member.memberRole}:${member.userId}`,
`${member.nickname || ''} ${member.rewardCents}${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
]),
...detailDialog.events.map((event) => [
'event',
`${event.action}:${shortDate(event.createdAt)}`,
`${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}`
]),
...detailDialog.submissions.flatMap((submission) => [
[
'submission',
`revision_${submission.revision}`,
`${submission.status} ${submission.rejectReason || submission.note || ''}`
],
...submission.photoUrls.map((url, index) => [
'photo',
`revision_${submission.revision}_photo_${index + 1}`,
url
])
])
]);
}
function exportTaskEvents() {
const task = detailDialog.task;
if (!task) return;
downloadCsv(`cleaning-task-events-${task.taskNo}-${Date.now()}.csv`, [
['任务号', '动作', '原状态', '新状态', '操作人', 'TraceId', '备注', '时间'],
...detailDialog.events.map((event) => [
task.taskNo,
event.action,
event.fromStatus || '',
event.toStatus,
event.actorId,
event.traceId,
event.note,
shortDate(event.createdAt)
])
]);
}
function exportTaskMembers() {
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
['任务号', '成员ID', '昵称', '角色', '分账金额(分)', '结算时间'],
...memberDialog.members.map((member) => [
memberDialog.taskNo,
member.userId,
member.nickname || '',
member.memberRole,
String(member.rewardCents),
shortDate(member.settledAt)
])
]);
}
function downloadCsv(filename: string, rows: string[][]) {
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string) {
const normalized = value.replace(/\r?\n/g, ' ');
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
}
</script>
+647
View File
@@ -0,0 +1,647 @@
<template>
<section class="cleaning-workspace">
<section class="metric-grid" aria-label="保洁概览">
<div class="metric">
<span>待验收</span>
<strong>{{ statistics.summary.pendingReview }}</strong>
</div>
<div class="metric">
<span>已完成</span>
<strong>{{ statistics.summary.completed }}</strong>
</div>
<div class="metric">
<span>待发放</span>
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
</div>
<div class="metric">
<span>待结算</span>
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
</div>
</section>
<section class="action-board" aria-label="运营待处理">
<button class="action-tile" type="button" @click="jumpToTaskStatus('SUBMITTED')">
<ClipboardCheck :size="20" />
<span>待验收任务</span>
<strong>{{ statistics.summary.pendingReview }}</strong>
</button>
<button class="action-tile" type="button" @click="jumpToTaskStatus('REJECTED')">
<RotateCcw :size="20" />
<span>驳回补做</span>
<strong>{{ statistics.summary.rejected }}</strong>
</button>
<button class="action-tile" type="button" @click="jumpToSettlementStatus('DRAFT')">
<ListTodo :size="20" />
<span>待确认结算</span>
<strong>{{ draftSettlementTotal }}</strong>
</button>
<button class="action-tile" type="button" @click="jumpToSettlementStatus('CONFIRMED')">
<Send :size="20" />
<span>待发放金额</span>
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
</button>
<button class="action-tile" type="button" @click="exportOperationalQueue">
<Download :size="20" />
<span>导出待处理</span>
<strong>{{ operationalQueueTotal }}</strong>
</button>
</section>
<el-alert
v-if="lastError"
class="alert-line"
:title="lastError"
type="error"
show-icon
closable
@close="lastError = ''"
/>
<el-alert
v-if="lastMessage"
class="alert-line"
:title="lastMessage"
type="success"
show-icon
closable
@close="lastMessage = ''"
/>
<el-tabs v-model="activeTab" class="work-tabs">
<el-tab-pane label="任务" name="tasks">
<CleaningTasksPanel
:session="session"
:loading="loading.tasks"
:items="tasks.items"
:cleaners="cleaners.items"
:total="tasks.total"
:page="tasks.page"
:page-size="tasks.pageSize"
:status="taskStatus"
:filters="taskFilters"
@refresh="loadTasks"
@status-change="changeTaskStatus"
@filter="changeTaskFilters"
@page-change="changeTaskPage"
@assign="handleAssign"
@complete="handleComplete"
@complete-many="handleCompleteMany"
@reject="handleReject"
@reject-many="handleRejectMany"
@exempt="handleExempt"
@exempt-many="handleExemptMany"
@reclaim="handleReclaim"
/>
</el-tab-pane>
<el-tab-pane label="结算" name="settlements">
<CleaningSettlementsPanel
:session="session"
:loading="loading.settlements"
:items="settlements.items"
:cleaners="cleaners.items"
:total="settlements.total"
:page="settlements.page"
:page-size="settlements.pageSize"
:status="settlementStatus"
:filters="settlementFilters"
@refresh="loadSettlements"
@status-change="changeSettlementStatus"
@filter="changeSettlementFilters"
@page-change="changeSettlementPage"
@generate="handleGenerateSettlement"
@confirm="handleConfirmSettlement"
@transfer="handleTransfer"
@sync-transfer="handleSyncTransfer"
@sync-transfer-many="handleSyncTransferMany"
@failure="handleFailure"
@cancel="handleCancelSettlement"
@paid="handleManualPaid"
/>
</el-tab-pane>
<el-tab-pane label="统计" name="statistics">
<CleaningStatisticsPanel
:loading="loading.statistics"
:statistics="statistics"
:filters="statisticsFilters"
:cleaners="cleaners.items"
@refresh="loadStatistics"
@filter="changeStatisticsFilters"
/>
</el-tab-pane>
<el-tab-pane label="保洁员" name="cleaners">
<CleanersPanel
:loading="loading.cleaners"
:items="cleaners.items"
:total="cleaners.total"
:page="cleaners.page"
:page-size="cleaners.pageSize"
:status="cleanerStatus"
:search="cleanerSearch"
@refresh="loadCleaners"
@status-change="changeCleanerStatus"
@search-change="changeCleanerSearch"
@page-change="changeCleanerPage"
@create="handleCreateCleaner"
@update="handleUpdateCleaner"
@status-toggle="handleToggleCleanerStatus"
@reset-sessions="handleResetCleanerSessions"
/>
</el-tab-pane>
<el-tab-pane label="清洁规则" name="rules">
<CleaningRulesPanel :session="session" />
</el-tab-pane>
<el-tab-pane label="联调" name="handoff">
<CleaningFieldHandoffPanel />
</el-tab-pane>
</el-tabs>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, toRef } from 'vue';
import { ElMessage } from 'element-plus';
import { ClipboardCheck, Download, ListTodo, RotateCcw, Send } from '@lucide/vue';
import CleaningTasksPanel from './CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue';
import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue';
import CleanersPanel from './CleanersPanel.vue';
import CleaningRulesPanel from './CleaningRulesPanel.vue';
import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
import {
ApiError,
assignCleaningTask,
cancelCleaningSettlement,
completeCleaningTask,
confirmCleaningSettlement,
createStaffUser,
exemptCleaningTask,
executeWechatTransfer,
generateCleaningSettlement,
getCleaningStatistics,
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
markCleaningSettlementPaid,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
resetStaffSessions,
syncWechatTransfer,
updateStaffUser,
type ApiSession
} from '../api';
import type {
CleaningSettlement,
CleaningStatistics,
CleaningTask,
ManagedUser,
PageResult,
PayoutStateFilter,
SettlementStatus,
TaskStatus,
TransferMode,
UserStatus
} from '../types';
import { money } from '../format';
const props = defineProps<{ session: ApiSession }>();
const session = toRef(props, 'session');
const activeTab = ref('tasks');
const lastError = ref('');
const lastMessage = ref('');
const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
const cleanerSearch = ref('');
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
const settlements = reactive<PageResult<CleaningSettlement>>({
items: [],
total: 0,
page: 1,
pageSize: 20
});
const cleaners = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
const taskFilters = reactive({ storeId: '', cleanerUserId: '' });
const settlementFilters = reactive<{
storeId: string;
cleanerUserId: string;
payoutState: PayoutStateFilter | '';
}>({ storeId: '', cleanerUserId: '', payoutState: '' });
const statisticsFilters = reactive({ from: '', to: '', storeId: '', cleanerUserId: '' });
const statistics = reactive<CleaningStatistics>({
summary: {
taskTotal: 0,
pendingReview: 0,
active: 0,
rejected: 0,
exempted: 0,
completed: 0,
rewardCents: 0,
pendingSettlementCents: 0,
draftSettlementCents: 0,
confirmedSettlementCents: 0,
paidSettlementCents: 0
},
byStatus: [],
byStore: [],
settlements: [],
members: [],
trend: []
});
const draftSettlementTotal = computed(() => statistics.settlements
.find((item) => item.status === 'DRAFT')?.total || 0);
const operationalQueueTotal = computed(() => (
statistics.summary.pendingReview
+ statistics.summary.rejected
+ draftSettlementTotal.value
));
function loadCleaningWorkspace() {
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
}
async function runAction(work: () => Promise<void>, success: string) {
lastError.value = '';
lastMessage.value = '';
try {
await work();
lastMessage.value = success;
ElMessage.success(success);
} catch (error) {
lastError.value = error instanceof ApiError
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
: error instanceof Error ? error.message : '操作失败';
}
}
async function loadTasks() {
loading.tasks = true;
await runAction(async () => {
Object.assign(tasks, await listCleaningTasks(session.value, {
page: tasks.page,
pageSize: tasks.pageSize,
status: taskStatus.value || undefined,
storeId: taskFilters.storeId || undefined,
cleanerUserId: taskFilters.cleanerUserId || undefined
}));
}, '任务已刷新');
loading.tasks = false;
}
async function loadSettlements() {
loading.settlements = true;
await runAction(async () => {
Object.assign(settlements, await listCleaningSettlements(session.value, {
page: settlements.page,
pageSize: settlements.pageSize,
status: settlementStatus.value || undefined,
payoutState: settlementFilters.payoutState || undefined,
storeId: settlementFilters.storeId || undefined,
cleanerUserId: settlementFilters.cleanerUserId || undefined
}));
}, '结算已刷新');
loading.settlements = false;
}
async function loadStatistics() {
loading.statistics = true;
await runAction(async () => {
const result = await getCleaningStatistics(session.value, {
from: statisticsFilters.from || undefined,
to: statisticsFilters.to || undefined,
storeId: statisticsFilters.storeId || undefined,
cleanerUserId: statisticsFilters.cleanerUserId || undefined
});
Object.assign(statistics.summary, result.summary);
statistics.byStatus = result.byStatus;
statistics.byStore = result.byStore;
statistics.settlements = result.settlements;
statistics.members = result.members;
statistics.trend = result.trend;
}, '统计已刷新');
loading.statistics = false;
}
async function loadCleaners() {
loading.cleaners = true;
await runAction(async () => {
const result = await listStaffUsers(session.value, {
page: cleaners.page,
pageSize: cleaners.pageSize,
role: 'CLEANER',
status: cleanerStatus.value || undefined,
search: cleanerSearch.value || undefined
});
Object.assign(cleaners, {
items: result.items,
total: result.total,
page: cleaners.page,
pageSize: cleaners.pageSize
});
}, '保洁员已刷新');
loading.cleaners = false;
}
function changeTaskStatus(status: TaskStatus | '') {
taskStatus.value = status;
tasks.page = 1;
void loadTasks();
}
function jumpToTaskStatus(status: TaskStatus) {
activeTab.value = 'tasks';
taskStatus.value = status;
tasks.page = 1;
void loadTasks();
}
function changeTaskFilters(input: { storeId: string; cleanerUserId: string }) {
Object.assign(taskFilters, input);
tasks.page = 1;
void loadTasks();
}
function changeSettlementStatus(status: SettlementStatus | '') {
settlementStatus.value = status;
settlements.page = 1;
void loadSettlements();
}
function jumpToSettlementStatus(status: SettlementStatus) {
activeTab.value = 'settlements';
settlementStatus.value = status;
settlementFilters.payoutState = '';
settlements.page = 1;
void loadSettlements();
}
function exportOperationalQueue() {
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
['队列', '数量', '金额(分)', '入口'],
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
['待发放金额', '', String(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', String(statistics.summary.pendingSettlementCents), '统计/待结算'],
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
]);
}
function downloadCsv(filename: string, rows: string[][]) {
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string) {
const normalized = value.replace(/\r?\n/g, ' ');
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
}
function changeSettlementFilters(input: {
storeId: string;
cleanerUserId: string;
payoutState: PayoutStateFilter | '';
}) {
Object.assign(settlementFilters, input);
settlements.page = 1;
void loadSettlements();
}
function changeTaskPage(page: number) {
tasks.page = page;
void loadTasks();
}
function changeSettlementPage(page: number) {
settlements.page = page;
void loadSettlements();
}
function changeStatisticsFilters(input: {
from: string;
to: string;
storeId: string;
cleanerUserId: string;
}) {
Object.assign(statisticsFilters, input);
void loadStatistics();
}
function changeCleanerStatus(status: UserStatus | '') {
cleanerStatus.value = status;
cleaners.page = 1;
void loadCleaners();
}
function changeCleanerSearch(search: string) {
cleanerSearch.value = search;
cleaners.page = 1;
void loadCleaners();
}
function changeCleanerPage(page: number) {
cleaners.page = page;
void loadCleaners();
}
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
await runAction(async () => {
await assignCleaningTask(session.value, input.taskId, input);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已指派');
}
async function handleComplete(input: { taskId: string; note?: string }) {
await runAction(async () => {
await completeCleaningTask(session.value, input.taskId, input.note);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已验收');
}
async function handleCompleteMany(input: { taskIds: string[]; note?: string }) {
await runAction(async () => {
for (const taskId of input.taskIds) await completeCleaningTask(session.value, taskId, input.note);
await Promise.all([loadTasks(), loadStatistics()]);
}, `已批量验收 ${input.taskIds.length} 个任务`);
}
async function handleReject(input: { taskId: string; reason: string }) {
await runAction(async () => {
await rejectCleaningTask(session.value, input.taskId, input.reason);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已驳回');
}
async function handleRejectMany(input: { taskIds: string[]; reason: string }) {
await runAction(async () => {
for (const taskId of input.taskIds) await rejectCleaningTask(session.value, taskId, input.reason);
await Promise.all([loadTasks(), loadStatistics()]);
}, `已批量驳回 ${input.taskIds.length} 个任务`);
}
async function handleExempt(input: { taskId: string; note?: string }) {
await runAction(async () => {
await exemptCleaningTask(session.value, input.taskId, input.note);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已标记免清洁');
}
async function handleExemptMany(input: { taskIds: string[]; note?: string }) {
await runAction(async () => {
for (const taskId of input.taskIds) await exemptCleaningTask(session.value, taskId, input.note);
await Promise.all([loadTasks(), loadStatistics()]);
}, `已批量标记免清洁 ${input.taskIds.length} 个任务`);
}
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
await runAction(async () => {
await reclaimCleaningTimeouts(session.value, input);
await Promise.all([loadTasks(), loadStatistics()]);
}, '已回收超时任务');
}
async function handleGenerateSettlement(input: {
cleanerUserId: string;
storeId?: string;
note?: string;
}) {
await runAction(async () => {
await generateCleaningSettlement(session.value, input);
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '已生成结算单');
}
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
await runAction(async () => {
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已确认结算单');
}
async function handleCancelSettlement(input: { settlementId: string; reason: string }) {
await runAction(async () => {
await cancelCleaningSettlement(session.value, input.settlementId, input.reason);
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '结算单已冲正');
}
async function handleManualPaid(input: {
settlementId: string;
payoutChannel: string;
payoutReference: string;
note?: string;
}) {
await runAction(async () => {
await markCleaningSettlementPaid(session.value, input.settlementId, {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
note: input.note
});
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '人工付款已完成');
}
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
await runAction(async () => {
await executeWechatTransfer(session.value, input.settlementId, {
mode: input.mode,
note: input.note
});
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已提交微信转账');
}
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
await runAction(async () => {
await syncWechatTransfer(session.value, input.settlementId, input.note);
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已同步微信状态');
}
async function handleSyncTransferMany(input: { settlementIds: string[]; note?: string }) {
await runAction(async () => {
for (const settlementId of input.settlementIds) {
await syncWechatTransfer(session.value, settlementId, input.note);
}
await Promise.all([loadSettlements(), loadStatistics()]);
}, `已批量同步 ${input.settlementIds.length} 个结算单`);
}
async function handleFailure(input: {
settlementId: string;
error: string;
payoutReference?: string;
note?: string;
}) {
await runAction(async () => {
await recordPayoutFailure(session.value, input.settlementId, {
error: input.error,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: input.payoutReference,
note: input.note
});
await Promise.all([loadSettlements(), loadStatistics()]);
}, '已记录失败');
}
async function handleCreateCleaner(input: {
nickname: string;
phone: string;
storeIds: string[];
note?: string;
}) {
await runAction(async () => {
await createStaffUser(session.value, {
nickname: input.nickname,
phone: input.phone,
roles: ['CLEANER'],
storeIds: input.storeIds,
note: input.note
});
await loadCleaners();
}, '已新增保洁员');
}
async function handleUpdateCleaner(input: {
userId: string;
nickname: string;
phone?: string;
storeIds: string[];
note?: string;
}) {
await runAction(async () => {
await updateStaffUser(session.value, input.userId, {
nickname: input.nickname,
phone: input.phone,
roles: ['CLEANER'],
storeIds: input.storeIds,
note: input.note
});
await loadCleaners();
}, '已更新保洁员');
}
async function handleToggleCleanerStatus(input: { userId: string; status: UserStatus }) {
await runAction(async () => {
await updateStaffUser(session.value, input.userId, { status: input.status });
await loadCleaners();
}, input.status === 'ACTIVE' ? '已启用保洁员' : '已停用保洁员');
}
async function handleResetCleanerSessions(userId: string) {
await runAction(async () => {
await resetStaffSessions(session.value, userId);
await loadCleaners();
}, '已重置保洁员会话');
}
onMounted(() => {
if (session.value.token) void loadCleaningWorkspace();
});
</script>
@@ -0,0 +1,162 @@
<template>
<section class="content-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="content-metrics">
<span><small>可用素材</small><strong>{{ assets.length }}</strong></span>
<span><small>装修版本</small><strong>{{ decorations.length }}</strong></span>
<span><small>已发布装修</small><strong>{{ publishedDecorationCount }}</strong></span>
<span><small>生效广告</small><strong>{{ activeAdvertisementCount }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar content-toolbar">
<div><p class="section-kicker">M08-D · 内容运营</p><h3>媒体素材门店装修与广告投放</h3></div>
<div class="toolbar-actions">
<el-select v-model="selectedStoreId" placeholder="选择门店" filterable @change="loadScopedData">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab" class="content-tabs">
<el-tab-pane label="媒体素材" name="assets">
<div class="content-tab-actions">
<p>图片会压缩并校验格式门店素材仅能用于当前门店广告</p>
<div class="toolbar-actions">
<el-select v-model="uploadScope" class="compact-select">
<el-option label="当前门店" value="STORE" />
<el-option label="租户公共" value="TENANT" />
</el-select>
<label class="file-button" :class="{ disabled: uploading }">
<Upload :size="16" />{{ uploading ? '上传中…' : '上传图片' }}
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="uploading" @change="uploadImage" />
</label>
</div>
</div>
<div v-loading="loading" class="asset-gallery">
<article v-for="asset in assets" :key="asset.id" class="asset-card">
<el-image :src="asset.url" fit="cover" :preview-src-list="[asset.url]" preview-teleported />
<div><strong>#{{ asset.id }}</strong><el-tag size="small" :type="asset.storeId ? 'info' : 'success'">{{ asset.storeId ? '门店素材' : '租户公共' }}</el-tag></div>
<p>{{ asset.width }} × {{ asset.height }} · {{ fileSize(asset.byteSize) }}</p>
</article>
<el-empty v-if="!loading && assets.length === 0" description="当前范围暂无素材" />
</div>
</el-tab-pane>
<el-tab-pane label="门店装修" name="decorations">
<div class="content-tab-actions">
<p>每次保存生成不可变草稿版本发布后自动归档上一版</p>
<el-button type="primary" :icon="Plus" :disabled="!selectedStoreId" @click="openDecoration">新建草稿</el-button>
</div>
<el-table v-loading="loading" :data="decorations" row-key="id" class="data-table">
<el-table-column label="版本" width="100"><template #default="{ row }"><strong>V{{ row.version }}</strong></template></el-table-column>
<el-table-column label="模板" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>Schema {{ row.schemaVersion }}</span></div></template></el-table-column>
<el-table-column label="组件" width="100"><template #default="{ row }">{{ row.content.components.length }}</template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="decorationTag(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" min-width="160"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="inspectDecoration = row">查看</el-button><el-button v-if="row.status !== 'PUBLISHED'" size="small" type="primary" :loading="publishingId === row.id" @click="publish(row)">发布</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="广告投放" name="advertisements">
<div class="content-tab-actions">
<p>按平台租户或门店范围投放支持排期排序与停用</p>
<el-button type="primary" :icon="Plus" @click="openAdvertisement()">新建广告</el-button>
</div>
<el-table v-loading="loading" :data="advertisements" row-key="id" class="data-table">
<el-table-column label="广告" min-width="240"><template #default="{ row }"><div class="ad-title"><el-image :src="row.imageUrl" fit="cover" /><div class="stack"><strong>{{ row.title }}</strong><span>#{{ row.id }} · {{ row.targetType }}</span></div></div></template></el-table-column>
<el-table-column label="范围" width="140"><template #default="{ row }"><div class="stack"><el-tag>{{ row.scopeType }}</el-tag><span>{{ row.storeId ? storeName(row.storeId) : '全部' }}</span></div></template></el-table-column>
<el-table-column label="排期" min-width="210"><template #default="{ row }"><div class="stack"><span>{{ row.startsAt ? shortDate(row.startsAt) : '立即开始' }}</span><span> {{ row.endsAt ? shortDate(row.endsAt) : '长期' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : row.status === 'DRAFT' ? 'warning' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Pencil" @click="openAdvertisement(row)">编辑</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="decorationDialog" title="新建装修草稿" width="min(820px, 95vw)">
<el-form label-position="top" class="decoration-form">
<el-form-item label="模板代码"><el-input v-model="decorationForm.templateCode" /></el-form-item>
<el-form-item label="Schema 版本"><el-input-number v-model="decorationForm.schemaVersion" :min="1" :max="100" /></el-form-item>
</el-form>
<div class="component-editor">
<header><strong>页面组件按顺序渲染</strong><el-button size="small" :icon="Plus" @click="addComponent">添加组件</el-button></header>
<article v-for="(component, index) in decorationForm.components" :key="component.key">
<el-select v-model="component.type"><el-option v-for="type in componentTypes" :key="type" :label="type" :value="type" /></el-select>
<el-input v-model="component.propsText" type="textarea" :rows="3" placeholder='组件属性 JSON,例如 {"title":"欢迎光临"}' />
<el-button text type="danger" :icon="Trash2" @click="decorationForm.components.splice(index, 1)">移除</el-button>
</article>
<el-empty v-if="decorationForm.components.length === 0" description="尚未添加组件" :image-size="70" />
</div>
<template #footer><el-button @click="decorationDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="createDecoration">保存草稿</el-button></template>
</el-dialog>
<el-drawer v-model="decorationDrawer" title="装修版本内容" size="min(560px, 94vw)">
<template v-if="inspectDecoration"><el-descriptions :column="1" border><el-descriptions-item label="版本">V{{ inspectDecoration.version }}</el-descriptions-item><el-descriptions-item label="模板">{{ inspectDecoration.templateCode }}</el-descriptions-item><el-descriptions-item label="状态">{{ inspectDecoration.status }}</el-descriptions-item></el-descriptions><pre class="json-preview">{{ JSON.stringify(inspectDecoration.content, null, 2) }}</pre></template>
</el-drawer>
<el-dialog v-model="advertisementDialog" :title="advertisementForm.id ? '编辑广告' : '新建广告'" width="min(760px, 95vw)">
<el-form label-position="top" class="asset-form">
<el-form-item label="广告标题"><el-input v-model="advertisementForm.title" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="投放范围"><el-select v-model="advertisementForm.scopeType" @change="syncAdvertisementScope"><el-option label="平台" value="PLATFORM" /><el-option label="租户" value="TENANT" /><el-option label="门店" value="STORE" /></el-select></el-form-item>
<el-form-item v-if="advertisementForm.scopeType === 'STORE'" label="投放门店"><el-select v-model="advertisementForm.storeId" filterable><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item>
<el-form-item label="广告图片"><el-select v-model="advertisementForm.imageAssetId" filterable><el-option v-for="asset in advertisementAssets" :key="asset.id" :label="`#${asset.id} · ${asset.width}×${asset.height}${asset.storeId ? ' · 门店' : ' · 公共'}`" :value="asset.id"><span class="asset-option"><img :src="asset.url" alt="" />#{{ asset.id }} · {{ asset.width }}×{{ asset.height }}</span></el-option></el-select></el-form-item>
<el-form-item label="跳转类型"><el-select v-model="advertisementForm.targetType"><el-option label="不跳转" value="NONE" /><el-option label="小程序页面" value="PAGE" /><el-option label="外部链接" value="URL" /></el-select></el-form-item>
<el-form-item label="跳转目标"><el-input v-model="advertisementForm.targetValue" :disabled="advertisementForm.targetType === 'NONE'" placeholder="页面路径或 HTTPS URL" /></el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="advertisementForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="advertisementForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item>
<el-form-item label="状态"><el-select v-model="advertisementForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="生效" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select></el-form-item>
<el-form-item label="排序"><el-input-number v-model="advertisementForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
</el-form>
<template #footer><el-button @click="advertisementDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="persistAdvertisement">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Pencil, Plus, RefreshCw, Trash2, Upload } from '@lucide/vue';
import {
ApiError, listAdvertisements, listDecorations, listManagedStores, listMediaAssets,
publishDecoration, saveAdvertisement, saveDecoration, updateAdvertisement,
uploadMediaImage, type ApiSession
} from '../api';
import { shortDate } from '../format';
import type {
Advertisement, AdvertisementInput, DecorationComponent, DecorationComponentType,
DecorationVersion, ManagedStore, MediaAsset
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const assets = ref<MediaAsset[]>([]); const decorations = ref<DecorationVersion[]>([]); const advertisements = ref<Advertisement[]>([]);
const selectedStoreId = ref(''); const activeTab = ref('assets'); const uploadScope = ref<'STORE' | 'TENANT'>('STORE'); const loading = ref(false); const uploading = ref(false); const saving = ref(false); const publishingId = ref(''); const lastError = ref('');
const decorationDialog = ref(false); const advertisementDialog = ref(false); const inspectDecoration = ref<DecorationVersion | null>(null);
const decorationDrawer = computed({ get: () => Boolean(inspectDecoration.value), set: (value) => { if (!value) inspectDecoration.value = null; } });
const componentTypes: DecorationComponentType[] = ['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']; let componentKey = 0;
const decorationForm = reactive({ templateCode: 'STANDARD', schemaVersion: 1, components: [] as Array<{ key: number; type: DecorationComponentType; propsText: string }> });
const advertisementForm = reactive({ id: '', scopeType: 'STORE' as AdvertisementInput['scopeType'], storeId: '', title: '', imageAssetId: '', targetType: 'NONE' as AdvertisementInput['targetType'], targetValue: '', startsAt: '', endsAt: '', status: 'DRAFT' as AdvertisementInput['status'], sortOrder: 0 });
const publishedDecorationCount = computed(() => decorations.value.filter((item) => item.status === 'PUBLISHED').length);
const activeAdvertisementCount = computed(() => advertisements.value.filter((item) => item.status === 'ACTIVE').length);
const advertisementAssets = computed(() => assets.value.filter((asset) => advertisementForm.scopeType === 'STORE' ? !asset.storeId || asset.storeId === advertisementForm.storeId : !asset.storeId));
function errorMessage(error: unknown) { return error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [storeRows, adRows] = await Promise.all([listManagedStores(props.session), listAdvertisements(props.session)]); stores.value = storeRows; advertisements.value = adRows; if (!selectedStoreId.value || !stores.value.some((item) => item.id === selectedStoreId.value)) selectedStoreId.value = stores.value[0]?.id || ''; await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { loading.value = false; } }
async function loadScopedData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [assetRows, decorationRows] = await Promise.all([listMediaAssets(props.session, selectedStoreId.value || undefined), selectedStoreId.value ? listDecorations(props.session, selectedStoreId.value) : Promise.resolve([])]); assets.value = assetRows; decorations.value = decorationRows; } catch (error) { lastError.value = errorMessage(error); } finally { loading.value = false; } }
async function uploadImage(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; if (uploadScope.value === 'STORE' && !selectedStoreId.value) return ElMessage.warning('请先选择素材所属门店'); if (file.size > 8 * 1024 * 1024) return ElMessage.warning('图片不得超过 8 MB'); uploading.value = true; lastError.value = ''; try { await uploadMediaImage(props.session, file, uploadScope.value === 'STORE' ? selectedStoreId.value : undefined); ElMessage.success('素材上传成功'); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { uploading.value = false; } }
function addComponent() { decorationForm.components.push({ key: ++componentKey, type: 'HERO', propsText: '{}' }); }
function openDecoration() { decorationForm.templateCode = 'STANDARD'; decorationForm.schemaVersion = 1; decorationForm.components.splice(0); addComponent(); decorationDialog.value = true; }
async function createDecoration() { if (!selectedStoreId.value || !decorationForm.templateCode.trim()) return ElMessage.warning('请选择门店并填写模板代码'); const components: DecorationComponent[] = []; try { for (const item of decorationForm.components) { const parsed = JSON.parse(item.propsText || '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('组件属性必须是 JSON 对象'); components.push({ type: item.type, props: parsed }); } } catch (error) { return ElMessage.warning(error instanceof Error ? error.message : '组件属性 JSON 无效'); } saving.value = true; try { const result = await saveDecoration(props.session, { storeId: selectedStoreId.value, templateCode: decorationForm.templateCode.trim(), schemaVersion: decorationForm.schemaVersion, content: { components } }); decorationDialog.value = false; ElMessage.success(`装修草稿 V${result.version} 已保存`); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { saving.value = false; } }
async function publish(row: DecorationVersion) { publishingId.value = row.id; try { await publishDecoration(props.session, row.id, row.storeId); ElMessage.success(`装修 V${row.version} 已发布`); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { publishingId.value = ''; } }
function openAdvertisement(row?: Advertisement) { if (row?.storeId && row.storeId !== selectedStoreId.value) { selectedStoreId.value = row.storeId; void loadScopedData(); } Object.assign(advertisementForm, row ? { id: row.id, scopeType: row.scopeType, storeId: row.storeId || '', title: row.title, imageAssetId: row.imageAssetId, targetType: row.targetType, targetValue: row.targetValue, startsAt: row.startsAt ? row.startsAt.slice(0, 19).replace('T', ' ') : '', endsAt: row.endsAt ? row.endsAt.slice(0, 19).replace('T', ' ') : '', status: row.status, sortOrder: row.sortOrder } : { id: '', scopeType: 'STORE', storeId: selectedStoreId.value, title: '', imageAssetId: '', targetType: 'NONE', targetValue: '', startsAt: '', endsAt: '', status: 'DRAFT', sortOrder: 0 }); advertisementDialog.value = true; }
function syncAdvertisementScope() { if (advertisementForm.scopeType === 'STORE') advertisementForm.storeId ||= selectedStoreId.value; else advertisementForm.storeId = ''; advertisementForm.imageAssetId = ''; }
function advertisementInput(): AdvertisementInput | null { if (!advertisementForm.title.trim() || !advertisementForm.imageAssetId) { ElMessage.warning('请填写广告标题并选择图片'); return null; } if (advertisementForm.scopeType === 'STORE' && !advertisementForm.storeId) { ElMessage.warning('请选择投放门店'); return null; } if (advertisementForm.targetType !== 'NONE' && !advertisementForm.targetValue.trim()) { ElMessage.warning('请填写跳转目标'); return null; } if (advertisementForm.startsAt && advertisementForm.endsAt && advertisementForm.endsAt <= advertisementForm.startsAt) { ElMessage.warning('结束时间必须晚于开始时间'); return null; } return { scopeType: advertisementForm.scopeType, storeId: advertisementForm.scopeType === 'STORE' ? advertisementForm.storeId : null, title: advertisementForm.title.trim(), imageAssetId: advertisementForm.imageAssetId, targetType: advertisementForm.targetType, targetValue: advertisementForm.targetType === 'NONE' ? '' : advertisementForm.targetValue.trim(), startsAt: advertisementForm.startsAt || null, endsAt: advertisementForm.endsAt || null, status: advertisementForm.status, sortOrder: advertisementForm.sortOrder }; }
async function persistAdvertisement() { const input = advertisementInput(); if (!input) return; saving.value = true; try { if (advertisementForm.id) await updateAdvertisement(props.session, advertisementForm.id, input); else await saveAdvertisement(props.session, input); advertisementDialog.value = false; ElMessage.success('广告已保存'); advertisements.value = await listAdvertisements(props.session); } catch (error) { lastError.value = errorMessage(error); } finally { saving.value = false; } }
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
function fileSize(bytes: number) { return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`; }
function decorationTag(status: string) { return status === 'PUBLISHED' ? 'success' : status === 'DRAFT' ? 'warning' : 'info'; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
+95
View File
@@ -0,0 +1,95 @@
<template>
<section class="devices-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="device-metrics">
<span><small>设备总数</small><strong>{{ topology.assets.length }}</strong></span>
<span><small>在线</small><strong>{{ onlineCount }}</strong></span>
<span><small>开放告警</small><strong>{{ topology.openAlerts.length }}</strong></span>
<span><small>维护中</small><strong>{{ openMaintenance.length }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar device-toolbar">
<div><p class="section-kicker">M08-D · 设备运营</p><h3>设备资产拓扑与控制</h3></div>
<div class="toolbar-actions"><el-select v-model="storeId" filterable placeholder="选择授权门店" @change="changeStore"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select><el-button :icon="RefreshCw" :loading="loading" @click="loadTopology">刷新</el-button><el-button type="primary" :icon="Plus" :disabled="!storeId" @click="openAsset">设备入库</el-button></div>
</header>
<el-tabs v-model="activeTab" class="device-tabs">
<el-tab-pane label="设备资产" name="assets">
<el-table v-loading="loading" :data="topology.assets" class="data-table" row-key="id">
<el-table-column label="设备" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ typeLabel(row.deviceType) }} · {{ row.model }}</strong><span>{{ row.deviceId }} · 资产 #{{ row.id }}</span></div></template></el-table-column>
<el-table-column label="门店 / 房间" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ storeName(row.storeId) }}</strong><span>{{ roomName(row.roomId) }}</span></div></template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><div class="stack"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag><span>{{ row.maintenanceStatus }}</span></div></template></el-table-column>
<el-table-column label="固件 / 信号" min-width="140"><template #default="{ row }"><div class="stack"><strong>{{ row.firmwareVersion || '-' }}</strong><span>{{ row.signalStrength == null ? '-' : `${row.signalStrength} dBm` }}</span></div></template></el-table-column>
<el-table-column label="能力" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="capability in row.capabilities" :key="capability" effect="plain">{{ capability }}</el-tag></div></template></el-table-column>
<el-table-column label="最近心跳" width="150"><template #default="{ row }">{{ shortDate(row.lastHeartbeatAt || row.lastSeenAt) }}</template></el-table-column>
<el-table-column label="操作" width="270" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Wrench" @click="openMaintenanceDialog(row)">维护</el-button><el-button v-if="row.deviceType !== 'SUB_LOCK'" size="small" :icon="Cable" @click="openChannel(row)">通道</el-button><el-button v-if="row.roomId" size="small" :icon="Power" @click="openControl(row)">控制</el-button></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="通道与拓扑" name="topology">
<div class="tab-action-row"><span>同一房间控制目标保持唯一Sub1G 门锁必须绑定控制箱父设备</span><el-button :icon="Link2" @click="openLink">绑定门锁</el-button></div>
<section class="device-topology-grid"><article><h4>控制通道</h4><el-table :data="topology.channels" class="data-table"><el-table-column label="设备" min-width="120"><template #default="{ row }">{{ assetName(row.assetId) }}</template></el-table-column><el-table-column label="房间" min-width="120"><template #default="{ row }">{{ roomName(row.roomId) }}</template></el-table-column><el-table-column prop="channelCode" label="通道" width="90" /><el-table-column prop="purpose" label="用途" min-width="140" /></el-table></article><article><h4>Sub1G 父子链路</h4><el-table :data="topology.links" class="data-table"><el-table-column label="父设备" min-width="120"><template #default="{ row }">{{ assetName(row.parentAssetId) }}</template></el-table-column><el-table-column label="子设备" min-width="120"><template #default="{ row }">{{ assetName(row.childAssetId) }}</template></el-table-column><el-table-column prop="subId" label="Sub ID" min-width="110" /><el-table-column prop="subtype" label="型号" width="90" /><el-table-column prop="status" label="状态" width="90" /></el-table></article></section>
</el-tab-pane>
<el-tab-pane :label="`开放告警 (${topology.openAlerts.length})`" name="alerts"><el-table :data="topology.openAlerts" class="data-table"><el-table-column label="设备 / 房间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ assetName(row.assetId) }}</strong><span>{{ roomName(row.roomId) }}</span></div></template></el-table-column><el-table-column prop="alertType" label="告警类型" min-width="140" /><el-table-column label="等级" width="100"><template #default="{ row }"><el-tag :type="severityType(row.severity)">{{ row.severity }}</el-tag></template></el-table-column><el-table-column prop="summary" label="摘要" min-width="240" /><el-table-column label="最后出现" width="150"><template #default="{ row }">{{ shortDate(row.lastSeenAt) }}</template></el-table-column></el-table></el-tab-pane>
<el-tab-pane label="维护记录" name="maintenance"><el-table :data="topology.maintenance" class="data-table"><el-table-column label="设备 / 房间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ assetName(row.assetId) }}</strong><span>{{ roomName(row.roomId) }}</span></div></template></el-table-column><el-table-column prop="recordType" label="类型" width="120" /><el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'OPEN' ? 'warning' : 'success'">{{ row.status }}</el-tag></template></el-table-column><el-table-column prop="description" label="说明" min-width="260" /><el-table-column label="创建 / 解决" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ shortDate(row.createdAt) }}</strong><span>{{ shortDate(row.resolvedAt) }}</span></div></template></el-table-column></el-table></el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="assetDialog" title="设备入库" width="min(700px, 94vw)"><el-form label-position="top" class="asset-form"><el-form-item label="设备类型"><el-select v-model="assetForm.deviceType"><el-option label="4G 控制箱" value="CONTROL_BOX" /><el-option label="Sub1G 门锁" value="SUB_LOCK" /><el-option label="4G 智慧插座" value="SMART_SOCKET" /></el-select></el-form-item><el-form-item label="房间"><el-select v-model="assetForm.roomId" clearable><el-option label="门店公共设备" value="" /><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="DeviceID"><el-input v-model="assetForm.deviceId" /></el-form-item><el-form-item label="型号"><el-input v-model="assetForm.model" /></el-form-item><el-form-item label="IMEI"><el-input v-model="assetForm.imei" /></el-form-item><el-form-item label="ICCID"><el-input v-model="assetForm.iccid" /></el-form-item><el-form-item label="固件版本"><el-input v-model="assetForm.firmwareVersion" /></el-form-item><el-form-item label="能力(逗号分隔)"><el-input v-model="assetForm.capabilitiesText" placeholder="POWER,LOCK,TTS" /></el-form-item></el-form><template #footer><el-button @click="assetDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAsset">入库</el-button></template></el-dialog>
<el-dialog v-model="channelDialog" title="绑定设备通道" width="min(580px, 94vw)"><el-form label-position="top"><el-form-item label="房间"><el-select v-model="channelForm.roomId"><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="通道"><el-select v-model="channelForm.channelCode"><el-option v-for="code in channelCodes" :key="code" :label="code" :value="code" /></el-select></el-form-item><el-form-item label="用途"><el-select v-model="channelForm.purpose"><el-option v-for="purpose in purposes" :key="purpose" :label="purpose" :value="purpose" /></el-select></el-form-item></el-form><template #footer><el-button @click="channelDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel">保存</el-button></template></el-dialog>
<el-dialog v-model="linkDialog" title="绑定 Sub1G 门锁" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="控制箱"><el-select v-model="linkForm.parentAssetId"><el-option v-for="item in controlBoxes" :key="item.id" :label="assetName(item.id)" :value="item.id" /></el-select></el-form-item><el-form-item label="门锁"><el-select v-model="linkForm.childAssetId"><el-option v-for="item in subLocks" :key="item.id" :label="assetName(item.id)" :value="item.id" /></el-select></el-form-item><el-form-item label="房间"><el-select v-model="linkForm.roomId"><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="Sub ID"><el-input v-model="linkForm.subId" /></el-form-item><el-form-item label="型号"><el-select v-model="linkForm.subtype"><el-option label="701C" value="701C" /><el-option label="701G" value="701G" /></el-select></el-form-item></el-form><template #footer><el-button @click="linkDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveLink">保存</el-button></template></el-dialog>
<el-dialog v-model="maintenanceDialog" title="设备维护记录" width="min(600px, 94vw)"><el-form label-position="top"><el-form-item label="类型"><el-select v-model="maintenanceForm.recordType"><el-option label="巡检" value="INSPECTION" /><el-option label="维修" value="REPAIR" /><el-option label="更换" value="REPLACEMENT" /></el-select></el-form-item><el-form-item label="状态"><el-radio-group v-model="maintenanceForm.status"><el-radio value="OPEN">处理中</el-radio><el-radio value="RESOLVED">已解决</el-radio></el-radio-group></el-form-item><el-form-item label="说明"><el-input v-model="maintenanceForm.description" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="maintenanceDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMaintenance">保存</el-button></template></el-dialog>
<el-dialog v-model="controlDialog" title="设备控制" width="min(620px, 94vw)"><el-alert title="命令发布不等于设备已 ACK;请刷新状态或结合现场确认。" type="warning" show-icon :closable="false" /><div class="device-control-grid"><el-button :icon="DoorOpen" @click="runControl('door', { order: 'open', holdopen: 0, delayTime: 4 })">临时开门</el-button><el-button :icon="Power" @click="runControl('power', { slotall: 'on' })">全屋通电</el-button><el-button :icon="PowerOff" @click="runControl('power', { slotall: 'off' })">全屋断电</el-button><el-button :icon="PlugZap" @click="runControl('socket/switch', { on: true, slotNum: 1 })">插座通电</el-button><el-button :icon="Unplug" @click="runControl('socket/switch', { on: false, slotNum: 1 })">插座断电</el-button></div></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Cable, DoorOpen, Link2, PlugZap, Plus, Power, PowerOff, RefreshCw, Unplug, Wrench } from '@lucide/vue';
import { ApiError, addDeviceMaintenance, bindDeviceChannel, bindSubDevice, controlDevice, createDeviceAsset, getDeviceTopology, listManagedRooms, listManagedStores, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { DeviceAsset, DeviceTopology, DeviceType, ManagedRoom, ManagedStore } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const rooms = ref<ManagedRoom[]>([]); const storeId = ref('');
const topology = reactive<DeviceTopology>({ assets: [], channels: [], links: [], openAlerts: [], maintenance: [] });
const activeTab = ref('assets'); const loading = ref(false); const saving = ref(false); const lastError = ref('');
const assetDialog = ref(false); const channelDialog = ref(false); const linkDialog = ref(false); const maintenanceDialog = ref(false); const controlDialog = ref(false);
const assetForm = reactive({ deviceType: 'CONTROL_BOX' as DeviceType, roomId: '', deviceId: '', model: '', imei: '', iccid: '', firmwareVersion: '', capabilitiesText: '' });
const channelForm = reactive({ assetId: '', roomId: '', channelCode: 'SLOT1', purpose: 'ROOM_POWER' });
const linkForm = reactive({ parentAssetId: '', childAssetId: '', roomId: '', subId: '', subtype: '701C' });
const maintenanceForm = reactive({ assetId: '', roomId: '', recordType: 'INSPECTION' as 'INSPECTION' | 'REPAIR' | 'REPLACEMENT', status: 'OPEN' as 'OPEN' | 'RESOLVED', description: '' });
const controlAsset = ref<DeviceAsset | null>(null);
const channelCodes = ['SLOT1', 'SLOT2', 'SLOT3', 'MAIN', 'LOCK', 'LED', 'TTS'];
const purposes = ['ROOM_POWER', 'AIR_CONDITIONER', 'LIGHTING', 'DOOR_MAGNET', 'STORE_DOOR', 'ROOM_DOOR', 'TTS', 'LED'];
const onlineCount = computed(() => topology.assets.filter((item) => item.status === 'ONLINE').length);
const openMaintenance = computed(() => topology.maintenance.filter((item) => item.status === 'OPEN'));
const controlBoxes = computed(() => topology.assets.filter((item) => item.deviceType === 'CONTROL_BOX'));
const subLocks = computed(() => topology.assets.filter((item) => item.deviceType === 'SUB_LOCK'));
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); storeId.value ||= stores.value[0]?.id || ''; await changeStore(); } catch { /* displayed */ } finally { loading.value = false; } }
async function changeStore() { if (!storeId.value) return; loading.value = true; try { [rooms.value] = await Promise.all([capture(() => listManagedRooms(props.session, storeId.value)), loadTopology(false)]); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadTopology(show = true) { if (!storeId.value) return; if (show) loading.value = true; try { Object.assign(topology, await capture(() => getDeviceTopology(props.session, storeId.value))); } catch { /* displayed */ } finally { if (show) loading.value = false; } }
function openAsset() { Object.assign(assetForm, { deviceType: 'CONTROL_BOX', roomId: '', deviceId: '', model: '', imei: '', iccid: '', firmwareVersion: '', capabilitiesText: '' }); assetDialog.value = true; }
async function saveAsset() { if (!assetForm.deviceId || !assetForm.model) return ElMessage.warning('请填写 DeviceID 和型号'); saving.value = true; try { await capture(() => createDeviceAsset(props.session, { storeId: storeId.value, roomId: assetForm.roomId || null, deviceId: assetForm.deviceId.trim(), model: assetForm.model.trim(), imei: assetForm.imei.trim(), iccid: assetForm.iccid.trim() || null, deviceType: assetForm.deviceType, firmwareVersion: assetForm.firmwareVersion.trim(), signalStrength: null, capabilities: assetForm.capabilitiesText.split(/[,\s]+/).map((item) => item.trim().toUpperCase()).filter(Boolean) })); assetDialog.value = false; ElMessage.success('设备已入库'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
function openChannel(asset: DeviceAsset) { Object.assign(channelForm, { assetId: asset.id, roomId: asset.roomId || rooms.value[0]?.id || '', channelCode: 'SLOT1', purpose: 'ROOM_POWER' }); channelDialog.value = true; }
async function saveChannel() { if (!channelForm.roomId) return ElMessage.warning('请选择房间'); saving.value = true; try { await capture(() => bindDeviceChannel(props.session, { ...channelForm, storeId: storeId.value })); channelDialog.value = false; ElMessage.success('控制通道已绑定'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
function openLink() { Object.assign(linkForm, { parentAssetId: controlBoxes.value[0]?.id || '', childAssetId: subLocks.value[0]?.id || '', roomId: rooms.value[0]?.id || '', subId: '', subtype: '701C' }); linkDialog.value = true; }
async function saveLink() { if (!linkForm.parentAssetId || !linkForm.childAssetId || !linkForm.roomId || !linkForm.subId) return ElMessage.warning('请完整填写父子设备、房间和 Sub ID'); saving.value = true; try { await capture(() => bindSubDevice(props.session, { ...linkForm, storeId: storeId.value })); linkDialog.value = false; ElMessage.success('门锁拓扑已绑定'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
function openMaintenanceDialog(asset: DeviceAsset) { Object.assign(maintenanceForm, { assetId: asset.id, roomId: asset.roomId || '', recordType: 'INSPECTION', status: 'OPEN', description: '' }); maintenanceDialog.value = true; }
async function saveMaintenance() { saving.value = true; try { await capture(() => addDeviceMaintenance(props.session, maintenanceForm.assetId, { storeId: storeId.value, roomId: maintenanceForm.roomId || null, recordType: maintenanceForm.recordType, status: maintenanceForm.status, description: maintenanceForm.description.trim() })); maintenanceDialog.value = false; ElMessage.success('维护记录已保存'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
function openControl(asset: DeviceAsset) { controlAsset.value = asset; controlDialog.value = true; }
async function runControl(action: 'door' | 'power' | 'socket/switch', command: Record<string, unknown>) { const asset = controlAsset.value; if (!asset?.roomId) return; try { await ElMessageBox.confirm(`确认对 ${roomName(asset.roomId)} 下发设备命令?`, '危险操作确认', { type: 'warning' }); const result = await capture(() => controlDevice(props.session, action, { storeId: storeId.value, roomId: asset.roomId, orderId: null, ...command })); ElMessage.success(`命令已受理${result.commandId ? ` · #${result.commandId}` : ''},等待设备 ACK`); controlDialog.value = false; } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
function roomName(id: string | null) { if (!id) return '门店公共设备'; const room = rooms.value.find((item) => item.id === id); return room ? `${room.roomNo} · ${room.name}` : `房间 #${id}`; }
function assetName(id: string) { const asset = topology.assets.find((item) => item.id === id); return asset ? `${typeLabel(asset.deviceType)} · ${asset.deviceId}` : `设备 #${id}`; }
function typeLabel(type: DeviceType) { return ({ CONTROL_BOX: '控制箱', SUB_LOCK: 'Sub1G 门锁', SMART_SOCKET: '智慧插座' })[type]; }
function statusType(status: string) { return status === 'ONLINE' ? 'success' : status === 'FAULT' ? 'danger' : 'info'; }
function severityType(value: string) { return ['CRITICAL', 'HIGH'].includes(value) ? 'danger' : value === 'MEDIUM' ? 'warning' : 'info'; }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
+57
View File
@@ -0,0 +1,57 @@
<template>
<section class="franchise-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="franchise-metrics"><span><small>新申请</small><strong>{{ count('NEW') }}</strong></span><span><small>已联系</small><strong>{{ count('CONTACTED') }}</strong></span><span><small>意向明确</small><strong>{{ count('QUALIFIED') }}</strong></span><span><small>本页待跟进</small><strong>{{ dueCount }}</strong></span></section>
<section class="panel">
<header class="panel-toolbar"><div><p class="section-kicker">M08-D · 加盟运营</p><h3>加盟申请与跟进队列</h3></div><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button></header>
<div class="franchise-filters">
<el-input v-model="filters.search" clearable placeholder="申请号、城市、联系人或电话" @keyup.enter="applyFilters" />
<el-select v-model="filters.status" clearable placeholder="全部状态"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
<el-select v-model="filters.assigneeUserId" clearable filterable placeholder="全部负责人"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select>
<el-button type="primary" @click="applyFilters">筛选</el-button>
</div>
<el-table v-loading="loading" :data="page.items" row-key="id" class="data-table">
<el-table-column label="申请" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.applicationNo }}</strong><span>{{ shortDate(row.createdAt) }} · {{ row.source }}</span></div></template></el-table-column>
<el-table-column label="联系人" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.contactName }} · {{ row.contactPhone }}</strong><span>{{ row.city }}</span></div></template></el-table-column>
<el-table-column label="需求" min-width="220" show-overflow-tooltip prop="message" />
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="负责人" min-width="130"><template #default="{ row }">{{ row.assigneeName || '待分派' }}</template></el-table-column>
<el-table-column label="下次跟进" min-width="150"><template #default="{ row }"><span :class="{ overdue: isOverdue(row.nextFollowUpAt) }">{{ row.nextFollowUpAt ? shortDate(row.nextFollowUpAt) : '-' }}</span></template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openDetail(row.id)">详情</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadData" />
</section>
<el-drawer v-model="drawer" title="加盟申请详情" size="min(680px, 96vw)">
<div v-if="detail" class="franchise-detail">
<el-descriptions :column="2" border><el-descriptions-item label="申请号">{{ detail.application.applicationNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusTag(detail.application.status)">{{ statusLabel(detail.application.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="联系人">{{ detail.application.contactName }}</el-descriptions-item><el-descriptions-item label="联系电话"><a :href="`tel:${detail.application.contactPhone}`">{{ detail.application.contactPhone }}</a></el-descriptions-item><el-descriptions-item label="意向城市">{{ detail.application.city }}</el-descriptions-item><el-descriptions-item label="提交时间">{{ shortDate(detail.application.createdAt) }}</el-descriptions-item><el-descriptions-item label="留言" :span="2">{{ detail.application.message || '无' }}</el-descriptions-item></el-descriptions>
<section class="franchise-assignment"><strong>负责人</strong><el-select :model-value="detail.application.assigneeUserId || ''" clearable filterable placeholder="待分派" @change="assign"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select></section>
<section class="follow-up-form"><h4>记录跟进</h4><el-form label-position="top"><el-form-item label="方式"><el-select v-model="followForm.followUpType"><el-option label="电话" value="CALL" /><el-option label="微信" value="WECHAT" /><el-option label="面谈" value="MEETING" /><el-option label="备注" value="NOTE" /></el-select></el-form-item><el-form-item label="更新状态"><el-select v-model="followForm.status" clearable><el-option v-for="item in allowedStatusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="下次跟进"><el-date-picker v-model="followForm.nextFollowUpAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item><el-form-item label="跟进内容"><el-input v-model="followForm.note" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></el-form><el-button type="primary" :loading="saving" @click="saveFollowUp">保存跟进</el-button></section>
<section><h4>跟进历史</h4><el-timeline><el-timeline-item v-for="item in detail.followUps" :key="item.id" :timestamp="shortDate(item.createdAt)" placement="top"><div class="follow-up-card"><strong>{{ followTypeLabel(item.followUpType) }} · {{ item.actorName }}</strong><p>{{ item.note }}</p><small v-if="item.fromStatus !== item.toStatus">{{ item.fromStatus ? statusLabel(item.fromStatus) : '-' }} → {{ item.toStatus ? statusLabel(item.toStatus) : '-' }}</small></div></el-timeline-item></el-timeline><el-empty v-if="detail.followUps.length === 0" description="尚无跟进记录" :image-size="70" /></section>
</div>
</el-drawer>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Eye, RefreshCw } from '@lucide/vue';
import { ApiError, addFranchiseFollowUp, assignFranchiseApplication, getFranchiseApplication, listFranchiseApplications, listStaffUsers, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { FranchiseApplication, FranchiseApplicationDetail, FranchiseFollowUpType, FranchiseStatus, ManagedUser, PageResult } from '../types';
const props = defineProps<{ session: ApiSession }>(); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const drawer = ref(false); const staff = ref<ManagedUser[]>([]); const detail = ref<FranchiseApplicationDetail | null>(null);
const page = reactive<PageResult<FranchiseApplication>>({ items: [], total: 0, page: 1, pageSize: 20 }); const filters = reactive({ search: '', status: '' as FranchiseStatus | '', assigneeUserId: '' }); const followForm = reactive({ followUpType: 'CALL' as Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>, status: '' as FranchiseStatus | '', nextFollowUpAt: '', note: '' });
const statusOptions: Array<{ value: FranchiseStatus; label: string }> = [{ value: 'NEW', label: '新申请' }, { value: 'CONTACTED', label: '已联系' }, { value: 'QUALIFIED', label: '意向明确' }, { value: 'REJECTED', label: '已拒绝' }, { value: 'CONVERTED', label: '已签约待开通' }];
const transitions: Record<FranchiseStatus, FranchiseStatus[]> = { NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'], QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: [] };
const allowedStatusOptions = computed(() => detail.value ? statusOptions.filter((item) => transitions[detail.value!.application.status].includes(item.value)) : []); const dueCount = computed(() => page.items.filter((item) => isOverdue(item.nextFollowUpAt)).length);
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [result, users] = await Promise.all([listFranchiseApplications(props.session, { page: page.page, pageSize: page.pageSize, status: filters.status || undefined, assigneeUserId: filters.assigneeUserId || undefined, search: filters.search.trim() || undefined }), staff.value.length ? Promise.resolve({ items: staff.value }) : listStaffUsers(props.session, { page: 1, pageSize: 100, status: 'ACTIVE' })]); Object.assign(page, result); staff.value = users.items; } catch (error) { captureError(error); } finally { loading.value = false; } }
function applyFilters() { page.page = 1; void loadData(); } async function openDetail(id: string) { drawer.value = true; detail.value = null; try { detail.value = await getFranchiseApplication(props.session, id); Object.assign(followForm, { followUpType: 'CALL', status: '', nextFollowUpAt: '', note: '' }); } catch (error) { captureError(error); } }
async function assign(value: string) { if (!detail.value) return; saving.value = true; try { await assignFranchiseApplication(props.session, detail.value.application.id, value || null); ElMessage.success('负责人已更新'); await refreshDetail(); await loadData(); } catch (error) { captureError(error); } finally { saving.value = false; } }
async function saveFollowUp() { if (!detail.value || !followForm.note.trim()) return ElMessage.warning('请填写跟进内容'); saving.value = true; try { await addFranchiseFollowUp(props.session, detail.value.application.id, { followUpType: followForm.followUpType, note: followForm.note.trim(), nextFollowUpAt: followForm.nextFollowUpAt || null, status: followForm.status || undefined }); ElMessage.success('跟进记录已保存'); await refreshDetail(); await loadData(); Object.assign(followForm, { status: '', note: '' }); } catch (error) { captureError(error); } finally { saving.value = false; } }
async function refreshDetail() { if (detail.value) detail.value = await getFranchiseApplication(props.session, detail.value.application.id); }
function count(status: FranchiseStatus) { return page.items.filter((item) => item.status === status).length; } function isOverdue(value: string | null) { return Boolean(value && new Date(value).getTime() < Date.now()); } function statusLabel(value: FranchiseStatus) { return statusOptions.find((item) => item.value === value)?.label || value; } function statusTag(value: FranchiseStatus) { return value === 'QUALIFIED' || value === 'CONVERTED' ? 'success' : value === 'REJECTED' ? 'info' : value === 'NEW' ? 'warning' : 'primary'; } function followTypeLabel(value: FranchiseFollowUpType) { return ({ CALL: '电话', WECHAT: '微信', MEETING: '面谈', NOTE: '备注', STATUS: '状态变化', ASSIGNMENT: '负责人变更' } as Record<FranchiseFollowUpType, string>)[value]; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<section class="system-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-tabs v-model="activeTab" class="panel system-tabs">
<el-tab-pane label="安全审计" name="audit">
<header class="panel-toolbar">
<div><p class="section-kicker">M08-D · 可追溯运营</p><h3>安全审计日志</h3></div>
<div class="toolbar-actions">
<el-button :icon="Download" :disabled="page.items.length === 0" @click="exportCsv">导出当前页</el-button>
<el-button :icon="RefreshCw" :loading="auditLoading" @click="loadAudit">刷新</el-button>
</div>
</header>
<div class="audit-filters">
<el-input v-model="filters.search" clearable placeholder="操作、资源、追踪号或操作人" @keyup.enter="applyFilters" />
<el-input v-model="filters.action" clearable placeholder="操作代码,如 ORDER_CREATED" @keyup.enter="applyFilters" />
<el-input v-model="filters.resourceType" clearable placeholder="资源类型,如 ORDER" @keyup.enter="applyFilters" />
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
<el-button type="primary" @click="applyFilters">筛选</el-button>
</div>
<el-table v-loading="auditLoading" :data="page.items" row-key="id" class="data-table">
<el-table-column label="时间" min-width="168"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
<el-table-column label="操作" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.action }}</strong><span>{{ row.resourceType }}{{ row.resourceId ? ` #${row.resourceId}` : '' }}</span></div></template></el-table-column>
<el-table-column label="操作人" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ row.actorName || row.actorType }}</strong><span>{{ row.actorId ? `#${row.actorId}` : '系统' }}</span></div></template></el-table-column>
<el-table-column label="来源" min-width="150"><template #default="{ row }"><div class="stack"><span>{{ row.ip || '-' }}</span><small>{{ compactAgent(row.userAgent) }}</small></div></template></el-table-column>
<el-table-column label="追踪号" min-width="180" show-overflow-tooltip prop="traceId" />
<el-table-column label="详情" width="92" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="selected = row; drawer = true">查看</el-button></template></el-table-column>
</el-table>
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadAudit" />
</el-tab-pane>
<el-tab-pane label="系统配置" name="system">
<div v-loading="systemLoading" class="system-workspace">
<section class="system-metrics">
<span><small>门店</small><strong>{{ overview?.counts.storeCount ?? 0 }}</strong></span>
<span><small>用户</small><strong>{{ overview?.counts.userCount ?? 0 }}</strong></span>
<span><small>活跃会话</small><strong>{{ overview?.counts.activeSessionCount ?? 0 }}</strong></span>
<span><small>今日审计</small><strong>{{ overview?.counts.auditTodayCount ?? 0 }}</strong></span>
</section>
<section class="system-config-grid">
<article class="config-card">
<header><div><p class="section-kicker">租户配置</p><h3>{{ overview?.tenant.code || '当前租户' }}</h3></div><el-tag :type="tenantStatusType">{{ overview?.tenant.status || '-' }}</el-tag></header>
<el-form label-position="top">
<el-form-item label="租户名称"><el-input v-model="tenantForm.name" maxlength="128" /></el-form-item>
<el-form-item label="默认时区"><el-input v-model="tenantForm.timezone" placeholder="Asia/Shanghai" /></el-form-item>
<el-form-item label="平台状态操作">
<div class="status-control"><el-switch v-model="applyStatus" active-text="本次更新状态" /><el-select v-model="tenantForm.status" :disabled="!applyStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></div>
<small class="field-help">只有平台管理员可以变更租户启停状态普通租户管理员请保持关闭</small>
</el-form-item>
</el-form>
<el-button type="primary" :loading="saving" @click="saveConfig">保存系统配置</el-button>
</article>
<article class="config-card migration-card">
<p class="section-kicker">数据库状态</p><h3>Schema 迁移</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="最新版本">{{ overview?.latestMigration?.version || '-' }}</el-descriptions-item>
<el-descriptions-item label="迁移名称">{{ overview?.latestMigration?.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="执行时间">{{ overview?.latestMigration?.appliedAt ? shortDate(overview.latestMigration.appliedAt) : '-' }}</el-descriptions-item>
<el-descriptions-item label="小程序绑定">{{ overview?.counts.appBindingCount ?? 0 }}</el-descriptions-item>
</el-descriptions>
<el-button :icon="RefreshCw" :loading="systemLoading" @click="loadSystem">刷新运行状态</el-button>
</article>
</section>
</div>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="drawer" title="审计事件详情" size="min(640px, 96vw)">
<el-descriptions v-if="selected" :column="1" border>
<el-descriptions-item label="操作">{{ selected.action }}</el-descriptions-item>
<el-descriptions-item label="资源">{{ selected.resourceType }} {{ selected.resourceId || '' }}</el-descriptions-item>
<el-descriptions-item label="追踪号">{{ selected.traceId }}</el-descriptions-item>
<el-descriptions-item label="操作人">{{ selected.actorName || selected.actorType }} {{ selected.actorId || '' }}</el-descriptions-item>
<el-descriptions-item label="脱敏来源 IP">{{ selected.ip || '-' }}</el-descriptions-item>
</el-descriptions>
<h4>脱敏元数据</h4><pre class="audit-metadata">{{ prettyMetadata }}</pre>
</el-drawer>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Download, Eye, RefreshCw } from '@lucide/vue';
import { ApiError, getSystemOverview, listAuditLogs, updateTenantSystemConfig, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { AuditLog, PageResult, SystemOverview } from '../types';
const props = defineProps<{ session: ApiSession }>();
const activeTab = ref('audit'); const auditLoading = ref(false); const systemLoading = ref(false); const saving = ref(false);
const lastError = ref(''); const drawer = ref(false); const selected = ref<AuditLog | null>(null);
const overview = ref<SystemOverview | null>(null); const dateRange = ref<[Date, Date] | null>(null);
const applyStatus = ref(false);
const filters = reactive({ search: '', action: '', resourceType: '' });
const tenantForm = reactive({ name: '', timezone: 'Asia/Shanghai', status: 'ACTIVE' as 'ACTIVE' | 'DISABLED' });
const page = reactive<PageResult<AuditLog>>({ items: [], total: 0, page: 1, pageSize: 20 });
const prettyMetadata = computed(() => JSON.stringify(selected.value?.metadata ?? {}, null, 2));
const tenantStatusType = computed(() => overview.value?.tenant.status === 'ACTIVE' ? 'success' : 'danger');
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
async function loadAudit() { if (!props.session.token) return; auditLoading.value = true; lastError.value = ''; try { const result = await listAuditLogs(props.session, { page: page.page, pageSize: page.pageSize, search: filters.search.trim() || undefined, action: filters.action.trim() || undefined, resourceType: filters.resourceType.trim() || undefined, from: dateRange.value?.[0].toISOString(), to: dateRange.value?.[1].toISOString() }); Object.assign(page, result); } catch (error) { captureError(error); } finally { auditLoading.value = false; } }
async function loadSystem() { if (!props.session.token) return; systemLoading.value = true; lastError.value = ''; try { overview.value = await getSystemOverview(props.session); Object.assign(tenantForm, { name: overview.value.tenant.name, timezone: overview.value.tenant.timezone, status: overview.value.tenant.status }); applyStatus.value = false; } catch (error) { captureError(error); } finally { systemLoading.value = false; } }
function applyFilters() { page.page = 1; void loadAudit(); }
async function saveConfig() { if (!tenantForm.name.trim() || !tenantForm.timezone.trim()) return ElMessage.warning('请填写租户名称和有效时区'); saving.value = true; try { await updateTenantSystemConfig(props.session, { name: tenantForm.name.trim(), timezone: tenantForm.timezone.trim(), status: applyStatus.value ? tenantForm.status : undefined }); ElMessage.success('系统配置已保存'); await loadSystem(); } catch (error) { captureError(error); } finally { saving.value = false; } }
function compactAgent(value: string) { return value ? value.slice(0, 36) : '-'; }
function csvCell(value: unknown) { return `"${String(value ?? '').replace(/"/g, '""')}"`; }
function exportCsv() { const rows = [['时间', '操作', '资源类型', '资源ID', '操作人', '来源IP', '追踪号'], ...page.items.map((item) => [item.createdAt, item.action, item.resourceType, item.resourceId, item.actorName || item.actorType, item.ip, item.traceId])]; const blob = new Blob([`\ufeff${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(url); }
watch(() => props.session.token, (token) => { if (token) void Promise.all([loadAudit(), loadSystem()]); }, { immediate: true });
</script>
+139
View File
@@ -0,0 +1,139 @@
<template>
<section class="people-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="people-metrics">
<span><small>当前页会员</small><strong>{{ members.items.length }}</strong></span>
<span><small>会员余额</small><strong>{{ money(memberBalance) }}</strong></span>
<span><small>累计消费</small><strong>{{ money(memberSpend) }}</strong></span>
<span><small>员工账号</small><strong>{{ staff.total }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar people-toolbar">
<div><p class="section-kicker">M08-D · 用户运营</p><h3>会员与员工用户管理</h3></div>
<el-button :icon="RefreshCw" :loading="loading" @click="loadActive">刷新</el-button>
</header>
<el-tabs v-model="activeTab" class="people-tabs" @tab-change="loadActive">
<el-tab-pane label="会员" name="members">
<div class="people-filter-row">
<el-input v-model="memberFilters.search" class="search-input" clearable placeholder="昵称 / 手机号 / ID" @keyup.enter="applyMemberFilters" />
<el-select v-model="memberFilters.status" class="filter-select" placeholder="全部状态" @change="applyMemberFilters"><el-option label="全部状态" value="" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select>
</div>
<el-table v-loading="loading" :data="members.items" class="data-table" row-key="memberId">
<el-table-column label="会员" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.nickname || `会员 #${row.memberId}` }}</strong><span>#{{ row.memberId }} · {{ row.maskedPhone || '未留手机号' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="余额" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ money(row.wallet.totalBalanceCents) }}</strong><span>现金 {{ money(row.wallet.cashBalanceCents) }} · 赠送 {{ money(row.wallet.giftBalanceCents) }}</span></div></template></el-table-column>
<el-table-column label="权益" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.benefits.availableCoupons }} 张券 · {{ row.benefits.activePackages }} 个套餐</strong><span>{{ row.benefits.packageMinutes }} 分钟 · {{ money(row.benefits.packageAmountCents) }}</span></div></template></el-table-column>
<el-table-column label="订单 / 消费" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.orders.orderCount }} · {{ money(row.orders.paidAmountCents) }}</strong><span>最近 {{ shortDate(row.orders.lastOrderAt) }}</span></div></template></el-table-column>
<el-table-column label="注册 / 登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ shortDate(row.registeredAt) }}</strong><span>登录 {{ shortDate(row.lastLoginAt) }}</span></div></template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openMember(row.memberId)">详情</el-button></template></el-table-column>
</el-table>
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="members.page" :page-size="members.pageSize" :total="members.total" @current-change="changeMemberPage" /></div>
</el-tab-pane>
<el-tab-pane label="员工" name="staff">
<div class="people-filter-row">
<el-input v-model="staffFilters.search" class="search-input" clearable placeholder="姓名 / 手机号 / ID" @keyup.enter="applyStaffFilters" />
<el-select v-model="staffFilters.role" class="filter-select" placeholder="全部角色" @change="applyStaffFilters"><el-option label="全部角色" value="" /><el-option v-for="item in roleOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
<el-select v-model="staffFilters.status" class="filter-select" placeholder="全部状态" @change="applyStaffFilters"><el-option label="全部状态" value="" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select>
<el-button type="primary" :icon="UserPlus" @click="openCreate">新增员工</el-button>
</div>
<el-table v-loading="loading" :data="staff.items" class="data-table" row-key="id">
<el-table-column label="员工" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.nickname || `员工 #${row.id}` }}</strong><span>#{{ row.id }} · {{ row.maskedPhone || '未留手机号' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="role in row.roles" :key="role" effect="plain">{{ roleLabel(role) }}</el-tag></div></template></el-table-column>
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
<el-table-column prop="note" label="运营备注" min-width="160" />
<el-table-column label="操作" width="335" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :icon="KeyRound" @click="openCredential(row)">登录</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
</el-table>
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
</el-tab-pane>
</el-tabs>
</section>
<el-drawer v-model="memberDrawer" title="会员详情" size="min(720px, 94vw)">
<div v-if="memberDetail" class="member-detail">
<section class="member-detail-grid">
<span><small>余额</small><strong>{{ money(memberDetail.wallet.totalBalanceCents) }}</strong></span>
<span><small>已支付订单</small><strong>{{ memberDetail.orders.paidOrderCount }}</strong></span>
<span><small>累计消费</small><strong>{{ money(memberDetail.orders.paidAmountCents) }}</strong></span>
<span><small>累计充值</small><strong>{{ money(memberDetail.recharge.creditedRechargeCents) }}</strong></span>
</section>
<el-descriptions :column="2" border><el-descriptions-item label="会员">{{ memberDetail.nickname || `#${memberDetail.memberId}` }}</el-descriptions-item><el-descriptions-item label="手机号">{{ memberDetail.maskedPhone || '-' }}</el-descriptions-item><el-descriptions-item label="可用券">{{ memberDetail.benefits.availableCoupons }}</el-descriptions-item><el-descriptions-item label="有效套餐">{{ memberDetail.benefits.activePackages }}</el-descriptions-item><el-descriptions-item label="注册时间">{{ shortDate(memberDetail.registeredAt) }}</el-descriptions-item><el-descriptions-item label="最近登录">{{ shortDate(memberDetail.lastLoginAt) }}</el-descriptions-item></el-descriptions>
<h4>最近余额流水</h4>
<el-table :data="memberDetail.recentLedger" class="data-table"><el-table-column prop="businessType" label="业务" min-width="120" /><el-table-column prop="entryType" label="类型" width="110" /><el-table-column label="现金变动" width="120"><template #default="{ row }">{{ signedMoney(row.cashDeltaCents) }}</template></el-table-column><el-table-column label="赠送变动" width="120"><template #default="{ row }">{{ signedMoney(row.giftDeltaCents) }}</template></el-table-column><el-table-column label="变动后余额" min-width="150"><template #default="{ row }">{{ money(row.cashBalanceAfterCents + row.giftBalanceAfterCents) }}</template></el-table-column><el-table-column label="时间" width="150"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column></el-table>
</div>
</el-drawer>
<el-dialog v-model="staffDialog" :title="staffForm.userId ? '编辑员工' : '新增员工'" width="min(680px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
</el-dialog>
<el-dialog v-model="credentialDialog" title="设置后台登录" width="min(480px, 94vw)">
<el-alert title="保存后将撤销该员工现有会话;密码不会在后台回显。" type="warning" show-icon :closable="false" />
<el-form label-position="top" class="credential-form">
<el-form-item label="员工"><el-input :model-value="credentialForm.nickname" disabled /></el-form-item>
<el-form-item label="登录账号"><el-input v-model="credentialForm.loginName" autocomplete="off" maxlength="64" placeholder="字母、数字、点、下划线或 @" /></el-form-item>
<el-form-item label="新密码"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
<el-form-item label="确认新密码"><el-input v-model="credentialForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
<small class="field-help">至少 12 同时包含字母数字和特殊字符</small>
</el-form>
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">保存登录凭据</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Eye, KeyRound, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, setAdminCredential, updateStaffUser, type ApiSession } from '../api';
import { money, shortDate } from '../format';
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
const props = defineProps<{ session: ApiSession }>();
const activeTab = ref('members');
const loading = ref(false);
const saving = ref(false);
const lastError = ref('');
const stores = ref<ManagedStore[]>([]);
const members = reactive<PageResult<MemberCard>>({ items: [], total: 0, page: 1, pageSize: 20 });
const staff = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
const memberFilters = reactive({ search: '', status: '' as UserStatus | '' });
const staffFilters = reactive({ search: '', status: '' as UserStatus | '', role: '' as StaffRole | '' });
const memberDrawer = ref(false);
const memberDetail = ref<MemberDetail | null>(null);
const staffDialog = ref(false);
const credentialDialog = ref(false);
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
const credentialForm = reactive({ userId: '', nickname: '', loginName: '', password: '', confirmPassword: '' });
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); await Promise.all([loadMembers(false), loadStaff(false)]); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadMembers(showLoading = true) { if (showLoading) loading.value = true; try { const result = await capture(() => listMembers(props.session, { page: members.page, pageSize: members.pageSize, status: memberFilters.status || undefined, search: memberFilters.search.trim() || undefined })); Object.assign(members, result); } catch { /* displayed */ } finally { if (showLoading) loading.value = false; } }
async function loadStaff(showLoading = true) { if (showLoading) loading.value = true; try { const result = await capture(() => listStaffUsers(props.session, { page: staff.page, pageSize: staff.pageSize, status: staffFilters.status || undefined, role: staffFilters.role || undefined, search: staffFilters.search.trim() || undefined })); Object.assign(staff, result); } catch { /* displayed */ } finally { if (showLoading) loading.value = false; } }
function loadActive() { return activeTab.value === 'members' ? loadMembers() : loadStaff(); }
function applyMemberFilters() { members.page = 1; void loadMembers(); }
function applyStaffFilters() { staff.page = 1; void loadStaff(); }
function changeMemberPage(page: number) { members.page = page; void loadMembers(); }
function changeStaffPage(page: number) { staff.page = page; void loadStaff(); }
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
function openCredential(row: ManagedUser) { Object.assign(credentialForm, { userId: row.id, nickname: row.nickname || `员工 #${row.id}`, loginName: '', password: '', confirmPassword: '' }); credentialDialog.value = true; }
async function saveCredential() { const valid = /^[\p{L}\p{N}._@+-]{3,64}$/u.test(credentialForm.loginName.trim()) && credentialForm.password.length >= 12 && /[A-Za-z]/.test(credentialForm.password) && /\d/.test(credentialForm.password) && /[^A-Za-z0-9]/.test(credentialForm.password); if (!valid) return ElMessage.warning('请填写有效登录账号和符合复杂度要求的密码'); if (credentialForm.password !== credentialForm.confirmPassword) return ElMessage.warning('两次输入的密码不一致'); saving.value = true; try { await capture(() => setAdminCredential(props.session, credentialForm.userId, { loginName: credentialForm.loginName.trim(), ['password']: credentialForm.password })); credentialDialog.value = false; ElMessage.success('后台登录凭据已更新,旧会话已撤销'); } catch { /* displayed */ } finally { saving.value = false; } }
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
function roleLabel(role: StaffRole) { return roleOptions.find((item) => item.value === role)?.label || role; }
function storeScopeLabel(ids: string[]) { if (!ids.length) return '未配置'; return ids.map((id) => stores.value.find((item) => item.id === id)?.name || `门店 #${id}`).join('、'); }
function signedMoney(cents: number) { return `${cents > 0 ? '+' : ''}${money(cents)}`; }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
@@ -0,0 +1,89 @@
<template>
<section class="notification-page">
<el-alert v-if="error" :title="error" type="error" show-icon closable @close="error = ''" />
<section class="notification-metrics">
<span><small>通知模板</small><strong>{{ templates.length }}</strong></span>
<span><small>路由规则</small><strong>{{ routes.length }}</strong></span>
<span><small>待重试</small><strong>{{ retryCount }}</strong></span>
<span><small>投递失败</small><strong>{{ failedCount }}</strong></span>
</section>
<section class="panel">
<header class="notification-toolbar">
<div><p class="section-kicker">M10-A · 通知中心</p><h3>事件路由模板与投递追踪</h3></div>
<div class="toolbar-actions"><el-select v-model="storeId" clearable filterable placeholder="全部授权门店" @change="loadAll"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select><el-button :loading="loading" @click="loadAll">刷新</el-button></div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane :label="`投递记录 (${deliveries.total})`" name="deliveries">
<div class="tab-tools"><el-select v-model="deliveryStatus" clearable placeholder="全部状态" @change="loadDeliveries"><el-option v-for="status in statuses" :key="status" :label="status" :value="status" /></el-select><el-select v-model="deliveryChannel" clearable placeholder="全部渠道" @change="loadDeliveries"><el-option v-for="item in channels" :key="item" :label="channelLabel(item)" :value="item" /></el-select></div>
<el-table v-loading="loading" :data="deliveries.items" row-key="id">
<el-table-column label="事件 / 内容" min-width="260"><template #default="{ row }"><div class="stack"><strong>{{ row.title }}</strong><span>{{ row.eventType }} · {{ row.body }}</span></div></template></el-table-column>
<el-table-column label="渠道 / 接收人" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ channelLabel(row.channel) }}</strong><span>{{ row.recipientMasked }}</span></div></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="尝试" width="100"><template #default="{ row }">{{ row.attempts }} / {{ row.maxAttempts }}</template></el-table-column>
<el-table-column label="异常 / 时间" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.lastErrorCode || '-' }}</strong><span>{{ row.lastErrorMessage || dateText(row.sentAt || row.createdAt) }}</span></div></template></el-table-column>
<el-table-column v-if="canManage" label="操作" width="110" fixed="right"><template #default="{ row }"><el-button v-if="row.status === 'FAILED'" size="small" type="primary" plain @click="retry(row)">人工补发</el-button></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`模板 (${templates.length})`" name="templates">
<div class="tab-tools"><el-button v-if="canManage" type="primary" @click="openTemplate">新增模板</el-button></div>
<el-table :data="templates" row-key="id"><el-table-column label="模板" min-width="220"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>{{ row.titleTemplate }}</span></div></template></el-table-column><el-table-column prop="eventType" label="事件" min-width="210" /><el-table-column label="渠道" width="140"><template #default="{ row }">{{ channelLabel(row.channel) }}</template></el-table-column><el-table-column prop="bodyTemplate" label="正文" min-width="260" /><el-table-column prop="status" label="状态" width="100" /></el-table>
</el-tab-pane>
<el-tab-pane :label="`路由 (${routes.length})`" name="routes">
<div class="tab-tools"><el-button v-if="canManage" type="primary" :disabled="!templates.length" @click="openRoute">新增路由</el-button></div>
<el-table :data="routes" row-key="id"><el-table-column prop="eventType" label="事件" min-width="210" /><el-table-column label="模板 / 渠道" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>{{ channelLabel(row.channel) }}</span></div></template></el-table-column><el-table-column label="接收人" min-width="190"><template #default="{ row }">{{ row.recipientType }} · {{ row.recipientValue }}</template></el-table-column><el-table-column label="静默时段" width="160"><template #default="{ row }">{{ row.quietStart ? `${row.quietStart} - ${row.quietEnd}` : '未设置' }}</template></el-table-column><el-table-column prop="status" label="状态" width="100" /></el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="templateDialog" title="新增通知模板" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="模板编码"><el-input v-model="templateForm.templateCode" /></el-form-item><el-form-item label="事件类型"><el-input v-model="templateForm.eventType" /></el-form-item><el-form-item label="渠道"><el-select v-model="templateForm.channel"><el-option v-for="item in channels" :key="item" :label="channelLabel(item)" :value="item" /></el-select></el-form-item><el-form-item label="标题模板"><el-input v-model="templateForm.titleTemplate" /></el-form-item><el-form-item label="正文模板"><el-input v-model="templateForm.bodyTemplate" type="textarea" :rows="4" placeholder="支持 {{field}} 占位符" /></el-form-item><el-form-item label="外部模板 ID"><el-input v-model="templateForm.externalTemplateId" /></el-form-item></el-form><template #footer><el-button @click="templateDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveTemplate">保存</el-button></template></el-dialog>
<el-dialog v-model="routeDialog" title="新增通知路由" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="模板"><el-select v-model="routeForm.templateId" filterable @change="syncRouteEvent"><el-option v-for="item in templates" :key="item.id" :label="`${item.templateCode} · ${item.eventType}`" :value="item.id" /></el-select></el-form-item><el-form-item label="事件类型"><el-input v-model="routeForm.eventType" disabled /></el-form-item><el-form-item label="接收人类型"><el-select v-model="routeForm.recipientType"><el-option label="事件顾客" value="CUSTOMER" /><el-option label="指定用户" value="USER" /><el-option label="角色" value="ROLE" /><el-option label="门店 Webhook" value="STORE_WEBHOOK" /></el-select></el-form-item><el-form-item label="接收人值"><el-input v-model="routeForm.recipientValue" :disabled="['CUSTOMER', 'STORE_WEBHOOK'].includes(routeForm.recipientType)" placeholder="角色编码或用户 ID" /></el-form-item><el-form-item label="静默开始/结束"><div class="quiet-row"><el-time-picker v-model="routeForm.quietStart" value-format="HH:mm:ss" placeholder="开始" /><el-time-picker v-model="routeForm.quietEnd" value-format="HH:mm:ss" placeholder="结束" /></div></el-form-item></el-form><template #footer><el-button @click="routeDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRoute">保存</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { ApiError, createNotificationRoute, listManagedStores, listNotificationDeliveries, listNotificationRoutes, listNotificationTemplates, retryNotificationDelivery, saveNotificationTemplate, type ApiSession } from '../api';
import { adminSessionState } from '../session';
import type { ManagedStore, NotificationChannel, NotificationDelivery, NotificationRoute, NotificationTemplate, PageResult } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('deliveries');
const templates = ref<NotificationTemplate[]>([]); const routes = ref<NotificationRoute[]>([]);
const deliveries = reactive<PageResult<NotificationDelivery>>({ items: [], total: 0, page: 1, pageSize: 50 });
const channels: NotificationChannel[] = ['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER'];
const statuses = ['PENDING', 'PROCESSING', 'SENT', 'RETRY', 'FAILED', 'SUPPRESSED'];
const deliveryStatus = ref(''); const deliveryChannel = ref<NotificationChannel | ''>('');
const loading = ref(false); const saving = ref(false); const error = ref('');
const templateDialog = ref(false); const routeDialog = ref(false);
const templateForm = reactive({ templateCode: '', eventType: '', channel: 'IN_APP' as NotificationChannel, titleTemplate: '', bodyTemplate: '', externalTemplateId: '' });
const routeForm = reactive({ templateId: '', eventType: '', recipientType: 'ROLE' as 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK', recipientValue: 'STORE_ADMIN', quietStart: null as string | null, quietEnd: null as string | null });
const canManage = computed(() => adminSessionState.access.roles.includes('PLATFORM_ADMIN') || adminSessionState.access.capabilities.some((item) => ['notification.manage', 'tenant.manage', 'platform.manage'].includes(item)));
const retryCount = computed(() => deliveries.items.filter((item) => item.status === 'RETRY').length);
const failedCount = computed(() => deliveries.items.filter((item) => item.status === 'FAILED').length);
async function capture<T>(work: () => Promise<T>) { error.value = ''; try { return await work(); } catch (reason) { error.value = reason instanceof ApiError ? reason.code : reason instanceof Error ? reason.message : '操作失败'; throw reason; } }
async function loadAll() { if (!props.session.token) return; loading.value = true; try { const [nextStores, nextTemplates, nextRoutes, nextDeliveries] = await Promise.all([stores.value.length ? Promise.resolve(stores.value) : capture(() => listManagedStores(props.session)), capture(() => listNotificationTemplates(props.session, { storeId: storeId.value || undefined })), capture(() => listNotificationRoutes(props.session, { storeId: storeId.value || undefined })), capture(() => listNotificationDeliveries(props.session, { storeId: storeId.value || undefined, status: deliveryStatus.value || undefined, channel: deliveryChannel.value || undefined, pageSize: 50 }))]); stores.value = nextStores; templates.value = nextTemplates; routes.value = nextRoutes; Object.assign(deliveries, nextDeliveries); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadDeliveries() { loading.value = true; try { Object.assign(deliveries, await capture(() => listNotificationDeliveries(props.session, { storeId: storeId.value || undefined, status: deliveryStatus.value || undefined, channel: deliveryChannel.value || undefined, pageSize: 50 }))); } catch { /* displayed */ } finally { loading.value = false; } }
function openTemplate() { Object.assign(templateForm, { templateCode: '', eventType: '', channel: 'IN_APP', titleTemplate: '', bodyTemplate: '', externalTemplateId: '' }); templateDialog.value = true; }
async function saveTemplate() { saving.value = true; try { await capture(() => saveNotificationTemplate(props.session, { ...templateForm, storeId: storeId.value || null })); templateDialog.value = false; ElMessage.success('通知模板已保存'); await loadAll(); } catch { /* displayed */ } finally { saving.value = false; } }
function openRoute() { const first = templates.value[0]; Object.assign(routeForm, { templateId: first?.id || '', eventType: first?.eventType || '', recipientType: 'ROLE', recipientValue: 'STORE_ADMIN', quietStart: null, quietEnd: null }); routeDialog.value = true; }
function syncRouteEvent() { routeForm.eventType = templates.value.find((item) => item.id === routeForm.templateId)?.eventType || ''; }
async function saveRoute() { saving.value = true; try { await capture(() => createNotificationRoute(props.session, { ...routeForm, storeId: storeId.value || null, recipientValue: ['CUSTOMER', 'STORE_WEBHOOK'].includes(routeForm.recipientType) ? undefined : routeForm.recipientValue })); routeDialog.value = false; ElMessage.success('通知路由已保存'); await loadAll(); } catch { /* displayed */ } finally { saving.value = false; } }
async function retry(row: NotificationDelivery) { try { await capture(() => retryNotificationDelivery(props.session, row.id)); ElMessage.success('补发任务已入队'); await loadDeliveries(); } catch { /* displayed */ } }
function channelLabel(value: string) { return ({ IN_APP: '后台站内', WECHAT_SUBSCRIBE: '微信订阅', WE_COM: '企业微信', WEBHOOK: 'Webhook', CLOUD_SPEAKER: '云喇叭' } as Record<string, string>)[value] || value; }
function statusType(value: string) { return value === 'SENT' ? 'success' : value === 'FAILED' ? 'danger' : ['RETRY', 'SUPPRESSED'].includes(value) ? 'warning' : 'info'; }
function dateText(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
watch(() => props.session.token, (token) => { if (token) void loadAll(); }, { immediate: true });
</script>
<style scoped>
.notification-page { display: grid; gap: 18px; }
.notification-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.notification-metrics span { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: #fff; }
.notification-metrics small, .stack span { color: var(--muted); }
.notification-metrics strong { font-size: 28px; }
.panel { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: #fff; }
.notification-toolbar, .toolbar-actions, .tab-tools, .quiet-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.notification-toolbar { justify-content: space-between; margin-bottom: 12px; }
.notification-toolbar h3 { margin: 2px 0 0; }.section-kicker { margin: 0; color: #2563eb; font-size: 12px; font-weight: 700; }
.tab-tools { justify-content: flex-end; margin-bottom: 14px; }.stack { display: grid; gap: 4px; }.stack span { font-size: 12px; }
@media (max-width: 760px) { .notification-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }.panel { padding: 12px; }.toolbar-actions, .toolbar-actions .el-select { width: 100%; } }
</style>
@@ -0,0 +1,284 @@
<template>
<section class="overview-page" aria-label="平台运营总览">
<div class="overview-toolbar panel">
<div>
<p class="section-kicker">授权数据范围</p>
<h3>门店经营快照</h3>
<p>实收只统计成功支付房态为当前快照</p>
</div>
<div class="overview-filters">
<el-select
v-model="selectedStoreId"
aria-label="选择门店"
placeholder="选择授权门店"
filterable
:loading="loadingStores"
>
<el-option
v-for="store in stores"
:key="store.id"
:label="`${store.name} · ${store.city || '未配置城市'}`"
:value="store.id"
/>
</el-select>
<el-radio-group v-model="periodDays" aria-label="统计周期">
<el-radio-button :value="7"> 7 </el-radio-button>
<el-radio-button :value="30"> 30 </el-radio-button>
<el-radio-button :value="90"> 90 </el-radio-button>
</el-radio-group>
<el-button :icon="RefreshCw" :loading="loadingStatistics" @click="loadStatistics">
刷新
</el-button>
</div>
</div>
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
show-icon
:closable="false"
/>
<el-empty
v-else-if="!session.token.trim()"
description="请先在右上角保存后台访问令牌"
/>
<el-empty
v-else-if="!loadingStores && stores.length === 0"
description="当前账号没有可查看的门店"
/>
<template v-if="selectedStore && statistics">
<section class="overview-context">
<div>
<strong>{{ selectedStore.name }}</strong>
<span>{{ storeAddress }}</span>
</div>
<el-tag :type="selectedStore.businessStatus === 'OPEN' ? 'success' : 'warning'">
{{ storeStatusText(selectedStore.businessStatus) }}
</el-tag>
<span>{{ rangeText }}</span>
</section>
<section class="overview-metrics" aria-label="经营核心指标">
<article class="overview-metric primary">
<span>成功实收</span>
<strong>{{ money(statistics.summary.collectedAmountCents) }}</strong>
<small>预订金额 {{ money(statistics.summary.bookedAmountCents) }}</small>
</article>
<article class="overview-metric">
<span>订单 / 活跃</span>
<strong>{{ statistics.summary.orderTotal }} / {{ statistics.summary.activeOrderTotal }}</strong>
<small>已完成 {{ statistics.summary.finishedOrderTotal }}</small>
</article>
<article class="overview-metric">
<span>下单会员</span>
<strong>{{ statistics.summary.payingMemberTotal }}</strong>
<small> OWNER 订单去重</small>
</article>
<article class="overview-metric">
<span>验券成功</span>
<strong>{{ statistics.summary.voucherSucceeded }}</strong>
<small>失败 {{ statistics.summary.voucherFailed }}</small>
</article>
</section>
<section class="overview-grid">
<article class="panel overview-card">
<div class="section-heading">
<div>
<p class="section-kicker">实时资源</p>
<h3>当前房态</h3>
</div>
<DoorOpen :size="22" />
</div>
<div class="room-state-grid">
<div><strong>{{ statistics.summary.roomTotal }}</strong><span>房间总数</span></div>
<div><strong>{{ statistics.summary.availableRoomTotal }}</strong><span>当前空闲</span></div>
<div><strong>{{ statistics.summary.attentionRoomTotal }}</strong><span>需要关注</span></div>
</div>
</article>
<article class="panel overview-card">
<div class="section-heading">
<div>
<p class="section-kicker">资金口径</p>
<h3>支付渠道</h3>
</div>
<Landmark :size="22" />
</div>
<div v-if="statistics.paymentChannels.length" class="channel-list">
<div v-for="channel in statistics.paymentChannels" :key="channel.channel">
<span>{{ channelText(channel.channel) }} · {{ channel.total }} </span>
<strong>{{ money(channel.amountCents) }}</strong>
</div>
</div>
<el-empty v-else :image-size="54" description="周期内暂无成功收款" />
</article>
<article class="panel overview-card revenue-card">
<div class="section-heading">
<div>
<p class="section-kicker">成功支付</p>
<h3>每日收入趋势</h3>
</div>
<TrendingUp :size="22" />
</div>
<div v-if="statistics.dailyRevenue.length" class="revenue-bars">
<div v-for="row in statistics.dailyRevenue" :key="row.date" class="revenue-row">
<span>{{ shortDate(row.date) }}</span>
<div><i :style="{ width: revenueWidth(row.amountCents) }" /></div>
<strong>{{ money(row.amountCents) }}</strong>
</div>
</div>
<el-empty v-else :image-size="54" description="周期内暂无收入趋势" />
</article>
</section>
<section class="panel module-board" aria-label="核心业务导航">
<div class="section-heading">
<div>
<p class="section-kicker">M08-D 工作区</p>
<h3>核心业务导航</h3>
</div>
</div>
<div class="module-grid">
<button type="button" @click="emit('open-cleaning')">
<Sparkles :size="22" />
<span><strong>保洁运营</strong><small>任务结算统计与现场联调</small></span>
<ArrowRight :size="18" />
</button>
<div v-for="item in upcomingModules" :key="item.name" class="module-placeholder">
<component :is="item.icon" :size="22" />
<span><strong>{{ item.name }}</strong><small>{{ item.description }}</small></span>
<el-tag size="small" type="info">后续增量</el-tag>
</div>
</div>
</section>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, markRaw, onMounted, ref, watch } from 'vue';
import {
ArrowRight,
DoorOpen,
Landmark,
RadioTower,
RefreshCw,
ShoppingBag,
Sparkles,
Store,
TrendingUp,
Users,
WalletCards
} from '@lucide/vue';
import { ApiError, getBusinessStatistics, listManagedStores, type ApiSession } from '../api';
import { money } from '../format';
import type { BusinessStatistics, ManagedStore } from '../types';
const props = defineProps<{ session: ApiSession }>();
const emit = defineEmits<{ (event: 'open-cleaning'): void }>();
const stores = ref<ManagedStore[]>([]);
const selectedStoreId = ref('');
const periodDays = ref(30);
const statistics = ref<BusinessStatistics | null>(null);
const loadingStores = ref(false);
const loadingStatistics = ref(false);
const errorMessage = ref('');
const selectedStore = computed(() => stores.value.find((item) => item.id === selectedStoreId.value));
const storeAddress = computed(() => {
const store = selectedStore.value;
return store ? [store.city, store.district, store.address].filter(Boolean).join(' · ') || '地址未配置' : '';
});
const rangeText = computed(() => statistics.value
? `${statistics.value.from.slice(0, 10)}${statistics.value.to.slice(0, 10)}` : '');
const maxRevenue = computed(() => Math.max(
1,
...(statistics.value?.dailyRevenue.map((item) => item.amountCents) ?? [1])
));
const upcomingModules = [
{ name: '门店与房间', description: '配置、房态和价格', icon: markRaw(Store) },
{ name: '订单与团购', description: '订单处置、核销和退款', icon: markRaw(ShoppingBag) },
{ name: '会员与员工', description: '画像、权益和账号权限', icon: markRaw(Users) },
{ name: '设备运营', description: '拓扑、告警和控制', icon: markRaw(RadioTower) },
{ name: '支付与分账', description: '收款、退款和分账记录', icon: markRaw(WalletCards) }
];
async function loadStores() {
if (!props.session.token.trim()) return;
loadingStores.value = true;
errorMessage.value = '';
try {
stores.value = await listManagedStores(props.session);
const nextStoreId = stores.value.some((item) => item.id === selectedStoreId.value)
? selectedStoreId.value : stores.value[0]?.id ?? '';
if (nextStoreId === selectedStoreId.value && nextStoreId) {
await loadStatistics();
} else {
selectedStoreId.value = nextStoreId;
}
} catch (error) {
showError(error);
} finally {
loadingStores.value = false;
}
}
async function loadStatistics() {
if (!props.session.token.trim() || !selectedStoreId.value) return;
loadingStatistics.value = true;
errorMessage.value = '';
const to = new Date();
const from = new Date(to.getTime() - periodDays.value * 86400000);
try {
statistics.value = await getBusinessStatistics(props.session, {
storeId: selectedStoreId.value,
from: from.toISOString(),
to: to.toISOString()
});
} catch (error) {
statistics.value = null;
showError(error);
} finally {
loadingStatistics.value = false;
}
}
function showError(error: unknown) {
errorMessage.value = error instanceof ApiError
? `${error.message}${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
: error instanceof Error ? error.message : '加载运营总览失败';
}
function revenueWidth(amountCents: number) {
return `${Math.max(4, Math.round(amountCents / maxRevenue.value * 100))}%`;
}
function shortDate(value: string) {
return value.slice(5, 10);
}
function storeStatusText(status: ManagedStore['businessStatus']) {
return ({ OPEN: '营业中', CLOSED: '已关闭', SUSPENDED: '已暂停' })[status];
}
function channelText(channel: string) {
return ({ WECHAT: '微信支付', GROUP_BUY: '团购验券', BALANCE: '会员余额' } as Record<string, string>)[channel]
?? channel;
}
watch(() => props.session.token, (token, previous) => {
if (token.trim() && token !== previous) void loadStores();
});
watch([selectedStoreId, periodDays], ([storeId], previous) => {
if (storeId && previous) void loadStatistics();
});
onMounted(() => {
if (props.session.token.trim()) void loadStores();
});
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<section class="orders-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="panel">
<header class="panel-toolbar order-toolbar">
<div>
<p class="section-kicker">M08-D · 订单运营</p>
<h3>订单筛选与处置</h3>
</div>
<div class="toolbar-actions">
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部授权门店" @change="applyFilters">
<el-option label="全部授权门店" value="" />
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-select v-model="status" class="filter-select" placeholder="全部状态" @change="applyFilters">
<el-option label="全部状态" value="" />
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button :icon="Download" :disabled="orders.items.length === 0" @click="exportOrders">导出当前页</el-button>
<el-button :icon="RefreshCw" :loading="loading" @click="loadOrders">刷新</el-button>
</div>
</header>
<div class="order-page-summary">
<span><small>当前筛选总数</small><strong>{{ orders.total }}</strong></span>
<span><small>当前页实收</small><strong>{{ money(pagePaidCents) }}</strong></span>
<span><small>当前页待支付</small><strong>{{ pendingCount }}</strong></span>
<span><small>当前页进行中</small><strong>{{ activeCount }}</strong></span>
</div>
<el-table v-loading="loading" :data="orders.items" class="data-table" row-key="id">
<el-table-column label="订单" min-width="190">
<template #default="{ row }">
<div class="stack"><strong>{{ row.orderNo }}</strong><span>#{{ row.id }} · {{ formatDate(row.createdAt) }}</span></div>
</template>
</el-table-column>
<el-table-column label="门店 / 房间" min-width="190">
<template #default="{ row }"><div class="stack"><strong>{{ row.storeName }}</strong><span>{{ row.roomNo }} · {{ row.roomName }}</span></div></template>
</el-table-column>
<el-table-column label="使用时段" min-width="205">
<template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.startAt) }}</strong><span> {{ formatDate(row.endAt) }}</span></div></template>
</el-table-column>
<el-table-column label="金额" min-width="135">
<template #default="{ row }"><div class="stack"><strong>{{ money(row.paidAmountCents) }}</strong><span>应收 {{ money(row.totalAmountCents) }}</span></div></template>
</el-table-column>
<el-table-column label="状态" width="130">
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" min-width="260" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-dropdown v-if="getAllowedActions(row.status).length" trigger="click" @command="openAction(row, $event)">
<el-button link type="primary">状态处置<ChevronDown :size="14" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in getAllowedActions(row.status)" :key="action" :command="action">{{ actionLabel(action) }}</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button link @click="openNote(row)">备注</el-button>
<el-button v-if="isAdjustable(row.status)" link @click="openAdjustment(row)">调整</el-button>
</div>
</template>
</el-table-column>
</el-table>
<footer class="pager"><el-pagination v-model:current-page="orders.page" :page-size="orders.pageSize" :total="orders.total" layout="prev, pager, next, total" @current-change="loadOrders" /></footer>
</section>
<el-drawer v-model="detailDrawer" title="订单详情" size="min(720px, 94vw)">
<div v-if="detail" class="order-detail-stack">
<div class="detail-header"><div class="stack"><strong>{{ detail.orderNo }}</strong><span>#{{ detail.id }}</span></div><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></div>
<div class="detail-grid">
<span><small>门店</small><strong>{{ detail.storeName }}</strong></span>
<span><small>房间</small><strong>{{ detail.roomNo }} · {{ detail.roomName }}</strong></span>
<span><small>开始</small><strong>{{ formatDate(detail.startAt) }}</strong></span>
<span><small>结束</small><strong>{{ formatDate(detail.endAt) }}</strong></span>
<span><small>应收</small><strong>{{ money(detail.totalAmountCents) }}</strong></span>
<span><small>实收</small><strong>{{ money(detail.paidAmountCents) }}</strong></span>
<span><small>支付渠道</small><strong>{{ detail.latestPayment?.provider || '-' }}</strong></span>
<span><small>支付状态</small><strong>{{ detail.latestPayment?.status || '-' }}</strong></span>
</div>
<header class="drawer-section-title"><div><p class="section-kicker">状态流水</p><h3>{{ history.length }} 条记录</h3></div><el-button :icon="RefreshCw" :loading="loadingDetail" @click="loadDetail">刷新</el-button></header>
<el-timeline>
<el-timeline-item v-for="item in history" :key="item.id" :timestamp="formatDate(item.createdAt)" placement="top">
<div class="history-card"><strong>{{ actionLabel(item.action) }} · {{ statusLabel(item.toStatus) }}</strong><span>{{ item.fromStatus ? `${statusLabel(item.fromStatus)}` : '' }}{{ statusLabel(item.toStatus) }}</span><small>{{ item.source }} · {{ item.actorId ? `操作人 ${item.actorId}` : item.actorType }} · {{ item.reason || '无备注' }}</small><code v-if="item.traceId">{{ item.traceId }}</code></div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!loadingDetail && history.length === 0" description="暂无状态流水" />
</div>
</el-drawer>
<el-dialog v-model="actionDialog.open" :title="actionLabel(actionDialog.action)" width="min(520px, 94vw)">
<el-form label-position="top"><el-form-item label="处置原因"><el-input v-model="actionDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="actionDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAction">确认处置</el-button></template>
</el-dialog>
<el-dialog v-model="noteDialog.open" title="订单运营备注" width="min(520px, 94vw)">
<el-form label-position="top"><el-form-item label="备注"><el-input v-model="noteDialog.note" type="textarea" :rows="4" maxlength="512" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="noteDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveNote">保存备注</el-button></template>
</el-dialog>
<el-dialog v-model="adjustDialog.open" title="调整订单" width="min(620px, 94vw)">
<el-tabs v-model="adjustDialog.mode">
<el-tab-pane label="调整时段" name="time">
<el-form label-position="top"><el-form-item label="开始时间"><el-date-picker v-model="adjustDialog.startAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="adjustDialog.endAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item></el-form>
</el-tab-pane>
<el-tab-pane label="续费延时" name="renew">
<el-form label-position="top"><el-form-item label="新的结束时间"><el-date-picker v-model="adjustDialog.renewEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="计价策略"><el-radio-group v-model="adjustDialog.pricingPolicy"><el-radio value="LOCKED">沿用下单价</el-radio><el-radio value="CURRENT">使用当前价</el-radio></el-radio-group></el-form-item></el-form>
</el-tab-pane>
<el-tab-pane label="更换房间" name="room">
<el-form label-position="top"><el-form-item label="目标房间"><el-select v-model="adjustDialog.roomId" filterable><el-option v-for="room in availableRooms" :key="room.id" :label="`${room.roomNo} · ${room.name} · ${money(room.basePriceCents)}`" :value="room.id" /></el-select></el-form-item></el-form>
</el-tab-pane>
</el-tabs>
<el-form label-position="top"><el-form-item label="调整原因"><el-input v-model="adjustDialog.reason" type="textarea" maxlength="512" show-word-limit /></el-form-item></el-form>
<template #footer><el-button @click="adjustDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAdjustment">确认调整</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { ChevronDown, Download, RefreshCw } from '@lucide/vue';
import {
ApiError,
addManagedOrderNote,
adjustManagedOrderTime,
changeManagedOrderRoom,
executeManagedOrderAction,
getManagedOrder,
listManagedOrderHistory,
listManagedOrders,
listManagedRooms,
listManagedStores,
renewManagedOrder,
type ApiSession
} from '../api';
import { money } from '../format';
import type { ManagedOrder, ManagedRoom, ManagedStore, OrderAction, OrderHistoryItem, OrderStatus, PageResult } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const availableRooms = ref<ManagedRoom[]>([]);
const orders = reactive<PageResult<ManagedOrder>>({ items: [], total: 0, page: 1, pageSize: 20 });
const storeId = ref('');
const status = ref<OrderStatus | ''>('');
const loading = ref(false);
const loadingDetail = ref(false);
const saving = ref(false);
const lastError = ref('');
const detailDrawer = ref(false);
const detail = ref<ManagedOrder | null>(null);
const history = ref<OrderHistoryItem[]>([]);
const actionDialog = reactive<{ open: boolean; orderId: string; action: OrderAction; reason: string }>({ open: false, orderId: '', action: 'SUBMIT', reason: '' });
const noteDialog = reactive({ open: false, orderId: '', note: '' });
const adjustDialog = reactive({ open: false, orderId: '', storeId: '', currentRoomId: '', mode: 'time' as 'time' | 'renew' | 'room', startAt: '', endAt: '', renewEndAt: '', pricingPolicy: 'LOCKED' as 'CURRENT' | 'LOCKED', roomId: '', reason: '' });
const statusOptions: Array<{ value: OrderStatus; label: string }> = [
{ value: 'DRAFT', label: '草稿' }, { value: 'PENDING_PAYMENT', label: '待支付' },
{ value: 'PAID', label: '已支付' }, { value: 'RESERVED', label: '已预订' },
{ value: 'IN_PROGRESS', label: '使用中' }, { value: 'FINISHED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' }, { value: 'REFUNDING', label: '退款中' },
{ value: 'REFUNDED', label: '已退款' }, { value: 'CLOSED', label: '已关闭' }
];
const allowedActions: Record<OrderStatus, OrderAction[]> = {
DRAFT: ['SUBMIT', 'CANCEL', 'CLOSE'], PENDING_PAYMENT: ['CONFIRM_PAYMENT', 'CANCEL', 'CLOSE'],
PAID: ['RESERVE', 'START', 'CANCEL', 'BEGIN_REFUND'], RESERVED: ['START', 'CANCEL', 'BEGIN_REFUND'],
IN_PROGRESS: ['FINISH', 'BEGIN_REFUND'], FINISHED: ['BEGIN_REFUND', 'CLOSE'],
CANCELLED: ['BEGIN_REFUND', 'CLOSE'], REFUNDING: ['COMPLETE_REFUND'], REFUNDED: ['CLOSE'], CLOSED: []
};
const actionLabels: Record<string, string> = {
SUBMIT: '提交待支付', CONFIRM_PAYMENT: '确认支付', RESERVE: '确认预订', START: '开始使用',
FINISH: '结束使用', CANCEL: '取消订单', BEGIN_REFUND: '发起退款', COMPLETE_REFUND: '完成退款',
CLOSE: '关闭订单', CREATED: '创建订单', EXPIRED: '订单过期', MIGRATED: '历史迁移'
};
const pagePaidCents = computed(() => orders.items.reduce((sum, item) => sum + Number(item.paidAmountCents || 0), 0));
const pendingCount = computed(() => orders.items.filter((item) => item.status === 'PENDING_PAYMENT').length);
const activeCount = computed(() => orders.items.filter((item) => ['PAID', 'RESERVED', 'IN_PROGRESS'].includes(item.status)).length);
async function capture<T>(work: () => Promise<T>) {
lastError.value = '';
try { return await work(); }
catch (error) {
lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败';
throw error;
}
}
async function loadWorkspace() {
try {
stores.value = await capture(() => listManagedStores(props.session));
await loadOrders();
} catch { /* displayed */ }
}
async function loadOrders() {
if (!props.session.token) return;
loading.value = true;
try {
Object.assign(orders, await capture(() => listManagedOrders(props.session, {
page: orders.page, pageSize: orders.pageSize, status: status.value || undefined, storeId: storeId.value || undefined
})));
} catch { /* displayed */ } finally { loading.value = false; }
}
function applyFilters() { orders.page = 1; void loadOrders(); }
async function openDetail(order: ManagedOrder) {
detail.value = order;
detailDrawer.value = true;
await loadDetail();
}
async function loadDetail() {
if (!detail.value) return;
loadingDetail.value = true;
try {
const [order, items] = await Promise.all([
capture(() => getManagedOrder(props.session, detail.value!.id)),
capture(() => listManagedOrderHistory(props.session, detail.value!.id))
]);
detail.value = order;
history.value = items;
} catch { /* displayed */ } finally { loadingDetail.value = false; }
}
function openAction(order: ManagedOrder, command: unknown) {
Object.assign(actionDialog, { open: true, orderId: order.id, action: String(command) as OrderAction, reason: '' });
}
async function saveAction() {
if (!actionDialog.reason.trim()) return ElMessage.warning('请填写处置原因');
saving.value = true;
try {
await capture(() => executeManagedOrderAction(props.session, actionDialog.orderId, actionDialog.action, actionDialog.reason.trim()));
actionDialog.open = false;
ElMessage.success('订单状态已更新');
await loadOrders();
if (detail.value?.id === actionDialog.orderId) await loadDetail();
} catch { /* displayed */ } finally { saving.value = false; }
}
function openNote(order: ManagedOrder) { Object.assign(noteDialog, { open: true, orderId: order.id, note: '' }); }
async function saveNote() {
if (!noteDialog.note.trim()) return ElMessage.warning('请填写备注');
saving.value = true;
try {
await capture(() => addManagedOrderNote(props.session, noteDialog.orderId, noteDialog.note.trim()));
noteDialog.open = false;
ElMessage.success('订单备注已保存');
if (detail.value?.id === noteDialog.orderId) await loadDetail();
} catch { /* displayed */ } finally { saving.value = false; }
}
async function openAdjustment(order: ManagedOrder) {
Object.assign(adjustDialog, {
open: true, orderId: order.id, storeId: order.storeId, currentRoomId: order.roomId,
mode: 'time', startAt: toPickerValue(order.startAt), endAt: toPickerValue(order.endAt),
renewEndAt: toPickerValue(order.endAt), pricingPolicy: 'LOCKED', roomId: '', reason: ''
});
try { availableRooms.value = (await capture(() => listManagedRooms(props.session, order.storeId))).filter((room) => room.id !== order.roomId && room.configurationStatus === 'ENABLED'); }
catch { availableRooms.value = []; }
}
async function saveAdjustment() {
if (!adjustDialog.reason.trim()) return ElMessage.warning('请填写调整原因');
saving.value = true;
try {
if (adjustDialog.mode === 'time') {
if (!adjustDialog.startAt && !adjustDialog.endAt) return ElMessage.warning('至少填写一个时间');
await capture(() => adjustManagedOrderTime(props.session, adjustDialog.orderId, {
startAt: adjustDialog.startAt || undefined, endAt: adjustDialog.endAt || undefined, reason: adjustDialog.reason.trim()
}));
} else if (adjustDialog.mode === 'renew') {
if (!adjustDialog.renewEndAt) return ElMessage.warning('请选择新的结束时间');
await capture(() => renewManagedOrder(props.session, adjustDialog.orderId, {
endAt: adjustDialog.renewEndAt, pricingPolicy: adjustDialog.pricingPolicy, reason: adjustDialog.reason.trim()
}));
} else {
if (!adjustDialog.roomId) return ElMessage.warning('请选择目标房间');
await capture(() => changeManagedOrderRoom(props.session, adjustDialog.orderId, {
roomId: adjustDialog.roomId, reason: adjustDialog.reason.trim()
}));
}
adjustDialog.open = false;
ElMessage.success('订单调整已完成');
await loadOrders();
if (detail.value?.id === adjustDialog.orderId) await loadDetail();
} catch { /* displayed */ } finally { saving.value = false; }
}
function exportOrders() {
const rows = [['订单号', '门店', '房间', '状态', '开始', '结束', '应收', '实收', '支付渠道', '支付状态']];
for (const order of orders.items) rows.push([order.orderNo, order.storeName, `${order.roomNo} ${order.roomName}`, statusLabel(order.status), formatDate(order.startAt), formatDate(order.endAt), money(order.totalAmountCents), money(order.paidAmountCents), order.latestPayment?.provider || '', order.latestPayment?.status || '']);
const csv = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }));
const link = document.createElement('a'); link.href = url; link.download = `orders-${Date.now()}.csv`; link.click(); URL.revokeObjectURL(url);
}
function csvCell(value: string) { const safe = String(value).replace(/\r?\n/g, ' '); return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; }
function statusLabel(value: OrderStatus) { return statusOptions.find((item) => item.value === value)?.label || value; }
function statusType(value: OrderStatus) { return value === 'IN_PROGRESS' ? 'success' : ['PENDING_PAYMENT', 'REFUNDING'].includes(value) ? 'warning' : ['CANCELLED', 'CLOSED'].includes(value) ? 'info' : 'primary'; }
function actionLabel(value: string) { return actionLabels[value] || value; }
function getAllowedActions(value: OrderStatus) { return allowedActions[value] || []; }
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
function toPickerValue(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toISOString(); }
function isAdjustable(value: OrderStatus) { return ['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(value); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
+155
View File
@@ -0,0 +1,155 @@
<template>
<section class="payments-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-alert v-if="lastResult" :title="lastResult" type="success" show-icon closable @close="lastResult = ''" />
<section class="payment-metrics">
<span><small>收款账户</small><strong>{{ snapshot.accounts.length }}</strong></span>
<span><small>已授权账户</small><strong>{{ authorizedAccounts }}</strong></span>
<span><small>分账接收方</small><strong>{{ snapshot.receivers.length }}</strong></span>
<span><small>近百笔分账金额</small><strong>{{ money(sharedAmountCents) }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar payment-toolbar">
<div><p class="section-kicker">M08-D · 资金运营</p><h3>支付退款与分账</h3></div>
<div class="toolbar-actions">
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部账户范围" @change="loadSnapshot">
<el-option label="全部账户范围" value="" />
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadSnapshot">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab" class="payment-tabs">
<el-tab-pane label="分账记录" name="shares">
<el-table v-loading="loading" :data="snapshot.shares" class="data-table" row-key="id">
<el-table-column label="分账单" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.shareNo }}</strong><span>支付 #{{ row.paymentId }} · 订单 #{{ row.orderId }}</span></div></template></el-table-column>
<el-table-column label="接收方" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.receiverMasked }}</strong><span>{{ row.receiverType }} · {{ percent(row.percentageBps) }}</span></div></template></el-table-column>
<el-table-column label="金额" width="130"><template #default="{ row }"><strong>{{ money(row.amountCents) }}</strong></template></el-table-column>
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="shareStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="门店 / 时间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ storeName(row.storeId) }}</strong><span>{{ formatDate(row.createdAt) }}</span><span v-if="row.failureCode">{{ row.failureCode }}</span></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="收款账户" name="accounts">
<div class="tab-action-row"><span>凭据只保存环境引用页面不读取或回显私钥</span><el-button type="primary" :icon="Plus" @click="openAccountForm">配置账户</el-button></div>
<el-table :data="snapshot.accounts" class="data-table" row-key="id">
<el-table-column prop="id" label="账户 ID" width="100" />
<el-table-column label="范围" min-width="150"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '租户默认' }}</template></el-table-column>
<el-table-column prop="merchantId" label="商户号" min-width="160" />
<el-table-column label="授权" width="130"><template #default="{ row }"><el-tag :type="authorizationType(row.authorizationStatus)">{{ authorizationLabel(row.authorizationStatus) }}</el-tag></template></el-table-column>
<el-table-column label="分账 / 启用" min-width="150"><template #default="{ row }"><div class="profile-badges"><el-tag :type="truthy(row.profitSharingEnabled) ? 'success' : 'info'" size="small">{{ truthy(row.profitSharingEnabled) ? '分账开启' : '分账关闭' }}</el-tag><el-tag :type="truthy(row.enabled) ? 'success' : 'info'" size="small">{{ truthy(row.enabled) ? '已启用' : '已停用' }}</el-tag></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="接收方与策略" name="policies">
<div class="tab-action-row"><span>分账比例由服务端校验总和不得超过 100%</span><div class="row-actions"><el-button :icon="Plus" @click="openReceiverForm">新增接收方</el-button><el-button type="primary" :icon="Plus" @click="openPolicyForm">新增策略</el-button></div></div>
<el-table :data="snapshot.receivers" class="data-table" row-key="id">
<el-table-column prop="name" label="接收方" min-width="150" />
<el-table-column prop="receiverMasked" label="脱敏标识" min-width="170" />
<el-table-column prop="receiverType" label="类型" min-width="150" />
<el-table-column prop="relationType" label="关系" min-width="120" />
<el-table-column label="授权" width="120"><template #default="{ row }"><el-tag :type="authorizationType(row.authorizationStatus)">{{ authorizationLabel(row.authorizationStatus) }}</el-tag></template></el-table-column>
</el-table>
<el-table :data="snapshot.policies" class="data-table policy-table" row-key="id">
<el-table-column prop="id" label="策略 ID" width="100" />
<el-table-column label="账户" min-width="120"><template #default="{ row }">#{{ row.collectionAccountId }}</template></el-table-column>
<el-table-column label="范围" min-width="160"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '账户默认范围' }}</template></el-table-column>
<el-table-column label="接收方" min-width="160"><template #default="{ row }">{{ receiverName(row.receiverId) }}</template></el-table-column>
<el-table-column label="比例" width="120"><template #default="{ row }"><strong>{{ percent(row.percentageBps) }}</strong></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="资金工具" name="tools">
<div class="payment-tools-grid">
<article><header><RotateCcw :size="20" /><div><strong>微信退款</strong><span>金额由操作人明确填写服务端校验可退余额与幂等键</span></div></header><el-button type="primary" plain @click="toolDialog = 'refund'">发起退款</el-button></article>
<article><header><ReceiptText :size="20" /><div><strong>微信对账单</strong><span>按门店账单日期与类型请求官方账单下载地址</span></div></header><el-button type="primary" plain @click="toolDialog = 'reconciliation'">请求对账</el-button></article>
<article><header><Split :size="20" /><div><strong>执行分账</strong><span>生产 API 模式需账户与接收方均授权Mock 仅非生产可用</span></div></header><el-button type="primary" plain @click="toolDialog = 'share'">执行分账</el-button></article>
</div>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="accountDialog" title="配置收款账户" width="min(620px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="门店范围"><el-select v-model="accountForm.storeId" clearable><el-option label="租户默认" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="商户号"><el-input v-model="accountForm.merchantId" /></el-form-item><el-form-item label="凭据环境引用" class="form-span-2"><el-input v-model="accountForm.credentialRef" placeholder="env:WECHAT_PAY_STORE_001" /></el-form-item><el-form-item label="授权状态"><el-select v-model="accountForm.authorizationStatus"><el-option v-for="item in authorizationOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="开关"><div class="row-actions"><el-switch v-model="accountForm.profitSharingEnabled" active-text="分账" /><el-switch v-model="accountForm.enabled" active-text="启用" /></div></el-form-item></el-form>
<template #footer><el-button @click="accountDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAccount">保存账户</el-button></template>
</el-dialog>
<el-dialog v-model="receiverDialog" title="新增或更新分账接收方" width="min(620px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="收款账户"><el-select v-model="receiverForm.collectionAccountId"><el-option v-for="item in snapshot.accounts" :key="item.id" :label="`#${item.id} ${item.merchantId}`" :value="item.id" /></el-select></el-form-item><el-form-item label="接收方类型"><el-select v-model="receiverForm.receiverType"><el-option label="商户号" value="MERCHANT_ID" /><el-option label="个人 OpenID" value="PERSONAL_OPENID" /></el-select></el-form-item><el-form-item label="接收方账户"><el-input v-model="receiverForm.receiverAccount" type="password" show-password /></el-form-item><el-form-item label="接收方凭据引用"><el-input v-model="receiverForm.receiverCredentialRef" /></el-form-item><el-form-item label="名称"><el-input v-model="receiverForm.name" /></el-form-item><el-form-item label="关系类型"><el-input v-model="receiverForm.relationType" placeholder="SERVICE_PROVIDER" /></el-form-item><el-form-item label="授权状态"><el-select v-model="receiverForm.authorizationStatus"><el-option v-for="item in authorizationOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="receiverForm.enabled" /></el-form-item></el-form>
<template #footer><el-button @click="receiverDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveReceiver">保存接收方</el-button></template>
</el-dialog>
<el-dialog v-model="policyDialog" title="新增或更新分账策略" width="min(560px, 94vw)">
<el-form label-position="top"><el-form-item label="收款账户"><el-select v-model="policyForm.collectionAccountId"><el-option v-for="item in snapshot.accounts" :key="item.id" :label="`#${item.id} ${item.merchantId}`" :value="item.id" /></el-select></el-form-item><el-form-item label="门店范围"><el-select v-model="policyForm.storeId" clearable><el-option label="账户默认范围" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="接收方"><el-select v-model="policyForm.receiverId"><el-option v-for="item in snapshot.receivers" :key="item.id" :label="`${item.name} · ${item.receiverMasked}`" :value="item.id" /></el-select></el-form-item><el-form-item label="分账比例(%"><el-input-number v-model="policyForm.percentage" :min="0.01" :max="100" :precision="2" /></el-form-item><el-form-item label="启用"><el-switch v-model="policyForm.enabled" /></el-form-item></el-form>
<template #footer><el-button @click="policyDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePolicy">保存策略</el-button></template>
</el-dialog>
<el-dialog :model-value="toolDialog !== ''" :title="toolTitle" width="min(560px, 94vw)" @close="toolDialog = ''">
<el-form v-if="toolDialog === 'refund'" label-position="top"><el-form-item label="支付 ID"><el-input v-model="refundForm.paymentId" /></el-form-item><el-form-item label="退款金额(元)"><el-input-number v-model="refundForm.amountYuan" :min="0.01" :precision="2" /></el-form-item><el-form-item label="退款原因"><el-input v-model="refundForm.reason" type="textarea" /></el-form-item></el-form>
<el-form v-else-if="toolDialog === 'reconciliation'" label-position="top"><el-form-item label="门店"><el-select v-model="reconciliationForm.storeId"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="账单日期"><el-date-picker v-model="reconciliationForm.billDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账单类型"><el-radio-group v-model="reconciliationForm.billType"><el-radio value="ALL">全部</el-radio><el-radio value="SUCCESS">支付</el-radio><el-radio value="REFUND">退款</el-radio></el-radio-group></el-form-item></el-form>
<el-form v-else label-position="top"><el-form-item label="支付 ID"><el-input v-model="shareForm.paymentId" /></el-form-item><el-form-item label="执行模式"><el-radio-group v-model="shareForm.mode"><el-radio value="API">生产 API</el-radio><el-radio value="MOCK">非生产 Mock</el-radio></el-radio-group></el-form-item></el-form>
<template #footer><el-button @click="toolDialog = ''">取消</el-button><el-button type="primary" :loading="saving" @click="executeTool">确认执行</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, ReceiptText, RefreshCw, RotateCcw, Split } from '@lucide/vue';
import {
ApiError, createWechatRefund, executeProfitSharing, getProfitSharingSnapshot,
listManagedStores, requestWechatReconciliation, saveCollectionAccount,
saveProfitSharePolicy, saveProfitShareReceiver, type ApiSession
} from '../api';
import { money } from '../format';
import type { ManagedStore, PaymentAuthorizationStatus, ProfitSharingSnapshot } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const snapshot = reactive<ProfitSharingSnapshot>({ accounts: [], receivers: [], policies: [], shares: [] });
const storeId = ref('');
const activeTab = ref('shares');
const loading = ref(false);
const saving = ref(false);
const lastError = ref('');
const lastResult = ref('');
const accountDialog = ref(false);
const receiverDialog = ref(false);
const policyDialog = ref(false);
const toolDialog = ref<'' | 'refund' | 'reconciliation' | 'share'>('');
const accountForm = reactive({ storeId: '', merchantId: '', credentialRef: '', authorizationStatus: 'UNAUTHORIZED' as PaymentAuthorizationStatus, profitSharingEnabled: false, enabled: true });
const receiverForm = reactive({ collectionAccountId: '', receiverType: 'MERCHANT_ID' as 'MERCHANT_ID' | 'PERSONAL_OPENID', receiverAccount: '', receiverCredentialRef: '', relationType: 'SERVICE_PROVIDER', name: '', authorizationStatus: 'UNAUTHORIZED' as PaymentAuthorizationStatus, enabled: true });
const policyForm = reactive({ collectionAccountId: '', storeId: '', receiverId: '', percentage: 1, enabled: true });
const refundForm = reactive({ paymentId: '', amountYuan: 0, reason: '' });
const reconciliationForm = reactive({ storeId: '', billDate: '', billType: 'ALL' as 'ALL' | 'SUCCESS' | 'REFUND' });
const shareForm = reactive({ paymentId: '', mode: 'API' as 'API' | 'MOCK' });
const authorizationOptions: Array<{ value: PaymentAuthorizationStatus; label: string }> = [{ value: 'UNAUTHORIZED', label: '未授权' }, { value: 'PENDING', label: '授权中' }, { value: 'AUTHORIZED', label: '已授权' }, { value: 'REVOKED', label: '已撤销' }];
const authorizedAccounts = computed(() => snapshot.accounts.filter((item) => item.authorizationStatus === 'AUTHORIZED').length);
const sharedAmountCents = computed(() => snapshot.shares.reduce((sum, item) => sum + Number(item.amountCents || 0), 0));
const toolTitle = computed(() => toolDialog.value === 'refund' ? '发起微信退款' : toolDialog.value === 'reconciliation' ? '请求微信对账单' : '执行微信分账');
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { try { stores.value = await capture(() => listManagedStores(props.session)); await loadSnapshot(); } catch { /* displayed */ } }
async function loadSnapshot() { if (!props.session.token) return; loading.value = true; try { Object.assign(snapshot, await capture(() => getProfitSharingSnapshot(props.session, storeId.value || undefined))); } catch { /* displayed */ } finally { loading.value = false; } }
function openAccountForm() { Object.assign(accountForm, { storeId: storeId.value, merchantId: '', credentialRef: '', authorizationStatus: 'UNAUTHORIZED', profitSharingEnabled: false, enabled: true }); accountDialog.value = true; }
function openReceiverForm() { Object.assign(receiverForm, { collectionAccountId: snapshot.accounts[0]?.id || '', receiverType: 'MERCHANT_ID', receiverAccount: '', receiverCredentialRef: '', relationType: 'SERVICE_PROVIDER', name: '', authorizationStatus: 'UNAUTHORIZED', enabled: true }); receiverDialog.value = true; }
function openPolicyForm() { Object.assign(policyForm, { collectionAccountId: snapshot.accounts[0]?.id || '', storeId: storeId.value, receiverId: snapshot.receivers[0]?.id || '', percentage: 1, enabled: true }); policyDialog.value = true; }
async function saveAccount() { if (!accountForm.merchantId || !accountForm.credentialRef) return ElMessage.warning('请填写商户号和凭据引用'); saving.value = true; try { await capture(() => saveCollectionAccount(props.session, { ...accountForm, storeId: accountForm.storeId || null })); accountDialog.value = false; ElMessage.success('收款账户已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
async function saveReceiver() { if (!receiverForm.collectionAccountId || !receiverForm.receiverAccount || !receiverForm.receiverCredentialRef || !receiverForm.name) return ElMessage.warning('请完整填写接收方资料'); saving.value = true; try { await capture(() => saveProfitShareReceiver(props.session, receiverForm)); receiverDialog.value = false; receiverForm.receiverAccount = ''; ElMessage.success('分账接收方已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
async function savePolicy() { if (!policyForm.collectionAccountId || !policyForm.receiverId) return ElMessage.warning('请选择账户和接收方'); saving.value = true; try { await capture(() => saveProfitSharePolicy(props.session, { collectionAccountId: policyForm.collectionAccountId, storeId: policyForm.storeId || null, receiverId: policyForm.receiverId, percentageBps: Math.round(policyForm.percentage * 100), enabled: policyForm.enabled })); policyDialog.value = false; ElMessage.success('分账策略已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
async function executeTool() { saving.value = true; lastResult.value = ''; try { if (toolDialog.value === 'refund') { if (!refundForm.paymentId || !refundForm.reason || refundForm.amountYuan <= 0) return ElMessage.warning('请完整填写退款信息'); const result = await capture(() => createWechatRefund(props.session, { paymentId: refundForm.paymentId, amountCents: Math.round(refundForm.amountYuan * 100), reason: refundForm.reason, clientRequestId: requestId('refund') })); lastResult.value = `退款 ${result.refundNo} 已提交,状态 ${result.status},剩余可退 ${money(result.refundableCents)}`; } else if (toolDialog.value === 'reconciliation') { if (!reconciliationForm.storeId || !reconciliationForm.billDate) return ElMessage.warning('请选择门店和账单日期'); const result = await capture(() => requestWechatReconciliation(props.session, reconciliationForm)); lastResult.value = `对账请求 #${result.id} 状态 ${result.status}${result.downloadUrl ? ',下载地址已由服务端返回' : ''}`; } else { if (!shareForm.paymentId) return ElMessage.warning('请填写支付 ID'); const result = await capture(() => executeProfitSharing(props.session, { paymentId: shareForm.paymentId, clientRequestId: requestId('share'), mode: shareForm.mode })); lastResult.value = `分账已提交,共 ${result.shares.length}${result.idempotent ? '(幂等复用)' : ''}`; await loadSnapshot(); } toolDialog.value = ''; } catch { /* displayed */ } finally { saving.value = false; } }
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; }
function storeName(id: string) { return stores.value.find((item) => item.id === String(id))?.name || `门店 #${id}`; }
function receiverName(id: string) { const item = snapshot.receivers.find((receiver) => receiver.id === String(id)); return item ? `${item.name} · ${item.receiverMasked}` : `接收方 #${id}`; }
function authorizationLabel(value: PaymentAuthorizationStatus) { return authorizationOptions.find((item) => item.value === value)?.label || value; }
function authorizationType(value: PaymentAuthorizationStatus) { return value === 'AUTHORIZED' ? 'success' : value === 'PENDING' ? 'warning' : 'info'; }
function shareStatusType(value: string) { return value === 'SUCCEEDED' ? 'success' : value === 'FAILED' ? 'danger' : 'warning'; }
function truthy(value: boolean | number) { return value === true || Number(value) === 1; }
function percent(bps: number) { return `${(Number(bps || 0) / 100).toFixed(2)}%`; }
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
@@ -0,0 +1,50 @@
<template>
<section class="platform-apps-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-alert title="AppSecret、微信支付密钥和证书只允许配置在服务端环境变量中,本页面不会读取、保存或回显。" type="info" show-icon :closable="false" />
<section class="platform-app-metrics"><span><small>已绑定应用</small><strong>{{ apps.length }}</strong></span><span><small>启用绑定</small><strong>{{ activeCount }}</strong></span><span><small>默认应用</small><strong>{{ defaultCount }}</strong></span><span><small>品牌配置完整</small><strong>{{ configuredCount }}</strong></span></section>
<section class="panel">
<header class="panel-toolbar platform-app-toolbar"><div><p class="section-kicker">M08-D · 多应用租户</p><h3>多小程序与租户品牌配置</h3></div><div class="toolbar-actions"><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button><el-button type="primary" :icon="Plus" @click="openBind">绑定应用</el-button></div></header>
<el-table v-loading="loading" :data="apps" class="data-table" row-key="platformAppId">
<el-table-column label="逻辑应用" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.appName }}</strong><span>{{ row.appId }} · #{{ row.platformAppId }}</span></div></template></el-table-column>
<el-table-column label="绑定" width="130"><template #default="{ row }"><div class="stack"><el-tag :type="row.bindingStatus === 'ACTIVE' ? 'success' : 'info'">{{ row.bindingStatus }}</el-tag><span>{{ row.isDefault ? '租户默认' : '普通绑定' }}</span></div></template></el-table-column>
<el-table-column label="品牌" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.brandName }}</strong><span><i class="theme-dot" :style="{ background: row.themeColor }" />{{ row.themeColor }} · {{ row.servicePhone || '未留客服电话' }}</span></div></template></el-table-column>
<el-table-column label="默认门店" min-width="160"><template #default="{ row }">{{ row.defaultStoreId ? storeName(row.defaultStoreId) : '未配置' }}</template></el-table-column>
<el-table-column label="分享" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.shareTitle || '-' }}</strong><span>{{ row.shareImageUrl ? '已配置分享图' : '未配置分享图' }}</span></div></template></el-table-column>
<el-table-column label="更新时间" width="150"><template #default="{ row }">{{ shortDate(row.updatedAt) }}</template></el-table-column>
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Settings2" @click="openEdit(row)">配置</el-button></template></el-table-column>
</el-table>
</section>
<el-dialog v-model="dialog" :title="form.platformAppId ? '编辑租户应用配置' : '绑定逻辑应用'" width="min(760px, 94vw)">
<el-form label-position="top" class="asset-form">
<template v-if="!form.platformAppId"><el-form-item label="微信 AppID"><el-input v-model="form.appId" autocomplete="off" /></el-form-item><el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item><el-form-item label="全局应用状态"><el-select v-model="form.appStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item></template>
<el-form-item label="品牌名称"><el-input v-model="form.brandName" /></el-form-item><el-form-item label="主题色"><el-color-picker v-model="form.themeColor" /><el-input v-model="form.themeColor" class="color-input" /></el-form-item><el-form-item label="客服电话"><el-input v-model="form.servicePhone" /></el-form-item><el-form-item label="加盟电话"><el-input v-model="form.franchisePhone" /></el-form-item><el-form-item label="默认门店"><el-select v-model="form.defaultStoreId" clearable><el-option label="不指定" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="绑定状态"><el-select v-model="form.bindingStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item><el-form-item label="租户默认应用"><el-switch v-model="form.isDefault" /></el-form-item><el-form-item label="Logo URL" class="form-span-2"><el-input v-model="form.logoUrl" /></el-form-item><el-form-item label="分享标题" class="form-span-2"><el-input v-model="form.shareTitle" /></el-form-item><el-form-item label="分享图片 URL" class="form-span-2"><el-input v-model="form.shareImageUrl" /></el-form-item><el-form-item label="非敏感扩展配置 JSON" class="form-span-2"><el-input v-model="form.extraConfigText" type="textarea" :rows="5" placeholder='{"bookingMode":"STANDARD"}' /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Plus, RefreshCw, Settings2 } from '@lucide/vue';
import { ApiError, bindPlatformApplication, listManagedStores, listPlatformApplications, updatePlatformApplicationConfig, type ApiSession } from '../api';
import { shortDate } from '../format';
import type { ManagedStore, PlatformApplication, TenantApplicationConfig } from '../types';
const props = defineProps<{ session: ApiSession }>();
const apps = ref<PlatformApplication[]>([]); const stores = ref<ManagedStore[]>([]); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const dialog = ref(false);
const form = reactive({ platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', isDefault: false, extraConfigText: '{}' });
const activeCount = computed(() => apps.value.filter((item) => item.bindingStatus === 'ACTIVE').length); const defaultCount = computed(() => apps.value.filter((item) => item.isDefault).length); const configuredCount = computed(() => apps.value.filter((item) => item.brandName && item.themeColor && item.shareTitle).length);
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadData() { if (!props.session.token) return; loading.value = true; try { [apps.value, stores.value] = await Promise.all([capture(() => listPlatformApplications(props.session)), capture(() => listManagedStores(props.session))]); } catch { /* displayed */ } finally { loading.value = false; } }
function defaults() { return { platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE', isDefault: apps.value.length === 0, extraConfigText: '{}' }; }
function openBind() { Object.assign(form, defaults()); dialog.value = true; }
function openEdit(row: PlatformApplication) { Object.assign(form, { platformAppId: row.platformAppId, appId: row.appId, appName: row.appName, appStatus: row.appStatus, brandName: row.brandName, logoUrl: row.logoUrl, themeColor: row.themeColor, servicePhone: row.servicePhone, franchisePhone: row.franchisePhone, shareTitle: row.shareTitle, shareImageUrl: row.shareImageUrl, defaultStoreId: row.defaultStoreId || '', bindingStatus: row.bindingStatus, isDefault: row.isDefault, extraConfigText: JSON.stringify(row.extraConfig, null, 2) }); dialog.value = true; }
function configInput(): TenantApplicationConfig | null { let extraConfig: unknown; try { extraConfig = JSON.parse(form.extraConfigText || '{}'); } catch { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } if (!extraConfig || typeof extraConfig !== 'object' || Array.isArray(extraConfig)) { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } return { brandName: form.brandName.trim(), logoUrl: form.logoUrl.trim(), themeColor: form.themeColor, servicePhone: form.servicePhone.trim(), franchisePhone: form.franchisePhone.trim(), shareTitle: form.shareTitle.trim(), shareImageUrl: form.shareImageUrl.trim(), defaultStoreId: form.defaultStoreId || null, extraConfig: extraConfig as Record<string, unknown>, bindingStatus: form.bindingStatus, isDefault: form.isDefault }; }
async function save() { const config = configInput(); if (!config || !config.brandName || !/^#[0-9A-Fa-f]{6}$/.test(config.themeColor) || (!form.platformAppId && (!form.appId || !form.appName))) return ElMessage.warning('请填写 AppID、应用名称、品牌名称和有效主题色'); saving.value = true; try { if (form.platformAppId) await capture(() => updatePlatformApplicationConfig(props.session, form.platformAppId, config)); else await capture(() => bindPlatformApplication(props.session, { appId: form.appId.trim(), appName: form.appName.trim(), appStatus: form.appStatus, config })); dialog.value = false; ElMessage.success('应用配置已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
</script>
@@ -0,0 +1,248 @@
<template>
<section class="goods-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<section class="goods-metrics">
<span><small>门店商品</small><strong>{{ listings.length }}</strong></span>
<span><small>低库存</small><strong>{{ lowStockCount }}</strong></span>
<span><small>待处理订单</small><strong>{{ pendingOrderCount }}</strong></span>
<span><small>有效寄存</small><strong>{{ activeStorageCount }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar goods-toolbar">
<div><p class="section-kicker">M09-D4 · 商品运营</p><h3>商品库存订单与寄存</h3></div>
<div class="toolbar-actions">
<el-select v-model="storeId" filterable placeholder="选择授权门店" @change="changeStore">
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
</el-select>
<el-button :icon="RefreshCw" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab">
<el-tab-pane label="商品目录" name="catalog" :disabled="!canCatalogRead">
<el-table v-loading="loading" :data="listings" row-key="id" class="data-table">
<el-table-column label="商品" min-width="220"><template #default="{ row }"><div class="stack"><strong>{{ row.productName }}</strong><span>{{ row.productCode }} · {{ row.categoryName }}</span></div></template></el-table-column>
<el-table-column label="履约" width="150"><template #default="{ row }"><el-tag effect="plain">{{ fulfillmentLabel(row.fulfillmentMode) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '上架' : '下架' }}</el-tag></template></el-table-column>
<el-table-column label="售卖时段" min-width="230"><template #default="{ row }">{{ rangeText(row.salesStartAt, row.salesEndAt) }}</template></el-table-column>
<el-table-column prop="version" label="版本" width="90" />
</el-table>
<el-empty v-if="!loading && !listings.length" description="当前门店暂无商品上架配置" />
</el-tab-pane>
<el-tab-pane :label="`库存 (${stocks.total})`" name="inventory" :disabled="!canInventoryRead">
<div class="tab-tools"><el-input v-model="stockSearch" clearable placeholder="搜索商品或 SKU" @keyup.enter="loadStocks" /><el-button @click="loadStocks">查询</el-button></div>
<el-table v-loading="loading" :data="stocks.items" row-key="id" class="data-table">
<el-table-column label="商品 / SKU" min-width="230"><template #default="{ row }"><div class="stack"><strong>{{ row.productName }} · {{ row.skuName }}</strong><span>{{ row.productCode }} / {{ row.skuCode }}</span></div></template></el-table-column>
<el-table-column label="策略" width="120"><template #default="{ row }"><el-tag effect="plain">{{ row.policyType }}</el-tag></template></el-table-column>
<el-table-column prop="availableQuantity" label="可用" width="90" />
<el-table-column prop="lockedQuantity" label="锁定" width="90" />
<el-table-column prop="lossQuantity" label="损耗" width="90" />
<el-table-column label="预警" width="100"><template #default="{ row }"><el-tag :type="row.lowStock ? 'danger' : 'success'">{{ row.lowStock ? '低库存' : '正常' }}</el-tag></template></el-table-column>
<el-table-column v-if="canInventoryWrite" label="操作" min-width="245" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="inventoryQuantity(row, 'inbound')">入库</el-button><el-button size="small" @click="inventoryQuantity(row, 'loss')">损耗</el-button><el-button size="small" @click="stocktake(row)">盘点</el-button></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`商品订单 (${orders.total})`" name="orders" :disabled="!canOrderRead">
<div class="tab-tools"><el-select v-model="orderStatus" clearable placeholder="全部状态" @change="loadOrders"><el-option v-for="status in orderStatuses" :key="status" :label="statusLabel(status)" :value="status" /></el-select><el-button @click="loadOrders">刷新订单</el-button></div>
<el-table v-loading="loading" :data="orders.items" row-key="id" class="data-table">
<el-table-column label="订单" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.orderNo }}</strong><span>会员 #{{ row.memberId || '-' }} · {{ dateText(row.createdAt) }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="orderStatusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="履约" width="130"><template #default="{ row }">{{ fulfillmentLabel(row.fulfillmentMode) }}</template></el-table-column>
<el-table-column label="数量 / 金额" min-width="150"><template #default="{ row }">{{ row.totalQuantity }} · {{ money(row.totalAmountCents) }}</template></el-table-column>
<el-table-column label="操作" min-width="340" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showOrder(row)">详情</el-button><el-button size="small" type="success" plain @click="showReconciliation(row)">逐单对账</el-button><template v-if="canOrderWrite"><el-button v-for="action in orderActions(row)" :key="action" size="small" type="primary" plain @click="actOnOrder(row, action)">{{ actionLabel(action) }}</el-button></template></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane :label="`寄存 (${storages.total})`" name="storages" :disabled="!canStorageRead">
<div class="tab-tools"><el-select v-model="storageStatus" clearable placeholder="全部状态" @change="loadStorages"><el-option v-for="status in storageStatuses" :key="status" :label="storageStatusLabel(status)" :value="status" /></el-select><el-button v-if="canStorageWrite" type="primary" @click="openCreateStorage">从订单寄存</el-button><el-button @click="loadStorages">刷新寄存</el-button></div>
<el-table v-loading="loading" :data="storages.items" row-key="id" class="data-table">
<el-table-column label="寄存单" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.storageNo }}</strong><span>会员 #{{ row.memberId }} · {{ row.sourceType === 'ORDER' ? `订单 #${row.sourceOrderId}` : '员工手工创建' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="storageStatusType(row.status)">{{ storageStatusLabel(row.status) }}</el-tag></template></el-table-column>
<el-table-column label="数量" width="130"><template #default="{ row }">{{ row.remainingQuantity }} / {{ row.totalQuantity }}</template></el-table-column>
<el-table-column label="到期" min-width="165"><template #default="{ row }">{{ dateText(row.expiresAt) }}</template></el-table-column>
<el-table-column label="凭证" width="120"><template #default="{ row }"> {{ row.claimCredentialVersion }} </template></el-table-column>
<el-table-column label="操作" min-width="320" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" @click="showStorage(row)">详情</el-button><template v-if="canStorageWrite && isStorageActive(row.status)"><el-button size="small" type="primary" plain @click="openRetrieve(row)">取出</el-button><el-button size="small" @click="rotateStorage(row)">换码</el-button><el-button size="small" type="danger" plain @click="cancelStorage(row)">取消</el-button></template></div></template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-drawer v-model="detailDrawer" :title="detailTitle" size="min(720px, 96vw)">
<template v-if="selectedReconciliation">
<el-alert :title="selectedReconciliation.consistent ? '金额、支付退款、库存和寄存账实一致' : '发现账实差异,请按失败检查项排查'" :type="selectedReconciliation.consistent ? 'success' : 'error'" show-icon :closable="false" />
<el-descriptions :column="2" border class="reconciliation-summary">
<el-descriptions-item label="订单金额">{{ money(selectedReconciliation.amounts.orderAmountCents) }}</el-descriptions-item>
<el-descriptions-item label="明细合计">{{ money(selectedReconciliation.amounts.itemAmountCents) }}</el-descriptions-item>
<el-descriptions-item label="成功实收">{{ money(selectedReconciliation.amounts.capturedAmountCents) }}</el-descriptions-item>
<el-descriptions-item label="成功退款">{{ money(selectedReconciliation.amounts.succeededRefundCents) }}</el-descriptions-item>
<el-descriptions-item label="净收入">{{ money(selectedReconciliation.amounts.netRevenueCents) }}</el-descriptions-item>
<el-descriptions-item label="库存状态">{{ selectedReconciliation.inventory.status }}</el-descriptions-item>
</el-descriptions>
<h4>一致性检查</h4>
<el-table :data="selectedReconciliation.checks" row-key="key">
<el-table-column label="检查项" min-width="210"><template #default="{ row }">{{ reconciliationLabel(row.key) }}</template></el-table-column>
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.passed ? 'success' : 'danger'">{{ row.passed ? '一致' : '差异' }}</el-tag></template></el-table-column>
<el-table-column prop="expected" label="应有" width="90" />
<el-table-column prop="actual" label="实际" width="90" />
</el-table>
<h4>库存不可变流水</h4>
<el-table :data="selectedReconciliation.inventory.ledger" row-key="id">
<el-table-column label="商品 / SKU" min-width="180"><template #default="{ row }">{{ row.productName }} · {{ row.skuName }}</template></el-table-column>
<el-table-column prop="operation" label="动作" width="100" />
<el-table-column prop="reservationDelta" label="占用变化" width="100" />
<el-table-column prop="saleDelta" label="销售变化" width="100" />
<el-table-column label="时间" min-width="160"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
</el-table>
<h4>关联寄存</h4>
<el-table :data="selectedReconciliation.storages" row-key="id">
<el-table-column prop="storageNo" label="寄存单" min-width="180" />
<el-table-column prop="status" label="状态" width="140" />
<el-table-column label="记录 / 明细剩余" min-width="150"><template #default="{ row }">{{ row.recordRemainingQuantity }} / {{ row.itemRemainingQuantity }}</template></el-table-column>
<el-table-column label="结果" width="90"><template #default="{ row }"><el-tag :type="row.consistent ? 'success' : 'danger'">{{ row.consistent ? '一致' : '差异' }}</el-tag></template></el-table-column>
</el-table>
</template>
<template v-if="selectedOrder">
<el-descriptions :column="2" border>
<el-descriptions-item label="订单号">{{ selectedOrder.orderNo }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(selectedOrder.status) }}</el-descriptions-item>
<el-descriptions-item label="会员">#{{ selectedOrder.memberId }}</el-descriptions-item>
<el-descriptions-item label="金额">{{ money(selectedOrder.totalAmountCents) }}</el-descriptions-item>
</el-descriptions>
<h4>订单明细</h4>
<el-table :data="selectedOrder.items || []">
<el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" />
<el-table-column prop="quantity" label="数量" width="80" /><el-table-column label="小计" width="110"><template #default="{ row }">{{ money(row.subtotalCents) }}</template></el-table-column>
</el-table>
<h4>支付流水</h4>
<el-table :data="selectedOrder.payments || []">
<el-table-column prop="paymentNo" label="支付单号" min-width="180" /><el-table-column prop="provider" label="通道" width="90" />
<el-table-column prop="status" label="状态" width="120" /><el-table-column label="金额" width="110"><template #default="{ row }">{{ money(row.amountCents) }}</template></el-table-column>
<el-table-column label="已退" width="110"><template #default="{ row }">{{ money(row.refundedAmountCents) }}</template></el-table-column>
</el-table>
<h4>退款流水</h4>
<el-table :data="selectedOrder.refunds || []">
<el-table-column prop="refundNo" label="退款单号" min-width="180" /><el-table-column prop="status" label="状态" width="110" />
<el-table-column label="金额" width="110"><template #default="{ row }">{{ money(row.amountCents) }}</template></el-table-column>
<el-table-column label="库存处理" min-width="150"><template #default="{ row }">{{ row.inventoryDisposition }} / {{ row.inventoryStatus }}</template></el-table-column>
</el-table>
<h4>不可变事件</h4>
<el-table :data="selectedOrder.events || []">
<el-table-column prop="action" label="动作" width="120" /><el-table-column label="状态迁移" min-width="180"><template #default="{ row }">{{ row.fromStatus || '创建' }} {{ row.toStatus }}</template></el-table-column>
<el-table-column prop="reason" label="原因" min-width="150" /><el-table-column label="时间" min-width="165"><template #default="{ row }">{{ dateText(row.createdAt) }}</template></el-table-column>
</el-table>
</template>
<template v-if="selectedStorage"><el-descriptions :column="2" border><el-descriptions-item label="寄存单">{{ selectedStorage.storageNo }}</el-descriptions-item><el-descriptions-item label="状态">{{ storageStatusLabel(selectedStorage.status) }}</el-descriptions-item><el-descriptions-item label="寄存人">#{{ selectedStorage.memberId }}</el-descriptions-item><el-descriptions-item label="到期">{{ dateText(selectedStorage.expiresAt) }}</el-descriptions-item></el-descriptions><el-table :data="selectedStorage.items || []"><el-table-column prop="productName" label="商品" /><el-table-column prop="skuName" label="规格" /><el-table-column prop="totalQuantity" label="寄存" width="80" /><el-table-column prop="remainingQuantity" label="剩余" width="80" /></el-table><h4>取出流水</h4><el-table :data="selectedStorage.movements || []"><el-table-column prop="storageItemId" label="明细" /><el-table-column prop="quantity" label="取出" width="80" /><el-table-column prop="remainingAfter" label="取出后" width="90" /></el-table></template>
</el-drawer>
<el-dialog v-model="retrieveDialog" title="核验凭证并取出商品" width="min(560px, 94vw)"><el-alert title="领取码只用于本次核验;后台不会读取或展示数据库中的凭证哈希。" type="warning" show-icon :closable="false" /><el-form label-position="top"><el-form-item label="寄存明细"><el-select v-model="retrieveForm.storageItemId"><el-option v-for="item in retrieveStorage?.items || []" :key="item.id" :label="`${item.productName} · ${item.skuName}(剩余 ${item.remainingQuantity}`" :value="item.id" /></el-select></el-form-item><el-form-item label="取出数量"><el-input-number v-model="retrieveForm.quantity" :min="1" :max="selectedRetrieveItem?.remainingQuantity || 1" /></el-form-item><el-form-item label="一次性领取码"><el-input v-model="retrieveForm.credential" type="password" show-password autocomplete="off" /></el-form-item></el-form><template #footer><el-button @click="retrieveDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitRetrieve">确认取出</el-button></template></el-dialog>
<el-dialog v-model="createStorageDialog" title="从已完成订单创建寄存" width="min(520px, 94vw)"><el-form label-position="top"><el-form-item label="商品订单 ID"><el-input v-model="createStorageForm.orderId" /></el-form-item><el-form-item label="到期时间"><el-date-picker v-model="createStorageForm.expiresAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss.SSSZ" /></el-form-item></el-form><template #footer><el-button @click="createStorageDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="submitCreateStorage">创建寄存</el-button></template></el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { RefreshCw } from '@lucide/vue';
import {
ApiError, createOrderProductStorage, getProductOrder, getProductOrderReconciliation, getProductStorage,
listInventoryStocks, listManagedStores, listProductListings, listProductOrders,
listProductStorages, mutateInventory, runProductOrderAction, runProductStorageAction,
type ApiSession
} from '../api';
import { adminSessionState } from '../session';
import type { InventoryStock, ManagedStore, PageResult, ProductListing, ProductOrder, ProductOrderReconciliation, ProductStorage } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]); const storeId = ref(''); const activeTab = ref('catalog');
const listings = ref<ProductListing[]>([]); const stocks = reactive<PageResult<InventoryStock>>({ items: [], total: 0, page: 1, pageSize: 100 });
const orders = reactive<PageResult<ProductOrder>>({ items: [], total: 0, page: 1, pageSize: 50 });
const storages = reactive<PageResult<ProductStorage>>({ items: [], total: 0, page: 1, pageSize: 50 });
const loading = ref(false); const saving = ref(false); const lastError = ref(''); const stockSearch = ref('');
const orderStatus = ref(''); const storageStatus = ref(''); const detailDrawer = ref(false);
const selectedOrder = ref<ProductOrder | null>(null); const selectedStorage = ref<ProductStorage | null>(null);
const selectedReconciliation = ref<ProductOrderReconciliation | null>(null);
const retrieveDialog = ref(false); const retrieveStorage = ref<ProductStorage | null>(null);
const retrieveForm = reactive({ storageItemId: '', quantity: 1, credential: '' });
const createStorageDialog = ref(false); const createStorageForm = reactive({ orderId: '', expiresAt: '' });
const orderStatuses = ['PENDING_PAYMENT', 'PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'COMPLETED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED'];
const storageStatuses = ['STORED', 'PARTIALLY_RETRIEVED', 'RETRIEVED', 'EXPIRED', 'CANCELLED'];
const lowStockCount = computed(() => stocks.items.filter((item) => item.lowStock).length);
const pendingOrderCount = computed(() => orders.items.filter((item) => ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(item.status)).length);
const activeStorageCount = computed(() => storages.items.filter((item) => isStorageActive(item.status)).length);
const hasAccess = (...required: string[]) => adminSessionState.access.roles.includes('PLATFORM_ADMIN')
|| adminSessionState.access.capabilities.some((item) => [...required, 'tenant.manage', 'platform.manage'].includes(item));
const canCatalogRead = computed(() => hasAccess('product.catalog.read', 'product.catalog.write'));
const canInventoryRead = computed(() => hasAccess('inventory.read', 'inventory.adjust'));
const canInventoryWrite = computed(() => hasAccess('inventory.adjust'));
const canOrderRead = computed(() => hasAccess('goods.order.read', 'goods.order.manage'));
const canOrderWrite = computed(() => hasAccess('goods.order.manage'));
const canStorageRead = computed(() => hasAccess('goods.storage.read', 'goods.storage.manage'));
const canStorageWrite = computed(() => hasAccess('goods.storage.manage'));
const selectedRetrieveItem = computed(() => retrieveStorage.value?.items?.find((item) => item.id === retrieveForm.storageItemId));
const detailTitle = computed(() => selectedReconciliation.value ? `逐单对账 ${selectedReconciliation.value.order.orderNo}` : selectedOrder.value ? `商品订单 ${selectedOrder.value.orderNo}` : selectedStorage.value ? `寄存单 ${selectedStorage.value.storageNo}` : '业务详情');
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); storeId.value ||= stores.value[0]?.id || ''; activeTab.value = canCatalogRead.value ? 'catalog' : canInventoryRead.value ? 'inventory' : canOrderRead.value ? 'orders' : 'storages'; await loadAll(); } catch { /* displayed */ } finally { loading.value = false; } }
async function changeStore() { await loadAll(); }
async function loadAll() { if (!storeId.value) return; loading.value = true; try { const [nextListings, nextStocks, nextOrders, nextStorages] = await Promise.all([canCatalogRead.value ? capture(() => listProductListings(props.session, storeId.value)) : Promise.resolve([] as ProductListing[]), canInventoryRead.value ? capture(() => listInventoryStocks(props.session, { storeId: storeId.value, pageSize: 100 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 100 } as PageResult<InventoryStock>), canOrderRead.value ? capture(() => listProductOrders(props.session, { storeId: storeId.value, pageSize: 50 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 } as PageResult<ProductOrder>), canStorageRead.value ? capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50 })) : Promise.resolve({ items: [], total: 0, page: 1, pageSize: 50 } as PageResult<ProductStorage>)]); listings.value = nextListings; Object.assign(stocks, nextStocks); Object.assign(orders, nextOrders); Object.assign(storages, nextStorages); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadStocks() { if (!storeId.value || !canInventoryRead.value) return; loading.value = true; try { Object.assign(stocks, await capture(() => listInventoryStocks(props.session, { storeId: storeId.value, pageSize: 100, search: stockSearch.value.trim() || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadOrders() { if (!storeId.value || !canOrderRead.value) return; loading.value = true; try { Object.assign(orders, await capture(() => listProductOrders(props.session, { storeId: storeId.value, pageSize: 50, status: orderStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function loadStorages() { if (!storeId.value || !canStorageRead.value) return; loading.value = true; try { Object.assign(storages, await capture(() => listProductStorages(props.session, { storeId: storeId.value, pageSize: 50, status: storageStatus.value || undefined }))); } catch { /* displayed */ } finally { loading.value = false; } }
async function inventoryQuantity(row: InventoryStock, action: 'inbound' | 'loss') { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入${action === 'inbound' ? '入库' : '损耗'}数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^[1-9]\d{0,8}$/, inputErrorMessage: '请输入正整数' }); await capture(() => mutateInventory(props.session, row.skuId, action, { storeId: storeId.value, requestId: requestId(`admin-${action}`), reason: action === 'inbound' ? '运营后台商品入库' : '运营后台登记损耗', expectedVersion: row.version, quantity: Number(value) })); ElMessage.success('库存已更新'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function stocktake(row: InventoryStock) { try { const { value } = await ElMessageBox.prompt(`当前可用 ${row.availableQuantity},请输入盘点后的可用数量`, `${row.productName} · ${row.skuName}`, { inputPattern: /^\d{1,9}$/, inputErrorMessage: '请输入非负整数' }); await capture(() => mutateInventory(props.session, row.skuId, 'stocktake', { storeId: storeId.value, requestId: requestId('admin-stocktake'), reason: '运营后台库存盘点', expectedVersion: row.version, availableQuantity: Number(value), lossQuantity: row.lossQuantity })); ElMessage.success('盘点已完成'); await loadStocks(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function showOrder(row: ProductOrder) { try { selectedStorage.value = null; selectedReconciliation.value = null; selectedOrder.value = await capture(() => getProductOrder(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
async function showReconciliation(row: ProductOrder) { try { selectedOrder.value = null; selectedStorage.value = null; selectedReconciliation.value = await capture(() => getProductOrderReconciliation(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
function orderActions(row: ProductOrder) { if (row.status === 'PAID') return ['ACCEPT', 'REFUND']; if (row.status === 'ACCEPTED') return row.fulfillmentMode === 'DELIVERY' ? ['START_DELIVERY', 'CANCEL'] : ['MARK_READY', 'CANCEL']; if (['DELIVERING', 'READY_FOR_SELF_SERVICE'].includes(row.status)) return ['COMPLETE']; if (row.status === 'COMPLETED') return ['REFUND']; if (row.status === 'PENDING_PAYMENT') return ['CANCEL']; return []; }
async function actOnOrder(row: ProductOrder, action: string) { try { const destructive = ['CANCEL', 'REFUND'].includes(action); let reason = ''; if (destructive) { const result = await ElMessageBox.prompt(`将对订单 ${row.orderNo} 执行“${actionLabel(action)}”,库存与退款状态会按当前履约阶段处理。请输入原因。`, '确认商品订单操作', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); reason = result.value.trim(); } await capture(() => runProductOrderAction(props.session, row.id, { requestId: requestId(`order-${action.toLowerCase()}`), action, reason })); ElMessage.success('订单状态已更新'); await loadOrders(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function showStorage(row: ProductStorage) { try { selectedOrder.value = null; selectedReconciliation.value = null; selectedStorage.value = await capture(() => getProductStorage(props.session, row.id)); detailDrawer.value = true; } catch { /* displayed */ } }
async function openRetrieve(row: ProductStorage) { try { retrieveStorage.value = await capture(() => getProductStorage(props.session, row.id)); const first = retrieveStorage.value.items?.find((item) => item.remainingQuantity > 0); Object.assign(retrieveForm, { storageItemId: first?.id || '', quantity: 1, credential: '' }); retrieveDialog.value = true; } catch { /* displayed */ } }
async function submitRetrieve() { if (!retrieveStorage.value || !retrieveForm.storageItemId || !retrieveForm.credential.trim()) return ElMessage.warning('请填写寄存明细、数量和领取码'); saving.value = true; try { const result = await capture(() => runProductStorageAction(props.session, retrieveStorage.value!.id, 'retrieve', { requestId: requestId('storage-retrieve'), claimCredential: retrieveForm.credential.trim(), items: [{ storageItemId: retrieveForm.storageItemId, quantity: retrieveForm.quantity }] })); retrieveDialog.value = false; retrieveForm.credential = ''; if (result.nextClaimCredential) await showOneTimeCredential(result.nextClaimCredential, '部分取出已完成,旧领取码已失效'); else ElMessage.success('商品已取出'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
async function rotateStorage(row: ProductStorage) { try { await ElMessageBox.confirm(`寄存单 ${row.storageNo} 的当前领取码会立即失效,新码只显示一次。`, '确认轮换领取码', { type: 'warning' }); const result = await capture(() => runProductStorageAction(props.session, row.id, 'credential/rotate', { requestId: requestId('storage-rotate') })); if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '领取码已轮换'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
async function cancelStorage(row: ProductStorage) { try { const { value } = await ElMessageBox.prompt(`取消寄存单 ${row.storageNo} 后不可继续领取,剩余 ${row.remainingQuantity} 件不会自动回库存。请输入原因。`, '确认取消寄存', { inputPattern: /\S{2,512}/, inputErrorMessage: '请填写至少 2 个字符的原因', type: 'warning' }); await capture(() => runProductStorageAction(props.session, row.id, 'cancel', { requestId: requestId('storage-cancel'), reason: value.trim() })); ElMessage.success('寄存已取消'); await loadStorages(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
function openCreateStorage() { Object.assign(createStorageForm, { orderId: '', expiresAt: new Date(Date.now() + 30 * 86400000).toISOString() }); createStorageDialog.value = true; }
async function submitCreateStorage() { if (!/^\d+$/.test(createStorageForm.orderId) || !createStorageForm.expiresAt) return ElMessage.warning('请填写已完成商品订单 ID 和到期时间'); saving.value = true; try { const result = await capture(() => createOrderProductStorage(props.session, { requestId: requestId('storage-create'), sourceType: 'ORDER', sourceOrderId: createStorageForm.orderId, expiresAt: new Date(createStorageForm.expiresAt).toISOString() })); createStorageDialog.value = false; if (result.claimCredential) await showOneTimeCredential(result.claimCredential, '寄存已创建'); await loadStorages(); } catch { /* displayed */ } finally { saving.value = false; } }
async function showOneTimeCredential(value: string, title: string) { await ElMessageBox.alert(`领取码:${value}\n\n请立即交给寄存人安全保存。关闭后后台无法再次查看原码,可通过“换码”签发新码。`, title, { confirmButtonText: '我已安全记录', type: 'success', dangerouslyUseHTMLString: false }); }
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }
function money(value: number) { return `${(Number(value || 0) / 100).toFixed(2)}`; }
function dateText(value: string | null) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'; }
function rangeText(start: string | null, end: string | null) { return start || end ? `${dateText(start)}${dateText(end)}` : '长期有效'; }
function fulfillmentLabel(value: string) { return ({ DELIVERY: '房间配送', SELF_SERVICE: '自助领取', BOTH: '配送 / 自助' } as Record<string, string>)[value] || value; }
function statusLabel(value: string) { return ({ PENDING_PAYMENT: '待支付', PAID: '已支付', ACCEPTED: '已接单', DELIVERING: '配送中', READY_FOR_SELF_SERVICE: '待自取', COMPLETED: '已完成', CANCELLED: '已取消', REFUNDING: '退款中', REFUNDED: '已退款', REFUND_FAILED: '退款失败' } as Record<string, string>)[value] || value; }
function actionLabel(value: string) { return ({ ACCEPT: '接单', START_DELIVERY: '开始配送', MARK_READY: '标记待取', COMPLETE: '完成', CANCEL: '取消', REFUND: '退款' } as Record<string, string>)[value] || value; }
function orderStatusType(value: string) { return ['COMPLETED', 'REFUNDED'].includes(value) ? 'success' : ['CANCELLED', 'REFUND_FAILED'].includes(value) ? 'danger' : ['PAID', 'ACCEPTED', 'DELIVERING', 'READY_FOR_SELF_SERVICE', 'REFUNDING'].includes(value) ? 'warning' : 'info'; }
function storageStatusLabel(value: string) { return ({ STORED: '已寄存', PARTIALLY_RETRIEVED: '部分取出', RETRIEVED: '已取完', EXPIRED: '已过期', CANCELLED: '已取消' } as Record<string, string>)[value] || value; }
function storageStatusType(value: string) { return value === 'RETRIEVED' ? 'success' : value === 'PARTIALLY_RETRIEVED' ? 'warning' : ['EXPIRED', 'CANCELLED'].includes(value) ? 'danger' : 'primary'; }
function reconciliationLabel(value: string) { return ({ ORDER_ITEM_AMOUNT: '订单金额 = 明细小计', ORDER_ITEM_QUANTITY: '订单数量 = 明细数量', ORDER_ITEM_COUNT: '订单行数 = 明细行数', PAYMENT_CAPTURED_AMOUNT: '订单实收 = 成功支付', PAYMENT_REFUNDED_AMOUNT: '订单退款 = 支付退款累计', REFUND_SUCCEEDED_AMOUNT: '订单退款 = 成功退款单', INVENTORY_RESERVATION_BALANCE: '库存占用余额', INVENTORY_SALE_BALANCE: '库存销售余额', STORAGE_ITEM_BALANCE: '寄存记录 = 寄存明细' } as Record<string, string>)[value] || value; }
function isStorageActive(value: string) { return ['STORED', 'PARTIALLY_RETRIEVED'].includes(value); }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
<style scoped>
.goods-page { display: grid; gap: 18px; }
.goods-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; }
.goods-metrics span { display: grid; gap: 5px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: #fff; }
.goods-metrics small { color: var(--muted); }
.goods-metrics strong { font-size: 28px; }
.panel { padding: 20px; border: 1px solid var(--line); border-radius: 18px; background: #fff; }
.goods-toolbar, .toolbar-actions, .tab-tools, .row-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.goods-toolbar { justify-content: space-between; margin-bottom: 12px; }
.goods-toolbar h3 { margin: 2px 0 0; }
.section-kicker { margin: 0; color: #2563eb; font-size: 12px; font-weight: 700; }
.tab-tools { justify-content: flex-end; margin-bottom: 14px; }
.tab-tools .el-input { width: min(320px, 100%); }
.stack { display: grid; gap: 4px; }
.stack span { color: var(--muted); font-size: 12px; }
h4 { margin: 22px 0 10px; }
@media (max-width: 760px) {
.goods-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.panel { padding: 12px; }
.goods-toolbar { align-items: stretch; }
.toolbar-actions, .toolbar-actions .el-select, .toolbar-actions .el-button { width: 100%; }
.tab-tools { justify-content: stretch; }
.tab-tools > * { flex: 1 1 160px; }
}
</style>
+545
View File
@@ -0,0 +1,545 @@
<template>
<section class="stores-page">
<el-alert
v-if="lastError"
class="alert-line"
:title="lastError"
type="error"
show-icon
closable
@close="lastError = ''"
/>
<section class="panel">
<header class="panel-toolbar store-toolbar">
<div>
<p class="section-kicker">M08-D · 门店资产</p>
<h3>门店与房间管理</h3>
</div>
<div class="toolbar-actions">
<el-input v-model="search" class="search-input" clearable placeholder="搜索门店、城市或地址" />
<el-select v-model="statusFilter" class="filter-select" placeholder="营业状态">
<el-option label="全部状态" value="" />
<el-option label="营业中" value="OPEN" />
<el-option label="已闭店" value="CLOSED" />
<el-option label="暂停营业" value="SUSPENDED" />
</el-select>
<el-button :icon="RefreshCw" :loading="loadingStores" @click="loadStores">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openStoreForm()">新增门店</el-button>
</div>
</header>
<div class="store-layout">
<aside class="store-list" aria-label="门店列表">
<button
v-for="store in filteredStores"
:key="store.id"
class="store-card"
:class="{ active: selectedStoreId === store.id }"
type="button"
@click="selectStore(store.id)"
>
<span class="store-card-title">
<strong>{{ store.name }}</strong>
<el-tag :type="storeStatusType(store.businessStatus)" size="small">
{{ storeStatusLabel(store.businessStatus) }}
</el-tag>
</span>
<span>{{ [store.city, store.district].filter(Boolean).join(' · ') || '未填写地区' }}</span>
<small>{{ store.address || '未填写地址' }}</small>
</button>
<el-empty v-if="!loadingStores && filteredStores.length === 0" description="没有匹配门店" />
</aside>
<div v-if="selectedStore" class="store-detail">
<header class="store-detail-header">
<div>
<p class="section-kicker">门店 #{{ selectedStore.id }}</p>
<h3>{{ selectedStore.name }}</h3>
<p>{{ selectedStore.address || '未填写地址' }}</p>
</div>
<div class="row-actions">
<el-button :icon="Edit3" @click="openStoreForm(selectedStore)">编辑门店</el-button>
<el-button type="danger" plain :icon="Archive" @click="archiveStore(selectedStore)">
归档
</el-button>
</div>
</header>
<div class="detail-grid store-summary-grid">
<span><small>联系电话</small><strong>{{ selectedStore.contactPhone || '-' }}</strong></span>
<span><small>时区</small><strong>{{ selectedStore.timezone }}</strong></span>
<span><small>访客 Wi-Fi</small><strong>{{ wifiSummary(selectedStore) }}</strong></span>
<span><small>通知地址</small><strong>{{ selectedStore.notificationUrl || '-' }}</strong></span>
</div>
<section class="business-hours-board">
<strong>营业时段</strong>
<div class="hours-chip-list">
<span v-for="hour in normalizedHours(selectedStore.businessHours)" :key="hour.weekday">
{{ weekdayLabel(hour.weekday) }}
<b>{{ hour.isClosed ? '休息' : `${minuteLabel(hour.openMinute)}${minuteLabel(hour.closeMinute)}` }}</b>
</span>
</div>
</section>
<header class="rooms-toolbar">
<div>
<p class="section-kicker">房间配置</p>
<h3>{{ rooms.length }} 个房间</h3>
</div>
<div class="toolbar-actions">
<el-input v-model="roomSearch" class="search-input" clearable placeholder="搜索房号、名称、分类" />
<el-select v-model="roomStatusFilter" class="filter-select" placeholder="房态">
<el-option label="全部房态" value="" />
<el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-button :icon="RefreshCw" :loading="loadingRooms" @click="loadRooms">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openRoomForm()">新增房间</el-button>
</div>
</header>
<el-table v-loading="loadingRooms" :data="filteredRooms" class="data-table" row-key="id">
<el-table-column label="房间" min-width="180">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.roomNo }} · {{ row.name }}</strong>
<span>{{ row.categoryName }} · {{ row.capacity }} </span>
</div>
</template>
</el-table-column>
<el-table-column label="价格" min-width="150">
<template #default="{ row }">
<div class="stack">
<strong>{{ money(row.basePriceCents) }}/时段</strong>
<span>节假日 {{ money(row.holidayPriceCents) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="状态" min-width="150">
<template #default="{ row }">
<div class="profile-badges">
<el-tag :type="row.configurationStatus === 'ENABLED' ? 'success' : 'info'" size="small">
{{ row.configurationStatus === 'ENABLED' ? '已启用' : '已停用' }}
</el-tag>
<el-tag :type="roomStatusType(row.operationalStatus)" size="small">
{{ roomStatusLabel(row.operationalStatus) }}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="标签" min-width="150">
<template #default="{ row }">
<div class="profile-badges">
<el-tag v-for="tag in row.tags.slice(0, 3)" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
<span v-if="row.tags.length === 0">-</span>
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="285" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openRoomForm(row)">编辑</el-button>
<el-button link type="primary" @click="openStatusForm(row)">改状态</el-button>
<el-button link @click="openDisabledPeriodForm(row)">停用时段</el-button>
<el-button link type="danger" @click="archiveRoom(row)">归档</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<el-empty v-else class="store-detail-empty" description="请选择门店查看详情与房间" />
</div>
</section>
<el-dialog v-model="storeDialog.open" :title="storeDialog.id ? '编辑门店' : '新增门店'" width="min(860px, 94vw)">
<el-form label-position="top" class="asset-form">
<el-form-item label="门店名称"><el-input v-model="storeForm.name" maxlength="128" /></el-form-item>
<el-form-item label="营业状态">
<el-select v-model="storeForm.businessStatus">
<el-option label="营业中" value="OPEN" />
<el-option label="已闭店" value="CLOSED" />
<el-option label="暂停营业" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item label="城市"><el-input v-model="storeForm.city" /></el-form-item>
<el-form-item label="区县"><el-input v-model="storeForm.district" /></el-form-item>
<el-form-item label="详细地址" class="form-span-2"><el-input v-model="storeForm.address" /></el-form-item>
<el-form-item label="联系电话"><el-input v-model="storeForm.contactPhone" /></el-form-item>
<el-form-item label="时区"><el-input v-model="storeForm.timezone" /></el-form-item>
<el-form-item label="经度"><el-input-number v-model="storeForm.longitude" :min="-180" :max="180" :precision="6" controls-position="right" /></el-form-item>
<el-form-item label="纬度"><el-input-number v-model="storeForm.latitude" :min="-90" :max="90" :precision="6" controls-position="right" /></el-form-item>
<el-form-item label="Wi-Fi 名称"><el-input v-model="storeForm.wifiSsid" /></el-form-item>
<el-form-item :label="storeDialog.id ? 'Wi-Fi 新密码(留空保持原值)' : 'Wi-Fi 密码'">
<el-input v-model="storeForm.wifiPassword" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item label="通知地址" class="form-span-2"><el-input v-model="storeForm.notificationUrl" placeholder="https://..." /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="storeForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
</el-form>
<section class="hours-editor">
<strong>每周营业时间</strong>
<div v-for="hour in storeForm.businessHours" :key="hour.weekday" class="hours-editor-row">
<span>{{ weekdayLabel(hour.weekday) }}</span>
<el-switch v-model="hour.isClosed" active-text="休息" inactive-text="营业" />
<el-time-select v-model="hour.openTime" :disabled="hour.isClosed" start="00:00" step="00:15" end="23:45" placeholder="开门" />
<el-time-select v-model="hour.closeTime" :disabled="hour.isClosed" start="00:00" step="00:15" end="23:45" placeholder="闭店" />
</div>
</section>
<template #footer>
<el-button @click="storeDialog.open = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveStore">保存门店</el-button>
</template>
</el-dialog>
<el-dialog v-model="roomDialog.open" :title="roomDialog.id ? '编辑房间' : '新增房间'" width="min(900px, 94vw)">
<el-form label-position="top" class="asset-form room-form">
<el-form-item label="房号"><el-input v-model="roomForm.roomNo" /></el-form-item>
<el-form-item label="房间名称"><el-input v-model="roomForm.name" /></el-form-item>
<el-form-item label="分类"><el-input v-model="roomForm.categoryName" /></el-form-item>
<el-form-item label="容纳人数"><el-input-number v-model="roomForm.capacity" :min="1" :max="100" /></el-form-item>
<el-form-item label="基础价(元)"><el-input-number v-model="roomForm.basePriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="工作日价(元)"><el-input-number v-model="roomForm.weekdayPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="节假日价(元)"><el-input-number v-model="roomForm.holidayPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="过夜价(元)"><el-input-number v-model="roomForm.overnightPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="全天价(元)"><el-input-number v-model="roomForm.fullDayPriceYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="最低消费(元)"><el-input-number v-model="roomForm.minimumSpendYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="押金(元)"><el-input-number v-model="roomForm.depositYuan" :min="0" :precision="2" /></el-form-item>
<el-form-item label="最短预订(分钟)"><el-input-number v-model="roomForm.minimumMinutes" :min="15" :max="1440" :step="15" /></el-form-item>
<el-form-item label="最早提前入场(分钟)"><el-input-number v-model="roomForm.maxAdvanceStartMinutes" :min="0" :max="1440" /></el-form-item>
<el-form-item label="最多提前预订(天)"><el-input-number v-model="roomForm.maxAdvanceDays" :min="0" :max="365" /></el-form-item>
<el-form-item label="配置状态">
<el-select v-model="roomForm.configurationStatus"><el-option label="启用" value="ENABLED" /><el-option label="停用" value="DISABLED" /></el-select>
</el-form-item>
<el-form-item label="运营房态">
<el-select v-model="roomForm.operationalStatus"><el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select>
</el-form-item>
<el-form-item label="标签" class="form-span-2">
<el-select v-model="roomForm.tags" multiple filterable allow-create default-first-option placeholder="输入后回车添加标签" />
</el-form-item>
<el-form-item label="图片 URL(每行一个)" class="form-span-2"><el-input v-model="roomForm.imagesText" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="roomForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="roomDialog.open = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveRoom">保存房间</el-button>
</template>
</el-dialog>
<el-dialog v-model="statusDialog.open" title="变更房间状态" width="min(520px, 94vw)">
<el-form label-position="top">
<el-form-item label="配置状态"><el-select v-model="statusDialog.configurationStatus" clearable><el-option label="启用" value="ENABLED" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
<el-form-item label="运营房态"><el-select v-model="statusDialog.operationalStatus" clearable><el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="变更原因"><el-input v-model="statusDialog.reason" type="textarea" maxlength="255" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="statusDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRoomStatus">确认变更</el-button></template>
</el-dialog>
<el-dialog v-model="periodDialog.open" title="新增房间停用时段" width="min(560px, 94vw)">
<el-form label-position="top">
<el-form-item label="开始时间"><el-date-picker v-model="periodDialog.startsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="periodDialog.endsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
<el-form-item label="原因"><el-input v-model="periodDialog.reason" maxlength="255" /></el-form-item>
</el-form>
<template #footer><el-button @click="periodDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveDisabledPeriod">保存时段</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Archive, Edit3, Plus, RefreshCw } from '@lucide/vue';
import {
ApiError,
addManagedRoomDisabledPeriod,
archiveManagedRoom,
archiveManagedStore,
createManagedRoom,
createManagedStore,
listManagedRooms,
listManagedStores,
updateManagedRoom,
updateManagedRoomStatus,
updateManagedStore,
type ApiSession
} from '../api';
import { money } from '../format';
import type {
ManagedRoom,
ManagedStore,
RoomConfigurationStatus,
RoomInput,
RoomOperationalStatus,
StoreInput
} from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const rooms = ref<ManagedRoom[]>([]);
const selectedStoreId = ref('');
const search = ref('');
const statusFilter = ref<ManagedStore['businessStatus'] | ''>('');
const roomSearch = ref('');
const roomStatusFilter = ref<RoomOperationalStatus | ''>('');
const loadingStores = ref(false);
const loadingRooms = ref(false);
const saving = ref(false);
const lastError = ref('');
const storeDialog = reactive({ open: false, id: '' });
const roomDialog = reactive({ open: false, id: '' });
const statusDialog = reactive<{
open: boolean; roomId: string; configurationStatus: RoomConfigurationStatus | '';
operationalStatus: RoomOperationalStatus | ''; reason: string;
}>({ open: false, roomId: '', configurationStatus: '', operationalStatus: '', reason: '' });
const periodDialog = reactive({ open: false, roomId: '', startsAt: '', endsAt: '', reason: '' });
const operationalStatuses: Array<{ value: RoomOperationalStatus; label: string }> = [
{ value: 'AVAILABLE', label: '空闲' },
{ value: 'MAINTENANCE', label: '维护中' },
{ value: 'RESERVED', label: '已预订' },
{ value: 'IN_USE', label: '使用中' },
{ value: 'CLEANING_REQUIRED', label: '待清洁' }
];
type StoreHourForm = { weekday: number; openTime: string; closeTime: string; isClosed: boolean };
const storeForm = reactive({
name: '', address: '', city: '', district: '', longitude: null as number | null,
latitude: null as number | null, contactPhone: '', timezone: 'Asia/Shanghai',
businessStatus: 'OPEN' as ManagedStore['businessStatus'], wifiSsid: '', wifiPassword: '',
notificationUrl: '', sortOrder: 0, businessHours: defaultHourForms() as StoreHourForm[]
});
const roomForm = reactive({
categoryName: '标准间', name: '', roomNo: '', capacity: 4,
basePriceYuan: 0, weekdayPriceYuan: 0, holidayPriceYuan: 0, overnightPriceYuan: 0,
fullDayPriceYuan: 0, minimumSpendYuan: 0, depositYuan: 0, minimumMinutes: 60,
maxAdvanceStartMinutes: 0, maxAdvanceDays: 30,
configurationStatus: 'ENABLED' as RoomConfigurationStatus,
operationalStatus: 'AVAILABLE' as RoomOperationalStatus,
tags: [] as string[], imagesText: '', sortOrder: 0
});
const filteredStores = computed(() => {
const keyword = search.value.trim().toLowerCase();
return stores.value.filter((store) => {
const matchesStatus = !statusFilter.value || store.businessStatus === statusFilter.value;
const haystack = `${store.name} ${store.city} ${store.district} ${store.address}`.toLowerCase();
return matchesStatus && (!keyword || haystack.includes(keyword));
});
});
const selectedStore = computed(() => stores.value.find((store) => store.id === selectedStoreId.value));
const filteredRooms = computed(() => {
const keyword = roomSearch.value.trim().toLowerCase();
return rooms.value.filter((room) => {
const matchesStatus = !roomStatusFilter.value || room.operationalStatus === roomStatusFilter.value;
const haystack = `${room.roomNo} ${room.name} ${room.categoryName} ${room.tags.join(' ')}`.toLowerCase();
return matchesStatus && (!keyword || haystack.includes(keyword));
});
});
async function runAction<T>(work: () => Promise<T>, success?: string) {
lastError.value = '';
try {
const result = await work();
if (success) ElMessage.success(success);
return result;
} catch (error) {
lastError.value = error instanceof ApiError
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
: error instanceof Error ? error.message : '操作失败';
throw error;
}
}
async function loadStores() {
if (!props.session.token) return;
loadingStores.value = true;
try {
const result = await runAction(() => listManagedStores(props.session));
stores.value = result;
if (!result.some((store) => store.id === selectedStoreId.value)) {
selectedStoreId.value = result[0]?.id || '';
}
if (selectedStoreId.value) await loadRooms();
} catch { /* error already displayed */ } finally { loadingStores.value = false; }
}
async function loadRooms() {
if (!selectedStoreId.value || !props.session.token) { rooms.value = []; return; }
loadingRooms.value = true;
try { rooms.value = await runAction(() => listManagedRooms(props.session, selectedStoreId.value)); }
catch { rooms.value = []; }
finally { loadingRooms.value = false; }
}
function selectStore(storeId: string) {
if (selectedStoreId.value === storeId) return;
selectedStoreId.value = storeId;
roomSearch.value = '';
roomStatusFilter.value = '';
void loadRooms();
}
function openStoreForm(store?: ManagedStore) {
storeDialog.id = store?.id || '';
Object.assign(storeForm, {
name: store?.name || '', address: store?.address || '', city: store?.city || '',
district: store?.district || '', longitude: store?.longitude ?? null,
latitude: store?.latitude ?? null, contactPhone: store?.contactPhone || '',
timezone: store?.timezone || 'Asia/Shanghai', businessStatus: store?.businessStatus || 'OPEN',
wifiSsid: store?.wifiSsid || '', wifiPassword: '', notificationUrl: store?.notificationUrl || '',
sortOrder: store?.sortOrder || 0,
businessHours: normalizedHours(store?.businessHours || []).map((hour) => ({
weekday: hour.weekday, openTime: minuteLabel(hour.openMinute),
closeTime: minuteLabel(hour.closeMinute), isClosed: hour.isClosed
}))
});
storeDialog.open = true;
}
async function saveStore() {
if (!storeForm.name.trim()) return ElMessage.warning('请填写门店名称');
saving.value = true;
const input: StoreInput = {
name: storeForm.name.trim(), address: storeForm.address.trim(), city: storeForm.city.trim(),
district: storeForm.district.trim(), longitude: storeForm.longitude,
latitude: storeForm.latitude, contactPhone: storeForm.contactPhone.trim(),
timezone: storeForm.timezone.trim() || 'Asia/Shanghai', businessStatus: storeForm.businessStatus,
wifiSsid: storeForm.wifiSsid.trim(), notificationUrl: storeForm.notificationUrl.trim(),
sortOrder: storeForm.sortOrder,
businessHours: storeForm.businessHours.map((hour) => ({
weekday: hour.weekday, openMinute: timeToMinute(hour.openTime),
closeMinute: timeToMinute(hour.closeTime), isClosed: hour.isClosed
}))
};
if (storeForm.wifiPassword) input.wifiPassword = storeForm.wifiPassword;
try {
const result = storeDialog.id
? await runAction(() => updateManagedStore(props.session, storeDialog.id, input), '门店已更新')
: await runAction(() => createManagedStore(props.session, input), '门店已创建');
storeDialog.open = false;
selectedStoreId.value = result.storeId;
await loadStores();
} catch { /* error already displayed */ } finally { saving.value = false; }
}
async function archiveStore(store: ManagedStore) {
try {
await ElMessageBox.confirm('仅无未归档房间的门店可归档,操作会写入审计日志。', `归档 ${store.name}`, { type: 'warning' });
await runAction(() => archiveManagedStore(props.session, store.id), '门店已归档');
await loadStores();
} catch (error) { if (error !== 'cancel' && error !== 'close' && error instanceof ApiError) lastError.value = error.code; }
}
function openRoomForm(room?: ManagedRoom) {
roomDialog.id = room?.id || '';
Object.assign(roomForm, {
categoryName: room?.categoryName || '标准间', name: room?.name || '', roomNo: room?.roomNo || '',
capacity: room?.capacity || 4, basePriceYuan: centsToYuan(room?.basePriceCents),
weekdayPriceYuan: centsToYuan(room?.weekdayPriceCents), holidayPriceYuan: centsToYuan(room?.holidayPriceCents),
overnightPriceYuan: centsToYuan(room?.overnightPriceCents), fullDayPriceYuan: centsToYuan(room?.fullDayPriceCents),
minimumSpendYuan: centsToYuan(room?.minimumSpendCents), depositYuan: centsToYuan(room?.depositCents),
minimumMinutes: room?.minimumMinutes || 60, maxAdvanceStartMinutes: room?.maxAdvanceStartMinutes || 0,
maxAdvanceDays: room?.maxAdvanceDays ?? 30, configurationStatus: room?.configurationStatus || 'ENABLED',
operationalStatus: room?.operationalStatus || 'AVAILABLE', tags: [...(room?.tags || [])],
imagesText: (room?.images || []).join('\n'), sortOrder: room?.sortOrder || 0
});
roomDialog.open = true;
}
async function saveRoom() {
if (!selectedStoreId.value || !roomForm.name.trim() || !roomForm.roomNo.trim()) {
return ElMessage.warning('请填写房号和房间名称');
}
const input: RoomInput = {
storeId: selectedStoreId.value, categoryName: roomForm.categoryName.trim(),
name: roomForm.name.trim(), roomNo: roomForm.roomNo.trim(), capacity: roomForm.capacity,
basePriceCents: yuanToCents(roomForm.basePriceYuan), weekdayPriceCents: yuanToCents(roomForm.weekdayPriceYuan),
holidayPriceCents: yuanToCents(roomForm.holidayPriceYuan), overnightPriceCents: yuanToCents(roomForm.overnightPriceYuan),
fullDayPriceCents: yuanToCents(roomForm.fullDayPriceYuan), minimumSpendCents: yuanToCents(roomForm.minimumSpendYuan),
depositCents: yuanToCents(roomForm.depositYuan), minimumMinutes: roomForm.minimumMinutes,
maxAdvanceStartMinutes: roomForm.maxAdvanceStartMinutes, maxAdvanceDays: roomForm.maxAdvanceDays,
configurationStatus: roomForm.configurationStatus, operationalStatus: roomForm.operationalStatus,
tags: roomForm.tags.map((item) => item.trim()).filter(Boolean),
images: roomForm.imagesText.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
sortOrder: roomForm.sortOrder
};
saving.value = true;
try {
if (roomDialog.id) await runAction(() => updateManagedRoom(props.session, roomDialog.id, input), '房间已更新');
else await runAction(() => createManagedRoom(props.session, input), '房间已创建');
roomDialog.open = false;
await loadRooms();
} catch { /* error already displayed */ } finally { saving.value = false; }
}
function openStatusForm(room: ManagedRoom) {
Object.assign(statusDialog, {
open: true, roomId: room.id, configurationStatus: room.configurationStatus,
operationalStatus: room.operationalStatus, reason: ''
});
}
async function saveRoomStatus() {
if (!statusDialog.reason.trim()) return ElMessage.warning('请填写状态变更原因');
saving.value = true;
try {
await runAction(() => updateManagedRoomStatus(props.session, statusDialog.roomId, {
storeId: selectedStoreId.value,
configurationStatus: statusDialog.configurationStatus || undefined,
operationalStatus: statusDialog.operationalStatus || undefined,
reason: statusDialog.reason.trim()
}), '房间状态已更新');
statusDialog.open = false;
await loadRooms();
} catch { /* error already displayed */ } finally { saving.value = false; }
}
function openDisabledPeriodForm(room: ManagedRoom) {
Object.assign(periodDialog, { open: true, roomId: room.id, startsAt: '', endsAt: '', reason: '' });
}
async function saveDisabledPeriod() {
if (!periodDialog.startsAt || !periodDialog.endsAt) return ElMessage.warning('请选择完整停用时段');
saving.value = true;
try {
await runAction(() => addManagedRoomDisabledPeriod(props.session, periodDialog.roomId, {
storeId: selectedStoreId.value, startsAt: periodDialog.startsAt,
endsAt: periodDialog.endsAt, reason: periodDialog.reason.trim()
}), '停用时段已添加');
periodDialog.open = false;
} catch { /* error already displayed */ } finally { saving.value = false; }
}
async function archiveRoom(room: ManagedRoom) {
try {
await ElMessageBox.confirm('归档后该房间不再出现在可用房间列表。', `归档 ${room.roomNo}`, { type: 'warning' });
await runAction(() => archiveManagedRoom(props.session, room.id, selectedStoreId.value), '房间已归档');
await loadRooms();
} catch (error) { if (error !== 'cancel' && error !== 'close' && error instanceof ApiError) lastError.value = error.code; }
}
function defaultHourForms(): StoreHourForm[] {
return Array.from({ length: 7 }, (_, index) => ({
weekday: index + 1, openTime: '09:00', closeTime: '22:00', isClosed: false
}));
}
function normalizedHours(hours: ManagedStore['businessHours']) {
const byDay = new Map(hours.map((hour) => [hour.weekday, hour]));
return Array.from({ length: 7 }, (_, index) => byDay.get(index + 1) || {
weekday: index + 1, openMinute: 540, closeMinute: 1320, isClosed: false
});
}
function weekdayLabel(weekday: number) { return ['一', '二', '三', '四', '五', '六', '日'][weekday - 1] ? `${['一', '二', '三', '四', '五', '六', '日'][weekday - 1]}` : `${weekday}`; }
function minuteLabel(value: number) { return `${String(Math.floor(value / 60)).padStart(2, '0')}:${String(value % 60).padStart(2, '0')}`; }
function timeToMinute(value: string) { const [hour, minute] = value.split(':').map(Number); return (hour || 0) * 60 + (minute || 0); }
function centsToYuan(value?: number) { return Number(((value || 0) / 100).toFixed(2)); }
function yuanToCents(value: number) { return Math.round(Number(value || 0) * 100); }
function storeStatusLabel(status: ManagedStore['businessStatus']) { return ({ OPEN: '营业中', CLOSED: '已闭店', SUSPENDED: '暂停营业' } as const)[status]; }
function storeStatusType(status: ManagedStore['businessStatus']) { return status === 'OPEN' ? 'success' : status === 'SUSPENDED' ? 'warning' : 'info'; }
function roomStatusLabel(status: RoomOperationalStatus) { return operationalStatuses.find((item) => item.value === status)?.label || status; }
function roomStatusType(status: RoomOperationalStatus) { return status === 'AVAILABLE' ? 'success' : status === 'MAINTENANCE' || status === 'CLEANING_REQUIRED' ? 'warning' : 'primary'; }
function wifiSummary(store: ManagedStore) { return store.wifiSsid ? `${store.wifiSsid}${store.wifiConfigured ? ' · 已设密码' : ''}` : '未配置'; }
watch(() => props.session.token, (token) => { if (token) void loadStores(); }, { immediate: true });
</script>
+139
View File
@@ -0,0 +1,139 @@
<template>
<section class="third-party-page">
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
<el-alert v-if="lastMessage" :title="lastMessage" type="success" show-icon closable @close="lastMessage = ''" />
<section class="third-party-metrics">
<span><small>直订记录</small><strong>{{ records.bookings.length }}</strong></span>
<span><small>待映射直订</small><strong>{{ pendingMappingCount }}</strong></span>
<span><small>核销记录</small><strong>{{ records.redemptions.length }}</strong></span>
<span><small>失败 / 待处理</small><strong>{{ attentionCount }}</strong></span>
</section>
<section class="panel">
<header class="panel-toolbar third-party-toolbar">
<div><p class="section-kicker">M08-D · 渠道运营</p><h3>团购与第三方平台</h3></div>
<div class="toolbar-actions">
<el-select v-model="provider" class="filter-select" placeholder="全部平台" @change="applyFilters"><el-option label="全部平台" value="" /><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部授权门店" @change="applyFilters"><el-option label="全部授权门店" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select>
<el-input v-model="status" class="search-input" clearable placeholder="状态精确筛选" @keyup.enter="applyFilters" />
<el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button>
</div>
</header>
<el-tabs v-model="activeTab" class="third-party-tabs">
<el-tab-pane label="直订记录" name="bookings">
<el-table v-loading="loading" :data="records.bookings" class="data-table" row-key="id">
<el-table-column label="平台订单" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.externalBookingNo }}</strong><span>{{ providerLabel(row.provider) }} · #{{ row.id }}</span></div></template></el-table-column>
<el-table-column label="门店 / 房间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.storeId ? storeName(row.storeId) : '待映射门店' }}</strong><span>{{ row.roomId ? roomName(row.roomId) : '待映射房间' }}</span></div></template></el-table-column>
<el-table-column label="使用时段" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.startsAt) }}</strong><span> {{ formatDate(row.endsAt) }}</span></div></template></el-table-column>
<el-table-column label="金额" width="120"><template #default="{ row }">{{ money(row.amountCents) }}</template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="recordStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="本地订单 / 异常" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.orderId ? `#${row.orderId}` : '-' }}</strong><span>{{ row.failureCode || formatDate(row.createdAt) }}</span></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="核销记录" name="redemptions">
<div class="tab-action-row"><span>券码只显示脱敏结果真实券码不会进入列表或日志</span><el-button type="primary" :icon="ScanLine" @click="openRedeem">验券</el-button></div>
<el-table v-loading="loading" :data="records.redemptions" class="data-table" row-key="id">
<el-table-column label="券码" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.voucherMasked }}</strong><span>{{ providerLabel(row.provider) }} · {{ row.mode }}</span></div></template></el-table-column>
<el-table-column label="订单 / 门店" min-width="180"><template #default="{ row }"><div class="stack"><strong>订单 #{{ row.orderId }}</strong><span>{{ storeName(row.storeId) }}</span></div></template></el-table-column>
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="recordStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
<el-table-column label="操作人" width="120"><template #default="{ row }">{{ row.actorId ? `#${row.actorId}` : '-' }}</template></el-table-column>
<el-table-column label="完成时间 / 异常" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.completedAt || row.createdAt) }}</strong><span>{{ row.failureCode || '-' }}</span></div></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="平台配置" name="configs">
<div class="tab-action-row"><span>只登记环境凭据引用敏感设置键由服务端过滤不在读取接口返回</span><el-button type="primary" :icon="Plus" @click="openConfig">配置平台</el-button></div>
<el-table :data="setup.configs" class="data-table" row-key="id">
<el-table-column label="平台" min-width="130"><template #default="{ row }"><strong>{{ providerLabel(row.provider) }}</strong></template></el-table-column>
<el-table-column label="范围" min-width="160"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '租户默认' }}</template></el-table-column>
<el-table-column prop="mode" label="模式" width="110" />
<el-table-column label="凭据" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.credentialConfigured ? '已配置引用' : '未配置' }}</strong><span>{{ row.credentialRef || '-' }}</span></div></template></el-table-column>
<el-table-column label="安全设置" min-width="240"><template #default="{ row }"><code class="settings-preview">{{ JSON.stringify(row.settings) }}</code></template></el-table-column>
<el-table-column label="启用" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="资源映射" name="mappings">
<div class="tab-action-row"><span>外部门店与房间必须映射到同一租户下的真实资源</span><el-button type="primary" :icon="Link2" @click="openMapping">新增映射</el-button></div>
<el-table :data="setup.mappings" class="data-table" row-key="id">
<el-table-column label="平台" min-width="130"><template #default="{ row }">{{ providerLabel(row.provider) }}</template></el-table-column>
<el-table-column prop="resourceType" label="资源类型" width="120" />
<el-table-column prop="externalRef" label="外部标识" min-width="220" />
<el-table-column label="本地资源" min-width="220"><template #default="{ row }">{{ mappingResourceName(row.resourceType, row.localResourceId) }}</template></el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="redeemDialog" title="团购验券" width="min(580px, 94vw)">
<el-form label-position="top"><el-form-item label="平台"><el-select v-model="redeemForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="订单 ID"><el-input v-model="redeemForm.orderId" /></el-form-item><el-form-item label="券码"><el-input v-model="redeemForm.voucherCode" type="password" show-password autocomplete="off" /></el-form-item><el-form-item label="核销方式"><el-radio-group v-model="redeemForm.mode"><el-radio value="API">平台 API/Mock</el-radio><el-radio value="MANUAL">人工确认</el-radio></el-radio-group></el-form-item><template v-if="redeemForm.mode === 'MANUAL'"><el-form-item label="确认金额(元)"><el-input-number v-model="redeemForm.amountYuan" :min="0.01" :precision="2" /></el-form-item><el-form-item label="人工确认备注"><el-input v-model="redeemForm.note" /></el-form-item></template></el-form>
<template #footer><el-button @click="redeemDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRedemption">确认验券</el-button></template>
</el-dialog>
<el-dialog v-model="configDialog" title="配置第三方平台" width="min(650px, 94vw)">
<el-form label-position="top" class="asset-form"><el-form-item label="平台"><el-select v-model="configForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="门店范围"><el-select v-model="configForm.storeId" clearable><el-option label="租户默认" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="模式"><el-select v-model="configForm.mode"><el-option label="人工" value="MANUAL" /><el-option label="非生产 Mock" value="MOCK" /><el-option label="平台 API" value="API" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="configForm.enabled" /></el-form-item><el-form-item label="凭据环境引用" class="form-span-2"><el-input v-model="configForm.credentialRef" placeholder="env:MEITUAN_STORE_001" /></el-form-item><el-form-item label="非敏感设置 JSON" class="form-span-2"><el-input v-model="configForm.settingsText" type="textarea" :rows="5" placeholder='{"redeemEndpoint":"https://..."}' /></el-form-item></el-form>
<template #footer><el-button @click="configDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button></template>
</el-dialog>
<el-dialog v-model="mappingDialog" title="新增或更新资源映射" width="min(620px, 94vw)">
<el-form label-position="top"><el-form-item label="平台"><el-select v-model="mappingForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="资源类型"><el-radio-group v-model="mappingForm.resourceType" @change="mappingForm.localResourceId = ''"><el-radio value="STORE">门店</el-radio><el-radio value="ROOM">房间</el-radio></el-radio-group></el-form-item><el-form-item label="外部资源标识"><el-input v-model="mappingForm.externalRef" /></el-form-item><el-form-item label="本地门店"><el-select v-model="mappingForm.storeId" filterable @change="loadMappingRooms"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item v-if="mappingForm.resourceType === 'ROOM'" label="本地房间"><el-select v-model="mappingForm.localResourceId" filterable><el-option v-for="room in mappingRooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item></el-form>
<template #footer><el-button @click="mappingDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMapping">保存映射</el-button></template>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { ElMessage } from 'element-plus';
import { Link2, Plus, RefreshCw, ScanLine } from '@lucide/vue';
import { ApiError, getThirdPartySetup, listManagedRooms, listManagedStores, listThirdPartyRecords, redeemGroupVoucher, redeemGroupVoucherManually, saveThirdPartyConfig, saveThirdPartyMapping, type ApiSession } from '../api';
import { money } from '../format';
import type { ManagedRoom, ManagedStore, ThirdPartyMode, ThirdPartyProvider, ThirdPartyRecords, ThirdPartySetup } from '../types';
const props = defineProps<{ session: ApiSession }>();
const stores = ref<ManagedStore[]>([]);
const allRooms = ref<ManagedRoom[]>([]);
const mappingRooms = ref<ManagedRoom[]>([]);
const records = reactive<ThirdPartyRecords>({ bookings: [], redemptions: [] });
const setup = reactive<ThirdPartySetup>({ configs: [], mappings: [] });
const provider = ref<ThirdPartyProvider | ''>('');
const storeId = ref('');
const status = ref('');
const activeTab = ref('bookings');
const loading = ref(false);
const saving = ref(false);
const lastError = ref('');
const lastMessage = ref('');
const redeemDialog = ref(false);
const configDialog = ref(false);
const mappingDialog = ref(false);
const providerOptions: Array<{ value: ThirdPartyProvider; label: string }> = [{ value: 'MEITUAN', label: '美团' }, { value: 'DIANPING', label: '大众点评' }, { value: 'DOUYIN', label: '抖音' }, { value: 'KUAISHOU', label: '快手' }];
const redeemForm = reactive({ provider: 'MEITUAN' as ThirdPartyProvider, orderId: '', voucherCode: '', mode: 'API' as 'API' | 'MANUAL', amountYuan: 0, note: '' });
const configForm = reactive({ provider: 'MEITUAN' as ThirdPartyProvider, storeId: '', mode: 'MANUAL' as ThirdPartyMode, enabled: true, credentialRef: '', settingsText: '{}' });
const mappingForm = reactive({ provider: 'MEITUAN' as ThirdPartyProvider, resourceType: 'STORE' as 'STORE' | 'ROOM', externalRef: '', storeId: '', localResourceId: '' });
const pendingMappingCount = computed(() => records.bookings.filter((item) => item.status === 'PENDING_MAPPING' || !item.storeId || !item.roomId).length);
const attentionCount = computed(() => [...records.bookings, ...records.redemptions].filter((item) => ['FAILED', 'PENDING', 'PENDING_MAPPING', 'MANUAL_REVIEW'].includes(item.status)).length);
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
async function loadWorkspace() { try { stores.value = await capture(() => listManagedStores(props.session)); const roomLists = await Promise.all(stores.value.map((store) => listManagedRooms(props.session, store.id))); allRooms.value = roomLists.flat(); await loadData(); } catch { /* displayed */ } }
async function loadData() { if (!props.session.token) return; loading.value = true; try { const [nextRecords, nextSetup] = await Promise.all([capture(() => listThirdPartyRecords(props.session, { provider: provider.value || undefined, status: status.value.trim() || undefined, storeId: storeId.value || undefined })), capture(() => getThirdPartySetup(props.session, provider.value || undefined))]); Object.assign(records, nextRecords); Object.assign(setup, nextSetup); } catch { /* displayed */ } finally { loading.value = false; } }
function applyFilters() { void loadData(); }
function openRedeem() { Object.assign(redeemForm, { provider: provider.value || 'MEITUAN', orderId: '', voucherCode: '', mode: 'API', amountYuan: 0, note: '' }); redeemDialog.value = true; }
async function saveRedemption() { if (!redeemForm.orderId || redeemForm.voucherCode.length < 4) return ElMessage.warning('请填写订单 ID 和券码'); saving.value = true; try { const clientRequestId = requestId('redeem'); const result = redeemForm.mode === 'MANUAL' ? await capture(() => redeemGroupVoucherManually(props.session, { provider: redeemForm.provider, orderId: redeemForm.orderId, voucherCode: redeemForm.voucherCode, amountCents: Math.round(redeemForm.amountYuan * 100), note: redeemForm.note, clientRequestId })) : await capture(() => redeemGroupVoucher(props.session, { provider: redeemForm.provider, orderId: redeemForm.orderId, voucherCode: redeemForm.voucherCode, clientRequestId })); redeemForm.voucherCode = ''; redeemDialog.value = false; lastMessage.value = `核销 #${result.redemptionId} 状态 ${result.status},券码 ${result.voucherMasked || '已脱敏'}`; await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
function openConfig() { Object.assign(configForm, { provider: provider.value || 'MEITUAN', storeId: storeId.value, mode: 'MANUAL', enabled: true, credentialRef: '', settingsText: '{}' }); configDialog.value = true; }
async function saveConfig() { let settings: Record<string, unknown>; try { const parsed: unknown = JSON.parse(configForm.settingsText || '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); settings = parsed as Record<string, unknown>; } catch { return ElMessage.warning('设置必须是 JSON 对象'); } saving.value = true; try { await capture(() => saveThirdPartyConfig(props.session, { provider: configForm.provider, storeId: configForm.storeId || null, mode: configForm.mode, enabled: configForm.enabled, credentialRef: configForm.credentialRef.trim(), settings })); configDialog.value = false; ElMessage.success('第三方配置已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
function openMapping() { Object.assign(mappingForm, { provider: provider.value || 'MEITUAN', resourceType: 'STORE', externalRef: '', storeId: storeId.value || stores.value[0]?.id || '', localResourceId: '' }); mappingDialog.value = true; void loadMappingRooms(); }
async function loadMappingRooms() { mappingRooms.value = allRooms.value.filter((room) => room.storeId === mappingForm.storeId); if (mappingForm.resourceType === 'STORE') mappingForm.localResourceId = mappingForm.storeId; }
async function saveMapping() { const localResourceId = mappingForm.resourceType === 'STORE' ? mappingForm.storeId : mappingForm.localResourceId; if (!mappingForm.externalRef.trim() || !localResourceId) return ElMessage.warning('请完整填写外部标识和本地资源'); saving.value = true; try { await capture(() => saveThirdPartyMapping(props.session, { provider: mappingForm.provider, resourceType: mappingForm.resourceType, externalRef: mappingForm.externalRef.trim(), localResourceId })); mappingDialog.value = false; ElMessage.success('资源映射已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
function providerLabel(value: ThirdPartyProvider) { return providerOptions.find((item) => item.value === value)?.label || value; }
function storeName(id: string) { return stores.value.find((item) => item.id === String(id))?.name || `门店 #${id}`; }
function roomName(id: string) { const room = allRooms.value.find((item) => item.id === String(id)); return room ? `${room.roomNo} · ${room.name}` : `房间 #${id}`; }
function mappingResourceName(type: 'STORE' | 'ROOM', id: string) { return type === 'STORE' ? storeName(id) : roomName(id); }
function recordStatusType(value: string) { return ['SUCCEEDED', 'READY_TO_CLAIM', 'CLAIMED'].includes(value) ? 'success' : ['FAILED', 'REJECTED'].includes(value) ? 'danger' : 'warning'; }
function formatDate(value: string | null) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; }
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
</script>
+19
View File
@@ -0,0 +1,19 @@
export function money(cents: number) {
return `¥${(Number(cents || 0) / 100).toFixed(2)}`;
}
export function shortDate(value?: string | null) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
export function compactText(value?: string | null) {
return value && value.trim().length > 0 ? value : '-';
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import './styles.css';
import App from './App.vue';
import { router } from './router';
createApp(App).use(router).mount('#app');
+42
View File
@@ -0,0 +1,42 @@
export type AdminMenuKey =
| 'overview'
| 'reports'
| 'stores'
| 'orders'
| 'products'
| 'notifications'
| 'payments'
| 'thirdParty'
| 'people'
| 'cleaning'
| 'devices'
| 'platformApps'
| 'content'
| 'franchise'
| 'system';
export interface AdminNavigationItem {
menuKey: AdminMenuKey;
path: string;
stage: string;
title: string;
label: string;
icon: string;
capabilities: readonly string[];
roles: readonly string[];
}
export interface AdminNavigationAccess {
menus: readonly string[];
capabilities: readonly string[];
roles: readonly string[];
}
export const ADMIN_NAVIGATION: readonly AdminNavigationItem[];
export function isMenuAllowed(menus: readonly string[], menuKey: AdminMenuKey): boolean;
export function pathForMenu(menuKey: AdminMenuKey): string | null;
export function firstAllowedPath(menus: readonly string[]): string | null;
export function resolveProtectedPath(menus: readonly string[], requestedMenu: AdminMenuKey): string | null;
export function isNavigationAllowed(access: AdminNavigationAccess, item: AdminNavigationItem): boolean;
export function firstAuthorizedPath(access: AdminNavigationAccess): string | null;
export function resolveAuthorizedPath(access: AdminNavigationAccess, requestedMenu: AdminMenuKey): string | null;
+52
View File
@@ -0,0 +1,52 @@
export const ADMIN_NAVIGATION = Object.freeze([
{ menuKey: 'reports', path: '/reports', stage: 'M10-B', title: '经营统计、日汇总与明细复算', label: '经营报表', icon: 'ChartNoAxesCombined', capabilities: ['report.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'products', path: '/products', stage: 'M09-D4', title: '商品、库存、订单与寄存', label: '商品运营', icon: 'ShoppingBasket', capabilities: ['product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust', 'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'notifications', path: '/notifications', stage: 'M10-A', title: '通知模板、路由与投递追踪', label: '通知中心', icon: 'BellRing', capabilities: ['notification.read', 'notification.manage', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'cleaning', path: '/cleaning', stage: 'M08-B/M09', title: '保洁任务与结算', label: '保洁运营', icon: 'Sparkles', capabilities: ['cleaning.task.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'devices', path: '/devices', stage: 'M08-D', title: '设备资产、拓扑与控制', label: '设备', icon: 'RadioTower', capabilities: ['device.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'platformApps', path: '/apps', stage: 'M08-D', title: '多小程序与租户品牌配置', label: '小程序租户', icon: 'PanelsTopLeft', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'content', path: '/content', stage: 'M08-D', title: '广告投放与门店装修', label: '广告装修', icon: 'Images', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'franchise', path: '/franchise', stage: 'M08-D', title: '加盟申请与跟进运营', label: '加盟跟进', icon: 'Handshake', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
{ menuKey: 'system', path: '/system', stage: 'M08-D', title: '安全审计与系统配置', label: '日志配置', icon: 'ScrollText', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }
]);
export function isMenuAllowed(menus, menuKey) {
return menus.includes(menuKey);
}
export function pathForMenu(menuKey) {
return ADMIN_NAVIGATION.find((item) => item.menuKey === menuKey)?.path ?? null;
}
export function firstAllowedPath(menus) {
return ADMIN_NAVIGATION.find((item) => menus.includes(item.menuKey))?.path ?? null;
}
export function resolveProtectedPath(menus, requestedMenu) {
return isMenuAllowed(menus, requestedMenu)
? pathForMenu(requestedMenu)
: firstAllowedPath(menus);
}
export function isNavigationAllowed(access, item) {
if (!isMenuAllowed(access.menus, item.menuKey)) return false;
return item.capabilities.some((capability) => access.capabilities.includes(capability))
|| item.roles.some((role) => access.roles.includes(role));
}
export function firstAuthorizedPath(access) {
return ADMIN_NAVIGATION.find((item) => isNavigationAllowed(access, item))?.path ?? null;
}
export function resolveAuthorizedPath(access, requestedMenu) {
const requested = ADMIN_NAVIGATION.find((item) => item.menuKey === requestedMenu);
return requested && isNavigationAllowed(access, requested)
? requested.path
: firstAuthorizedPath(access);
}
+56
View File
@@ -0,0 +1,56 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
import {
ADMIN_NAVIGATION,
firstAuthorizedPath,
isNavigationAllowed,
type AdminMenuKey
} from './navigation.mjs';
import { adminSessionState } from './session';
const routeComponents = {
overview: () => import('./components/OperationsOverviewPanel.vue'),
reports: () => import('./components/BusinessReportsPanel.vue'),
stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.vue'),
products: () => import('./components/ProductOperationsPanel.vue'),
notifications: () => import('./components/NotificationCenterPanel.vue'),
payments: () => import('./components/PaymentsPanel.vue'),
thirdParty: () => import('./components/ThirdPartyPanel.vue'),
people: () => import('./components/MembersStaffPanel.vue'),
cleaning: () => import('./components/CleaningWorkspace.vue'),
devices: () => import('./components/DevicesPanel.vue'),
platformApps: () => import('./components/PlatformAppsPanel.vue'),
content: () => import('./components/ContentManagementPanel.vue'),
franchise: () => import('./components/FranchisePanel.vue'),
system: () => import('./components/LogsSystemPanel.vue')
} satisfies Record<AdminMenuKey, () => Promise<unknown>>;
const businessRoutes = ADMIN_NAVIGATION.map<RouteRecordRaw>((item) => ({
path: item.path,
name: item.menuKey,
component: routeComponents[item.menuKey],
meta: {
menuKey: item.menuKey,
stage: item.stage,
title: item.title
}
}));
export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', redirect: '/overview' },
...businessRoutes,
{ path: '/:pathMatch(.*)*', redirect: '/overview' }
],
scrollBehavior: () => ({ top: 0 })
});
router.beforeEach((to) => {
const menuKey = to.meta.menuKey as AdminMenuKey | undefined;
if (!menuKey || !adminSessionState.token || !adminSessionState.ready) return true;
const navigation = ADMIN_NAVIGATION.find((item) => item.menuKey === menuKey);
if (navigation && isNavigationAllowed(adminSessionState.access, navigation)) return true;
const fallback = firstAuthorizedPath(adminSessionState.access);
return fallback ? { path: fallback, replace: true } : false;
});
+36
View File
@@ -0,0 +1,36 @@
import { reactive } from 'vue';
import type { AdminAccess, AdminIdentity, AdminSessionPayload } from './types';
const emptyAccess = (): AdminAccess => ({ roles: [], capabilities: [], storeIds: [], menus: [] });
export const adminSessionState = reactive<{
token: string;
user: AdminIdentity | null;
access: AdminAccess;
ready: boolean;
}>({
token: localStorage.getItem('qipai.admin.token') || '',
user: null,
access: emptyAccess(),
ready: !localStorage.getItem('qipai.admin.token')
});
export function applyAdminSession(payload: AdminSessionPayload) {
adminSessionState.token = payload.accessToken;
adminSessionState.user = payload.user;
adminSessionState.access = payload.access;
adminSessionState.ready = true;
}
export function applyAdminIdentity(user: AdminIdentity, access: AdminAccess) {
adminSessionState.user = user;
adminSessionState.access = access;
adminSessionState.ready = true;
}
export function clearAdminSessionState() {
adminSessionState.token = '';
adminSessionState.user = null;
adminSessionState.access = emptyAccess();
adminSessionState.ready = true;
}
+2750
View File
File diff suppressed because it is too large Load Diff
+1017
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import {
ADMIN_NAVIGATION,
firstAuthorizedPath,
isNavigationAllowed,
pathForMenu,
resolveAuthorizedPath
} from '../src/navigation.mjs';
assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path),
['/reports', '/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
);
assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party');
assert.equal(pathForMenu('products'), '/products');
assert.equal(pathForMenu('notifications'), '/notifications');
assert.equal(pathForMenu('reports'), '/reports');
const storeOperator = {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'],
menus: ['overview', 'stores', 'orders', 'thirdParty']
};
assert.equal(resolveAuthorizedPath(storeOperator, 'orders'), '/orders');
assert.equal(resolveAuthorizedPath(storeOperator, 'system'), '/overview');
assert.equal(firstAuthorizedPath(storeOperator), '/overview');
const goodsOperator = {
roles: ['STAFF'],
capabilities: ['goods.order.read'],
menus: ['products']
};
assert.equal(resolveAuthorizedPath(goodsOperator, 'products'), '/products');
assert.equal(firstAuthorizedPath(goodsOperator), '/products');
const notificationOperator = {
roles: ['STORE_ADMIN'], capabilities: ['notification.read'], menus: ['notifications']
};
assert.equal(resolveAuthorizedPath(notificationOperator, 'notifications'), '/notifications');
const reportOperator = {
roles: ['STORE_ADMIN'], capabilities: ['report.read'], menus: ['reports']
};
assert.equal(resolveAuthorizedPath(reportOperator, 'reports'), '/reports');
const forgedSystemMenu = {
roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'],
menus: ['system']
};
const systemNavigation = ADMIN_NAVIGATION.find(({ menuKey }) => menuKey === 'system');
assert.ok(systemNavigation);
assert.equal(isNavigationAllowed(forgedSystemMenu, systemNavigation), false);
assert.equal(firstAuthorizedPath(forgedSystemMenu), null);
const cleaner = {
roles: ['CLEANER'],
capabilities: ['cleaning.task.read'],
menus: ['cleaning']
};
assert.equal(resolveAuthorizedPath(cleaner, 'cleaning'), '/cleaning');
assert.equal(resolveAuthorizedPath(cleaner, 'devices'), '/cleaning');
const platformAdmin = {
roles: ['PLATFORM_ADMIN'],
capabilities: [],
menus: ADMIN_NAVIGATION.map(({ menuKey }) => menuKey)
};
assert.equal(resolveAuthorizedPath(platformAdmin, 'system'), '/system');
console.log('PASS: M08-D-R1 deep links, menu/capability guards and refresh fallbacks are deterministic.');
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.mts", "src/**/*.vue"]
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
Components({
dts: false,
resolvers: [ElementPlusResolver({ importStyle: 'css' })]
})
],
base: '/admin/',
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router'],
icons: ['@lucide/vue']
}
}
}
},
server: {
port: 5173,
proxy: {
'/admin-api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true
}
}
}
});
+7
View File
@@ -16,6 +16,13 @@ QIPAI_TEST_PAYMENT_ENABLED=false
QIPAI_WECHAT_PAY_CREDENTIALS={}
QIPAI_PROFIT_SHARE_MOCK_ENABLED=false
QIPAI_THIRD_PARTY_CREDENTIALS={}
QIPAI_NOTIFICATION_HTTP_TIMEOUT_MS=10000
QIPAI_NOTIFICATION_WECHAT_GATEWAY_URL=
QIPAI_NOTIFICATION_WECHAT_GATEWAY_TOKEN=
QIPAI_NOTIFICATION_WECOM_GATEWAY_URL=
QIPAI_NOTIFICATION_WECOM_GATEWAY_TOKEN=
QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_URL=
QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_TOKEN=
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
QIPAI_MQTT_CLIENT_ID=qipai-backend
QIPAI_MQTT_USERNAME=
+2 -1
View File
@@ -17,7 +17,8 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs"
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs && node tests/notification-adapter.test.mjs && node tests/notification-route.test.mjs && node tests/business-report.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+67
View File
@@ -8,6 +8,7 @@ import {
registerPlatformBootstrapRoutes,
type PlatformConfigResolver
} from './routes/platform-bootstrap.js';
import { registerPlatformManagementRoutes, type PlatformManagementRouteOptions } from './routes/platform-management.js';
import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js';
import {
registerUserManagementRoutes,
@@ -50,10 +51,33 @@ import {
} from './routes/device-control.js';
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
import {
registerBusinessStatisticsRoutes,
type BusinessStatisticsRouteOptions
} from './routes/business-statistics.js';
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
import {
registerSystemOperationsRoutes,
type SystemOperationsRouteOptions
} from './routes/system-operations.js';
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
import {
registerProductOrderRoutes,
type ProductOrderRouteOptions
} from './routes/product-orders.js';
import {
registerProductStorageRoutes,
type ProductStorageRouteOptions
} from './routes/product-storages.js';
import { registerNotificationRoutes, type NotificationRouteOptions } from './routes/notifications.js';
export interface BuildAppOptions {
config?: AppConfig;
platformConfigRepository?: PlatformConfigResolver;
platformManagement?: PlatformManagementRouteOptions;
auth?: AuthRouteOptions;
userManagement?: UserManagementRouteOptions;
storeRoom?: StoreRoomRouteOptions;
@@ -72,6 +96,16 @@ export interface BuildAppOptions {
deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions;
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
businessStatistics?: BusinessStatisticsRouteOptions;
franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions;
products?: ProductRouteOptions;
inventory?: InventoryRouteOptions;
productOrders?: ProductOrderRouteOptions;
productStorages?: ProductStorageRouteOptions;
notifications?: NotificationRouteOptions;
}
declare module 'fastify' {
@@ -119,6 +153,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.platformConfigRepository) {
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
}
if (options.platformManagement) {
await registerPlatformManagementRoutes(app, options.platformManagement);
}
if (options.auth) {
await registerAuthRoutes(app, options.auth);
}
@@ -170,6 +207,36 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.recharge) {
await registerRechargeRoutes(app, options.recharge);
}
if (options.cleaning) {
await registerCleaningRoutes(app, options.cleaning);
}
if (options.businessStatistics) {
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
}
if (options.franchise) {
await registerFranchiseRoutes(app, options.franchise);
}
if (options.systemOperations) {
await registerSystemOperationsRoutes(app, options.systemOperations);
}
if (options.adminAuth) {
await registerAdminAuthRoutes(app, options.adminAuth);
}
if (options.products) {
await registerProductRoutes(app, options.products);
}
if (options.inventory) {
await registerInventoryRoutes(app, options.inventory);
}
if (options.productOrders) {
await registerProductOrderRoutes(app, options.productOrders);
}
if (options.productStorages) {
await registerProductStorageRoutes(app, options.productStorages);
}
if (options.notifications) {
await registerNotificationRoutes(app, options.notifications);
}
return app;
}
+216
View File
@@ -0,0 +1,216 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { ManagementActor } from './user-management-repository.js';
import { verifyPassword } from './password.js';
import type { AuthSession, AuthUser } from './auth-repository.js';
interface CredentialRow extends RowDataPacket {
credentialId: string; tenantId: string; platformAppId: string; passwordHash: string;
credentialStatus: string; failedAttempts: number; lockedUntil: Date | null;
id: string; userType: string; status: string; roleVersion: number;
nickname: string; avatarUrl: string; phone: string;
}
interface RefreshRow extends RowDataPacket {
sessionId: string; tenantId: string; platformAppId: string; refreshTokenHash: string;
refreshExpiresAt: Date; id: string; userType: string; status: string; roleVersion: number;
nickname: string; avatarUrl: string; phone: string;
}
export class AdminAuthError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class AdminAuthRepository {
constructor(private readonly pool: MySqlPool) {}
async loginWithPassword(input: {
tenantCode: string; loginName: string; password: string; sessionId: string;
refreshTokenHash: string; expiresAt: Date; ip: string; userAgent: string; traceId: string;
}): Promise<AuthSession> {
const loginName = normalizeLoginName(input.loginName);
const [rows] = await this.pool.execute<CredentialRow[]>(
`SELECT c.id AS credentialId, c.tenant_id AS tenantId,
ta.platform_app_id AS platformAppId, c.password_hash AS passwordHash,
c.status AS credentialStatus, c.failed_attempts AS failedAttempts,
c.locked_until AS lockedUntil, u.id, u.user_type AS userType, u.status,
u.role_version AS roleVersion, u.nickname, u.avatar_url AS avatarUrl, u.phone
FROM qipai_admin_credentials c
INNER JOIN qipai_tenants t ON t.id = c.tenant_id AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
INNER JOIN qipai_users u ON u.id = c.user_id AND u.tenant_id = c.tenant_id
AND u.user_type = 'STAFF' AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
INNER JOIN qipai_tenant_apps ta ON ta.tenant_id = c.tenant_id
AND ta.status = 'ACTIVE' AND ta.deleted_at IS NULL
WHERE t.code = ? AND c.login_name = ? AND c.deleted_at IS NULL
AND EXISTS (SELECT 1 FROM qipai_user_roles ur
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
AND r.status = 'ACTIVE' AND r.code IN ('STAFF', 'CLEANER', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
WHERE ur.tenant_id = c.tenant_id AND ur.user_id = c.user_id)
ORDER BY ta.is_default DESC, ta.id ASC LIMIT 1`,
[input.tenantCode.trim(), loginName]
);
const row = rows[0];
const validPassword = await verifyPassword(input.password, row?.passwordHash);
if (!row || row.credentialStatus !== 'ACTIVE' || !validPassword) {
if (row) await this.recordFailure(row, loginName, input);
throw new AdminAuthError('ADMIN_LOGIN_INVALID');
}
if (row.lockedUntil && row.lockedUntil.getTime() > Date.now()) {
throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
}
const user = mapUser(row);
await this.transaction(async (connection) => {
await connection.execute(
`UPDATE qipai_admin_credentials SET failed_attempts = 0, locked_until = NULL,
last_login_at = UTC_TIMESTAMP(3) WHERE id = ?`, [row.credentialId]
);
await connection.execute(
`INSERT INTO qipai_auth_sessions
(id, tenant_id, platform_app_id, user_id, role_version, refresh_token_hash,
expires_at, refresh_expires_at, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.sessionId, row.tenantId, row.platformAppId, user.id, user.roleVersion,
input.refreshTokenHash, input.expiresAt, input.expiresAt, input.ip,
input.userAgent.slice(0, 255)]
);
await connection.execute(
'UPDATE qipai_users SET last_login_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?',
[row.tenantId, user.id]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ADMIN_LOGIN_SUCCEEDED', 'USER', ?, ?, ?, ?, '{}')`,
[row.tenantId, user.id, user.id, input.traceId, input.ip, input.userAgent.slice(0, 255)]
);
});
return { id: input.sessionId, tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId), user, expiresAt: input.expiresAt };
}
async rotateRefreshToken(input: {
sessionId: string; currentHash: string; nextHash: string; ip: string; userAgent: string;
}): Promise<AuthSession> {
const [rows] = await this.pool.execute<RefreshRow[]>(
`SELECT s.id AS sessionId, s.tenant_id AS tenantId, s.platform_app_id AS platformAppId,
s.refresh_token_hash AS refreshTokenHash, s.refresh_expires_at AS refreshExpiresAt,
u.id, u.user_type AS userType, u.status, u.role_version AS roleVersion,
u.nickname, u.avatar_url AS avatarUrl, u.phone
FROM qipai_auth_sessions s INNER JOIN qipai_users u
ON u.id = s.user_id AND u.tenant_id = s.tenant_id AND u.deleted_at IS NULL
WHERE s.id = ? AND s.status = 'ACTIVE' AND s.revoked_at IS NULL
AND s.refresh_token_hash IS NOT NULL AND s.refresh_expires_at > UTC_TIMESTAMP(3)
AND u.status = 'ACTIVE' AND u.role_version = s.role_version LIMIT 1`, [input.sessionId]
);
const row = rows[0];
if (!row || !safeHashEqual(row.refreshTokenHash, input.currentHash)) {
throw new AdminAuthError('ADMIN_REFRESH_INVALID');
}
const [updated] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_auth_sessions SET refresh_token_hash = ?, last_seen_at = UTC_TIMESTAMP(3),
ip = ?, user_agent = ? WHERE id = ? AND refresh_token_hash = ? AND status = 'ACTIVE'`,
[input.nextHash, input.ip, input.userAgent.slice(0, 255), input.sessionId, input.currentHash]
);
if (updated.affectedRows !== 1) throw new AdminAuthError('ADMIN_REFRESH_REUSED');
return { id: row.sessionId, tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId), user: mapUser(row), expiresAt: row.refreshExpiresAt };
}
async setCredential(actor: ManagementActor, tenantId: string, input: {
userId: string; loginName: string; passwordHash: string;
}) {
const loginName = normalizeLoginName(input.loginName);
try {
return await this.transaction(async (connection) => {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_users WHERE id = ? AND tenant_id = ? AND user_type = 'STAFF'
AND status = 'ACTIVE' AND deleted_at IS NULL FOR UPDATE`, [input.userId, tenantId]
);
if (!users[0]) throw new AdminAuthError('ADMIN_CREDENTIAL_USER_INVALID');
const [conflicts] = await connection.execute<RowDataPacket[]>(
`SELECT user_id AS userId FROM qipai_admin_credentials
WHERE tenant_id = ? AND login_name = ? AND user_id <> ? AND deleted_at IS NULL FOR UPDATE`,
[tenantId, loginName, input.userId]
);
if (conflicts[0]) throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
const [current] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_admin_credentials
WHERE tenant_id = ? AND user_id = ? FOR UPDATE`, [tenantId, input.userId]
);
if (current[0]) {
await connection.execute(
`UPDATE qipai_admin_credentials SET login_name = ?, password_hash = ?, status = 'ACTIVE',
failed_attempts = 0, locked_until = NULL, deleted_at = NULL,
password_changed_at = UTC_TIMESTAMP(3) WHERE id = ?`,
[loginName, input.passwordHash, current[0].id]
);
} else {
await connection.execute(
`INSERT INTO qipai_admin_credentials
(tenant_id, user_id, login_name, password_hash) VALUES (?, ?, ?, ?)`,
[tenantId, input.userId, loginName, input.passwordHash]
);
}
await connection.execute(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'PASSWORD_CHANGED' WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[tenantId, input.userId]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ADMIN_CREDENTIAL_UPDATED', 'USER', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, input.userId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
JSON.stringify({ loginNameHash: hashToken(loginName) })]
);
return { userId: input.userId, configured: true };
});
} catch (error) {
if ((error as { code?: string }).code === 'ER_DUP_ENTRY') {
throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
}
throw error;
}
}
private async recordFailure(row: CredentialRow, loginName: string, input: {
traceId: string; ip: string; userAgent: string;
}) {
await this.pool.execute(
`UPDATE qipai_admin_credentials SET failed_attempts = failed_attempts + 1,
locked_until = CASE WHEN failed_attempts + 1 >= 5
THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END
WHERE id = ?`, [row.credentialId]
);
await this.pool.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'SYSTEM', NULL, 'ADMIN_LOGIN_FAILED', 'ADMIN_CREDENTIAL', ?, ?, ?, ?, ?)`,
[row.tenantId, row.credentialId, input.traceId, input.ip, input.userAgent.slice(0, 255),
JSON.stringify({ loginNameHash: hashToken(loginName) })]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
}
}
export function normalizeLoginName(value: string) { return value.normalize('NFKC').trim().toLowerCase(); }
export function hashToken(value: string) { return createHash('sha256').update(value).digest('hex'); }
function safeHashEqual(left: string, right: string) {
const a = Buffer.from(left); const b = Buffer.from(right);
return a.length === b.length && timingSafeEqual(a, b);
}
function mapUser(row: { id: string; tenantId: string; userType: string; status: string;
roleVersion: number; nickname: string; avatarUrl: string; phone: string }): AuthUser {
return { id: String(row.id), tenantId: String(row.tenantId), userType: row.userType,
status: row.status, roleVersion: row.roleVersion, nickname: row.nickname,
avatarUrl: row.avatarUrl, phone: row.phone };
}
+19 -2
View File
@@ -140,13 +140,30 @@ export class AuthRepository {
SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read',
'product.catalog.read', 'inventory.read',
'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage'))
OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage',
'report.read', 'report.export'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage',
'report.read', 'report.export', 'report.manage'))
WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId]
);
+46
View File
@@ -0,0 +1,46 @@
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
const algorithm = 'scrypt';
const cost = 16384;
const blockSize = 8;
const parallelization = 1;
const keyLength = 64;
const maxmem = 64 * 1024 * 1024;
const dummyHash = 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$4fXWGR0BUXE6uC3v9sPYUjV_QlLQaq9EOrlgkxMVXngIWJ2unUaOv9OnZ47Z0RFKmK0LLBeMdNNB6W1RgPBplA';
export async function hashPassword(plainTextPassword: string): Promise<string> {
const salt = randomBytes(16);
const derived = await derive(plainTextPassword, salt, cost, blockSize, parallelization);
return [algorithm, cost, blockSize, parallelization,
salt.toString('base64url'), derived.toString('base64url')].join('$');
}
export async function verifyPassword(plainTextPassword: string, encoded = dummyHash): Promise<boolean> {
const parts = encoded.split('$');
if (parts.length !== 6 || parts[0] !== algorithm) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const [, nValue, rValue, pValue, saltValue, hashValue] = parts;
const n = Number(nValue); const r = Number(rValue); const p = Number(pValue);
if (n !== cost || r !== blockSize || p !== parallelization) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const salt = Buffer.from(saltValue, 'base64url');
const expected = Buffer.from(hashValue, 'base64url');
if (salt.length !== 16 || expected.length !== keyLength) {
await verifyPassword(plainTextPassword, dummyHash);
return false;
}
const actual = await derive(plainTextPassword, salt, n, r, p);
return timingSafeEqual(actual, expected);
}
function derive(plainTextPassword: string, salt: Buffer, N: number, r: number, p: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(plainTextPassword, salt, keyLength, { N, r, p, maxmem }, (error, derivedKey) => {
if (error) reject(error); else resolve(derivedKey);
});
});
}
+26 -2
View File
@@ -36,13 +36,37 @@ export class RbacRepository {
SELECT ?, r.id, p.id FROM qipai_roles r
INNER JOIN qipai_permissions p ON
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
OR (r.code = 'STAFF'
AND p.code IN ('profile.read', 'store.operation.read',
'product.catalog.read', 'inventory.read',
'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage'))
OR (r.code = 'CLEANER'
AND p.code IN ('profile.read', 'cleaning.task.read',
'cleaning.task.write', 'cleaning.statistics.read'))
OR (r.code = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage',
'report.read', 'report.export',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write'))
'device.read', 'device.write',
'product.catalog.read', 'product.catalog.write',
'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage',
'report.read', 'report.export', 'report.manage',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
WHERE r.tenant_id = ?`,
[tenantId, tenantId]
);
+32 -3
View File
@@ -10,11 +10,13 @@ export interface ManagedUser {
userType: string;
status: string;
nickname: string;
avatarUrl: string;
maskedPhone: string;
maskedLastIp: string;
note: string;
roles: string[];
storeIds: string[];
wechatMiniappBound: boolean;
registeredAt: Date;
lastLoginAt: Date | null;
}
@@ -42,9 +44,11 @@ interface UserRow extends RowDataPacket {
userType: string;
status: string;
nickname: string;
avatarUrl: string;
phone: string;
lastIp: string | null;
note: string;
wechatMiniappBound: number;
registeredAt: Date;
lastLoginAt: Date | null;
}
@@ -66,7 +70,9 @@ export class UserManagementRepository {
page: number;
pageSize: number;
status?: 'ACTIVE' | 'DISABLED';
role?: AssignableRole;
search?: string;
userType?: 'STAFF';
}): Promise<{ items: ManagedUser[]; total: number }> {
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
const params: Array<string | number> = [input.actor.tenantId];
@@ -74,6 +80,19 @@ export class UserManagementRepository {
filters.push('u.status = ?');
params.push(input.status);
}
if (input.userType) {
filters.push('u.user_type = ?');
params.push(input.userType);
}
if (input.role) {
filters.push(`EXISTS (
SELECT 1 FROM qipai_user_roles fur
INNER JOIN qipai_roles fr ON fr.tenant_id = fur.tenant_id AND fr.id = fur.role_id
WHERE fur.tenant_id = u.tenant_id AND fur.user_id = u.id
AND fr.code = ? AND fr.status = 'ACTIVE' AND fr.deleted_at IS NULL
)`);
params.push(input.role);
}
if (input.search) {
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
const like = `%${input.search}%`;
@@ -88,10 +107,18 @@ export class UserManagementRepository {
WHERE ${filters.join(' AND ')}`,
params
);
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
const [rows] = await this.pool.execute<UserRow[]>(
`SELECT u.id, u.user_type AS userType, u.status, u.nickname, u.phone,
`SELECT u.id, u.user_type AS userType, u.status, u.nickname,
u.avatar_url AS avatarUrl, u.phone,
u.created_at AS registeredAt, u.last_login_at AS lastLoginAt,
COALESCE(p.note, '') AS note,
EXISTS (
SELECT 1 FROM qipai_user_identities i
WHERE i.tenant_id = u.tenant_id AND i.user_id = u.id
AND i.provider = 'WECHAT_MINIAPP' AND i.deleted_at IS NULL
) AS wechatMiniappBound,
(SELECT s.ip FROM qipai_auth_sessions s
WHERE s.tenant_id = u.tenant_id AND s.user_id = u.id
ORDER BY s.created_at DESC LIMIT 1) AS lastIp
@@ -99,19 +126,21 @@ export class UserManagementRepository {
LEFT JOIN qipai_user_admin_profiles p
ON p.tenant_id = u.tenant_id AND p.user_id = u.id
WHERE ${filters.join(' AND ')}
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
[...params, input.pageSize, (input.page - 1) * input.pageSize]
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
params
);
const items = await Promise.all(rows.map(async (row) => ({
id: String(row.id),
userType: row.userType,
status: row.status,
nickname: row.nickname,
avatarUrl: row.avatarUrl,
maskedPhone: maskPhone(row.phone),
maskedLastIp: maskIp(row.lastIp ?? ''),
note: row.note,
roles: await this.getCodes('role', input.actor.tenantId, String(row.id)),
storeIds: await this.getCodes('store', input.actor.tenantId, String(row.id)),
wechatMiniappBound: Boolean(row.wechatMiniappBound),
registeredAt: row.registeredAt,
lastLoginAt: row.lastLoginAt
})));
@@ -0,0 +1,547 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import {
WechatPayClient,
WechatPayError,
type WechatNotificationHeaders,
type WechatPayCredential
} from '../payments/wechat-pay-client.js';
import {
CleaningTaskError,
type CleaningActor,
type CleaningTaskRepository
} from './cleaning-task-repository.js';
interface SettlementPayoutRow extends RowDataPacket {
id: string;
settlementNo: string;
cleanerUserId: string;
generatedBy: string;
storeId: string | null;
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
totalRewardCents: number;
payoutChannel: string;
payoutRequestNo: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
}
interface AccountRow extends RowDataPacket {
id: string;
platformAppId: string | null;
storeId: string | null;
merchantId: string;
credentialRef: string;
authorizationStatus: string;
}
interface IdentityRow extends RowDataPacket {
openid: string;
}
type PreflightStatus = 'PASS' | 'WARN' | 'FAIL';
interface PreflightCheck {
key: string;
status: PreflightStatus;
message: string;
}
export class CleaningPayoutError extends Error {
constructor(public readonly code: string, message = code) {
super(message);
}
}
export class CleaningPayoutService {
constructor(
private readonly pool: MySqlPool,
private readonly repository: Pick<CleaningTaskRepository,
'beginSettlementPayout' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
private readonly client: WechatPayClient,
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
private readonly mockEnabled: boolean
) {}
async preflightWechatTransfer(input: CleaningActor & { settlementId: string }) {
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
const checks: PreflightCheck[] = [];
checks.push({
key: 'settlement_status',
status: settlement.status === 'CONFIRMED' ? 'PASS' : 'FAIL',
message: settlement.status === 'CONFIRMED'
? 'Settlement is confirmed and can start WeChat transfer.'
: `Settlement status must be CONFIRMED, current status is ${settlement.status}.`
});
checks.push({
key: 'settlement_amount',
status: Number(settlement.totalRewardCents) > 0 ? 'PASS' : 'FAIL',
message: Number(settlement.totalRewardCents) > 0
? 'Settlement amount is greater than zero.'
: 'Settlement amount must be greater than zero.'
});
let account: AccountRow | null = null;
try {
account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
checks.push({
key: 'collection_account',
status: 'PASS',
message: account.storeId ? 'Store scoped WeChat collection account is configured.'
: 'Tenant level WeChat collection account is configured.'
});
checks.push({
key: 'collection_account_authorized',
status: account.authorizationStatus === 'AUTHORIZED' ? 'PASS' : 'FAIL',
message: account.authorizationStatus === 'AUTHORIZED'
? 'Collection account is authorized.'
: `Collection account authorization status is ${account.authorizationStatus}.`
});
} catch (error) {
if (!(error instanceof CleaningPayoutError)) throw error;
checks.push({
key: 'collection_account',
status: 'FAIL',
message: 'No enabled WeChat collection account matches this settlement store.'
});
}
const credential = account ? this.resolveCredential(account.credentialRef) : null;
checks.push({
key: 'wechat_credential',
status: credential ? 'PASS' : 'FAIL',
message: credential ? 'WeChat Pay credential reference is resolvable.'
: 'WeChat Pay credential reference is missing or not loaded.'
});
if (account && credential) {
checks.push({
key: 'merchant_match',
status: credential.merchantId === account.merchantId ? 'PASS' : 'FAIL',
message: credential.merchantId === account.merchantId
? 'Credential merchant id matches the collection account.'
: 'Credential merchant id does not match the collection account.'
});
checks.push({
key: 'transfer_scene_id',
status: credential.transferSceneId ? 'PASS' : 'FAIL',
message: credential.transferSceneId
? 'Merchant transfer scene id is configured.'
: 'Merchant transfer scene id is required before real transfer.'
});
checks.push({
key: 'transfer_scene_report_infos',
status: credential.transferSceneReportInfos?.length ? 'PASS' : 'WARN',
message: credential.transferSceneReportInfos?.length
? `${credential.transferSceneReportInfos.length} transfer scene report info item(s) configured.`
: 'No transfer scene report info is configured; confirm whether the selected WeChat scene requires it.'
});
const notifyUrl = credential.transferNotifyUrl || '';
checks.push({
key: 'transfer_notify_url',
status: notifyUrl.startsWith('https://') && notifyUrl.includes('/app-api/cleaning/wechat-transfer/notify')
? 'PASS' : 'WARN',
message: notifyUrl
? 'Transfer notification URL is configured.'
: 'Transfer notification URL is not configured; active polling can still sync intermediate states.'
});
checks.push({
key: 'platform_certificates',
status: Object.keys(credential.platformCertificates).length > 0 ? 'PASS' : 'FAIL',
message: Object.keys(credential.platformCertificates).length > 0
? 'At least one WeChat platform certificate is loaded for notification verification.'
: 'No WeChat platform certificate is loaded.'
});
}
let cleanerOpenidConfigured = false;
if (account) {
try {
await this.resolveCleanerOpenid(input.tenantId, account.platformAppId, settlement.cleanerUserId);
cleanerOpenidConfigured = true;
} catch (error) {
if (!(error instanceof CleaningPayoutError)) throw error;
}
}
checks.push({
key: 'cleaner_openid',
status: cleanerOpenidConfigured ? 'PASS' : 'FAIL',
message: cleanerOpenidConfigured
? 'Cleaner has a WeChat miniapp openid for this platform app.'
: 'Cleaner WeChat miniapp openid is missing.'
});
return {
ready: checks.every((check) => check.status !== 'FAIL'),
settlement: {
id: settlement.id,
settlementNo: settlement.settlementNo,
status: settlement.status,
totalRewardCents: Number(settlement.totalRewardCents),
cleanerUserId: settlement.cleanerUserId,
storeId: settlement.storeId
},
account: account ? {
configured: true,
id: account.id,
storeScoped: account.storeId !== null,
merchantIdMasked: maskIdentifier(account.merchantId),
credentialRefMasked: maskIdentifier(account.credentialRef),
authorizationStatus: account.authorizationStatus
} : { configured: false },
credential: credential ? {
configured: true,
appIdPresent: credential.appId.length > 0,
serialNoPresent: credential.serialNo.length > 0,
transferSceneId: credential.transferSceneId || '',
reportInfoCount: credential.transferSceneReportInfos?.length ?? 0,
transferNotifyUrlConfigured: Boolean(credential.transferNotifyUrl),
platformCertificateCount: Object.keys(credential.platformCertificates).length
} : { configured: false },
cleaner: { openidConfigured: cleanerOpenidConfigured },
checks
};
}
async executeWechatTransfer(input: CleaningActor & {
settlementId: string;
mode: 'API' | 'MOCK';
note?: string;
}) {
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
if (settlement.status === 'PAID') {
return { settlement, idempotent: true, transferState: 'SUCCESS' };
}
if (settlement.status !== 'CONFIRMED') {
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
}
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
throw new CleaningPayoutError('CLEANING_PAYOUT_ALREADY_STARTED');
}
if (Number(settlement.totalRewardCents) <= 0) {
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
}
if (input.mode === 'MOCK' && !this.mockEnabled) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MOCK_DISABLED');
}
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
if (account.authorizationStatus !== 'AUTHORIZED') {
throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_AUTHORIZED');
}
const credential = this.resolveCredential(account.credentialRef);
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
if (credential.merchantId !== account.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
if (input.mode === 'MOCK') {
const paid = await this.repository.markSettlementPaid({
...input,
payoutChannel: 'WECHAT_TRANSFER_MOCK',
payoutReference: outBillNo,
providerConfirmed: true,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
}
const openid = await this.resolveCleanerOpenid(
input.tenantId,
account.platformAppId,
settlement.cleanerUserId
);
const sceneId = credential.transferSceneId;
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
await this.repository.beginSettlementPayout({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: outBillNo,
note: input.note
});
try {
const result = await this.client.createMerchantTransfer(credential, {
outBillNo,
openid,
amountCents: Number(settlement.totalRewardCents),
remark: `Cleaning settlement ${settlement.id}`,
sceneId,
notifyUrl: credential.transferNotifyUrl || undefined,
reportInfos: credential.transferSceneReportInfos ?? []
});
if (result.state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
providerConfirmed: true,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: result.state };
}
if (result.state === 'FAIL') {
const failed = await this.repository.recordSettlementPayoutFailure({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
providerConfirmed: true,
note: input.note
});
return { settlement: failed, idempotent: false, transferState: result.state };
}
const pending = await this.repository.recordSettlementPayoutPending({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
payoutState: result.state,
payoutPackageInfo: result.packageInfo,
note: input.note
});
return { settlement: pending, idempotent: false, transferState: result.state };
} catch (error) {
// A transport/API exception is not proof that WeChat rejected the transfer.
// Keep PROCESSING so cancellation and duplicate payout are blocked until sync/reconciliation.
throw error;
}
}
async syncWechatTransfer(input: CleaningActor & { settlementId: string; note?: string }) {
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
if (settlement.status === 'PAID') {
return { settlement, idempotent: true, transferState: 'SUCCESS' };
}
if (settlement.status !== 'CONFIRMED') {
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
}
if (settlement.payoutChannel !== 'WECHAT_TRANSFER' || !settlement.payoutReference) {
throw new CleaningPayoutError('WECHAT_TRANSFER_NOT_STARTED');
}
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
const credential = this.resolveCredential(account.credentialRef);
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
if (credential.merchantId !== account.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
const result = await this.client.queryMerchantTransferByOutBillNo(credential, outBillNo);
if (result.merchantId !== account.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
if (result.amountCents > 0 && result.amountCents !== Number(settlement.totalRewardCents)) {
throw new CleaningPayoutError('WECHAT_TRANSFER_AMOUNT_MISMATCH');
}
if (result.state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
providerConfirmed: true,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: result.state };
}
if (result.state === 'FAIL') {
const failed = await this.repository.recordSettlementPayoutFailure({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
providerConfirmed: true,
note: input.note
});
return { settlement: failed, idempotent: false, transferState: result.state };
}
const pending = await this.repository.recordSettlementPayoutPending({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
payoutState: result.state,
payoutPackageInfo: settlement.payoutPackageInfo,
note: input.note
});
return { settlement: pending, idempotent: false, transferState: result.state };
}
async processWechatTransferNotification(
headers: WechatNotificationHeaders,
rawBody: string,
traceId: string
) {
const { credential, payload } = this.decryptNotification(headers, rawBody);
const merchantId = stringPayload(payload, 'mch_id');
if (merchantId !== credential.merchantId) {
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
}
const outBillNo = stringPayload(payload, 'out_bill_no');
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
const settlement = await this.findSettlementByTransferReference(
credential.merchantId,
outBillNo,
transferBillNo
);
const actor: CleaningActor = {
tenantId: settlement.tenantId,
userId: String(settlement.generatedBy),
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId,
actorType: 'SYSTEM'
};
const state = stringPayload(payload, 'state');
if (settlement.status === 'PAID' && state === 'SUCCESS') {
return { settlement, transferState: state, idempotent: true };
}
if (state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
providerConfirmed: true,
note: '微信转账回调确认成功'
});
return { settlement: paid, transferState: state, idempotent: false };
}
if (state === 'FAIL') {
const failed = await this.repository.recordSettlementPayoutFailure({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
providerConfirmed: true,
note: '微信转账回调确认失败'
});
return { settlement: failed, transferState: state, idempotent: false };
}
const pending = await this.repository.recordSettlementPayoutPending({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
payoutState: state,
payoutPackageInfo: settlement.payoutPackageInfo,
note: '微信转账回调更新中间态'
});
return { settlement: pending, transferState: state, idempotent: false };
}
private async loadSettlement(tenantId: string, settlementId: string) {
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
generated_by AS generatedBy,
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
payout_channel AS payoutChannel, payout_request_no AS payoutRequestNo,
payout_reference AS payoutReference,
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
[tenantId, settlementId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
return rows[0];
}
private async resolveCollectionAccount(tenantId: string, storeId: string | null) {
const [rows] = await this.pool.execute<AccountRow[]>(
`SELECT id, platform_app_id AS platformAppId, store_id AS storeId,
merchant_id AS merchantId, credential_ref AS credentialRef,
authorization_status AS authorizationStatus
FROM qipai_collection_accounts
WHERE tenant_id = ? AND provider = 'WECHAT' AND enabled = 1
AND (store_id IS NULL OR store_id <=> ?)
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
[tenantId, storeId]
);
if (!rows[0]) throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_FOUND');
return rows[0];
}
private async resolveCleanerOpenid(tenantId: string, platformAppId: string | null, cleanerUserId: string) {
const params: Array<string | number> = [tenantId, cleanerUserId];
const platformFilter = platformAppId ? 'AND platform_app_id = ?' : '';
if (platformAppId) params.push(platformAppId);
const [rows] = await this.pool.execute<IdentityRow[]>(
`SELECT openid FROM qipai_user_identities
WHERE tenant_id = ? AND user_id = ? AND provider = 'WECHAT_MINIAPP'
AND deleted_at IS NULL ${platformFilter}
ORDER BY updated_at DESC, id DESC LIMIT 1`,
params
);
if (!rows[0]?.openid) throw new CleaningPayoutError('WECHAT_OPENID_NOT_FOUND');
return rows[0].openid;
}
private resolveCredential(reference: string) {
return this.credentials.get(reference)
?? this.credentials.get(reference.replace(/^env:/, ''));
}
private decryptNotification(headers: WechatNotificationHeaders, rawBody: string) {
let lastError: unknown;
for (const credential of this.credentials.values()) {
if (!credential.platformCertificates[headers.serial]) continue;
try {
return { credential, payload: this.client.verifyAndDecrypt(credential, headers, rawBody) };
} catch (error) {
lastError = error;
}
}
if (lastError instanceof Error) throw lastError;
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
}
private async findSettlementByTransferReference(
merchantId: string,
outBillNo: string,
transferBillNo: string
) {
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
`SELECT s.tenant_id AS tenantId, s.id, s.settlement_no AS settlementNo,
s.cleaner_user_id AS cleanerUserId, s.generated_by AS generatedBy,
s.store_id AS storeId, s.status,
s.total_reward_cents AS totalRewardCents, s.payout_channel AS payoutChannel,
s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference, s.payout_state AS payoutState,
s.payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements s
INNER JOIN qipai_collection_accounts account
ON account.tenant_id = s.tenant_id
AND account.provider = 'WECHAT' AND account.merchant_id = ?
AND account.enabled = 1
AND (account.store_id IS NULL OR account.store_id <=> s.store_id)
WHERE s.payout_channel = 'WECHAT_TRANSFER'
AND s.deleted_at IS NULL
AND (s.payout_request_no IN (${references.map(() => '?').join(',')})
OR s.payout_reference IN (${references.map(() => '?').join(',')}))
ORDER BY (account.store_id IS NOT NULL) DESC,
s.updated_at DESC, s.id DESC LIMIT 1`,
[merchantId, ...references, ...references]
);
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
return rows[0];
}
}
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
void settlementNo;
return `CLP${settlementId}`;
}
function stringPayload(payload: Record<string, unknown>, key: string) {
const value = payload[key];
if (typeof value !== 'string' || value.length === 0) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return value;
}
function optionalStringPayload(payload: Record<string, unknown>, key: string) {
const value = payload[key];
return typeof value === 'string' ? value : '';
}
function maskIdentifier(value: string) {
if (value.length <= 4) return '****';
return `${value.slice(0, 2)}***${value.slice(-4)}`;
}
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -19,6 +19,7 @@ const configSchema = z.object({
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
QIPAI_PROFIT_SHARE_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
QIPAI_MQTT_CLIENT_ID: z.string().min(1).max(128).default('qipai-backend'),
@@ -74,7 +75,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS,
profitShareMockEnabled: parsed.NODE_ENV !== 'production'
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true'
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true',
cleaningPayoutMockEnabled: parsed.NODE_ENV !== 'production'
&& parsed.QIPAI_CLEANING_PAYOUT_MOCK_ENABLED === 'true'
},
thirdParty: {
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
+160 -6
View File
@@ -31,11 +31,27 @@ export interface AdvertisementInput {
interface IdRow extends RowDataPacket { id: string }
interface VersionRow extends RowDataPacket { nextVersion: number }
interface ContentRow extends RowDataPacket {
id: string; scopeType: string; storeId: string | null; title: string; imageUrl: string;
id: string; scopeType: string; storeId: string | null; title: string;
imageAssetId: string; imageUrl: string;
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
status: string; sortOrder: number;
}
interface AssetRow extends RowDataPacket {
id: string; storeId: string | null; url: string; mimeType: string; byteSize: number;
width: number; height: number; createdAt: Date;
}
interface DecorationRow extends RowDataPacket {
id: string; storeId: string; templateCode: string; schemaVersion: number;
content: string | Record<string, unknown>; status: string; version: number;
publishedAt: Date | null; createdAt: Date;
}
interface AdvertisementScopeRow extends RowDataPacket {
scopeType: AdvertisementInput['scopeType']; storeId: string | null;
}
export class ContentError extends Error {
constructor(public readonly code: string) { super(code); }
}
@@ -44,7 +60,7 @@ export class ContentRepository {
constructor(private readonly pool: MySqlPool) {}
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
if (storeId) this.assertStoreScope(actor, storeId);
this.assertAssetWriteScope(actor, storeId);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_media_assets
@@ -61,6 +77,24 @@ export class ContentRepository {
});
}
async listAssets(actor: ManagementActor, storeId?: string) {
if (storeId) this.assertStoreScope(actor, storeId);
const scope = this.assetReadScope(actor, storeId);
const [rows] = await this.pool.execute<AssetRow[]>(
`SELECT id, store_id AS storeId, public_url AS url, mime_type AS mimeType,
byte_size AS byteSize, width, height, created_at AS createdAt
FROM qipai_media_assets
WHERE tenant_id = ? AND deleted_at IS NULL AND ${scope.sql}
ORDER BY id DESC LIMIT 200`,
[actor.tenantId, ...scope.params]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId)
}));
}
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
@@ -85,6 +119,25 @@ export class ContentRepository {
});
}
async listDecorations(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
const [rows] = await this.pool.execute<DecorationRow[]>(
`SELECT id, store_id AS storeId, template_code AS templateCode,
schema_version AS schemaVersion, content_json AS content,
status, version, published_at AS publishedAt, created_at AS createdAt
FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL
ORDER BY version DESC LIMIT 100`,
[actor.tenantId, storeId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
content: parseJson(row.content)
}));
}
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
@@ -114,7 +167,8 @@ export class ContentRepository {
const scope = this.adScope(actor);
const [rows] = await this.pool.execute<ContentRow[]>(
`SELECT a.id, a.scope_type AS scopeType, a.store_id AS storeId, a.title,
m.public_url AS imageUrl, a.target_type AS targetType,
a.image_asset_id AS imageAssetId, m.public_url AS imageUrl,
a.target_type AS targetType,
a.target_value AS targetValue, a.starts_at AS startsAt, a.ends_at AS endsAt,
a.status, a.sort_order AS sortOrder
FROM qipai_advertisements a
@@ -123,11 +177,17 @@ export class ContentRepository {
ORDER BY a.sort_order, a.id DESC`,
[actor.tenantId, ...scope.params]
);
return rows.map((row) => ({ ...row, id: String(row.id), storeId: row.storeId && String(row.storeId) }));
return rows.map((row) => ({
...row,
id: String(row.id),
imageAssetId: String(row.imageAssetId),
storeId: row.storeId === null ? null : String(row.storeId)
}));
}
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
this.assertAdScope(actor, input);
const assetScope = this.adAssetScope(input);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_advertisements
@@ -135,10 +195,12 @@ export class ContentRepository {
target_value, starts_at, ends_at, status, sort_order, created_by)
SELECT ?, ?, ?, ?, m.id, ?, ?, ?, ?, ?, ?, ?
FROM qipai_media_assets m
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL`,
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
AND ${assetScope.sql}`,
[actor.tenantId, input.scopeType, input.storeId ?? null, input.title,
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId]
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId,
...assetScope.params]
);
if (result.affectedRows !== 1) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
const advertisementId = String(result.insertId);
@@ -147,6 +209,45 @@ export class ContentRepository {
});
}
async updateAdvertisement(
actor: ManagementActor, advertisementId: string, input: AdvertisementInput
) {
this.assertAdScope(actor, input);
const assetScope = this.adAssetScope(input);
return this.transaction(async (connection) => {
const [advertisements] = await connection.execute<AdvertisementScopeRow[]>(
`SELECT scope_type AS scopeType, store_id AS storeId
FROM qipai_advertisements
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, advertisementId]
);
const existing = advertisements[0];
if (!existing) throw new ContentError('ADVERTISEMENT_NOT_FOUND');
this.assertExistingAdScope(actor, existing);
const [assets] = await connection.execute<IdRow[]>(
`SELECT m.id FROM qipai_media_assets m
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
AND ${assetScope.sql}`,
[actor.tenantId, input.imageAssetId, ...assetScope.params]
);
if (!assets[0]) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
await connection.execute(
`UPDATE qipai_advertisements
SET scope_type = ?, store_id = ?, title = ?, image_asset_id = ?,
target_type = ?, target_value = ?, starts_at = ?, ends_at = ?,
status = ?, sort_order = ?
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.scopeType, input.storeId ?? null, input.title, input.imageAssetId,
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
input.status, input.sortOrder, actor.tenantId, advertisementId]
);
await this.audit(connection, actor, 'ADVERTISEMENT_UPDATED', 'ADVERTISEMENT', advertisementId);
return { advertisementId };
});
}
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN');
@@ -160,6 +261,26 @@ export class ContentRepository {
}
}
private assertExistingAdScope(actor: ManagementActor, advertisement: AdvertisementScopeRow) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (advertisement.scopeType !== 'STORE' || !advertisement.storeId
|| !actor.access.storeIds.includes(String(advertisement.storeId))) {
throw new ContentError('ADVERTISEMENT_SCOPE_FORBIDDEN');
}
}
private assertAssetWriteScope(actor: ManagementActor, storeId?: string) {
if (storeId) {
this.assertStoreScope(actor, storeId);
return;
}
if (!actor.access.capabilities.includes('tenant.manage')
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
throw new ContentError('GLOBAL_ASSET_FORBIDDEN');
}
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
@@ -180,6 +301,30 @@ export class ContentRepository {
};
}
private assetReadScope(actor: ManagementActor, storeId?: string) {
if (storeId) {
return { sql: '(store_id IS NULL OR store_id = ?)', params: [storeId] };
}
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (actor.access.storeIds.length === 0) {
return { sql: 'store_id IS NULL', params: [] as string[] };
}
return {
sql: `(store_id IS NULL OR store_id IN (${actor.access.storeIds.map(() => '?').join(',')}))`,
params: actor.access.storeIds
};
}
private adAssetScope(input: AdvertisementInput) {
if (input.scopeType === 'STORE' && input.storeId) {
return { sql: '(m.store_id IS NULL OR m.store_id = ?)', params: [input.storeId] };
}
return { sql: 'm.store_id IS NULL', params: [] as string[] };
}
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_stores
@@ -218,3 +363,12 @@ export class ContentRepository {
}
}
}
function parseJson(value: string | Record<string, unknown>) {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
throw new ContentError('INVALID_DECORATION_CONTENT');
}
}
+31 -3
View File
@@ -1,5 +1,5 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { extname, resolve, sep } from 'node:path';
import sharp from 'sharp';
@@ -33,18 +33,33 @@ export class MediaStorage {
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
throw new MediaValidationError('IMAGE_SIZE_INVALID');
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.contentType)) {
const declaredFormat = {
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp'
}[input.contentType];
if (!declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_INVALID');
}
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
const declaredExtension = {
'.jpg': 'jpeg',
'.jpeg': 'jpeg',
'.png': 'png',
'.webp': 'webp'
}[extname(input.originalName).toLowerCase()];
if (!declaredExtension) {
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
}
if (declaredExtension !== declaredFormat) {
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
}
let result: Buffer;
let metadata: sharp.Metadata;
try {
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
metadata = await source.metadata();
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
result = await source
.rotate()
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
@@ -74,4 +89,17 @@ export class MediaStorage {
checksumSha256: createHash('sha256').update(result).digest('hex')
};
}
async deleteImage(storagePath: string): Promise<void> {
const safeRoot = resolve(this.root);
const target = resolve(safeRoot, storagePath);
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
throw new MediaValidationError('IMAGE_PATH_INVALID');
}
try {
await unlink(target);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
}
+383 -6
View File
@@ -11,6 +11,15 @@ export interface MigrationPlan {
file: string;
checksum: string;
statements: readonly string[];
migrations?: readonly MigrationFilePlan[];
}
export interface MigrationFilePlan {
version: string;
name: string;
file: string;
checksum: string;
statements: readonly string[];
}
export interface MigrationExecutionResult extends MigrationPlan {
@@ -18,6 +27,12 @@ export interface MigrationExecutionResult extends MigrationPlan {
affectedRows: number;
}
type MigrationQueryExecutor = Pick<MySqlPool, 'query'>;
type MigrationPool = MigrationQueryExecutor & Partial<Pick<MySqlPool, 'getConnection'>>;
const migrationLockExpression =
"CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
up: [
@@ -44,7 +59,23 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql',
'database/migrations/2026062524_m08a_recharge_wechat.up.sql'
'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql',
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql',
'database/migrations/2026081109_m09d3_product_storage.up.sql',
'database/migrations/2026081110_m10a_notification_center.up.sql',
'database/migrations/2026081111_m10b_business_reports.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -70,9 +101,41 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql',
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql'
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql',
'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql',
'database/migrations/2026081109_m09d3_product_storage.verify.sql',
'database/migrations/2026081110_m10a_notification_center.verify.sql',
'database/migrations/2026081111_m10b_business_reports.verify.sql'
],
down: [
'database/migrations/2026081111_m10b_business_reports.down.sql',
'database/migrations/2026081110_m10a_notification_center.down.sql',
'database/migrations/2026081109_m09d3_product_storage.down.sql',
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
'database/migrations/2026062626_m08b_cleaning_settlements.down.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
'database/migrations/2026062423_m07c_benefits.down.sql',
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
@@ -190,17 +253,30 @@ export async function loadMigrationPlan(direction: MigrationDirection): Promise<
relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
);
const sql = sqlParts.join('\n');
const migrations = relativeFiles.map((file, index) => {
const match = /(?:^|\/)(\d+)_([a-z0-9_]+)\.(?:up|verify|down)\.sql$/iu.exec(file);
if (!match) throw new Error(`Invalid migration file name: ${file}`);
const migrationSql = sqlParts[index];
return {
version: match[1],
name: match[2],
file,
checksum: createHash('sha256').update(migrationSql).digest('hex'),
statements: splitSqlStatements(migrationSql)
};
});
return {
direction,
file: relativeFiles.join(','),
checksum: createHash('sha256').update(sql).digest('hex'),
statements: splitSqlStatements(sql)
statements: splitSqlStatements(sql),
migrations
};
}
export async function executeMigrationPlan(
pool: Pick<MySqlPool, 'query'>,
pool: MigrationPool,
plan: MigrationPlan,
dryRun = false
): Promise<MigrationExecutionResult> {
@@ -208,9 +284,64 @@ export async function executeMigrationPlan(
return { ...plan, executed: false, affectedRows: 0 };
}
if (pool.getConnection) {
const connection = await pool.getConnection();
let lockAcquired = false;
let executionFailed = false;
try {
const [rows] = await connection.query(
`SELECT GET_LOCK(${migrationLockExpression}, 30) AS acquired`
);
const acquired = Array.isArray(rows)
? Number((rows[0] as Record<string, unknown> | undefined)?.acquired ?? 0)
: 0;
if (acquired !== 1) {
throw new Error('MIGRATION_LOCK_TIMEOUT: another migration process is still running.');
}
lockAcquired = true;
return await executeMigrationPlanUnlocked(connection, plan);
} catch (error) {
executionFailed = true;
throw error;
} finally {
let releaseError: unknown;
if (lockAcquired) {
try {
const [rows] = await connection.query(
`SELECT RELEASE_LOCK(${migrationLockExpression}) AS released`
);
const released = Array.isArray(rows)
? Number((rows[0] as Record<string, unknown> | undefined)?.released ?? 0)
: 0;
if (released !== 1) {
releaseError = new Error('MIGRATION_LOCK_RELEASE_FAILED: migration lock was lost.');
}
} catch (error) {
releaseError = error;
}
}
connection.release();
if (!executionFailed && releaseError) throw releaseError;
}
}
return executeMigrationPlanUnlocked(pool, plan);
}
async function executeMigrationPlanUnlocked(
pool: MigrationQueryExecutor,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
if (plan.direction === 'up' && plan.migrations?.length) {
return executeVersionedUpPlan(pool, plan);
}
if (plan.direction === 'up') await assertTriggerConfiguration(pool, plan.statements);
let affectedRows = 0;
for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement);
const result = await queryMigrationStatement(pool, statement);
if (plan.direction === 'verify') {
const minimumRows = [
10, 26, 1,
@@ -235,7 +366,21 @@ export async function executeMigrationPlan(
3, 3, 9, 1,
1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1,
5, 10, 7, 3, 1
5, 10, 7, 3, 1,
5, 2,
2, 8, 4, 3, 1,
2, 9, 5, 2, 1,
1, 7, 5, 1,
4, 1, 1, 1,
2, 1, 1,
1, 1, 1,
1, 2, 3, 1,
1, 2, 3, 1,
3, 7, 3, 1,
2, 8, 4, 1,
9, 63, 18, 28, 15, 2, 4, 1, 1,
7, 54, 22, 18, 34, 6, 2, 1, 1,
4, 30, 17, 14, 16, 4, 2, 1, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -253,3 +398,235 @@ export async function executeMigrationPlan(
return { ...plan, executed: true, affectedRows };
}
interface AppliedMigrationRow extends Record<string, unknown> {
version?: unknown;
name?: unknown;
checksum?: unknown;
}
async function executeVersionedUpPlan(
pool: Pick<MySqlPool, 'query'>,
plan: MigrationPlan
): Promise<MigrationExecutionResult> {
const migrations = plan.migrations ?? [];
const appliedRows = await readAppliedMigrations(pool);
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
for (const migration of migrations) {
const row = applied.get(migration.version);
if (!row) continue;
if (String(row.name) !== migration.name) {
throw new Error(
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
);
}
const storedChecksum = String(row.checksum ?? '');
if (storedChecksum && storedChecksum !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
const pending = migrations.filter((migration) => !applied.has(migration.version));
await assertTriggerConfiguration(
pool,
pending.flatMap((migration) => [...migration.statements])
);
let affectedRows = 0;
for (const migration of pending) {
for (const statement of migration.statements) {
const result = await queryMigrationStatement(pool, statement);
if (result && typeof result === 'object' && 'affectedRows' in result) {
const value = Reflect.get(result, 'affectedRows');
if (typeof value === 'number') affectedRows += value;
}
}
const [markerRows] = await pool.query(
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const marker = Array.isArray(markerRows)
? markerRows[0] as Record<string, unknown> | undefined
: undefined;
if (!marker || String(marker.name) !== migration.name) {
throw new Error(
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
);
}
await storeMigrationChecksum(pool, migration);
}
for (const migration of migrations) {
await storeMigrationChecksum(pool, migration);
}
return { ...plan, executed: true, affectedRows };
}
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
try {
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
throw error;
}
}
async function storeMigrationChecksum(
pool: Pick<MySqlPool, 'query'>,
migration: MigrationFilePlan
) {
try {
const [result] = await pool.query(
`UPDATE qipai_schema_migrations
SET checksum = ?
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
[migration.checksum, migration.version, migration.checksum]
);
if (result && typeof result === 'object'
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
const [rows] = await pool.query(
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
[migration.version]
);
const stored = Array.isArray(rows)
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
: '';
if (stored && stored !== migration.checksum) {
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
}
}
} catch (error) {
if (error && typeof error === 'object'
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
throw error;
}
}
async function assertTriggerConfiguration(
pool: Pick<MySqlPool, 'query'>,
statements: readonly string[]
) {
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
const [rows] = await pool.query(
`SELECT @@GLOBAL.log_bin AS logBin,
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
);
const setting = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
if (Number(setting?.logBin ?? 0) === 1
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
throw new Error(
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
);
}
}
async function queryMigrationStatement(
pool: Pick<MySqlPool, 'query'>,
statement: string
) {
try {
const [result] = await pool.query(statement);
return result;
} catch (error) {
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
throw error;
}
}
async function isMissingM09D1DownObject(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (indexTarget) {
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${indexTarget.table}'
AND index_name = '${indexTarget.index}'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT 1
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'
LIMIT 1`
);
return Array.isArray(rows) && rows.length === 0;
}
async function isMatchingM09D1ChecksumColumn(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
const [rows] = await pool.query(
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
is_nullable AS isNullable, column_default AS columnDefault,
collation_name AS collationName
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_schema_migrations'
AND column_name = 'checksum'`
);
const column = Array.isArray(rows)
? rows[0] as Record<string, unknown> | undefined
: undefined;
return column?.dataType === 'char'
&& Number(column.maxLength) === 64
&& column.isNullable === 'NO'
&& String(column.columnDefault ?? '') === ''
&& column.collationName === 'ascii_bin';
}
async function isMatchingM09D1ScopeIndex(
pool: Pick<MySqlPool, 'query'>,
statement: string,
error: unknown
) {
if (!error || typeof error !== 'object'
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
return false;
}
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
: null;
if (!target) return false;
const [rows] = await pool.query(
`SELECT non_unique AS nonUnique,
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = '${target.table}'
AND index_name = '${target.index}'
GROUP BY non_unique`
);
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
}
+145 -16
View File
@@ -11,7 +11,7 @@ import {
interface DeviceRow extends RowDataPacket {
id: string; deviceId: string; storeId: string; roomId: string | null;
deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
status: string;
status: string; model?: string | null; capabilities?: unknown;
}
export class DeviceControlError extends Error {
@@ -47,7 +47,8 @@ export class DeviceControlService {
}
async playTts(context: CommandContext, input: {
content: string; volume?: number; playCount?: number; priority?: number;
content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
speaker?: number; style?: number; speed?: number; intonation?: number;
}) {
return this.issueControlBox(context, 'PlayTTS',
(id) => this.controlBox.playTts({ id, ...input }));
@@ -86,18 +87,24 @@ export class DeviceControlService {
}
async controlSubLock(context: CommandContext, input: {
subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
| 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
dangerConfirmation?: string;
}) {
if (['factoryreset'].includes(input.order)) {
const credentialOrder = ['setkey', 'delkey', 'setcard', 'delcard', 'factoryreset']
.includes(input.order);
if (credentialOrder && !this.isManager(context.access)) {
throw new DeviceControlError('DEVICE_CREDENTIAL_ACTION_FORBIDDEN');
}
if (input.order === 'factoryreset') {
if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
}
}
if (['delkey', 'delcard'].includes(input.order) && !input.content) {
if (['delkey', 'delcard'].includes(input.order) && input.value === 'all') {
if (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
@@ -108,9 +115,13 @@ export class DeviceControlService {
(id) => this.subLock.control({ id, ...vendorInput }));
}
async readSmartSocket(context: CommandContext, target: 'basicInfo' | 'workInfo') {
return this.issueSmartSocket(context, `socket:${target}`,
() => this.smartSocket.read(target));
async readSmartSocket(context: CommandContext, input: 'basicInfo' | 'workInfo' | {
target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
slotNum?: number; taskNum?: number;
}) {
const request = typeof input === 'string' ? { target: input } : input;
return this.issueSmartSocket(context, `socket:${request.target}`,
() => this.smartSocket.read(request));
}
async switchSmartSocket(context: CommandContext, input: {
@@ -122,16 +133,78 @@ export class DeviceControlService {
}
async scheduleSmartSocket(context: CommandContext, input: {
taskNum: number; action: 'on' | 'off'; mode: 'once' | 'daily' | 'weekly';
slotNum?: number; taskNum: number; action: 'on' | 'off';
mode: 'once' | 'daily' | 'weekly';
time: string; weekdays?: number[];
}) {
return this.issueSmartSocket(context, 'localtask',
(id) => this.smartSocket.localTask({ id, ...input }), context.orderId);
}
async clearSmartSocketTask(context: CommandContext, taskNum: number) {
async clearSmartSocketTask(context: CommandContext, input: number | {
slotNum?: number; taskNum: number; dangerConfirmation?: string;
}) {
const request = typeof input === 'number' ? { taskNum: input } : input;
if (request.taskNum === 0) {
this.assertDangerousSocketAction(
context, request.dangerConfirmation, 'CONFIRM_CLEAR_ALL_SOCKET_TASKS'
);
}
const { dangerConfirmation: _, ...vendorInput } = request;
return this.issueSmartSocket(context, 'clearTask',
(id) => this.smartSocket.clearTask({ id, taskNum }), context.orderId);
(id) => this.smartSocket.clearTask({ id, ...vendorInput }), context.orderId);
}
async rebootSmartSocket(context: CommandContext, dangerConfirmation?: string) {
this.assertDangerousSocketAction(context, dangerConfirmation, 'CONFIRM_REBOOT_SOCKET');
return this.issueSmartSocket(context, 'reboot', (id) => this.smartSocket.reboot(id));
}
async emptySmartSocketEnergy(context: CommandContext, dangerConfirmation?: string) {
this.assertDangerousSocketAction(
context, dangerConfirmation, 'CONFIRM_EMPTY_SOCKET_ENERGY'
);
return this.issueSmartSocket(context, 'emptyPower',
(id) => this.smartSocket.emptyPower(id));
}
async resetSmartSocket(context: CommandContext, input: {
password: string; dangerConfirmation?: string;
}) {
this.assertDangerousSocketAction(
context, input.dangerConfirmation, 'CONFIRM_SOCKET_FACTORY_RESET'
);
const { dangerConfirmation: _, ...vendorInput } = input;
return this.issueSmartSocket(context, 'returnFactory',
(id) => this.smartSocket.returnFactory({ id, ...vendorInput }));
}
async changeSmartSocketReturnKey(context: CommandContext, input: {
old: string; new: string; dangerConfirmation?: string;
}) {
this.assertDangerousSocketAction(
context, input.dangerConfirmation, 'CONFIRM_CHANGE_SOCKET_RETURN_KEY'
);
return this.issueSmartSocket(context, 'setReturnKey',
(id) => this.smartSocket.setReturnKey({ id, old: input.old, new: input.new }));
}
async configureSmartSocketProtection(context: CommandContext, input: {
maxPower: number; maxCurrent: number; maxTemperature: number;
pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number;
chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number;
}) {
return this.issueSmartSocket(context, 'protectOff',
(id, device) => this.smartSocket.protectOff({
id, ratedAmps: readRatedAmps(device), ...input
}));
}
async configureSmartSocketParameter(context: CommandContext, input: {
resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1;
}) {
return this.issueSmartSocket(context, 'parameter',
(id) => this.smartSocket.parameter({ id, ...input }));
}
private async issueControlBox(
@@ -143,6 +216,7 @@ export class DeviceControlService {
this.assertWriteScope(context.access, context.storeId);
const device = await this.resolveControlBox(context);
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
await this.auditCommand(context, device, commandType, orderId);
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
@@ -160,12 +234,13 @@ export class DeviceControlService {
private async issueSmartSocket(
context: CommandContext,
commandType: string,
payloadFactory: (id: string) => Record<string, unknown>,
payloadFactory: (id: string, device: DeviceRow) => Record<string, unknown>,
orderId?: string | null
) {
this.assertWriteScope(context.access, context.storeId);
const device = await this.resolveSmartSocket(context);
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
await this.auditCommand(context, device, commandType, orderId);
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
@@ -174,7 +249,7 @@ export class DeviceControlService {
roomId: context.roomId,
orderId,
commandType,
payloadFactory,
payloadFactory: (id) => payloadFactory(id, device),
traceId: context.traceId,
expiresAt: context.expiresAt
});
@@ -183,7 +258,7 @@ export class DeviceControlService {
private async resolveControlBox(context: CommandContext) {
const [rows] = await this.pool.execute<DeviceRow[]>(
`SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId,
device_type AS deviceType, status
device_type AS deviceType, status, model, capabilities
FROM qipai_devices
WHERE tenant_id = ? AND store_id = ? AND room_id = ?
AND device_type = 'CONTROL_BOX' AND deleted_at IS NULL
@@ -217,6 +292,47 @@ export class DeviceControlService {
}
}
private assertDangerousSocketAction(
context: CommandContext, actual: string | undefined, expected: string
) {
if (context.actorType === 'CUSTOMER'
|| !context.access.roles.includes('PLATFORM_ADMIN')
|| actual !== expected) {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
}
}
private async auditCommand(
context: CommandContext,
device: DeviceRow,
commandType: string,
orderId?: string | null
) {
await this.pool.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, ?, ?, 'DEVICE_COMMAND_REQUESTED', 'DEVICE', ?, ?, ?, ?, ?)`,
[
context.tenantId,
context.actorType ?? 'SYSTEM',
context.actorId ?? null,
device.id,
context.traceId,
(context.ip ?? '').slice(0, 64),
(context.userAgent ?? '').slice(0, 255),
JSON.stringify({
commandType,
storeId: context.storeId,
roomId: context.roomId,
orderId: orderId ?? context.orderId ?? null,
deviceId: device.deviceId,
deviceType: device.deviceType
})
]
);
}
private isManager(access: AccessProfile) {
return access.roles.some((role) =>
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
@@ -230,6 +346,19 @@ export interface CommandContext {
roomId: string;
orderId?: string | null;
traceId: string;
actorType?: 'CUSTOMER' | 'STAFF' | 'ADMIN' | 'SYSTEM';
actorId?: string | null;
ip?: string;
userAgent?: string;
access: AccessProfile;
expiresAt?: Date | null;
}
function readRatedAmps(device: DeviceRow): 10 | 16 | 63 {
const source = `${device.model ?? ''} ${JSON.stringify(device.capabilities ?? '')}`;
const match = /(?:^|[^0-9])(10|16|63)A(?:$|[^0-9])/i.exec(source);
if (match?.[1] === '10') return 10;
if (match?.[1] === '16') return 16;
if (match?.[1] === '63') return 63;
throw new DeviceControlError('SOCKET_RATED_CURRENT_UNKNOWN');
}
+8 -2
View File
@@ -36,6 +36,7 @@ interface HardwareSmokeConfig {
controlBoxDeviceId: string;
smartSocketDeviceId: string;
subLockSubId: string;
subLockSubtype: '14' | '15' | '';
timeoutMs: number;
}
@@ -49,13 +50,17 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '',
subLockSubtype: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '15'
? '15'
: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '14' ? '14' : '',
timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
};
}
export function buildHardwareSmokeCases(
config: Pick<HardwareSmokeConfig,
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'>
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId'
| 'subLockSubtype' | 'allowActions'>
): HardwareSmokeCase[] {
const controlBox = new JilianControlBoxAdapter();
const socket = new JilianSmartSocketAdapter();
@@ -86,7 +91,7 @@ export function buildHardwareSmokeCases(
destructive: true
});
}
if (config.allowActions && config.subLockSubId) {
if (config.allowActions && config.subLockSubId && config.subLockSubtype) {
cases.push({
id: 'sub-lock-open',
deviceId: config.controlBoxDeviceId,
@@ -94,6 +99,7 @@ export function buildHardwareSmokeCases(
commandType: 'CtrlDevice',
payload: lock.control({
id: generateCommandId(Date.now(), cases.length),
subtype: config.subLockSubtype,
subID: config.subLockSubId,
order: 'open',
delayTime: 4
+142 -66
View File
@@ -32,7 +32,10 @@ export class IotMessageService {
const payloadHash = createHash('sha256').update(payload).digest('hex');
const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic);
if (!topicMatch) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_TOPIC_INVALID');
await this.deadLetter(
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
'MQTT_TOPIC_INVALID'
);
return;
}
@@ -46,46 +49,69 @@ export class IotMessageService {
);
const device = devices[0];
if (!device) {
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_DEVICE_UNKNOWN');
await this.deadLetter(
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
'MQTT_DEVICE_UNKNOWN'
);
return;
}
let rawPayload: unknown;
let normalized: NormalizedVendorMessage;
try {
if (topicMatch[1] === 'devicewill') {
rawPayload = parseWillPayload(payloadText);
normalized = normalizeWill(rawPayload);
} else {
rawPayload = JSON.parse(payloadText);
normalized = topicMatch[1] === 'devicewill'
? normalizeWill(rawPayload)
: this.adapters[device.deviceType].parseUplink(rawPayload);
normalized = this.adapters[device.deviceType].parseUplink(rawPayload);
}
if (normalized.deviceId && normalized.deviceId !== device.deviceId) {
throw new Error('Payload DeviceID does not match MQTT topic.');
}
} catch (error) {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, payloadText,
device.tenantId, device.id, topic, payloadHash,
sanitizePayloadTextForStorage(payloadText),
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
);
return;
}
const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
normalized = { ...normalized, payload: safePayload };
const safeRawPayload = sanitizeSensitivePayload(rawPayload, normalized);
const safeNormalizedPayload = sanitizeSensitivePayload(normalized.payload, normalized);
normalized = { ...normalized, payload: safeNormalizedPayload };
const protocolCode = normalized.result === 'UNKNOWN_VENDOR_RESULT'
? 'UNKNOWN_VENDOR_RESULT'
: normalized.protocolIssues[0] ?? null;
const processingStatus = protocolCode
? 'PROTOCOL_ERROR'
: 'PROCESSED';
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_device_events
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
payload_hash, raw_payload, normalized_payload, event_at, processing_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PROCESSED')
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
received_at = UTC_TIMESTAMP(3)`,
[device.tenantId, device.id, device.storeId, device.roomId,
normalized.commandId, topic, normalized.eventType, payloadHash,
JSON.stringify(safePayload), JSON.stringify(normalized),
normalized.eventAt]
JSON.stringify(safeRawPayload), JSON.stringify(normalized),
normalized.eventAt, processingStatus]
);
if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, safePayload);
await this.updateDevice(device, normalized, safeNormalizedPayload);
await this.applyAlerts(device, normalized);
if (protocolCode) {
await this.deadLetter(
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safeRawPayload),
protocolCode,
protocolCode === 'UNKNOWN_VENDOR_RESULT'
? `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
: `Protocol issue for ${normalized.eventType}: ${protocolCode}`
);
}
if (normalized.kind === 'ACK' && normalized.commandId) {
await this.applyAcknowledgement(device, normalized);
}
@@ -106,7 +132,8 @@ export class IotMessageService {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.assetId, input.storeId, input.roomId ?? null,
input.orderId ?? null, input.commandId, input.commandType,
JSON.stringify(input.payload), input.traceId, input.expiresAt ?? null]
JSON.stringify(sanitizeSensitivePayload(input.payload)),
input.traceId, input.expiresAt ?? null]
);
return { recordId: String(result.insertId), commandId: input.commandId };
}
@@ -141,8 +168,17 @@ export class IotMessageService {
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
const successful = message.result === 'ok';
if (successful && message.eventType === 'AddDevice'
&& (!readString(message.payload.subID ?? message.payload.subId)
|| !readString(message.payload.subtype))) {
return;
}
const status = successful ? 'ACKED' : 'FAILED';
const failureCode = successful ? '' : `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
const failureCode = successful
? ''
: message.result === 'UNKNOWN_VENDOR_RESULT'
? 'UNKNOWN_VENDOR_RESULT'
: `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
await this.pool.execute(
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
@@ -198,6 +234,17 @@ export class IotMessageService {
}
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
if (message.eventType === 'will') {
await this.upsertAlert(device, 'DEVICE_OFFLINE', 'HIGH', 'Device reported an offline will event.');
}
if (message.result === 'UNKNOWN_VENDOR_RESULT') {
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
`Unsupported vendor result for ${message.eventType}.`);
}
for (const issue of message.protocolIssues) {
await this.upsertAlert(device, issue, 'MEDIUM',
`Device payload reported protocol issue ${issue}.`);
}
const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
? `DEVICE_${message.result?.toUpperCase()}`
: null;
@@ -217,47 +264,23 @@ export class IotMessageService {
}
private async applySmartSocketAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
const payload = message.payload;
const eventType = message.eventType.toLowerCase();
const triggered = (names: string[]) => names.some((name) =>
eventType === name.toLowerCase() || readBoolean(payload[name])
);
if (triggered(['overload', 'overLoad', 'overpower', 'overPower'])) {
await this.upsertAlert(device, 'SOCKET_OVERLOAD', 'HIGH',
'Smart socket reported overload or overpower protection.');
}
if (triggered(['overheat', 'overHeat', 'overTemperature'])) {
await this.upsertAlert(device, 'SOCKET_OVERHEAT', 'HIGH',
'Smart socket reported over-temperature protection.');
}
if (triggered(['overcurrent', 'overCurrent'])) {
await this.upsertAlert(device, 'SOCKET_OVERCURRENT', 'HIGH',
'Smart socket reported over-current protection.');
}
if (triggered(['overvoltage', 'overVoltage'])) {
await this.upsertAlert(device, 'SOCKET_OVERVOLTAGE', 'MEDIUM',
'Smart socket reported over-voltage protection.');
}
if (triggered(['undervoltage', 'underVoltage'])) {
await this.upsertAlert(device, 'SOCKET_UNDERVOLTAGE', 'MEDIUM',
'Smart socket reported under-voltage protection.');
}
const powerWatts = readNumber(payload.powerW ?? payload.power ?? payload.watt);
if (powerWatts !== null && powerWatts > 3500) {
await this.upsertAlert(device, 'SOCKET_POWER_LIMIT', 'HIGH',
`Smart socket power is ${powerWatts}W.`);
}
const temperature = readNumber(payload.temperature ?? payload.temp);
if (temperature !== null && temperature >= 75) {
await this.upsertAlert(device, 'SOCKET_TEMPERATURE_LIMIT', 'HIGH',
`Smart socket temperature is ${temperature}C.`);
}
if (message.eventType !== 'special') return;
const closeReason = readInteger(message.payload.closeReason);
const alerts: Record<number, [string, string]> = {
3: ['SOCKET_PULL_OUT_STOP', 'Smart socket stopped after appliance removal.'],
4: ['SOCKET_CHARGE_FULL_STOP', 'Smart socket stopped after full charge.'],
5: ['SOCKET_OVERPOWER', 'Smart socket stopped on maximum power protection.'],
6: ['SOCKET_OVERCURRENT', 'Smart socket stopped on maximum current protection.'],
7: ['SOCKET_OVERTEMPERATURE', 'Smart socket stopped on maximum temperature protection.']
};
const alert = closeReason === null ? undefined : alerts[closeReason];
if (alert) await this.upsertAlert(device, alert[0], 'HIGH', alert[1]);
}
private async upsertAlert(
device: DeviceRow, alertType: string, severity: string, summary: string
) {
await this.pool.execute(
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_device_alerts
(tenant_id, device_id, store_id, room_id, alert_type, severity, summary)
VALUES (?, ?, ?, ?, ?, ?, ?)
@@ -266,6 +289,17 @@ export class IotMessageService {
[device.tenantId, device.id, device.storeId, device.roomId,
alertType, severity, summary]
);
if (result.affectedRows !== 1 || !result.insertId) return;
await this.pool.execute(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
VALUES (?, 'DEVICE_ALERT', ?, ?, ?, JSON_OBJECT(
'storeId', ?, 'roomId', ?, 'deviceId', ?, 'alertId', ?,
'alertType', ?, 'severity', ?, 'summary', ?))`,
[device.tenantId, String(result.insertId), `DEVICE_ALERT_${alertType}`,
`device-alert:${result.insertId}:notification`, device.storeId, device.roomId,
device.id, String(result.insertId), alertType, severity, summary]
);
}
private async updateDevice(
@@ -305,18 +339,30 @@ export function generateCommandId(now = Date.now(), sequence = 0): string {
}
function normalizeWill(payload: unknown): NormalizedVendorMessage {
const record = zRecord(payload);
const record = typeof payload === 'string' ? { will: payload } : zRecord(payload);
return {
kind: 'EVENT',
commandId: null,
eventType: 'will',
result: null,
rawResult: null,
deviceId: readDeviceId(record),
eventAt: null,
payload: record
payload: record,
protocolIssues: []
};
}
function parseWillPayload(payloadText: string): unknown {
try {
return JSON.parse(payloadText);
} catch {
const will = payloadText.trim();
if (!will || will.length > 64) throw new Error('MQTT will payload is invalid.');
return will;
}
}
function zRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error('MQTT payload must be a JSON object.');
@@ -330,26 +376,60 @@ function readDeviceId(record: Record<string, unknown>): string | null {
}
function sanitizeSensitivePayload(
payload: unknown, normalized: NormalizedVendorMessage
payload: unknown, normalized?: NormalizedVendorMessage
): Record<string, unknown> {
const record = { ...zRecord(payload) };
if (normalized.eventType === 'record' && typeof record.content === 'string') {
const record: Record<string, unknown> = typeof payload === 'string'
? { will: payload }
: { ...zRecord(payload) };
const eventType = normalized?.eventType ?? readString(record.event);
if (eventType === 'record' && typeof record.content === 'string') {
const content = record.content;
record.contentHash = createHash('sha256').update(content).digest('hex');
record.contentMasked = content.length <= 4
? '*'.repeat(content.length)
: `${content.slice(0, 2)}${'*'.repeat(Math.min(8, content.length - 4))}${content.slice(-2)}`;
addMaskedSecret(record, 'content', content);
delete record.content;
}
const sensitiveLockOrder = ['setkey', 'delkey', 'setcard', 'delcard']
.includes(readString(record.order) ?? '');
if (record.action === 'CtrlDevice' && sensitiveLockOrder
&& typeof record.value === 'string') {
addMaskedSecret(record, 'value', record.value);
delete record.value;
}
if (typeof record.password === 'string') {
addMaskedSecret(record, 'password', record.password);
record.password = '<redacted>';
}
if (typeof record.card === 'string') {
addMaskedSecret(record, 'card', record.card);
record.card = '<redacted>';
}
if (record.action === 'setReturnKey') {
for (const field of ['old', 'new']) {
if (typeof record[field] === 'string') {
addMaskedSecret(record, field, record[field]);
delete record[field];
}
}
}
return record;
}
function addMaskedSecret(
record: Record<string, unknown>, field: string, secret: string
): void {
record[`${field}Hash`] = createHash('sha256').update(secret).digest('hex');
record[`${field}Masked`] = secret.length <= 4
? '*'.repeat(secret.length)
: `${secret.slice(0, 2)}${'*'.repeat(Math.min(8, secret.length - 4))}${secret.slice(-2)}`;
}
function sanitizePayloadTextForStorage(payloadText: string): string {
try {
return JSON.stringify(sanitizeSensitivePayload(JSON.parse(payloadText)));
} catch {
return payloadText.slice(0, 1_000_000);
}
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
@@ -362,11 +442,7 @@ function readNumber(value: unknown): number | null {
return null;
}
function readBoolean(value: unknown): boolean {
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
return ['1', 'true', 'yes', 'on', 'alarm'].includes(value.trim().toLowerCase());
}
return false;
function readInteger(value: unknown): number | null {
const number = readNumber(value);
return number !== null && Number.isInteger(number) ? number : null;
}
+346 -48
View File
@@ -1,29 +1,37 @@
import { z } from 'zod';
const commandId = z.string().regex(/^\d{1,13}$/);
const resultCode = z.enum([
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
]);
const vendorPasswordField = 'password' as const;
const vendorResult = z.string().trim().min(1).max(64);
const knownResultCodes = [
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full',
'paraerror', 'error', 'update'
] as const;
const knownResultCode = z.enum(knownResultCodes);
const vendorMessage = z.object({
id: commandId.optional(),
DeviceID: z.string().min(1).max(64).optional(),
deviceID: z.string().min(1).max(64).optional(),
IMEI: z.string().max(64).optional(),
result: resultCode.optional(),
result: vendorResult.optional(),
event: z.string().max(64).optional(),
action: z.string().max(64).optional(),
read: z.string().max(64).optional(),
setting: z.string().max(64).optional(),
timestamp: z.union([z.string(), z.number()]).optional()
}).passthrough();
export type KnownVendorResult = z.infer<typeof knownResultCode>;
export type NormalizedVendorMessage = {
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
commandId: string | null;
eventType: string;
result: z.infer<typeof resultCode> | null;
result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null;
rawResult: string | null;
deviceId: string | null;
eventAt: Date | null;
payload: Record<string, unknown>;
protocolIssues: string[];
};
export interface ProtocolAdapter {
@@ -60,20 +68,32 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
}
playTts(input: {
id: string; content: string; volume?: number; playCount?: number;
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
id: string; content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
speaker?: number; style?: number; speed?: number; intonation?: number;
}) {
return z.object({
action: z.literal('PlayTTS'), id: commandId,
const domain = z.object({
id: commandId,
content: z.string().trim().min(1).max(500),
volume: z.number().int().min(0).max(100).default(80),
playCount: z.number().int().min(1).max(10).default(1),
priority: z.number().int().min(0).max(10).default(0),
speaker: z.number().int().min(0).max(20).default(0),
style: z.number().int().min(0).max(20).default(0),
speed: z.number().int().min(-500).max(500).default(0),
pitch: z.number().int().min(-500).max(500).default(0)
}).parse({ action: 'PlayTTS', ...input });
volume: z.number().int().min(0).max(10).default(8),
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(0).max(10).default(5),
intonation: z.number().int().min(0).max(10).default(5)
}).parse(input);
return {
action: 'PlayTTS' as const,
content: domain.content,
vol: domain.volume,
firstPlay: domain.firstPlay,
loop: domain.loop,
speaker: domain.speaker,
style: domain.style,
speed: domain.speed,
intona: domain.intonation,
id: domain.id
};
}
stopTts(id: string) {
@@ -121,31 +141,65 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
export class JilianSub1GLockAdapter implements ProtocolAdapter {
pair(input: { id: string; timeout?: number }) {
return z.object({
action: z.literal('AddDevice'), id: commandId,
const domain = z.object({
id: commandId,
timeout: z.number().int().min(10).max(300).default(60)
}).parse({ action: 'AddDevice', ...input });
}).parse(input);
return { action: 'AddDevice' as const, time: domain.timeout, id: domain.id };
}
control(input: {
id: string; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
id: string; subtype: '14' | '15'; subID: string;
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
| 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; value?: string;
}) {
return z.object({
action: z.literal('CtrlDevice'), id: commandId,
const schema = z.object({
id: commandId,
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
]),
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
delayTime: z.number().int().min(1).max(14).optional(),
content: z.string().min(1).max(128).optional()
value: z.string().min(1).max(128).optional()
}).superRefine((value, context) => {
if (['setkey', 'setcard'].includes(value.order) && !value.content) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' });
const passwordList = /^(?:\d{6})+$/;
const cardList = /^(?:[A-Fa-f0-9]{8})+$/;
if (value.order === 'setkey' && !passwordList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 6-digit passwords' });
}
}).parse({ action: 'CtrlDevice', ...input });
if (value.order === 'delkey'
&& value.value !== 'all' && !passwordList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 6-digit passwords' });
}
if (value.order === 'setcard' && !cardList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 8-digit hex card IDs' });
}
if (value.order === 'delcard'
&& value.value !== 'all' && !cardList.test(value.value ?? '')) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 8-digit hex card IDs' });
}
if (['open', 'close', 'none', 'factoryreset'].includes(value.order) && value.value) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value is not allowed for this order' });
}
});
const domain = schema.parse(input);
return {
action: 'CtrlDevice' as const,
subtype: domain.subtype,
subID: domain.subID,
order: domain.order,
...(domain.holdopen === undefined ? {} : { holdopen: domain.holdopen }),
...(domain.delayTime === undefined ? {} : { delayTime: domain.delayTime }),
...(domain.value === undefined ? {} : {
value: domain.value !== 'all' && ['setcard', 'delcard'].includes(domain.order)
? domain.value.toUpperCase()
: domain.value
}),
id: domain.id
};
}
parseUplink(payload: unknown) {
@@ -154,8 +208,25 @@ export class JilianSub1GLockAdapter implements ProtocolAdapter {
}
export class JilianSmartSocketAdapter implements ProtocolAdapter {
read(target: 'basicInfo' | 'workInfo') {
return z.object({ read: z.literal(target) }).parse({ read: target });
read(input: 'basicInfo' | 'workInfo' | {
target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
slotNum?: number; taskNum?: number;
}) {
const domain = typeof input === 'string' ? { target: input } : input;
if (domain.target === 'basicInfo' || domain.target === 'mqttConfig') {
return { read: domain.target };
}
if (domain.target === 'workInfo') {
return z.object({
read: z.literal('workInfo'),
slotNum: z.number().int().min(1).max(20).default(1)
}).parse({ read: 'workInfo', slotNum: domain.slotNum });
}
return z.object({
read: z.literal('localtask'),
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(0).max(20)
}).parse({ read: 'localtask', slotNum: domain.slotNum, taskNum: domain.taskNum });
}
switch(input: { id: string; on: boolean; slotNum?: number }) {
@@ -166,29 +237,130 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
}
localTask(input: {
id: string; taskNum: number; action: 'on' | 'off';
id: string; slotNum?: number; taskNum: number; action: 'on' | 'off';
mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
}) {
return z.object({
action: z.literal('localtask'), id: commandId,
const domain = z.object({
id: commandId,
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(1).max(20),
switch: z.enum(['on', 'off']),
action: z.enum(['on', 'off']),
mode: z.enum(['once', 'daily', 'weekly']),
time: z.string().regex(/^\d{2}:\d{2}$/),
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional()
}).parse({
action: 'localtask', id: input.id, taskNum: input.taskNum,
switch: input.action, mode: input.mode, time: input.time, weekdays: input.weekdays
});
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
.refine((items) => new Set(items).size === items.length, 'weekdays must be unique')
.optional()
}).superRefine((value, context) => {
if (value.mode === 'weekly' && !value.weekdays) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are required weekly' });
}
if (value.mode !== 'weekly' && value.weekdays) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are only valid weekly' });
}
}).parse(input);
const weekdaySet = new Set(domain.weekdays ?? []);
const weekdata = domain.mode === 'daily'
? '1111111'
: domain.mode === 'weekly'
? Array.from({ length: 7 }, (_, index) => weekdaySet.has(index + 1) ? '1' : '0').join('')
: '0000000';
return {
action: 'localtask' as const,
slotNum: domain.slotNum,
taskNum: domain.taskNum,
tasktype: domain.action,
cyctype: domain.mode === 'daily' ? 'daycyc' : domain.mode === 'weekly' ? 'weekcyc' : 'once',
weekdata,
actiontime: domain.time.replace(':', ''),
id: domain.id
};
}
clearTask(input: { id: string; taskNum: number }) {
clearTask(input: { id: string; slotNum?: number; taskNum: number }) {
return z.object({
action: z.literal('clearTask'), id: commandId,
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(0).max(20)
}).parse({ action: 'clearTask', ...input });
}
reboot(id: string) {
return z.object({ action: z.literal('reboot'), id: commandId })
.parse({ action: 'reboot', id });
}
emptyPower(id: string) {
return z.object({ action: z.literal('emptyPower'), id: commandId })
.parse({ action: 'emptyPower', id });
}
returnFactory(input: { id: string; password: string }) {
return z.object({
action: z.literal('returnFactory'), id: commandId,
[vendorPasswordField]: z.string().min(1).max(32)
}).parse({ action: 'returnFactory', ...input });
}
setReturnKey(input: { id: string; old: string; new: string }) {
return z.object({
action: z.literal('setReturnKey'), id: commandId,
old: z.string().min(1).max(32), new: z.string().min(1).max(32)
}).parse({ action: 'setReturnKey', ...input });
}
protectOff(input: {
id: string; ratedAmps: 10 | 16 | 63;
maxPower: number; maxCurrent: number; maxTemperature: number;
pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number;
chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number;
}) {
const domain = z.object({
id: commandId, ratedAmps: z.union([z.literal(10), z.literal(16), z.literal(63)]),
maxPower: z.number().positive(), maxCurrent: z.number().positive(),
maxTemperature: z.number().min(1).max(125),
pullOutStop: z.union([z.literal(0), z.literal(1)]),
pullOutPower: z.number().nonnegative(),
pullOutSec: z.number().int().min(1).max(86400),
chargeFullStop: z.union([z.literal(0), z.literal(1)]),
chargeFullPower: z.number().nonnegative(),
chargeFullSec: z.number().int().min(1).max(86400)
}).superRefine((value, context) => {
const powerCeiling = value.ratedAmps * 250;
if (value.maxCurrent > value.ratedAmps) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxCurrent'],
message: `maxCurrent exceeds ${value.ratedAmps}A capability` });
}
if (value.maxPower > powerCeiling) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxPower'],
message: `maxPower exceeds ${powerCeiling}W capability` });
}
if (value.pullOutPower > value.maxPower || value.chargeFullPower > value.maxPower) {
context.addIssue({ code: z.ZodIssueCode.custom,
message: 'auto-stop power cannot exceed maxPower' });
}
}).parse(input);
const { ratedAmps: _, ...wire } = domain;
return { setting: 'protectOff' as const, ...wire };
}
parameter(input: {
id: string; resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1;
}) {
const domain = z.object({
id: commandId,
resetHold: z.union([z.literal(0), z.literal(1)]),
keyLock: z.union([z.literal(0), z.literal(1)]),
keyOff: z.union([z.literal(0), z.literal(1)])
}).parse(input);
return {
setting: 'parameter' as const,
resetHold: domain.resetHold,
keyLock: domain.keyLock,
KeyOff: domain.keyOff,
id: domain.id
};
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
@@ -196,19 +368,145 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
const parsed = vendorMessage.parse(payload);
const record = parsed as Record<string, unknown>;
const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot';
const protocolIssues: string[] = [];
const variants = normalizeReceiveVariants(
parsed as Record<string, unknown>, protocolIssues
);
const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot';
const eventName = canonicalMessageName(rawEventName);
const record = normalizeSocketStatus(variants, eventName, protocolIssues);
const rawResult = parsed.result ?? null;
const result = normalizeResult(eventName, rawResult);
return {
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
kind: parsed.event ? 'EVENT' : parsed.result ? 'ACK' : 'SNAPSHOT',
commandId: parsed.id ?? null,
eventType: eventName,
result: parsed.result ?? null,
result,
rawResult,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp),
payload: record
payload: record,
protocolIssues
};
}
function normalizeResult(
eventName: string, result: string | null
): KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null {
if (result === null) return null;
const parsed = knownResultCode.safeParse(result);
if (!parsed.success) return 'UNKNOWN_VENDOR_RESULT';
const allowedByCommand: Record<string, readonly KnownVendorResult[]> = {
PlayTTS: ['ok', 'paraerror'],
stopTTS: ['ok'],
task: ['ok', 'busy', 'fail', 'unconfirm'],
addtask: ['ok', 'fail'],
canceltask: ['ok'],
AddDevice: ['ok', 'fail', 'timeout'],
CtrlDevice: ['ok', 'fail', 'timeout', 'full'],
on: ['ok'],
off: ['ok', 'update', 'paraerror'],
localtask: ['ok', 'paraerror', 'error', 'update'],
clearTask: ['ok', 'paraerror', 'error'],
reboot: ['ok', 'error'],
emptyPower: ['ok', 'paraerror', 'error'],
returnFactory: ['ok', 'paraerror', 'error'],
setReturnKey: ['ok', 'paraerror', 'error'],
protectOff: ['ok', 'paraerror', 'error', 'update'],
parameter: ['ok', 'paraerror', 'error', 'update'],
mqttConfig: ['ok', 'paraerror', 'error', 'update']
};
const allowed = allowedByCommand[eventName];
return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data;
}
function canonicalMessageName(value: string): string {
return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value;
}
function normalizeReceiveVariants(
record: Record<string, unknown>, protocolIssues: string[]
): Record<string, unknown> {
const normalized = { ...record };
if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) {
normalized.welcomevoice = normalized.welvoice;
}
if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig';
if (normalized.read === 'mqttconfig') normalized.read = 'mqttConfig';
if (normalized.keyOff === undefined && normalized.KeyOff !== undefined) {
normalized.keyOff = normalized.KeyOff;
}
if (normalized.keyOff !== undefined && normalized.KeyOff !== undefined
&& String(normalized.keyOff) !== String(normalized.KeyOff)) {
protocolIssues.push('SOCKET_KEY_OFF_CONFLICT');
}
return normalized;
}
function normalizeSocketStatus(
record: Record<string, unknown>, eventName: string, protocolIssues: string[]
): Record<string, unknown> {
const isWorkInfo = eventName === 'workInfo'
|| ['onOff', 'vol', 'cur', 'pow', 'temp', 'temperature', 'energy']
.some((field) => record[field] !== undefined);
if (!isWorkInfo) return record;
const normalized = { ...record };
const temperature = normalizeDecimalField(record.temp, 'temp', protocolIssues);
const temperatureVariant = normalizeDecimalField(
record.temperature, 'temperature', protocolIssues
);
if (temperature !== null && temperatureVariant !== null
&& temperature !== temperatureVariant) {
protocolIssues.push('SOCKET_TEMPERATURE_CONFLICT');
}
const switchOn = normalizeOnOff(record.onOff);
if (record.onOff !== undefined && switchOn === null) {
protocolIssues.push('SOCKET_ON_OFF_INVALID');
}
return {
...normalized,
...(switchOn === null ? {} : { switchOn }),
...decimalProperty('voltage', record.vol, 'vol', protocolIssues),
...decimalProperty('current', record.cur, 'cur', protocolIssues),
...decimalProperty('power', record.pow, 'pow', protocolIssues),
...((temperature ?? temperatureVariant) === null
? {}
: { temperature: temperature ?? temperatureVariant }),
...decimalProperty('energy', record.energy, 'energy', protocolIssues)
};
}
function decimalProperty(
name: string, value: unknown, source: string, protocolIssues: string[]
): Record<string, string> {
const normalized = normalizeDecimalField(value, source, protocolIssues);
return normalized === null ? {} : { [name]: normalized };
}
function normalizeDecimalField(
value: unknown, source: string, protocolIssues: string[]
): string | null {
if (value === undefined || value === null || value === '') return null;
const text = typeof value === 'number' && Number.isFinite(value)
? String(value)
: typeof value === 'string' ? value.trim() : '';
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
if (!match) {
protocolIssues.push(`SOCKET_${source.toUpperCase()}_INVALID`);
return null;
}
const integer = match[2].replace(/^0+(?=\d)/, '');
const fraction = (match[3] ?? '').replace(/0+$/, '');
const zero = integer === '0' && !fraction;
return `${zero ? '' : match[1]}${integer}${fraction ? `.${fraction}` : ''}`;
}
function normalizeOnOff(value: unknown): boolean | null {
if (value === true || value === 1 || value === '1' || value === 'on') return true;
if (value === false || value === 0 || value === '0' || value === 'off') return false;
return null;
}
function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null;
const date = typeof value === 'number'
@@ -60,12 +60,17 @@ export class OrderDeviceAutomationService {
}
if (payload.event === 'ORDER_ROOM_CHANGED') {
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
}
}
await this.startOrderDevices(order, payload.traceId);
return { orderId: order.id, action: 'ROOM_CHANGED' };
}
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
}
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
return { orderId: order.id, action: 'CANCELLED' };
}
@@ -107,10 +112,22 @@ export class OrderDeviceAutomationService {
roomId,
orderId: order.id,
traceId,
actorType: 'SYSTEM' as const,
access: systemDeviceAccess()
};
}
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_orders
WHERE tenant_id = ? AND room_id = ? AND id <> ?
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
LIMIT 1`,
[tenantId, roomId, excludedOrderId]
);
return Boolean(rows[0]);
}
private async loadOrder(tenantId: string, orderId: string) {
const [rows] = await this.pool.execute<OrderRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
@@ -0,0 +1,232 @@
import { randomUUID } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
export type FollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
export interface FranchiseApplicationInput {
tenantId: string;
submittedUserId?: string | null;
city: string;
contactName: string;
contactPhone: string;
message: string;
source: 'MINIAPP' | 'ADMIN' | 'IMPORT';
clientRequestId: string;
traceId: string;
ip: string;
userAgent: string;
}
interface ApplicationRow extends RowDataPacket {
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
contactPhone: string; message: string; source: string; status: FranchiseStatus;
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
nextFollowUpAt: Date | null; closedAt: Date | null; createdAt: Date; updatedAt: Date;
}
interface FollowUpRow extends RowDataPacket {
id: string; actorUserId: string; actorName: string; followUpType: FollowUpType;
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
note: string; nextFollowUpAt: Date | null; createdAt: Date;
}
interface StatusRow extends RowDataPacket { status: FranchiseStatus }
export class FranchiseError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class FranchiseRepository {
constructor(private readonly pool: MySqlPool) {}
async submitApplication(input: FranchiseApplicationInput) {
return this.transaction(async (connection) => {
const applicationNo = `FR${Date.now().toString(36).toUpperCase()}${randomUUID().slice(0, 6).toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_franchise_applications
(tenant_id, application_no, client_request_id, city, contact_name,
contact_phone, message, source, submitted_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[input.tenantId, applicationNo, input.clientRequestId, input.city, input.contactName,
input.contactPhone, input.message, input.source, input.submittedUserId ?? null]
);
const applicationId = String(result.insertId);
const [rows] = await connection.execute<Array<RowDataPacket & { applicationNo: string }>>(
`SELECT application_no AS applicationNo FROM qipai_franchise_applications
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, applicationId]
);
const created = String(rows[0]?.applicationNo ?? '') === applicationNo;
if (created) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, ?, ?, 'FRANCHISE_APPLICATION_SUBMITTED', 'FRANCHISE_APPLICATION',
?, ?, ?, ?, JSON_OBJECT('source', ?, 'city', ?))`,
[input.tenantId, input.submittedUserId ? 'USER' : 'ANONYMOUS',
input.submittedUserId ?? null, applicationId, input.traceId, input.ip,
input.userAgent.slice(0, 255), input.source, input.city]
);
}
return { applicationId, applicationNo: String(rows[0]?.applicationNo ?? applicationNo), idempotent: !created };
});
}
async listApplications(input: {
tenantId: string; page: number; pageSize: number; status?: FranchiseStatus;
assigneeUserId?: string; search?: string;
}) {
const where = ['a.tenant_id = ?', 'a.deleted_at IS NULL'];
const params: Array<string> = [input.tenantId];
if (input.status) { where.push('a.status = ?'); params.push(input.status); }
if (input.assigneeUserId) { where.push('a.assignee_user_id = ?'); params.push(input.assigneeUserId); }
if (input.search) {
where.push('(a.application_no LIKE ? OR a.city LIKE ? OR a.contact_name LIKE ? OR a.contact_phone LIKE ?)');
const term = `%${input.search}%`; params.push(term, term, term, term);
}
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_franchise_applications a WHERE ${where.join(' AND ')}`,
params
);
const offset = (input.page - 1) * input.pageSize;
const [rows] = await this.pool.execute<ApplicationRow[]>(
`${this.applicationSelect()} WHERE ${where.join(' AND ')}
ORDER BY FIELD(a.status, 'NEW', 'CONTACTED', 'QUALIFIED', 'CONVERTED', 'REJECTED'),
a.next_follow_up_at IS NULL, a.next_follow_up_at, a.id DESC
LIMIT ${input.pageSize} OFFSET ${offset}`,
params
);
return { items: rows.map(normalizeApplication), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize };
}
async getApplication(tenantId: string, applicationId: string) {
const [rows] = await this.pool.execute<ApplicationRow[]>(
`${this.applicationSelect()} WHERE a.tenant_id = ? AND a.id = ? AND a.deleted_at IS NULL`,
[tenantId, applicationId]
);
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
const [followUps] = await this.pool.execute<FollowUpRow[]>(
`SELECT f.id, f.actor_user_id AS actorUserId, u.nickname AS actorName,
f.follow_up_type AS followUpType, f.from_status AS fromStatus,
f.to_status AS toStatus, f.note, f.next_follow_up_at AS nextFollowUpAt,
f.created_at AS createdAt
FROM qipai_franchise_follow_ups f
INNER JOIN qipai_users u ON u.id = f.actor_user_id
WHERE f.tenant_id = ? AND f.application_id = ? ORDER BY f.id DESC`,
[tenantId, applicationId]
);
return { application: normalizeApplication(rows[0]), followUps: followUps.map((row) => ({ ...row, id: String(row.id), actorUserId: String(row.actorUserId) })) };
}
async assignApplication(actor: ManagementActor, tenantId: string, applicationId: string, assigneeUserId: string | null) {
return this.transaction(async (connection) => {
await this.lockApplication(connection, tenantId, applicationId);
if (assigneeUserId) {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_users WHERE tenant_id = ? AND id = ? AND user_type = 'STAFF'
AND status = 'ACTIVE' AND deleted_at IS NULL`,
[tenantId, assigneeUserId]
);
if (!users[0]) throw new FranchiseError('FRANCHISE_ASSIGNEE_INVALID');
}
await connection.execute(
'UPDATE qipai_franchise_applications SET assignee_user_id = ? WHERE tenant_id = ? AND id = ?',
[assigneeUserId, tenantId, applicationId]
);
await this.addEvent(connection, actor, tenantId, applicationId, 'ASSIGNMENT', null, null,
assigneeUserId ? '分派加盟线索负责人' : '取消加盟线索分派', null);
await this.audit(connection, actor, tenantId, 'FRANCHISE_APPLICATION_ASSIGNED', applicationId,
{ assigneeUserId });
return { applicationId, assigneeUserId };
});
}
async addFollowUp(actor: ManagementActor, tenantId: string, applicationId: string, input: {
followUpType: Exclude<FollowUpType, 'ASSIGNMENT' | 'STATUS'>;
note: string; nextFollowUpAt?: Date | null; status?: FranchiseStatus;
}) {
return this.transaction(async (connection) => {
const current = await this.lockApplication(connection, tenantId, applicationId);
const nextStatus = input.status ?? current.status;
if (nextStatus !== current.status && !allowedTransitions[current.status].includes(nextStatus)) {
throw new FranchiseError('FRANCHISE_STATUS_TRANSITION_INVALID');
}
await connection.execute(
`UPDATE qipai_franchise_applications SET status = ?, next_follow_up_at = ?,
closed_at = CASE WHEN ? IN ('REJECTED', 'CONVERTED') THEN UTC_TIMESTAMP(3) ELSE NULL END
WHERE tenant_id = ? AND id = ?`,
[nextStatus, input.nextFollowUpAt ?? null, nextStatus, tenantId, applicationId]
);
const followUpId = await this.addEvent(connection, actor, tenantId, applicationId,
input.followUpType, current.status, nextStatus, input.note, input.nextFollowUpAt ?? null);
await this.audit(connection, actor, tenantId, 'FRANCHISE_FOLLOW_UP_ADDED', applicationId,
{ followUpId, followUpType: input.followUpType, fromStatus: current.status, toStatus: nextStatus });
return { applicationId, followUpId, status: nextStatus };
});
}
private applicationSelect() {
return `SELECT a.id, a.tenant_id AS tenantId, a.application_no AS applicationNo,
a.city, a.contact_name AS contactName, a.contact_phone AS contactPhone,
a.message, a.source, a.status, a.assignee_user_id AS assigneeUserId,
assignee.nickname AS assigneeName, a.submitted_user_id AS submittedUserId,
a.next_follow_up_at AS nextFollowUpAt, a.closed_at AS closedAt,
a.created_at AS createdAt, a.updated_at AS updatedAt
FROM qipai_franchise_applications a
LEFT JOIN qipai_users assignee ON assignee.id = a.assignee_user_id AND assignee.tenant_id = a.tenant_id`;
}
private async lockApplication(connection: PoolConnection, tenantId: string, id: string) {
const [rows] = await connection.execute<StatusRow[]>(
'SELECT status FROM qipai_franchise_applications WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE',
[tenantId, id]
);
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
return rows[0];
}
private async addEvent(connection: PoolConnection, actor: ManagementActor, tenantId: string,
applicationId: string, type: FollowUpType, fromStatus: FranchiseStatus | null,
toStatus: FranchiseStatus | null, note: string, nextFollowUpAt: Date | null) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_franchise_follow_ups
(tenant_id, application_id, actor_user_id, follow_up_type, from_status,
to_status, note, next_follow_up_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[tenantId, applicationId, actor.userId, type, fromStatus, toStatus, note, nextFollowUpAt]
);
return String(result.insertId);
}
private async audit(connection: PoolConnection, actor: ManagementActor, tenantId: string,
action: string, resourceId: string, metadata: object) {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, ?,
'FRANCHISE_APPLICATION', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, action, resourceId, actor.traceId, actor.ip,
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; }
finally { connection.release(); }
}
}
const allowedTransitions: Record<FranchiseStatus, FranchiseStatus[]> = {
NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'],
QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: []
};
function normalizeApplication(row: ApplicationRow) {
return { ...row, id: String(row.id), tenantId: String(row.tenantId),
assigneeUserId: row.assigneeUserId === null ? null : String(row.assigneeUserId),
submittedUserId: row.submittedUserId === null ? null : String(row.submittedUserId) };
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,625 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { AsyncTask } from '../tasks/task-repository.js';
import { TaskRepository } from '../tasks/task-repository.js';
export type NotificationChannel =
| 'IN_APP' | 'WECHAT_SUBSCRIBE' | 'WE_COM' | 'WEBHOOK' | 'CLOUD_SPEAKER';
export interface NotificationSendInput {
deliveryId: string; tenantId: string; channel: NotificationChannel;
recipientId: string; title: string; body: string; payload: Record<string, unknown>;
externalTemplateId: string;
}
export interface NotificationAdapter {
send(input: NotificationSendInput): Promise<{ providerMessageId: string }>;
}
export class InAppNotificationAdapter implements NotificationAdapter {
async send(input: NotificationSendInput) {
return { providerMessageId: `in-app:${input.deliveryId}` };
}
}
export class HttpNotificationAdapter implements NotificationAdapter {
constructor(private readonly options: {
endpoint?: string; endpointFromRecipient?: boolean; bearerToken?: string;
timeoutMs?: number; fetcher?: typeof fetch;
}) {}
async send(input: NotificationSendInput) {
const endpoint = this.options.endpointFromRecipient ? input.recipientId : this.options.endpoint;
let url: URL;
try { url = new URL(endpoint ?? ''); } catch { throw new NotificationError('NOTIFICATION_ENDPOINT_INVALID'); }
if (url.protocol !== 'https:' || url.username || url.password) {
throw new NotificationError('NOTIFICATION_ENDPOINT_INVALID');
}
const headers: Record<string, string> = {
'content-type': 'application/json',
'x-qipai-delivery-id': input.deliveryId,
'idempotency-key': `notification:${input.deliveryId}`
};
if (this.options.bearerToken) headers.authorization = `Bearer ${this.options.bearerToken}`;
const response = await (this.options.fetcher ?? fetch)(url, {
method: 'POST', headers,
body: JSON.stringify({ deliveryId: input.deliveryId, channel: input.channel,
recipientId: input.recipientId, externalTemplateId: input.externalTemplateId,
title: input.title, body: input.body, payload: input.payload }),
signal: AbortSignal.timeout(this.options.timeoutMs ?? 10_000)
});
if (!response.ok) {
throw new NotificationError('NOTIFICATION_PROVIDER_HTTP_ERROR', `HTTP ${response.status}`);
}
let responseBody: unknown;
try { responseBody = await response.json(); } catch { responseBody = null; }
const record = safeObject(responseBody);
const providerId = String(record.providerMessageId ?? record.messageId
?? response.headers.get('x-request-id') ?? `${input.channel.toLowerCase()}:${input.deliveryId}`);
return { providerMessageId: providerId.slice(0, 191) };
}
}
export function createNotificationAdaptersFromEnvironment(
environment: NodeJS.ProcessEnv = process.env,
fetcher?: typeof fetch
): ReadonlyMap<NotificationChannel, NotificationAdapter> {
const configuredTimeout = Number(environment.QIPAI_NOTIFICATION_HTTP_TIMEOUT_MS ?? 10_000);
const timeout = Number.isFinite(configuredTimeout)
? Math.min(60_000, Math.max(100, Math.trunc(configuredTimeout))) : 10_000;
const adapters = new Map<NotificationChannel, NotificationAdapter>([
['IN_APP', new InAppNotificationAdapter()],
['WEBHOOK', new HttpNotificationAdapter({ endpointFromRecipient: true, timeoutMs: timeout, fetcher })]
]);
const configured: Array<[NotificationChannel, string | undefined, string | undefined]> = [
['WECHAT_SUBSCRIBE', environment.QIPAI_NOTIFICATION_WECHAT_GATEWAY_URL,
environment.QIPAI_NOTIFICATION_WECHAT_GATEWAY_TOKEN],
['WE_COM', environment.QIPAI_NOTIFICATION_WECOM_GATEWAY_URL,
environment.QIPAI_NOTIFICATION_WECOM_GATEWAY_TOKEN],
['CLOUD_SPEAKER', environment.QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_URL,
environment.QIPAI_NOTIFICATION_CLOUD_SPEAKER_GATEWAY_TOKEN]
];
for (const [channel, endpoint, bearerToken] of configured) {
if (endpoint?.trim()) adapters.set(channel, new HttpNotificationAdapter({
endpoint: endpoint.trim(), bearerToken: bearerToken?.trim(), timeoutMs: timeout, fetcher
}));
}
return adapters;
}
export class NotificationError extends Error {
constructor(public readonly code: string, message = code) { super(message); }
}
interface OutboxRow extends RowDataPacket {
id: string; tenantId: string; aggregateType: string; aggregateId: string;
eventType: string; payload: unknown; status: string;
}
interface RouteRow extends RowDataPacket {
id: string; tenantId: string; storeId: string | null; eventType: string;
templateId: string; recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK';
recipientValue: string; quietStart: string | null; quietEnd: string | null;
channel: NotificationChannel; templateCode: string; titleTemplate: string;
bodyTemplate: string; externalTemplateId: string;
}
interface DeliveryRow extends RowDataPacket {
id: string; tenantId: string; channel: NotificationChannel; recipientId: string;
titleSnapshot: string; bodySnapshot: string; payloadSnapshot: unknown;
externalTemplateId: string; status: string; attempts: number; maxAttempts: number;
}
const idPattern = /^[1-9]\d{0,19}$/;
const codePattern = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/;
const sensitiveKey = /(password|credential|secret|token|openid|phone|voucher|card|key)/i;
export class NotificationService {
private readonly tasks: TaskRepository;
constructor(
private readonly pool: MySqlPool,
private readonly adapters: ReadonlyMap<NotificationChannel, NotificationAdapter>,
tasks?: TaskRepository,
private readonly now = () => new Date()
) { this.tasks = tasks ?? new TaskRepository(pool); }
async listTemplates(actor: ManagementActor, input: { storeId?: string; eventType?: string }) {
assertCapability(actor, 'notification.read');
const filters = ['tenant_id = ?'];
const params: Array<string> = [actor.tenantId];
if (input.storeId) {
assertStore(actor, input.storeId);
filters.push('(store_id IS NULL OR store_id = ?)'); params.push(input.storeId);
} else {
appendStoreScope(actor, 'store_id', true, filters, params);
}
if (input.eventType) { assertCode(input.eventType); filters.push('event_type = ?'); params.push(input.eventType); }
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, store_id AS storeId, template_code AS templateCode,
event_type AS eventType, channel, title_template AS titleTemplate,
body_template AS bodyTemplate, external_template_id AS externalTemplateId,
status, version, created_at AS createdAt, updated_at AS updatedAt
FROM qipai_notification_templates WHERE ${filters.join(' AND ')}
ORDER BY event_type, channel, id`, params
);
return rows.map(normalizeRow);
}
async saveTemplate(actor: ManagementActor, input: {
id?: string; storeId?: string | null; templateCode: string; eventType: string;
channel: NotificationChannel; titleTemplate: string; bodyTemplate: string;
externalTemplateId?: string; status?: 'ACTIVE' | 'DISABLED'; expectedVersion?: number;
}) {
assertCapability(actor, 'notification.manage');
assertWritableScope(actor, input.storeId ?? null);
assertCode(input.templateCode); assertCode(input.eventType); assertChannel(input.channel);
const title = boundedText(input.titleTemplate, 256, 'NOTIFICATION_TITLE_INVALID');
const body = boundedText(input.bodyTemplate, 2000, 'NOTIFICATION_BODY_INVALID');
if (!input.id) {
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_notification_templates
(tenant_id, store_id, template_code, event_type, channel,
title_template, body_template, external_template_id, status, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.storeId ?? null, input.templateCode, input.eventType,
input.channel, title, body, input.externalTemplateId?.trim() ?? '',
input.status ?? 'ACTIVE', actor.userId, actor.userId]
);
return { id: String(result.insertId), version: 1 };
}
assertId(input.id); const version = positiveVersion(input.expectedVersion);
const [currentRows] = await this.pool.execute<Array<RowDataPacket & { storeId: string | null }>>(
`SELECT store_id AS storeId FROM qipai_notification_templates
WHERE tenant_id = ? AND id = ?`, [actor.tenantId, input.id]
);
if (!currentRows[0]) throw new NotificationError('NOTIFICATION_TEMPLATE_NOT_FOUND');
assertWritableScope(actor, currentRows[0].storeId === null ? null : String(currentRows[0].storeId));
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_notification_templates SET store_id = ?, template_code = ?,
event_type = ?, channel = ?, title_template = ?, body_template = ?,
external_template_id = ?, status = ?, version = version + 1, updated_by = ?
WHERE tenant_id = ? AND id = ? AND version = ?`,
[input.storeId ?? null, input.templateCode, input.eventType, input.channel, title, body,
input.externalTemplateId?.trim() ?? '', input.status ?? 'ACTIVE', actor.userId,
actor.tenantId, input.id, version]
);
if (result.affectedRows !== 1) throw new NotificationError('NOTIFICATION_VERSION_CONFLICT');
return { id: input.id, version: version + 1 };
}
async listRoutes(actor: ManagementActor, input: { storeId?: string; eventType?: string }) {
assertCapability(actor, 'notification.read');
const filters = ['r.tenant_id = ?']; const params: string[] = [actor.tenantId];
if (input.storeId) { assertStore(actor, input.storeId); filters.push('(r.store_id IS NULL OR r.store_id = ?)'); params.push(input.storeId); }
else appendStoreScope(actor, 'r.store_id', true, filters, params);
if (input.eventType) { assertCode(input.eventType); filters.push('r.event_type = ?'); params.push(input.eventType); }
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT r.id, r.store_id AS storeId, r.event_type AS eventType,
r.template_id AS templateId, t.template_code AS templateCode, t.channel,
r.recipient_type AS recipientType, r.recipient_value AS recipientValue,
r.quiet_start AS quietStart, r.quiet_end AS quietEnd, r.status, r.version
FROM qipai_notification_routes r
INNER JOIN qipai_notification_templates t
ON t.tenant_id = r.tenant_id AND t.id = r.template_id
WHERE ${filters.join(' AND ')} ORDER BY r.event_type, r.id`, params
);
return rows.map(normalizeRow);
}
async createRoute(actor: ManagementActor, input: {
storeId?: string | null; eventType: string; templateId: string;
recipientType: 'CUSTOMER' | 'USER' | 'ROLE' | 'STORE_WEBHOOK'; recipientValue?: string;
quietStart?: string | null; quietEnd?: string | null;
}) {
assertCapability(actor, 'notification.manage');
const routeStoreId = input.storeId ?? null;
assertWritableScope(actor, routeStoreId);
assertCode(input.eventType); assertId(input.templateId);
const value = validateRecipient(input.recipientType, input.recipientValue ?? '');
const quietStart = validateTime(input.quietStart); const quietEnd = validateTime(input.quietEnd);
if ((quietStart === null) !== (quietEnd === null)) throw new NotificationError('NOTIFICATION_QUIET_HOURS_INVALID');
const [template] = await this.pool.execute<Array<RowDataPacket & { storeId: string | null }>>(
`SELECT id, store_id AS storeId FROM qipai_notification_templates
WHERE tenant_id = ? AND id = ? AND event_type = ? AND status = 'ACTIVE'`,
[actor.tenantId, input.templateId, input.eventType]
);
if (!template[0]) throw new NotificationError('NOTIFICATION_TEMPLATE_NOT_FOUND');
const templateStoreId = template[0].storeId === null ? null : String(template[0].storeId);
if (templateStoreId !== null && templateStoreId !== routeStoreId) {
throw new NotificationError('NOTIFICATION_TEMPLATE_SCOPE_MISMATCH');
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_notification_routes
(tenant_id, store_id, event_type, template_id, recipient_type,
recipient_value, quiet_start, quiet_end, created_by, updated_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, routeStoreId, input.eventType, input.templateId,
input.recipientType, value, quietStart, quietEnd, actor.userId, actor.userId]
);
return { id: String(result.insertId), version: 1 };
}
async listDeliveries(actor: ManagementActor, input: {
storeId?: string; status?: string; channel?: string; page?: number; pageSize?: number;
}) {
assertCapability(actor, 'notification.read');
const page = Math.max(1, Math.trunc(input.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Math.trunc(input.pageSize ?? 20)));
const filters = ['d.tenant_id = ?']; const params: string[] = [actor.tenantId];
if (input.storeId) { assertStore(actor, input.storeId); filters.push('d.store_id = ?'); params.push(input.storeId); }
else appendStoreScope(actor, 'd.store_id', false, filters, params);
if (input.status) { filters.push('d.status = ?'); params.push(input.status); }
if (input.channel) { assertChannel(input.channel as NotificationChannel); filters.push('d.channel = ?'); params.push(input.channel); }
const where = filters.join(' AND ');
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_notification_deliveries d WHERE ${where}`, params
);
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT d.id, d.store_id AS storeId, d.outbox_event_id AS outboxEventId,
d.event_type AS eventType, d.channel, d.recipient_type AS recipientType,
d.recipient_masked AS recipientMasked, d.title_snapshot AS title,
d.body_snapshot AS body, d.status, d.attempts, d.max_attempts AS maxAttempts,
d.last_error_code AS lastErrorCode, d.last_error_message AS lastErrorMessage,
d.provider_message_id AS providerMessageId, d.manual_retry_count AS manualRetryCount,
d.sent_at AS sentAt, d.read_at AS readAt, d.created_at AS createdAt
FROM qipai_notification_deliveries d WHERE ${where}
ORDER BY d.created_at DESC, d.id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`,
params
);
return { items: rows.map(normalizeRow), total: Number(counts[0]?.total ?? 0), page, pageSize };
}
async materializeOutboxEvent(tenantId: string, eventId: string) {
assertId(tenantId); assertId(eventId);
const [events] = await this.pool.execute<OutboxRow[]>(
`SELECT id, tenant_id AS tenantId, aggregate_type AS aggregateType,
aggregate_id AS aggregateId, event_type AS eventType, payload, status
FROM qipai_outbox_events WHERE tenant_id = ? AND id = ?`, [tenantId, eventId]
);
const event = events[0]; if (!event) throw new NotificationError('NOTIFICATION_EVENT_NOT_FOUND');
const payload = safeObject(event.payload); const storeId = scalarId(payload.storeId);
const [routes] = await this.pool.execute<RouteRow[]>(
`SELECT r.id, r.tenant_id AS tenantId, r.store_id AS storeId,
r.event_type AS eventType, r.template_id AS templateId,
r.recipient_type AS recipientType, r.recipient_value AS recipientValue,
CAST(r.quiet_start AS CHAR) AS quietStart, CAST(r.quiet_end AS CHAR) AS quietEnd,
t.channel, t.template_code AS templateCode,
t.title_template AS titleTemplate, t.body_template AS bodyTemplate,
t.external_template_id AS externalTemplateId
FROM qipai_notification_routes r
INNER JOIN qipai_notification_templates t
ON t.tenant_id = r.tenant_id AND t.id = r.template_id AND t.status = 'ACTIVE'
WHERE r.tenant_id = ? AND r.event_type = ? AND r.status = 'ACTIVE'
AND (r.store_id IS NULL OR r.store_id = ?)`, [tenantId, event.eventType, storeId ?? '0']
);
let created = 0; let suppressed = 0;
const timezoneByStore = new Map<string, string>();
for (const route of routes) {
const recipients = await this.resolveRecipients(route, payload, storeId);
const deliveryStoreId = route.storeId ?? storeId;
const timezoneKey = deliveryStoreId ?? 'tenant';
let timezone = timezoneByStore.get(timezoneKey);
if (!timezone) {
timezone = await this.resolveTimezone(tenantId, deliveryStoreId);
timezoneByStore.set(timezoneKey, timezone);
}
for (const recipient of recipients) {
const subscriptionAllowed = route.channel !== 'WECHAT_SUBSCRIBE'
|| await this.hasSubscription(tenantId, recipient.id, route.templateCode);
const isSuppressed = !subscriptionAllowed
|| inQuietHours(this.now(), route.quietStart, route.quietEnd, timezone);
const status = isSuppressed ? 'SUPPRESSED' : 'PENDING';
const snapshot = redact(payload);
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_notification_deliveries
(tenant_id, store_id, outbox_event_id, route_id, template_id, event_type,
channel, recipient_type, recipient_id, recipient_masked, title_snapshot,
body_snapshot, payload_snapshot, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`,
[tenantId, deliveryStoreId, event.id, route.id, route.templateId,
event.eventType, route.channel, route.recipientType, recipient.id, recipient.masked,
render(route.titleTemplate, snapshot), render(route.bodyTemplate, snapshot),
JSON.stringify(snapshot), status]
);
if (!result.insertId) continue;
created += 1;
if (isSuppressed) { suppressed += 1; continue; }
await this.tasks.enqueue({ tenantId, taskType: 'notification.dispatch',
idempotencyKey: `notification:${result.insertId}:auto:1`,
payload: { deliveryId: String(result.insertId) }, maxAttempts: 8 });
}
}
return { eventId, matchedRoutes: routes.length, created, suppressed };
}
async enqueuePendingOutbox(limit = 100) {
const safeLimit = Math.min(500, Math.max(1, Math.trunc(limit)));
const [rows] = await this.pool.execute<Array<RowDataPacket & { id: string; tenantId: string }>>(
`SELECT id, tenant_id AS tenantId FROM qipai_outbox_events
WHERE status = 'PENDING' AND available_at <= UTC_TIMESTAMP(3)
ORDER BY available_at, id LIMIT ${safeLimit}`
);
let created = 0;
for (const row of rows) {
const result = await this.tasks.enqueue({ tenantId: String(row.tenantId),
taskType: 'outbox.publish', idempotencyKey: `outbox:${row.id}:notification`,
payload: { eventId: String(row.id) }, maxAttempts: 8 });
if (result.created) created += 1;
}
return { scanned: rows.length, created };
}
async handleTask(task: AsyncTask) {
const payload = safeObject(task.payload); const deliveryId = scalarId(payload.deliveryId);
if (!deliveryId) throw new NotificationError('NOTIFICATION_TASK_INVALID');
const trigger = payload.trigger === 'MANUAL' ? 'MANUAL' : 'AUTO';
const operatorId = trigger === 'MANUAL' ? scalarId(payload.operatorId) : null;
if (trigger === 'MANUAL' && !operatorId) throw new NotificationError('NOTIFICATION_TASK_INVALID');
await this.sendDelivery(task.tenantId, deliveryId, trigger, operatorId);
}
async manualRetry(actor: ManagementActor, deliveryId: string) {
assertCapability(actor, 'notification.manage'); assertId(deliveryId);
const [deliveries] = await this.pool.execute<Array<RowDataPacket & { storeId: string | null; status: string }>>(
`SELECT store_id AS storeId, status FROM qipai_notification_deliveries
WHERE tenant_id = ? AND id = ?`, [actor.tenantId, deliveryId]
);
const delivery = deliveries[0];
if (!delivery || delivery.status !== 'FAILED') {
throw new NotificationError('NOTIFICATION_RETRY_NOT_ALLOWED');
}
assertWritableScope(actor, delivery.storeId === null ? null : String(delivery.storeId));
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_notification_deliveries
SET status = 'PENDING', available_at = UTC_TIMESTAMP(3),
max_attempts = LEAST(20, max_attempts + 1),
manual_retry_count = manual_retry_count + 1,
last_error_code = '', last_error_message = ''
WHERE tenant_id = ? AND id = ? AND status = 'FAILED' AND attempts < 20`,
[actor.tenantId, deliveryId]
);
if (result.affectedRows !== 1) throw new NotificationError('NOTIFICATION_RETRY_NOT_ALLOWED');
const [rows] = await this.pool.execute<Array<RowDataPacket & { retry: number }>>(
`SELECT manual_retry_count AS retry FROM qipai_notification_deliveries
WHERE tenant_id = ? AND id = ?`, [actor.tenantId, deliveryId]
);
await this.tasks.enqueue({ tenantId: actor.tenantId, taskType: 'notification.dispatch',
idempotencyKey: `notification:${deliveryId}:manual:${rows[0].retry}`,
payload: { deliveryId, trigger: 'MANUAL', operatorId: actor.userId }, maxAttempts: 1 });
return { id: deliveryId, queued: true };
}
async setSubscription(tenantId: string, userId: string, input: {
channel: 'WECHAT_SUBSCRIBE'; templateCode: string; status: 'AUTHORIZED' | 'REVOKED';
}) {
assertId(tenantId); assertId(userId); assertCode(input.templateCode);
if (input.channel !== 'WECHAT_SUBSCRIBE') throw new NotificationError('NOTIFICATION_CHANNEL_INVALID');
await this.pool.execute(
`INSERT INTO qipai_notification_subscriptions
(tenant_id, user_id, channel, template_code, status, authorized_at, revoked_at)
VALUES (?, ?, 'WECHAT_SUBSCRIBE', ?, ?, UTC_TIMESTAMP(3),
IF(? = 'REVOKED', UTC_TIMESTAMP(3), NULL))
ON DUPLICATE KEY UPDATE status = VALUES(status),
authorized_at = IF(VALUES(status) = 'AUTHORIZED', UTC_TIMESTAMP(3), authorized_at),
revoked_at = IF(VALUES(status) = 'REVOKED', UTC_TIMESTAMP(3), NULL)`,
[tenantId, userId, input.templateCode, input.status, input.status]
);
return { channel: input.channel, templateCode: input.templateCode, status: input.status };
}
async listInbox(tenantId: string, userId: string, input: { page?: number; pageSize?: number }) {
assertId(tenantId); assertId(userId);
const page = Math.max(1, Math.trunc(input.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Math.trunc(input.pageSize ?? 20)));
const params = [tenantId, userId];
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_notification_deliveries
WHERE tenant_id = ? AND recipient_id = ? AND channel = 'IN_APP' AND status = 'SENT'`, params
);
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, event_type AS eventType, title_snapshot AS title, body_snapshot AS body,
read_at AS readAt, sent_at AS sentAt, created_at AS createdAt
FROM qipai_notification_deliveries
WHERE tenant_id = ? AND recipient_id = ? AND channel = 'IN_APP' AND status = 'SENT'
ORDER BY created_at DESC, id DESC LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`, params
);
return { items: rows.map(normalizeRow), total: Number(counts[0]?.total ?? 0), page, pageSize };
}
async markInboxRead(tenantId: string, userId: string, deliveryId: string) {
assertId(tenantId); assertId(userId); assertId(deliveryId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_notification_deliveries SET read_at = COALESCE(read_at, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ? AND recipient_id = ?
AND channel = 'IN_APP' AND status = 'SENT'`, [tenantId, deliveryId, userId]
);
if (result.affectedRows !== 1) throw new NotificationError('NOTIFICATION_INBOX_NOT_FOUND');
return { id: deliveryId, read: true };
}
private async sendDelivery(tenantId: string, deliveryId: string, trigger: 'AUTO' | 'MANUAL', operatorId: string | null) {
const connection = await this.pool.getConnection();
let delivery: DeliveryRow;
try {
await connection.beginTransaction();
const [rows] = await connection.execute<DeliveryRow[]>(
`SELECT d.id, d.tenant_id AS tenantId, d.channel, d.recipient_id AS recipientId,
d.title_snapshot AS titleSnapshot, d.body_snapshot AS bodySnapshot,
d.payload_snapshot AS payloadSnapshot, t.external_template_id AS externalTemplateId,
d.status, d.attempts, d.max_attempts AS maxAttempts
FROM qipai_notification_deliveries d
INNER JOIN qipai_notification_templates t
ON t.tenant_id = d.tenant_id AND t.id = d.template_id
WHERE d.tenant_id = ? AND d.id = ? FOR UPDATE`, [tenantId, deliveryId]
);
delivery = rows[0];
if (!delivery || !['PENDING', 'RETRY'].includes(delivery.status)) {
throw new NotificationError('NOTIFICATION_DELIVERY_NOT_DISPATCHABLE');
}
await connection.execute(
`UPDATE qipai_notification_deliveries SET status = 'PROCESSING', attempts = attempts + 1
WHERE tenant_id = ? AND id = ?`, [tenantId, deliveryId]
);
await connection.commit();
} catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
const attemptNo = Number(delivery.attempts) + 1;
try {
const adapter = this.adapters.get(delivery.channel);
if (!adapter) throw new NotificationError('NOTIFICATION_CHANNEL_NOT_CONFIGURED');
const sent = await adapter.send({ deliveryId, tenantId, channel: delivery.channel,
recipientId: delivery.recipientId, title: delivery.titleSnapshot,
body: delivery.bodySnapshot, payload: safeObject(delivery.payloadSnapshot),
externalTemplateId: delivery.externalTemplateId });
await this.recordAttempt(tenantId, deliveryId, attemptNo, trigger, 'SENT', '', '', sent.providerMessageId, operatorId);
await this.pool.execute(
`UPDATE qipai_notification_deliveries SET status = 'SENT', sent_at = UTC_TIMESTAMP(3),
provider_message_id = ?, last_error_code = '', last_error_message = ''
WHERE tenant_id = ? AND id = ? AND status = 'PROCESSING'`,
[sent.providerMessageId.slice(0, 191), tenantId, deliveryId]
);
} catch (error) {
const code = error instanceof NotificationError ? error.code : 'NOTIFICATION_PROVIDER_ERROR';
const message = error instanceof Error ? error.message : String(error);
const terminal = attemptNo >= Number(delivery.maxAttempts);
await this.recordAttempt(tenantId, deliveryId, attemptNo, trigger, 'FAILED', code, message, '', operatorId);
await this.pool.execute(
`UPDATE qipai_notification_deliveries
SET status = ?, available_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? SECOND),
last_error_code = ?, last_error_message = ?
WHERE tenant_id = ? AND id = ? AND status = 'PROCESSING'`,
[terminal ? 'FAILED' : 'RETRY', Math.min(3600, 2 ** Math.min(attemptNo, 10)),
code, message.slice(0, 512), tenantId, deliveryId]
);
throw error;
}
}
private async recordAttempt(tenantId: string, deliveryId: string, attemptNo: number,
trigger: string, status: string, code: string, message: string,
providerId: string, operatorId: string | null) {
await this.pool.execute(
`INSERT INTO qipai_notification_attempts
(tenant_id, delivery_id, attempt_no, trigger_type, status, error_code,
error_message, provider_message_id, operator_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[tenantId, deliveryId, attemptNo, trigger, status, code, message.slice(0, 512),
providerId.slice(0, 191), operatorId]
);
}
private async resolveRecipients(route: RouteRow, payload: Record<string, unknown>, storeId: string | null) {
if (route.recipientType === 'CUSTOMER') {
const id = scalarId(payload.memberId ?? payload.customerId ?? payload.userId);
return id ? [{ id, masked: `用户 #${id}` }] : [];
}
if (route.recipientType === 'USER') return [{ id: route.recipientValue, masked: `用户 #${route.recipientValue}` }];
if (route.recipientType === 'STORE_WEBHOOK') {
if (!storeId) return [];
const [rows] = await this.pool.execute<Array<RowDataPacket & { url: string }>>(
`SELECT notification_url AS url FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [route.tenantId, storeId]
);
const url = rows[0]?.url?.trim();
return url && /^https:\/\//i.test(url) ? [{ id: url, masked: maskUrl(url) }] : [];
}
const params: string[] = [route.tenantId, route.recipientValue];
let scope = '';
if (storeId) { scope = `AND (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR EXISTS (
SELECT 1 FROM qipai_user_store_scopes us
WHERE us.tenant_id = u.tenant_id AND us.user_id = u.id AND us.store_id = ?))`;
params.push(storeId);
}
const [rows] = await this.pool.execute<Array<RowDataPacket & { id: string }>>(
`SELECT DISTINCT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
WHERE u.tenant_id = ? AND r.code = ? AND u.status = 'ACTIVE'
AND u.deleted_at IS NULL AND r.status = 'ACTIVE' AND r.deleted_at IS NULL ${scope}`,
params
);
return rows.map((row) => ({ id: String(row.id), masked: `用户 #${row.id}` }));
}
private async hasSubscription(tenantId: string, userId: string, templateCode: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_notification_subscriptions
WHERE tenant_id = ? AND user_id = ? AND channel = 'WECHAT_SUBSCRIBE'
AND template_code = ? AND status = 'AUTHORIZED'`, [tenantId, userId, templateCode]
);
return Boolean(rows[0]);
}
private async resolveTimezone(tenantId: string, storeId: string | null) {
const [rows] = storeId
? await this.pool.execute<Array<RowDataPacket & { timezone: string }>>(
`SELECT timezone FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [tenantId, storeId]
)
: await this.pool.execute<Array<RowDataPacket & { timezone: string }>>(
`SELECT timezone FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
);
return rows[0]?.timezone || 'Asia/Shanghai';
}
}
function assertCapability(actor: ManagementActor, capability: string) {
if (actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (actor.access.capabilities.some((item) => [capability, 'tenant.manage', 'platform.manage'].includes(item))) return;
throw new NotificationError('NOTIFICATION_FORBIDDEN');
}
function assertStore(actor: ManagementActor, storeId: string) {
assertId(storeId);
if (actor.access.roles.includes('PLATFORM_ADMIN') || actor.access.capabilities.some((item) => ['tenant.manage', 'platform.manage'].includes(item))) return;
if (!actor.access.storeIds.includes(storeId)) throw new NotificationError('NOTIFICATION_STORE_SCOPE_FORBIDDEN');
}
function isGlobalManager(actor: ManagementActor) {
return actor.access.roles.includes('PLATFORM_ADMIN')
|| actor.access.capabilities.some((item) => ['tenant.manage', 'platform.manage'].includes(item));
}
function assertWritableScope(actor: ManagementActor, storeId: string | null) {
if (storeId === null) {
if (!isGlobalManager(actor)) throw new NotificationError('NOTIFICATION_STORE_SCOPE_FORBIDDEN');
return;
}
assertStore(actor, storeId);
}
function appendStoreScope(actor: ManagementActor, column: string, includeGlobal: boolean,
filters: string[], params: string[]) {
if (isGlobalManager(actor)) return;
if (!actor.access.storeIds.length) {
filters.push(includeGlobal ? `${column} IS NULL` : '1 = 0');
return;
}
const placeholders = actor.access.storeIds.map(() => '?').join(', ');
filters.push(includeGlobal
? `(${column} IS NULL OR ${column} IN (${placeholders}))`
: `${column} IN (${placeholders})`);
params.push(...actor.access.storeIds);
}
function assertId(value: string) { if (!idPattern.test(value)) throw new NotificationError('NOTIFICATION_ID_INVALID'); }
function assertCode(value: string) { if (!codePattern.test(value)) throw new NotificationError('NOTIFICATION_CODE_INVALID'); }
function assertChannel(value: NotificationChannel) { if (!['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER'].includes(value)) throw new NotificationError('NOTIFICATION_CHANNEL_INVALID'); }
function positiveVersion(value: number | undefined) { const result = Math.trunc(value ?? 0); if (result < 1) throw new NotificationError('NOTIFICATION_VERSION_INVALID'); return result; }
function boundedText(value: string, max: number, code: string) { const result = value.trim(); if (!result || result.length > max) throw new NotificationError(code); return result; }
function validateTime(value: string | null | undefined) { if (!value) return null; if (!/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/.test(value)) throw new NotificationError('NOTIFICATION_QUIET_HOURS_INVALID'); return value.length === 5 ? `${value}:00` : value; }
function validateRecipient(type: string, value: string) { const result = value.trim(); if (type === 'CUSTOMER' || type === 'STORE_WEBHOOK') return result || '*'; if (type === 'USER') assertId(result); else assertCode(result); return result; }
function safeObject(value: unknown): Record<string, unknown> { if (typeof value === 'string') { try { value = JSON.parse(value); } catch { return {}; } } return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function scalarId(value: unknown) { const text = value === null || value === undefined ? '' : String(value); return idPattern.test(text) ? text : null; }
function redact(value: unknown): any { if (Array.isArray(value)) return value.map(redact); if (!value || typeof value !== 'object') return value; return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [key, sensitiveKey.test(key) ? '[REDACTED]' : redact(item)])); }
function render(template: string, payload: Record<string, unknown>) { return template.replace(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g, (_, key: string) => { const value = key.split('.').reduce<any>((current, part) => current && typeof current === 'object' ? current[part] : undefined, payload); return value === undefined || value === null || typeof value === 'object' ? '' : String(value); }); }
function inQuietHours(now: Date, start: string | null, end: string | null, timezone: string) {
if (!start || !end) return false;
let current: string;
try {
current = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
}).format(now);
} catch {
current = now.toISOString().slice(11, 19);
}
return start <= end ? current >= start && current < end : current >= start || current < end;
}
function maskUrl(value: string) { try { const url = new URL(value); return `${url.protocol}//${url.host}/***`; } catch { return 'https://***/'; } }
function normalizeRow(row: RowDataPacket) { return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, typeof value === 'bigint' ? value.toString() : value])); }
@@ -0,0 +1,482 @@
import { createHash } from 'node:crypto';
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { AsyncTask } from '../tasks/task-repository.js';
export type ReportChannel = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'OTHER';
export interface BusinessDailyMetric {
storeId: string;
storeName: string;
date: string;
timezone: string;
orderCount: number;
customerCount: number;
usedMinutes: number;
capacityMinutes: number;
utilizationBasisPoints: number;
roomGrossCents: number;
roomRefundCents: number;
roomNetCents: number;
productGrossCents: number;
productRefundCents: number;
productNetCents: number;
totalNetCents: number;
cleaningCostCents: number;
contributionCents: number;
channels: Record<ReportChannel, number>;
sourceChecksum: string;
}
interface StoreRow extends RowDataPacket { id: string; name: string; timezone: string }
interface MoneyRow extends RowDataPacket {
provider: string; amountCents: number; occurredAt: string | Date;
}
interface OrderUsageRow extends RowDataPacket {
id: string; userId: string | null; status: string; createdAt: string | Date;
bookedStart: string | Date; bookedEnd: string | Date;
actualStart: string | Date | null; actualEnd: string | Date | null;
}
interface CostRow extends RowDataPacket { amountCents: number; occurredAt: string | Date }
interface AggregateRow extends RowDataPacket {
storeId: string; businessDate: string | Date; sourceChecksum: string; calculatedAt: string | Date;
}
export class BusinessReportError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class BusinessReportService {
private nextAutomaticEnqueueAt = 0;
constructor(private readonly pool: MySqlPool, private readonly now = () => new Date()) {}
async report(actor: ManagementActor, input: { storeId?: string; from: string; to: string }) {
this.assertCapability(actor, 'report.read');
assertDateRange(input.from, input.to);
const stores = await this.resolveStores(actor, input.storeId);
const daily: BusinessDailyMetric[] = [];
const customerIds = new Set<string>();
const detailCounts = { payments: 0, refunds: 0, productPayments: 0, productRefunds: 0, orders: 0, cleaningEntries: 0 };
for (const store of stores) {
const result = await this.calculateStore(store, actor.tenantId, input.from, input.to);
daily.push(...result.daily);
for (const customerId of result.customerIds) customerIds.add(customerId);
for (const key of Object.keys(detailCounts) as Array<keyof typeof detailCounts>) {
detailCounts[key] += result.counts[key];
}
}
daily.sort((left, right) => left.date.localeCompare(right.date)
|| left.storeName.localeCompare(right.storeName) || left.storeId.localeCompare(right.storeId));
const persisted = await this.loadPersisted(actor.tenantId, stores.map((store) => String(store.id)), input.from, input.to);
const persistedByKey = new Map(persisted.map((row) => [
`${row.storeId}:${formatDate(row.businessDate)}`, row
]));
const mismatches = daily.filter((row) => persistedByKey.get(`${row.storeId}:${row.date}`)?.sourceChecksum !== row.sourceChecksum);
return {
scope: { storeId: input.storeId ?? null, storeCount: stores.length, from: input.from, to: input.to },
summary: { ...summarize(daily), customerCount: customerIds.size },
daily,
detailCounts,
reconciliation: {
status: mismatches.length === 0 ? 'MATCHED' : persisted.length === 0 ? 'NOT_AGGREGATED' : 'MISMATCH',
expectedRows: daily.length,
persistedRows: persisted.length,
mismatchRows: mismatches.length,
lastCalculatedAt: persisted.length
? persisted.map((row) => new Date(row.calculatedAt).toISOString()).sort().at(-1) ?? null : null
},
metricVersion: 'M10-B-v1'
};
}
async enqueueRange(actor: ManagementActor, input: { storeId?: string; from: string; to: string }) {
this.assertCapability(actor, 'report.manage');
assertDateRange(input.from, input.to);
const stores = await this.resolveStores(actor, input.storeId);
let queued = 0;
for (const store of stores) {
for (const date of dateKeys(input.from, input.to)) {
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_async_tasks
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
VALUES (?, 'statistics.aggregate', ?, CAST(? AS JSON), 10, UTC_TIMESTAMP(3), 5)`,
[actor.tenantId, `report:${store.id}:${date}:${actor.traceId}`,
JSON.stringify({ storeId: String(store.id), date })]
);
queued += result.affectedRows;
}
}
return { queued, storeCount: stores.length, dayCount: dateKeys(input.from, input.to).length };
}
async enqueueDueAggregations() {
const now = this.now();
if (now.getTime() < this.nextAutomaticEnqueueAt) return 0;
this.nextAutomaticEnqueueAt = now.getTime() + 5 * 60_000;
const [stores] = await this.pool.execute<StoreRow[]>(
`SELECT id, name, timezone FROM qipai_stores
WHERE business_status <> 'SUSPENDED' AND deleted_at IS NULL`
);
const hourBucket = now.toISOString().slice(0, 13).replace(/\D/g, '');
let queued = 0;
for (const store of stores) {
const today = localDateKey(now, store.timezone);
for (let offset = -6; offset <= 0; offset += 1) {
const date = shiftDate(today, offset);
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_async_tasks
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
SELECT tenant_id, 'statistics.aggregate', ?, CAST(? AS JSON), 5, UTC_TIMESTAMP(3), 5
FROM qipai_stores WHERE id = ? AND deleted_at IS NULL`,
[`report:auto:${store.id}:${date}:${hourBucket}`,
JSON.stringify({ storeId: String(store.id), date }), store.id]
);
queued += result.affectedRows;
}
}
return queued;
}
async handleTask(task: AsyncTask) {
const payload = task.payload as { storeId?: unknown; date?: unknown };
const storeId = typeof payload?.storeId === 'string' ? payload.storeId : '';
const date = typeof payload?.date === 'string' ? payload.date : '';
if (!/^[1-9]\d{0,19}$/.test(storeId) || !isDateKey(date)) {
throw new BusinessReportError('BUSINESS_REPORT_TASK_INVALID');
}
const [stores] = await this.pool.execute<StoreRow[]>(
`SELECT id, name, timezone FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [task.tenantId, storeId]
);
if (!stores[0]) throw new BusinessReportError('BUSINESS_REPORT_STORE_NOT_FOUND');
const result = await this.calculateStore(stores[0], task.tenantId, date, date);
await this.persist(task.tenantId, result.daily[0]);
}
private async resolveStores(actor: ManagementActor, storeId?: string) {
if (storeId && !this.canAccessStore(actor, storeId)) {
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
}
const scopedIds = this.hasTenantScope(actor) ? [] : actor.access.storeIds;
if (!storeId && scopedIds.length === 0 && !this.hasTenantScope(actor)) {
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
}
const filters = ['tenant_id = ?', 'deleted_at IS NULL'];
const params: string[] = [actor.tenantId];
if (storeId) { filters.push('id = ?'); params.push(storeId); }
else if (scopedIds.length) {
filters.push(`id IN (${scopedIds.map(() => '?').join(', ')})`); params.push(...scopedIds);
}
const [rows] = await this.pool.execute<StoreRow[]>(
`SELECT id, name, timezone FROM qipai_stores WHERE ${filters.join(' AND ')} ORDER BY id`, params
);
if (storeId && !rows[0]) throw new BusinessReportError('BUSINESS_REPORT_STORE_NOT_FOUND');
return rows.map((row) => ({ ...row, id: String(row.id) }));
}
private async calculateStore(store: StoreRow, tenantId: string, from: string, to: string) {
const days = dateKeys(from, to);
const boundaries = new Map(days.map((date) => [date, {
start: zonedDateStart(date, store.timezone),
end: zonedDateStart(shiftDate(date, 1), store.timezone)
}]));
const utcFrom = boundaries.get(days[0])!.start;
const utcTo = boundaries.get(days.at(-1)!)!.end;
const params = [tenantId, store.id, utcFrom, utcTo];
const [paymentsResult, refundsResult, productPaymentsResult, productRefundsResult,
ordersResult, roomResult, paidCostsResult, reversedCostsResult] = await Promise.all([
this.pool.execute<MoneyRow[]>(
`SELECT channel AS provider, amount_cents AS amountCents, paid_at AS occurredAt
FROM qipai_payments WHERE tenant_id = ? AND store_id = ?
AND status IN ('SUCCEEDED', 'PARTIALLY_REFUNDED', 'REFUNDED')
AND paid_at >= ? AND paid_at < ? AND deleted_at IS NULL`, params),
this.pool.execute<MoneyRow[]>(
`SELECT p.channel AS provider, r.amount_cents AS amountCents, r.completed_at AS occurredAt
FROM qipai_refunds r INNER JOIN qipai_payments p
ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
WHERE r.tenant_id = ? AND p.store_id = ? AND r.status = 'SUCCEEDED'
AND r.completed_at >= ? AND r.completed_at < ?`, params),
this.pool.execute<MoneyRow[]>(
`SELECT channel AS provider, amount_cents AS amountCents, paid_at AS occurredAt
FROM qipai_product_payments WHERE tenant_id = ? AND store_id = ?
AND status IN ('SUCCEEDED', 'REFUNDED')
AND paid_at >= ? AND paid_at < ?`, params),
this.pool.execute<MoneyRow[]>(
`SELECT p.channel AS provider, r.amount_cents AS amountCents, r.completed_at AS occurredAt
FROM qipai_product_refunds r INNER JOIN qipai_product_payments p
ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
WHERE r.tenant_id = ? AND r.store_id = ? AND r.status = 'SUCCEEDED'
AND r.completed_at >= ? AND r.completed_at < ?`, params),
this.pool.execute<OrderUsageRow[]>(
`SELECT o.id, owner.user_id AS userId, o.status, o.created_at AS createdAt,
o.start_at AS bookedStart, o.end_at AS bookedEnd,
MIN(CASE WHEN h.to_status = 'IN_PROGRESS' THEN h.created_at END) AS actualStart,
MIN(CASE WHEN h.to_status = 'FINISHED' THEN h.created_at END) AS actualEnd
FROM qipai_orders o
LEFT JOIN qipai_order_user_access owner
ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id AND owner.access_type = 'OWNER'
LEFT JOIN qipai_order_status_history h
ON h.tenant_id = o.tenant_id AND h.order_id = o.id
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
AND ((o.created_at >= ? AND o.created_at < ?)
OR (o.start_at < ? AND o.end_at > ?)
OR (EXISTS (
SELECT 1 FROM qipai_order_status_history started
WHERE started.tenant_id = o.tenant_id AND started.order_id = o.id
AND started.to_status = 'IN_PROGRESS' AND started.created_at < ?
) AND (
NOT EXISTS (
SELECT 1 FROM qipai_order_status_history finished
WHERE finished.tenant_id = o.tenant_id AND finished.order_id = o.id
AND finished.to_status = 'FINISHED'
) OR EXISTS (
SELECT 1 FROM qipai_order_status_history finished
WHERE finished.tenant_id = o.tenant_id AND finished.order_id = o.id
AND finished.to_status = 'FINISHED' AND finished.created_at > ?
)
)))
GROUP BY o.id, owner.user_id, o.status, o.created_at, o.start_at, o.end_at`,
[tenantId, store.id, utcFrom, utcTo, utcTo, utcFrom, utcTo, utcFrom]),
this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`, [tenantId, store.id]),
this.pool.execute<CostRow[]>(
`SELECT i.reward_cents AS amountCents, s.paid_at AS occurredAt
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_settlements s
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
INNER JOIN qipai_cleaning_tasks t
ON t.tenant_id = i.tenant_id AND t.id = i.task_id
WHERE i.tenant_id = ? AND t.store_id = ? AND s.paid_at >= ? AND s.paid_at < ?`, params),
this.pool.execute<CostRow[]>(
`SELECT -CAST(i.reward_cents AS SIGNED) AS amountCents, i.reversed_at AS occurredAt
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_tasks t
ON t.tenant_id = i.tenant_id AND t.id = i.task_id
WHERE i.tenant_id = ? AND t.store_id = ?
AND i.reversed_at >= ? AND i.reversed_at < ?`, params)
]);
const roomCount = Number(roomResult[0][0]?.total ?? 0);
const metrics = new Map(days.map((date) => [date, emptyMetric(store, date, roomCount,
Math.round((boundaries.get(date)!.end.getTime() - boundaries.get(date)!.start.getTime()) / 60000))]));
applyMoney(metrics, paymentsResult[0], store.timezone, 'roomGrossCents', 1);
applyMoney(metrics, refundsResult[0], store.timezone, 'roomRefundCents', -1);
applyMoney(metrics, productPaymentsResult[0], store.timezone, 'productGrossCents', 1);
applyMoney(metrics, productRefundsResult[0], store.timezone, 'productRefundCents', -1);
for (const row of [...paidCostsResult[0], ...reversedCostsResult[0]]) {
const metric = metrics.get(localDateKey(new Date(row.occurredAt), store.timezone));
if (metric) metric.cleaningCostCents += Number(row.amountCents);
}
const customerSets = new Map(days.map((date) => [date, new Set<string>()]));
for (const order of ordersResult[0]) {
const createdDate = localDateKey(new Date(order.createdAt), store.timezone);
const createdMetric = metrics.get(createdDate);
if (createdMetric) {
createdMetric.orderCount += 1;
if (order.userId) customerSets.get(createdDate)!.add(String(order.userId));
}
const actualStart = order.actualStart ? new Date(order.actualStart)
: ['IN_PROGRESS', 'FINISHED', 'CLOSED'].includes(order.status) ? new Date(order.bookedStart) : null;
const actualEnd = order.actualEnd ? new Date(order.actualEnd)
: order.status === 'IN_PROGRESS' ? this.now()
: ['FINISHED', 'CLOSED'].includes(order.status) ? new Date(order.bookedEnd) : null;
if (!actualStart || !actualEnd || actualEnd <= actualStart) continue;
for (const date of days) {
const boundary = boundaries.get(date)!;
const overlapMs = Math.max(0, Math.min(actualEnd.getTime(), boundary.end.getTime())
- Math.max(actualStart.getTime(), boundary.start.getTime()));
metrics.get(date)!.usedMinutes += Math.round(overlapMs / 60000);
}
}
const daily = days.map((date) => finalizeMetric(metrics.get(date)!, customerSets.get(date)!.size));
return {
daily,
customerIds: new Set([...customerSets.values()].flatMap((set) => [...set])),
counts: {
payments: paymentsResult[0].length, refunds: refundsResult[0].length,
productPayments: productPaymentsResult[0].length, productRefunds: productRefundsResult[0].length,
orders: ordersResult[0].length,
cleaningEntries: paidCostsResult[0].length + reversedCostsResult[0].length
}
};
}
private async loadPersisted(tenantId: string, storeIds: string[], from: string, to: string) {
if (!storeIds.length) return [];
const [rows] = await this.pool.execute<AggregateRow[]>(
`SELECT store_id AS storeId, business_date AS businessDate,
source_checksum AS sourceChecksum, calculated_at AS calculatedAt
FROM qipai_business_daily_summaries
WHERE tenant_id = ? AND store_id IN (${storeIds.map(() => '?').join(', ')})
AND business_date >= ? AND business_date <= ?`, [tenantId, ...storeIds, from, to]
);
return rows.map((row) => ({ ...row, storeId: String(row.storeId) }));
}
private async persist(tenantId: string, metric: BusinessDailyMetric) {
await this.pool.execute(
`INSERT INTO qipai_business_daily_summaries
(tenant_id, store_id, business_date, timezone, order_count, customer_count,
used_minutes, capacity_minutes, room_gross_cents, room_refund_cents,
product_gross_cents, product_refund_cents, cleaning_cost_cents,
wechat_net_cents, balance_net_cents, package_net_cents, group_buy_net_cents,
other_net_cents, source_checksum, calculated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE timezone = VALUES(timezone), order_count = VALUES(order_count),
customer_count = VALUES(customer_count), used_minutes = VALUES(used_minutes),
capacity_minutes = VALUES(capacity_minutes), room_gross_cents = VALUES(room_gross_cents),
room_refund_cents = VALUES(room_refund_cents), product_gross_cents = VALUES(product_gross_cents),
product_refund_cents = VALUES(product_refund_cents), cleaning_cost_cents = VALUES(cleaning_cost_cents),
wechat_net_cents = VALUES(wechat_net_cents), balance_net_cents = VALUES(balance_net_cents),
package_net_cents = VALUES(package_net_cents), group_buy_net_cents = VALUES(group_buy_net_cents),
other_net_cents = VALUES(other_net_cents), source_checksum = VALUES(source_checksum),
calculated_at = VALUES(calculated_at)`,
[tenantId, metric.storeId, metric.date, metric.timezone, metric.orderCount, metric.customerCount,
metric.usedMinutes, metric.capacityMinutes, metric.roomGrossCents, metric.roomRefundCents,
metric.productGrossCents, metric.productRefundCents, metric.cleaningCostCents,
metric.channels.WECHAT, metric.channels.BALANCE, metric.channels.PACKAGE,
metric.channels.GROUP_BUY, metric.channels.OTHER, metric.sourceChecksum]
);
}
private assertCapability(actor: ManagementActor, capability: string) {
if (this.hasTenantScope(actor) || actor.access.capabilities.includes(capability)) return;
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
}
private hasTenantScope(actor: ManagementActor) {
return actor.access.roles.includes('PLATFORM_ADMIN')
|| actor.access.capabilities.includes('tenant.manage');
}
private canAccessStore(actor: ManagementActor, storeId: string) {
return this.hasTenantScope(actor) || actor.access.storeIds.includes(storeId);
}
}
type MutableMetric = Omit<BusinessDailyMetric, 'customerCount' | 'utilizationBasisPoints' |
'roomNetCents' | 'productNetCents' | 'totalNetCents' | 'contributionCents' | 'sourceChecksum'>;
function emptyMetric(store: StoreRow, date: string, roomCount: number, dayMinutes: number): MutableMetric {
return {
storeId: String(store.id), storeName: store.name, date, timezone: store.timezone,
orderCount: 0, usedMinutes: 0, capacityMinutes: roomCount * dayMinutes,
roomGrossCents: 0, roomRefundCents: 0, productGrossCents: 0, productRefundCents: 0,
cleaningCostCents: 0,
channels: { WECHAT: 0, BALANCE: 0, PACKAGE: 0, GROUP_BUY: 0, OTHER: 0 }
};
}
function applyMoney(
metrics: Map<string, MutableMetric>, rows: MoneyRow[], timezone: string,
field: 'roomGrossCents' | 'roomRefundCents' | 'productGrossCents' | 'productRefundCents',
channelSign: 1 | -1
) {
for (const row of rows) {
const metric = metrics.get(localDateKey(new Date(row.occurredAt), timezone));
if (!metric) continue;
const amount = Number(row.amountCents);
metric[field] += amount;
metric.channels[normalizeChannel(row.provider)] += amount * channelSign;
}
}
function finalizeMetric(metric: MutableMetric, customerCount: number): BusinessDailyMetric {
metric.usedMinutes = Math.min(metric.usedMinutes, metric.capacityMinutes);
const roomNetCents = metric.roomGrossCents - metric.roomRefundCents;
const productNetCents = metric.productGrossCents - metric.productRefundCents;
const totalNetCents = roomNetCents + productNetCents;
const value = {
...metric,
customerCount,
utilizationBasisPoints: metric.capacityMinutes
? Math.round(metric.usedMinutes * 10000 / metric.capacityMinutes) : 0,
roomNetCents,
productNetCents,
totalNetCents,
contributionCents: totalNetCents - metric.cleaningCostCents
};
return { ...value, sourceChecksum: checksum(value) };
}
function summarize(rows: BusinessDailyMetric[]) {
const result = {
orderCount: 0, customerCount: 0, usedMinutes: 0, capacityMinutes: 0,
utilizationBasisPoints: 0, roomGrossCents: 0, roomRefundCents: 0, roomNetCents: 0,
productGrossCents: 0, productRefundCents: 0, productNetCents: 0,
totalNetCents: 0, cleaningCostCents: 0, contributionCents: 0,
channels: { WECHAT: 0, BALANCE: 0, PACKAGE: 0, GROUP_BUY: 0, OTHER: 0 } as Record<ReportChannel, number>
};
for (const row of rows) {
for (const key of ['orderCount', 'customerCount', 'usedMinutes', 'capacityMinutes',
'roomGrossCents', 'roomRefundCents', 'roomNetCents', 'productGrossCents',
'productRefundCents', 'productNetCents', 'totalNetCents', 'cleaningCostCents',
'contributionCents'] as const) result[key] += row[key];
for (const channel of Object.keys(result.channels) as ReportChannel[]) result.channels[channel] += row.channels[channel];
}
result.utilizationBasisPoints = result.capacityMinutes
? Math.round(result.usedMinutes * 10000 / result.capacityMinutes) : 0;
return result;
}
function normalizeChannel(value: string): ReportChannel {
return (['WECHAT', 'BALANCE', 'PACKAGE', 'GROUP_BUY'] as const).includes(value as never)
? value as ReportChannel : 'OTHER';
}
function checksum(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
export function assertDateRange(from: string, to: string) {
if (!isDateKey(from) || !isDateKey(to) || from > to || dateKeys(from, to).length > 93) {
throw new BusinessReportError('BUSINESS_REPORT_RANGE_INVALID');
}
}
function isDateKey(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value;
}
export function dateKeys(from: string, to: string) {
const result: string[] = [];
for (let value = from; value <= to; value = shiftDate(value, 1)) result.push(value);
return result;
}
function shiftDate(value: string, days: number) {
const date = new Date(`${value}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
export function localDateKey(date: Date, timezone: string) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function zonedDateStart(value: string, timezone: string) {
const [year, month, day] = value.split('-').map(Number);
const desired = Date.UTC(year, month - 1, day);
let timestamp = desired;
for (let attempt = 0; attempt < 4; attempt += 1) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
}).formatToParts(new Date(timestamp));
const get = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value ?? 0);
const displayed = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
timestamp += desired - displayed;
}
return new Date(timestamp);
}
function formatDate(value: string | Date) {
return typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10);
}
@@ -0,0 +1,180 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
interface OrderSummaryRow extends RowDataPacket {
orderTotal: number;
activeOrderTotal: number;
finishedOrderTotal: number;
bookedAmountCents: number;
payingMemberTotal: number;
}
interface OrderStatusRow extends RowDataPacket {
status: string;
total: number;
amountCents: number;
}
interface PaymentChannelRow extends RowDataPacket {
channel: string;
total: number;
amountCents: number;
}
interface DailyRevenueRow extends RowDataPacket {
date: string | Date;
total: number;
amountCents: number;
}
interface RoomSummaryRow extends RowDataPacket {
roomTotal: number;
availableRoomTotal: number;
attentionRoomTotal: number;
}
interface VoucherSummaryRow extends RowDataPacket {
voucherTotal: number;
voucherSucceeded: number;
voucherFailed: number;
}
export class BusinessStatisticsError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class BusinessStatisticsRepository {
constructor(private readonly pool: MySqlPool) {}
async overview(
actor: ManagementActor,
input: { storeId: string; from: Date; to: Date }
) {
this.assertStoreScope(actor, input.storeId);
const rangeParams = [actor.tenantId, input.storeId, input.from, input.to];
const [
orderSummaryResult,
orderStatusResult,
paymentChannelResult,
dailyRevenueResult,
roomSummaryResult,
voucherSummaryResult
] = await Promise.all([
this.pool.execute<OrderSummaryRow[]>(
`SELECT COUNT(*) AS orderTotal,
COALESCE(SUM(o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')), 0) AS activeOrderTotal,
COALESCE(SUM(o.status = 'FINISHED'), 0) AS finishedOrderTotal,
COALESCE(SUM(o.total_amount_cents), 0) AS bookedAmountCents,
COUNT(DISTINCT owner.user_id) AS payingMemberTotal
FROM qipai_orders o
LEFT JOIN (
SELECT tenant_id, order_id, MIN(user_id) AS user_id
FROM qipai_order_user_access
WHERE access_type = 'OWNER'
GROUP BY tenant_id, order_id
) owner ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
AND o.created_at >= ? AND o.created_at < ?`,
rangeParams
),
this.pool.execute<OrderStatusRow[]>(
`SELECT o.status, COUNT(*) AS total,
COALESCE(SUM(o.total_amount_cents), 0) AS amountCents
FROM qipai_orders o
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
AND o.created_at >= ? AND o.created_at < ?
GROUP BY o.status ORDER BY total DESC, o.status`,
rangeParams
),
this.pool.execute<PaymentChannelRow[]>(
`SELECT p.channel, COUNT(*) AS total,
COALESCE(SUM(p.amount_cents), 0) AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
AND p.paid_at >= ? AND p.paid_at < ?
GROUP BY p.channel ORDER BY amountCents DESC, p.channel`,
rangeParams
),
this.pool.execute<DailyRevenueRow[]>(
`SELECT DATE_FORMAT(p.paid_at, '%Y-%m-%d') AS date, COUNT(*) AS total,
COALESCE(SUM(p.amount_cents), 0) AS amountCents
FROM qipai_payments p
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
AND p.paid_at >= ? AND p.paid_at < ?
GROUP BY DATE_FORMAT(p.paid_at, '%Y-%m-%d') ORDER BY date`,
rangeParams
),
this.pool.execute<RoomSummaryRow[]>(
`SELECT COUNT(*) AS roomTotal,
COALESCE(SUM(configuration_status = 'ENABLED'
AND operational_status = 'AVAILABLE'), 0) AS availableRoomTotal,
COALESCE(SUM(configuration_status = 'DISABLED'
OR operational_status IN ('MAINTENANCE', 'CLEANING_REQUIRED')), 0) AS attentionRoomTotal
FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`,
[actor.tenantId, input.storeId]
),
this.pool.execute<VoucherSummaryRow[]>(
`SELECT COUNT(*) AS voucherTotal,
COALESCE(SUM(status = 'SUCCEEDED'), 0) AS voucherSucceeded,
COALESCE(SUM(status = 'FAILED'), 0) AS voucherFailed
FROM qipai_group_redemptions
WHERE tenant_id = ? AND store_id = ? AND created_at >= ? AND created_at < ?`,
rangeParams
)
]);
const order = orderSummaryResult[0][0];
const room = roomSummaryResult[0][0];
const voucher = voucherSummaryResult[0][0];
const paymentChannels = paymentChannelResult[0].map((row) => ({
channel: row.channel,
total: Number(row.total),
amountCents: Number(row.amountCents)
}));
return {
storeId: input.storeId,
from: input.from.toISOString(),
to: input.to.toISOString(),
summary: {
orderTotal: Number(order?.orderTotal ?? 0),
activeOrderTotal: Number(order?.activeOrderTotal ?? 0),
finishedOrderTotal: Number(order?.finishedOrderTotal ?? 0),
bookedAmountCents: Number(order?.bookedAmountCents ?? 0),
collectedAmountCents: paymentChannels.reduce((sum, row) => sum + row.amountCents, 0),
payingMemberTotal: Number(order?.payingMemberTotal ?? 0),
voucherTotal: Number(voucher?.voucherTotal ?? 0),
voucherSucceeded: Number(voucher?.voucherSucceeded ?? 0),
voucherFailed: Number(voucher?.voucherFailed ?? 0),
roomTotal: Number(room?.roomTotal ?? 0),
availableRoomTotal: Number(room?.availableRoomTotal ?? 0),
attentionRoomTotal: Number(room?.attentionRoomTotal ?? 0)
},
orderStatuses: orderStatusResult[0].map((row) => ({
status: row.status,
total: Number(row.total),
amountCents: Number(row.amountCents)
})),
paymentChannels,
dailyRevenue: dailyRevenueResult[0].map((row) => ({
date: formatDate(row.date),
total: Number(row.total),
amountCents: Number(row.amountCents)
}))
};
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (!actor.access.capabilities.includes('store.operation.read')
|| !actor.access.storeIds.includes(storeId)) {
throw new BusinessStatisticsError('BUSINESS_STATISTICS_FORBIDDEN');
}
}
}
function formatDate(value: string | Date) {
if (typeof value === 'string') return value.slice(0, 10);
return value.toISOString().slice(0, 10);
}
@@ -0,0 +1,128 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export interface AuditLogQuery {
tenantId: string; page: number; pageSize: number; action?: string; resourceType?: string;
actorId?: string; search?: string; from?: Date; to?: Date;
}
interface AuditRow extends RowDataPacket {
id: string; tenantId: string; actorType: string; actorId: string | null; actorName: string | null;
action: string; resourceType: string; resourceId: string | null; traceId: string;
ip: string; userAgent: string; metadata: unknown; createdAt: Date;
}
export class SystemOperationsError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class SystemOperationsRepository {
constructor(private readonly pool: MySqlPool) {}
async listAuditLogs(input: AuditLogQuery) {
const page = Number.isSafeInteger(input.page) && input.page > 0 ? input.page : 1;
const pageSize = Number.isSafeInteger(input.pageSize)
? Math.min(100, Math.max(1, input.pageSize)) : 20;
const where = ['l.tenant_id = ?']; const params: Array<string | Date> = [input.tenantId];
if (input.action) { where.push('l.action = ?'); params.push(input.action); }
if (input.resourceType) { where.push('l.resource_type = ?'); params.push(input.resourceType); }
if (input.actorId) { where.push('l.actor_id = ?'); params.push(input.actorId); }
if (input.from) { where.push('l.created_at >= ?'); params.push(input.from); }
if (input.to) { where.push('l.created_at < ?'); params.push(input.to); }
if (input.search) {
where.push('(l.action LIKE ? OR l.resource_type LIKE ? OR l.trace_id LIKE ? OR u.nickname LIKE ?)');
const term = `%${input.search}%`; params.push(term, term, term, term);
}
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_audit_logs l
LEFT JOIN qipai_users u ON u.id = l.actor_id WHERE ${where.join(' AND ')}`, params
);
const offset = (page - 1) * pageSize;
const [rows] = await this.pool.execute<AuditRow[]>(
`SELECT l.id, l.tenant_id AS tenantId, l.actor_type AS actorType,
l.actor_id AS actorId, u.nickname AS actorName, l.action,
l.resource_type AS resourceType, l.resource_id AS resourceId,
l.trace_id AS traceId, l.ip, l.user_agent AS userAgent,
l.metadata, l.created_at AS createdAt
FROM qipai_audit_logs l LEFT JOIN qipai_users u ON u.id = l.actor_id
WHERE ${where.join(' AND ')} ORDER BY l.id DESC
LIMIT ${pageSize} OFFSET ${offset}`, params
);
return { items: rows.map((row) => ({ ...row, id: String(row.id), tenantId: String(row.tenantId),
actorId: row.actorId === null ? null : String(row.actorId),
resourceId: row.resourceId === null ? null : String(row.resourceId),
ip: maskIp(row.ip), metadata: sanitizeMetadata(parseJson(row.metadata)) })),
total: Number(counts[0]?.total ?? 0), page, pageSize };
}
async getSystemOverview(tenantId: string) {
const [tenants] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, code, name, status, timezone, created_at AS createdAt, updated_at AS updatedAt
FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
);
if (!tenants[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
const [counts] = await this.pool.execute<RowDataPacket[]>(
`SELECT
(SELECT COUNT(*) FROM qipai_stores WHERE tenant_id = ? AND deleted_at IS NULL) AS storeCount,
(SELECT COUNT(*) FROM qipai_users WHERE tenant_id = ? AND deleted_at IS NULL) AS userCount,
(SELECT COUNT(*) FROM qipai_auth_sessions WHERE tenant_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3)) AS activeSessionCount,
(SELECT COUNT(*) FROM qipai_tenant_apps WHERE tenant_id = ? AND deleted_at IS NULL) AS appBindingCount,
(SELECT COUNT(*) FROM qipai_audit_logs WHERE tenant_id = ? AND created_at >= UTC_DATE()) AS auditTodayCount`,
[tenantId, tenantId, tenantId, tenantId, tenantId]
);
const [migrations] = await this.pool.execute<RowDataPacket[]>(
'SELECT version, name, applied_at AS appliedAt FROM qipai_schema_migrations ORDER BY version DESC LIMIT 1'
);
return { tenant: { ...tenants[0], id: String(tenants[0].id) },
counts: Object.fromEntries(Object.entries(counts[0] ?? {}).map(([key, value]) => [key, Number(value)])),
latestMigration: migrations[0] ?? null };
}
async updateTenant(actor: ManagementActor, tenantId: string, input: {
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
}) {
return this.transaction(async (connection) => {
const [rows] = await connection.execute<Array<RowDataPacket & { name: string; timezone: string; status: string }>>(
'SELECT name, timezone, status FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL FOR UPDATE', [tenantId]
);
if (!rows[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
await connection.execute<ResultSetHeader>(
`UPDATE qipai_tenants SET name = ?, timezone = ?, status = COALESCE(?, status)
WHERE id = ? AND deleted_at IS NULL`, [input.name, input.timezone, input.status ?? null, tenantId]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'TENANT_SYSTEM_CONFIG_UPDATED',
'TENANT', ?, ?, ?, ?, ?)`,
[tenantId, actor.userId, tenantId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
JSON.stringify({ before: rows[0], after: input })]
);
return { tenantId, updated: true };
});
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
}
}
function parseJson(value: unknown): unknown {
if (typeof value !== 'string') return value;
try { return JSON.parse(value); } catch { return {}; }
}
function sanitizeMetadata(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sanitizeMetadata);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) =>
[key, /(secret|token|password|private|credential|certificate|api.?key|phone|openid|receiver|voucher)/i.test(key)
? '[REDACTED]' : sanitizeMetadata(item)]));
}
function maskIp(ip: string) {
if (!ip) return '';
if (ip.includes(':')) return `${ip.split(':').slice(0, 2).join(':')}::****`;
const parts = ip.split('.'); return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.***` : '***';
}
+10 -4
View File
@@ -41,6 +41,7 @@ export class OrderQueryRepository {
page: number;
pageSize: number;
status?: OrderStatus;
storeId?: string;
}) {
const where = [
'o.tenant_id = ?',
@@ -52,8 +53,13 @@ export class OrderQueryRepository {
where.push('o.status = ?');
params.push(input.status);
}
if (input.storeId) {
where.push('o.store_id = ?');
params.push(input.storeId);
}
const whereSql = where.join(' AND ');
const offset = (input.page - 1) * input.pageSize;
const pageSize = Math.max(1, Math.min(50, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT o.id) AS total
FROM qipai_orders o
@@ -84,14 +90,14 @@ export class OrderQueryRepository {
)
WHERE ${whereSql}
ORDER BY o.created_at DESC, o.id DESC
LIMIT ? OFFSET ?`,
[...params, input.pageSize, offset]
LIMIT ${pageSize} OFFSET ${offset}`,
params
);
return {
items: rows.map(publicOrder),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize: input.pageSize
pageSize
};
}
+28 -2
View File
@@ -74,7 +74,13 @@ export class OrderStateError extends Error {
export class OrderStateRepository {
constructor(
private readonly pool: MySqlPool,
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>,
private readonly cleaningTasks?: {
createForFinishedOrder(
connection: PoolConnection,
input: { tenantId: string; orderId: string; actorId: string; traceId: string }
): Promise<void>;
}
) {}
async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') {
@@ -103,6 +109,14 @@ export class OrderStateRepository {
[targetStatus, nextVersion, actor.tenantId, orderId]
);
await this.applyReservationState(connection, actor.tenantId, orderId, targetStatus);
if (targetStatus === 'FINISHED' && this.cleaningTasks) {
await this.cleaningTasks.createForFinishedOrder(connection, {
tenantId: actor.tenantId,
orderId,
actorId: actor.userId,
traceId: actor.traceId
});
}
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
@@ -112,6 +126,18 @@ export class OrderStateRepository {
actor.actorType, actor.userId, actor.source, reason.slice(0, 512),
actor.traceId, nextVersion]
);
await connection.execute(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
VALUES (?, 'ORDER', ?, ?, ?, JSON_OBJECT(
'storeId', ?, 'orderId', ?, 'customerId', (
SELECT user_id FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1
), 'fromStatus', ?, 'status', ?, 'action', ?))`,
[actor.tenantId, orderId, `ORDER_${action}`,
`order:${orderId}:history:${result.insertId}:notification`, order.storeId,
orderId, actor.tenantId, orderId, order.status, targetStatus, action]
);
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
@@ -230,7 +256,7 @@ export class OrderStateRepository {
if (this.benefits) {
const [users] = await connection.execute<RowDataPacket[]>(
`SELECT user_id AS userId FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? ORDER BY id LIMIT 1`,
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1`,
[tenantId, orderId]
);
if (users[0]?.userId) {
+62 -3
View File
@@ -45,6 +45,7 @@ interface RoomPricingRow extends RowDataPacket {
roomCategoryId: string | null;
}
interface CountRow extends RowDataPacket { total: number }
interface UserRow extends RowDataPacket { id: string }
export class PricingError extends Error {
constructor(public readonly code: string) { super(code); }
@@ -69,8 +70,16 @@ export class PricingRepository {
holdMinutes?: number;
allowedStoreIds?: string[] | null;
benefits?: OrderBenefitInput | null;
actor?: {
userId: string;
source: 'APP' | 'ADMIN';
traceId: string;
ip: string;
userAgent: string;
};
}) {
return this.transaction(async (connection) => {
await this.assertActiveTenantUser(connection, input.tenantId, input.userId);
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) {
throw new PricingError('STORE_SCOPE_FORBIDDEN');
@@ -158,9 +167,34 @@ export class PricingRepository {
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?,
'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`,
[input.tenantId, orderId, input.userId, `order-created-${orderId}`]
?, ?, ?, JSON_OBJECT('statusVersion', 1, 'targetUserId', ?))`,
[input.tenantId, orderId, input.actor?.userId ?? input.userId,
input.actor?.source ?? 'APP',
input.actor?.source === 'ADMIN' ? 'Order created on behalf' : 'Room hold created',
input.actor?.traceId ?? `order-created-${orderId}`, input.userId]
);
await connection.execute(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
VALUES (?, 'ORDER', ?, 'ORDER_CREATED', ?, JSON_OBJECT(
'storeId', ?, 'roomId', ?, 'orderId', ?, 'orderNo', ?,
'customerId', ?, 'status', 'PENDING_PAYMENT', 'source', ?))`,
[input.tenantId, orderId, `order:${orderId}:created:notification`,
String(room.storeId), input.roomId, orderId, orderNo, input.userId,
input.actor?.source ?? 'APP']
);
if (input.actor?.source === 'ADMIN') {
await connection.execute(
`INSERT INTO qipai_audit_logs
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
trace_id, ip, user_agent, metadata)
VALUES (?, 'USER', ?, 'ORDER_CREATED_ON_BEHALF', 'ORDER', ?, ?, ?, ?,
JSON_OBJECT('targetUserId', ?, 'storeId', ?, 'roomId', ?))`,
[input.tenantId, input.actor.userId, orderId, input.actor.traceId,
input.actor.ip, input.actor.userAgent.slice(0, 255), input.userId,
String(room.storeId), input.roomId]
);
}
return {
orderId,
orderNo,
@@ -173,6 +207,17 @@ export class PricingRepository {
});
}
private async assertActiveTenantUser(
connection: PoolConnection, tenantId: string, userId: string
) {
const [rows] = await connection.execute<UserRow[]>(
`SELECT id FROM qipai_users
WHERE tenant_id = ? AND id = ? AND status = 'ACTIVE' LIMIT 1`,
[tenantId, userId]
);
if (!rows[0]) throw new PricingError('USER_NOT_FOUND');
}
async releaseExpired(
tenantId?: string,
roomId?: string,
@@ -206,6 +251,20 @@ export class PricingRepository {
WHERE ${filters.join(' AND ')}`,
params
);
await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
SELECT o.tenant_id, 'ORDER', CAST(o.id AS CHAR), 'ORDER_EXPIRED',
CONCAT('order:', o.id, ':expired:notification'),
JSON_OBJECT('storeId', o.store_id, 'roomId', o.room_id, 'orderId', o.id,
'customerId', (SELECT a.user_id FROM qipai_order_user_access a
WHERE a.tenant_id = o.tenant_id AND a.order_id = o.id ORDER BY a.user_id LIMIT 1),
'status', 'CLOSED')
FROM qipai_room_reservations r
INNER JOIN qipai_orders o ON o.id = r.order_id AND o.tenant_id = r.tenant_id
WHERE ${filters.join(' AND ')}`,
params
);
await connection.execute<ResultSetHeader>(
`UPDATE qipai_room_reservations r
INNER JOIN qipai_orders o
@@ -304,7 +363,7 @@ export class PricingRepository {
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_days AS maxAdvanceDays,
r.room_category_id AS roomCategoryId
r.category_id AS roomCategoryId
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
@@ -353,6 +353,18 @@ export class PaymentRepository {
NULL, 'PAYMENT', ?, ?, JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
[input.tenantId, input.orderId, input.reason, input.traceId, nextVersion, input.paymentId]
);
await connection.execute(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
VALUES (?, 'ORDER', ?, 'ORDER_CONFIRM_PAYMENT', ?, JSON_OBJECT(
'storeId', ?, 'orderId', ?, 'customerId', (
SELECT user_id FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1
), 'paymentId', ?, 'amountCents', ?, 'status', 'PAID'))`,
[input.tenantId, input.orderId, `payment:${input.paymentId}:succeeded:notification`,
order.storeId, input.orderId, input.tenantId, input.orderId,
input.paymentId, input.amountCents]
);
}
}
+76 -1
View File
@@ -10,6 +10,9 @@ export interface WechatPayCredential {
apiV3Key: string;
platformCertificates: Record<string, string>;
profitShareReceivers?: Record<string, string>;
transferSceneId?: string;
transferSceneReportInfos?: Array<{ infoType: string; infoContent: string }>;
transferNotifyUrl?: string;
}
export interface WechatPayTransport {
@@ -173,6 +176,56 @@ export class WechatPayClient {
});
}
async createMerchantTransfer(
credential: WechatPayCredential,
input: {
outBillNo: string;
openid: string;
amountCents: number;
remark: string;
sceneId: string;
notifyUrl?: string;
reportInfos: Array<{ infoType: string; infoContent: string }>;
}
) {
const result = await this.apiRequest(credential, 'POST', '/v3/fund-app/mch-transfer/transfer-bills', {
appid: credential.appId,
out_bill_no: input.outBillNo,
transfer_scene_id: input.sceneId,
openid: input.openid,
transfer_amount: input.amountCents,
transfer_remark: input.remark.slice(0, 32),
notify_url: input.notifyUrl,
transfer_scene_report_infos: input.reportInfos.length > 0 ? input.reportInfos.map((item) => ({
info_type: item.infoType.slice(0, 15),
info_content: item.infoContent.slice(0, 32)
})) : undefined
});
return {
outBillNo: stringField(result, 'out_bill_no'),
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
state: stringField(result, 'state'),
failReason: optionalStringField(result, 'fail_reason'),
packageInfo: optionalStringField(result, 'package_info')
};
}
async queryMerchantTransferByOutBillNo(credential: WechatPayCredential, outBillNo: string) {
const result = await this.apiRequest(
credential,
'GET',
`/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`
);
return {
merchantId: stringField(result, 'mch_id'),
outBillNo: stringField(result, 'out_bill_no'),
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
state: stringField(result, 'state'),
failReason: optionalStringField(result, 'fail_reason'),
amountCents: optionalNumberField(result, 'transfer_amount')
};
}
verifyAndDecrypt(
credential: WechatPayCredential,
headers: WechatNotificationHeaders,
@@ -279,7 +332,10 @@ export function parseWechatPayCredentials(value: string) {
}
return [reference, account];
})
) : {}
) : {},
transferSceneId: optionalStringField(item, 'transferSceneId'),
transferSceneReportInfos: parseTransferReportInfos(item.transferSceneReportInfos),
transferNotifyUrl: optionalStringField(item, 'transferNotifyUrl')
});
}
return credentials;
@@ -327,3 +383,22 @@ function optionalStringField(value: Record<string, unknown>, key: string) {
const field = value[key];
return typeof field === 'string' ? field : '';
}
function optionalNumberField(value: Record<string, unknown>, key: string) {
const field = value[key];
return typeof field === 'number' ? field : 0;
}
function parseTransferReportInfos(value: unknown) {
if (!Array.isArray(value)) return undefined;
return value.map((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new WechatPayError('WECHAT_TRANSFER_REPORT_INVALID');
}
const raw = item as Record<string, unknown>;
return {
infoType: stringField(raw, 'infoType'),
infoContent: stringField(raw, 'infoContent')
};
});
}
+13 -1
View File
@@ -443,7 +443,7 @@ export class WechatPaymentService {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT user_id AS userId FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND revoked_at IS NULL
ORDER BY id LIMIT 1`,
ORDER BY user_id LIMIT 1`,
[tenantId, orderId]
);
return rows[0]?.userId ? String(rows[0].userId) : undefined;
@@ -523,6 +523,18 @@ export class WechatPaymentService {
[tenantId, refund.orderId, payment.orderStatus, traceId,
refund.id, refund.amountCents]
);
await connection.execute(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type, idempotency_key, payload)
VALUES (?, 'ORDER', ?, 'ORDER_COMPLETE_REFUND', ?, JSON_OBJECT(
'storeId', ?, 'orderId', ?, 'customerId', (
SELECT user_id FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? ORDER BY user_id LIMIT 1
), 'refundId', ?, 'amountCents', ?, 'status', 'REFUNDED'))`,
[tenantId, refund.orderId, `refund:${refund.id}:succeeded:notification`,
payment.storeId, refund.orderId, tenantId, refund.orderId,
refund.id, refund.amountCents]
);
}
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
import { randomBytes, randomUUID } from 'node:crypto';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import { AdminAuthError, hashToken, type AdminAuthRepository } from '../auth/admin-auth-repository.js';
import { signAccessToken } from '../auth/jwt.js';
import { hashPassword } from '../auth/password.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const loginName = z.string().trim().min(3).max(64).regex(/^[\p{L}\p{N}._@+-]+$/u);
const strongPassword = z.string().min(12).max(128)
.refine((value) => /[A-Za-z]/.test(value) && /\d/.test(value) && /[^A-Za-z0-9]/.test(value));
const loginSchema = z.object({ tenantCode: z.string().trim().min(2).max(64), loginName,
['password']: z.string().min(1).max(128) }).strict();
const refreshSchema = z.object({ refreshToken: z.string().min(60).max(160) }).strict();
const credentialSchema = z.object({ tenantId: id.optional(), loginName, ['password']: strongPassword }).strict();
export interface AdminAuthRouteOptions {
repository: Pick<AdminAuthRepository, 'loginWithPassword' | 'rotateRefreshToken' | 'setCredential'>;
authRepository: Pick<AuthRepository, 'validateSession' | 'revokeSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
accessTokenTtlSeconds: number;
sessionTtlSeconds: number;
}
export async function registerAdminAuthRoutes(app: FastifyInstance, options: AdminAuthRouteOptions) {
app.post('/admin-api/auth/login', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = loginSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
const sessionId = randomUUID(); const refreshToken = createRefreshToken(sessionId);
try {
const session = await options.repository.loginWithPassword({ ...body.data, sessionId,
refreshTokenHash: hashToken(refreshToken),
expiresAt: new Date(Date.now() + options.sessionTtlSeconds * 1000),
ip: request.ip, userAgent: request.headers['user-agent'] ?? '', traceId: request.traceId });
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
return { code: 0, data: sessionResponse(session, access, refreshToken, options), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
app.post('/admin-api/auth/refresh', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = refreshSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
const sessionId = body.data.refreshToken.split('.', 1)[0];
if (!z.string().uuid().safeParse(sessionId).success) return invalid(reply, request.traceId);
const nextRefreshToken = createRefreshToken(sessionId);
try {
const session = await options.repository.rotateRefreshToken({ sessionId,
currentHash: hashToken(body.data.refreshToken), nextHash: hashToken(nextRefreshToken),
ip: request.ip, userAgent: request.headers['user-agent'] ?? '' });
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
return { code: 0, data: sessionResponse(session, access, nextRefreshToken, options), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
app.get('/admin-api/auth/me', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) return unauthorized(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
return { code: 0, data: { user: safeUser(auth.session.user), access: adminAccess(access) }, traceId: request.traceId };
});
app.post('/admin-api/auth/logout', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) return unauthorized(reply, request.traceId);
await options.authRepository.revokeSession(auth.sessionId, 'ADMIN_LOGOUT');
return { code: 0, data: { revoked: true }, traceId: request.traceId };
});
app.put('/admin-api/auth/credentials/:userId', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ userId: id }).safeParse(request.params);
const body = credentialSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const tenantId = resolveTenant(actor, body.data.tenantId);
if (!tenantId) return reply.status(403).send({ code: 'ADMIN_AUTH_TENANT_FORBIDDEN',
message: 'The tenant is outside the allowed scope.', traceId: request.traceId });
try {
const passwordHash = await hashPassword(body.data.password);
return { code: 0, data: await options.repository.setCredential(actor, tenantId,
{ userId: params.data.userId, loginName: body.data.loginName, passwordHash }), traceId: request.traceId };
} catch (error) { return authError(reply, request.traceId, error); }
});
}
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: AdminAuthRouteOptions) {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { unauthorized(reply, request.traceId); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({ code: 'ADMIN_CREDENTIAL_FORBIDDEN',
message: 'Tenant management permission is required.', traceId: request.traceId }); return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
function createRefreshToken(sessionId: string) { return `${sessionId}.${randomBytes(32).toString('base64url')}`; }
function sessionResponse(session: { id: string; tenantId: string; platformAppId: string; user: Parameters<typeof safeUser>[0] },
access: AccessProfile, refreshToken: string, options: AdminAuthRouteOptions) {
return { accessToken: signAccessToken({ sub: session.user.id, sid: session.id, tid: session.tenantId,
aid: session.platformAppId, rv: session.user.roleVersion }, options.jwtSecret, options.accessTokenTtlSeconds),
refreshToken, expiresIn: options.accessTokenTtlSeconds, user: safeUser(session.user),
access: adminAccess(access) };
}
function adminAccess(access: AccessProfile) {
const tenant = access.capabilities.includes('tenant.manage') || isPlatform(access);
const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant || access.capabilities.includes('report.read') ? ['reports'] : []),
...(tenant || access.capabilities.some((capability) => [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage'
].includes(capability)) ? ['products'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : []),
...(tenant || access.capabilities.some((capability) =>
['notification.read', 'notification.manage'].includes(capability)) ? ['notifications'] : [])
];
return { ...access, menus: [...new Set(menus)] };
}
function safeUser(user: { id: string; tenantId: string; userType: string; nickname: string; avatarUrl: string; roleVersion: number }) {
return { id: user.id, tenantId: user.tenantId, userType: user.userType,
nickname: user.nickname, avatarUrl: user.avatarUrl, roleVersion: user.roleVersion };
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({
code: 'INVALID_ADMIN_AUTH_REQUEST', message: 'The admin authentication request is invalid.', traceId }); }
function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'The admin session is invalid or expired.', traceId }); }
function authError(reply: FastifyReply, traceId: string, error: unknown) {
if (!(error instanceof AdminAuthError)) throw error;
const status = error.code === 'ADMIN_LOGIN_LOCKED' ? 423
: error.code.includes('CONFLICT') ? 409
: error.code.includes('USER_INVALID') ? 404 : 401;
return reply.status(status).send({ code: error.code,
message: 'The admin authentication request was rejected.', traceId });
}
+187
View File
@@ -0,0 +1,187 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import {
BusinessStatisticsError,
type BusinessStatisticsRepository
} from '../operations/business-statistics-repository.js';
import {
BusinessReportError,
type BusinessReportService
} from '../operations/business-report-service.js';
const querySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
from: z.coerce.date().optional(),
to: z.coerce.date().optional()
}).strict();
const reportQuerySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
}).strict();
export interface BusinessStatisticsRouteOptions {
repository: Pick<BusinessStatisticsRepository, 'overview'>;
reportService?: Pick<BusinessReportService, 'report' | 'enqueueRange'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerBusinessStatisticsRoutes(
app: FastifyInstance,
options: BusinessStatisticsRouteOptions
) {
for (const path of ['/app-api/management/statistics', '/admin-api/statistics']) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
const query = querySchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
const to = query.data.to ?? new Date();
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
const duration = to.getTime() - from.getTime();
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
try {
return {
code: 0,
data: await options.repository.overview(actor, {
storeId: query.data.storeId,
from,
to
}),
traceId: request.traceId
};
} catch (error) {
if (!(error instanceof BusinessStatisticsError)) throw error;
return reply.status(403).send({
code: error.code,
message: 'Business statistics permission is required.',
traceId: request.traceId
});
}
});
}
if (!options.reportService) return;
for (const path of ['/app-api/management/reports/business', '/admin-api/reports/business']) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
const query = reportQuerySchema.safeParse(request.query);
if (!query.success) return invalidReport(reply, request.traceId);
try {
return { code: 0, data: await options.reportService!.report(actor, query.data), traceId: request.traceId };
} catch (error) {
return handleReportError(error, reply, request.traceId);
}
});
}
app.get('/admin-api/reports/business/export', async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
if (!actor.access.capabilities.includes('report.export')
&& !actor.access.capabilities.includes('tenant.manage')
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
return reply.status(403).send({ code: 'BUSINESS_REPORT_FORBIDDEN', message: 'Report export permission is required.', traceId: request.traceId });
}
const query = reportQuerySchema.safeParse(request.query);
if (!query.success) return invalidReport(reply, request.traceId);
try {
const report = await options.reportService!.report(actor, query.data);
const rows: Array<Array<string | number>> = [[
'日期', '门店', '时区', '订单数', '下单人数', '实际使用分钟', '可用分钟', '利用率(%)',
'房费实收(分)', '房费退款(分)', '房费净收入(分)', '商品实收(分)', '商品退款(分)',
'商品净收入(分)', '总净收入(分)', '保洁成本(分)', '贡献额(分)',
'微信净收入(分)', '余额净收入(分)', '套餐净收入(分)', '团购净收入(分)', '其他净收入(分)'
]];
for (const item of report.daily) rows.push([
item.date, item.storeName, item.timezone, item.orderCount, item.customerCount,
item.usedMinutes, item.capacityMinutes, (item.utilizationBasisPoints / 100).toFixed(2),
item.roomGrossCents, item.roomRefundCents, item.roomNetCents,
item.productGrossCents, item.productRefundCents, item.productNetCents,
item.totalNetCents, item.cleaningCostCents, item.contributionCents,
item.channels.WECHAT, item.channels.BALANCE, item.channels.PACKAGE,
item.channels.GROUP_BUY, item.channels.OTHER
]);
const csv = `\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\r\n')}`;
return reply.header('content-type', 'text/csv; charset=utf-8')
.header('content-disposition', `attachment; filename="business-report-${query.data.from}-${query.data.to}.csv"`)
.send(csv);
} catch (error) {
return handleReportError(error, reply, request.traceId);
}
});
app.post('/admin-api/reports/business/rebuild', async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
const body = reportQuerySchema.safeParse(request.body);
if (!body.success) return invalidReport(reply, request.traceId);
try {
return { code: 0, data: await options.reportService!.enqueueRange(actor, body.data), traceId: request.traceId };
} catch (error) {
return handleReportError(error, reply, request.traceId);
}
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: BusinessStatisticsRouteOptions
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_BUSINESS_STATISTICS_REQUEST',
message: 'The statistics request is invalid.',
traceId
});
}
function invalidReport(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_BUSINESS_REPORT_REQUEST',
message: 'The business report request is invalid.',
traceId
});
}
function handleReportError(error: unknown, reply: FastifyReply, traceId: string) {
if (!(error instanceof BusinessReportError)) throw error;
const status = error.code.endsWith('_FORBIDDEN') ? 403
: error.code.endsWith('_NOT_FOUND') ? 404 : 400;
return reply.status(status).send({ code: error.code, message: 'Business report request failed.', traceId });
}
function csvCell(value: string | number) {
const text = String(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
+870
View File
@@ -0,0 +1,870 @@
import { Transform } from 'node:stream';
import type {
FastifyInstance, FastifyReply, FastifyRequest, preParsingHookHandler
} from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import { MediaStorage, MediaValidationError } from '../content/media-storage.js';
import {
CleaningTaskError,
cleaningTaskStatuses,
type CleaningActor,
type CleaningTaskRepository
} from '../cleaning/cleaning-task-repository.js';
import {
CleaningPayoutError,
type CleaningPayoutService
} from '../cleaning/cleaning-payout-service.js';
import { WechatPayError, type WechatNotificationHeaders } from '../payments/wechat-pay-client.js';
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: z.enum(cleaningTaskStatuses).optional()
});
const managerTaskListSchema = listSchema.extend({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).strict();
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
const submitSchema = z.object({
photoUrls: z.array(z.string().url()).max(9).default([]),
note: z.string().trim().max(512).optional()
});
const assignSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
note: z.string().trim().max(512).optional()
}).strict();
const memberParamsSchema = z.object({
taskId: z.string().regex(/^[1-9]\d{0,19}$/),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/)
});
const memberSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
rewardCents: z.coerce.number().int().min(0).max(1000000),
note: z.string().trim().max(512).optional()
}).strict();
const memberRemoveSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
const exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const cleaningTemplateSchema = z.object({
scopeType: z.enum(['TENANT', 'STORE', 'ROOM']),
scopeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
name: z.string().trim().min(1).max(128),
requirement: z.string().trim().max(512).default(''),
photoRequired: z.boolean().default(true),
minPhotoCount: z.coerce.number().int().min(0).max(9).default(1),
maxPhotoCount: z.coerce.number().int().min(1).max(9).default(9),
exemptPolicy: z.enum(['DISABLED', 'BEFORE_START', 'ANY_ACTIVE']).default('ANY_ACTIVE'),
status: z.enum(['ACTIVE', 'DISABLED']).default('ACTIVE')
}).strict().superRefine((value, context) => {
if ((value.scopeType === 'TENANT') !== !value.scopeId) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['scopeId'], message: 'scope mismatch' });
}
if (value.minPhotoCount > value.maxPhotoCount
|| (value.photoRequired && value.minPhotoCount < 1)
|| (!value.photoRequired && value.minPhotoCount !== 0)) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['minPhotoCount'], message: 'photo rule mismatch' });
}
});
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
const settlementPayoutStateSchema = z.enum([
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
]);
const settlementListSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: settlementStatusSchema.optional(),
payoutState: settlementPayoutStateSchema.optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).strict();
const settlementExportSchema = settlementListSchema.omit({ page: true, pageSize: true });
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
const settlementGenerateSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
note: z.string().trim().max(512).optional()
}).strict();
const settlementConfirmSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const settlementCancelSchema = z.object({
reason: z.string().trim().min(1).max(512)
}).strict();
const settlementPaidSchema = z.object({
payoutChannel: z.string().trim().min(1).max(32),
payoutReference: z.string().trim().min(1).max(128),
note: z.string().trim().max(512).optional()
}).strict();
const settlementPayoutFailureSchema = z.object({
payoutChannel: z.string().trim().max(32).optional(),
payoutReference: z.string().trim().max(128).optional(),
error: z.string().trim().min(1).max(512),
note: z.string().trim().max(512).optional()
}).strict();
const settlementWechatTransferSchema = z.object({
mode: z.enum(['API', 'MOCK']).default('API'),
note: z.string().trim().max(512).optional()
}).strict();
const settlementWechatSyncSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const statisticsSchema = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).strict();
const reclaimSchema = z.object({
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
const photoCleanupSchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20)
}).strict();
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'exportSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'cancelSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
| 'listTemplates' | 'upsertTemplate'
| 'stats' | 'managerStatistics'>;
mediaStorage?: MediaStorage;
payoutService?: Pick<CleaningPayoutService,
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
| 'processWechatTransferNotification'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerCleaningRoutes(
app: FastifyInstance,
options: CleaningRouteOptions
): Promise<void> {
if (options.mediaStorage && !app.hasContentTypeParser('application/octet-stream')) {
app.addContentTypeParser(
'application/octet-stream',
{ parseAs: 'buffer', bodyLimit: 8 * 1024 * 1024 },
(_request, body, done) => done(null, body)
);
}
app.get('/admin-api/cleaning/templates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listTemplates(actor),
traceId: request.traceId
}));
});
app.put('/admin-api/cleaning/templates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = cleaningTemplateSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.upsertTemplate({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listHall({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listMine({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/stats', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.stats(actor),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/tasks',
'/app-api/management/cleaning/tasks'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = managerTaskListSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listManage({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
}
for (const path of [
'/admin-api/cleaning/statistics',
'/app-api/management/cleaning/statistics'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = statisticsSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
}
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = managerTaskListSchema.omit({ status: true }).safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.settlementCandidates({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = settlementListSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSettlements({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlements/export', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = settlementExportSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.exportSettlements({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getSettlementDetail({
...actor,
settlementId: params.data.settlementId
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.claim({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/assign', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = assignSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.assign({
...actor,
taskId: params.data.taskId,
cleanerUserId: body.data.cleanerUserId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listMembers({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/tasks/:taskId/events', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listEvents({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/tasks/:taskId/submissions', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSubmissions({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = memberSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.addMember({
...actor,
taskId: params.data.taskId,
cleanerUserId: body.data.cleanerUserId,
rewardCents: body.data.rewardCents,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/members/:cleanerUserId/remove', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = memberParamsSchema.safeParse(request.params);
const body = memberRemoveSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.removeMember({
...actor,
taskId: params.data.taskId,
cleanerUserId: params.data.cleanerUserId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.start({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/rework', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.rework({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/photos', async (request, reply) => {
if (!options.mediaStorage) return reply.status(501).send({
code: 'CLEANING_PHOTO_UPLOAD_UNAVAILABLE',
message: 'Cleaning photo upload is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const originalName = singleHeader(request.headers['x-file-name']);
if (!params.success || !originalName || !Buffer.isBuffer(request.body)) {
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => {
const context = await options.repository.assertCanUploadPhoto({
...actor, taskId: params.data.taskId
});
const image = await options.mediaStorage!.storeImage({
tenantId: actor.tenantId,
storeId: context.storeId,
originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer
});
try {
const recorded = await options.repository.recordPhotoUpload({
...actor, taskId: params.data.taskId, image
});
return reply.status(201).send({ code: 0, data: recorded, traceId: request.traceId });
} catch (error) {
await options.mediaStorage!.deleteImage(image.storagePath);
throw error;
}
});
});
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = submitSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.submit({
...actor,
taskId: params.data.taskId,
photoUrls: body.data.photoUrls,
note: body.data.note
}),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/cleaning/tasks/:taskId/complete',
'/app-api/management/cleaning/tasks/:taskId/complete'
]) {
app.post(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = completeSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.complete({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
for (const path of [
'/admin-api/cleaning/tasks/:taskId/reject',
'/app-api/management/cleaning/tasks/:taskId/reject'
]) {
app.post(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = rejectSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.reject({
...actor,
taskId: params.data.taskId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
}
for (const path of [
'/admin-api/cleaning/tasks/:taskId/exempt',
'/app-api/management/cleaning/tasks/:taskId/exempt'
]) {
app.post(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = exemptSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.exempt({
...actor,
taskId: params.data.taskId,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = reclaimSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.reclaimTimeouts({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/photos/cleanup', async (request, reply) => {
if (!options.mediaStorage) return reply.status(501).send({
code: 'CLEANING_PHOTO_CLEANUP_UNAVAILABLE',
message: 'Cleaning photo storage is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = photoCleanupSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
const claimed = await options.repository.claimExpiredPhotoUploads({
...actor, limit: body.data.limit
});
const deletedIds: string[] = [];
const failedIds: string[] = [];
for (const photo of claimed) {
try {
await options.mediaStorage!.deleteImage(photo.storagePath);
deletedIds.push(photo.id);
} catch {
failedIds.push(photo.id);
}
}
const result = await options.repository.markPhotoUploadsDeleted({
...actor, photoIds: deletedIds
});
return {
code: 0,
data: { claimed: claimed.length, deleted: result.deleted, failedIds },
traceId: request.traceId
};
});
});
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = settlementGenerateSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.generateSettlement({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/confirm', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementConfirmSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.confirmSettlement({
...actor,
settlementId: params.data.settlementId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/cancel', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementCancelSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.cancelSettlement({
...actor,
settlementId: params.data.settlementId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementPaidSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.markSettlementPaid({
...actor,
settlementId: params.data.settlementId,
payoutChannel: body.data.payoutChannel,
payoutReference: body.data.payoutReference,
note: body.data.note,
manualOverride: true
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/payout-failure', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementPayoutFailureSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.recordSettlementPayoutFailure({
...actor,
settlementId: params.data.settlementId,
payoutChannel: body.data.payoutChannel,
payoutReference: body.data.payoutReference,
error: body.data.error,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer', async (request, reply) => {
if (!options.payoutService) return reply.status(501).send({
code: 'CLEANING_PAYOUT_UNAVAILABLE',
message: 'Cleaning payout service is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementWechatTransferSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.payoutService!.executeWechatTransfer({
...actor,
settlementId: params.data.settlementId,
mode: body.data.mode,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/preflight', async (request, reply) => {
if (!options.payoutService) return reply.status(501).send({
code: 'CLEANING_PAYOUT_UNAVAILABLE',
message: 'Cleaning payout service is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.payoutService!.preflightWechatTransfer({
...actor,
settlementId: params.data.settlementId
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync', async (request, reply) => {
if (!options.payoutService) return reply.status(501).send({
code: 'CLEANING_PAYOUT_UNAVAILABLE',
message: 'Cleaning payout service is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementWechatSyncSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.payoutService!.syncWechatTransfer({
...actor,
settlementId: params.data.settlementId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/wechat-transfer/notify', {
preParsing: captureRawBody
}, async (request, reply) => {
if (!options.payoutService) return reply.status(501).send({
code: 'CLEANING_PAYOUT_UNAVAILABLE',
message: 'Cleaning payout service is not configured.',
traceId: request.traceId
});
const headers = wechatHeaders(request);
if (!headers) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
await options.payoutService!.processWechatTransferNotification(
headers,
request.rawBody,
request.traceId
);
return { code: 'SUCCESS', message: '成功', traceId: request.traceId };
});
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: CleaningRouteOptions,
mode: 'read' | 'write'
): Promise<CleaningActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'CLEANING_TASK_FORBIDDEN',
message: 'Cleaning task permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (error instanceof MediaValidationError) {
return reply.status(400).send({
code: error.code,
message: 'The cleaning task request cannot be completed.',
traceId
});
}
if (!(error instanceof CleaningTaskError)
&& !(error instanceof CleaningPayoutError)
&& !(error instanceof WechatPayError)) throw error;
const code = error.code;
const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
return reply.status(statusCode).send({
code,
message: 'The cleaning task request cannot be completed.',
traceId
});
}
}
function singleHeader(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_CLEANING_TASK_REQUEST',
message: 'The cleaning task request is invalid.',
traceId
});
}
function wechatHeaders(request: FastifyRequest): WechatNotificationHeaders | null {
const read = (key: string) => singleHeader(request.headers[key]);
const headers = {
timestamp: read('wechatpay-timestamp'),
nonce: read('wechatpay-nonce'),
serial: read('wechatpay-serial'),
signature: read('wechatpay-signature')
};
return headers.timestamp && headers.nonce && headers.serial && headers.signature
? headers as WechatNotificationHeaders
: null;
}
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
const chunks: Buffer[] = [];
const capture = new Transform({
transform(chunk, _encoding, callback) {
chunks.push(Buffer.from(chunk));
callback(null, chunk);
},
flush(callback) {
request.rawBody = Buffer.concat(chunks).toString('utf8');
callback();
}
});
const transformed = payload.pipe(capture) as typeof payload;
transformed.receivedEncodedLength = payload.receivedEncodedLength;
done(null, transformed);
};
+39 -2
View File
@@ -9,6 +9,9 @@ import { MediaStorage, MediaValidationError } from '../content/media-storage.js'
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const storeQuerySchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
const optionalStoreQuerySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const componentSchema = z.object({
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
props: z.record(z.unknown())
@@ -34,8 +37,9 @@ const adSchema = z.object({
export interface ContentRouteOptions {
repository: Pick<ContentRepository,
'registerAsset' | 'saveDecoration' | 'publishDecoration'
| 'listAdvertisements' | 'saveAdvertisement'>;
'registerAsset' | 'listAssets' | 'saveDecoration' | 'listDecorations'
| 'publishDecoration' | 'listAdvertisements' | 'saveAdvertisement'
| 'updateAdvertisement'>;
mediaStorage: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
@@ -50,6 +54,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
(_request, body, done) => done(null, body)
);
}
app.get('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const query = optionalStoreQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
if (!actor) return;
@@ -72,6 +86,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
});
});
});
app.get('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const query = storeQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listDecorations(actor, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const body = decorationSchema.safeParse(request.body);
@@ -109,6 +133,19 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
traceId: request.traceId
}));
});
app.put('/admin-api/advertisements/:id', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = adSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateAdvertisement(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
}
async function requireContentManager(
+114 -26
View File
@@ -14,6 +14,7 @@ import {
} from '../devices/device-control-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const vendorPasswordField = 'password' as const;
const contextSchema = z.object({
storeId: id,
roomId: id,
@@ -32,9 +33,13 @@ const doorSchema = contextSchema.extend({
});
const ttsSchema = contextSchema.extend({
content: z.string().trim().min(1).max(500),
volume: z.number().int().min(0).max(100).default(80),
playCount: z.number().int().min(1).max(10).default(1),
priority: z.number().int().min(0).max(10).default(0)
volume: z.number().int().min(0).max(10).default(8),
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
loop: z.number().int().positive().max(100).default(1),
speaker: z.number().int().min(0).max(5).default(0),
style: z.number().int().min(0).max(2).default(0),
speed: z.number().int().min(0).max(10).default(5),
intonation: z.number().int().min(0).max(10).default(5)
});
const minuteSchema = contextSchema.extend({
minute: z.number().int().min(0).max(10080)
@@ -53,31 +58,78 @@ const pairSchema = contextSchema.extend({
timeout: z.number().int().min(10).max(300).default(60)
});
const subLockSchema = contextSchema.extend({
subtype: z.enum(['14', '15']),
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
]),
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
delayTime: z.number().int().min(1).max(14).optional(),
content: z.string().min(1).max(128).optional(),
value: z.string().min(1).max(128).optional(),
dangerConfirmation: z.string().max(64).optional()
});
const socketReadSchema = contextSchema.extend({
target: z.enum(['basicInfo', 'workInfo']).default('workInfo')
target: z.enum(['basicInfo', 'workInfo', 'localtask', 'mqttConfig']).default('workInfo'),
slotNum: z.number().int().min(1).max(20).optional(),
taskNum: z.number().int().min(0).max(20).optional()
}).superRefine((value, context) => {
if (value.target === 'localtask' && value.taskNum === undefined) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['taskNum'],
message: 'taskNum is required for localtask reads' });
}
});
const socketSwitchSchema = contextSchema.extend({
on: z.boolean(),
slotNum: z.number().int().min(1).max(20).default(1)
});
const socketTaskSchema = contextSchema.extend({
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(1).max(20),
action: z.enum(['on', 'off']),
mode: z.enum(['once', 'daily', 'weekly']),
time: z.string().regex(/^\d{2}:\d{2}$/),
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional()
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
.refine((items) => new Set(items).size === items.length).optional()
}).superRefine((value, context) => {
if (value.mode === 'weekly' && !value.weekdays) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'],
message: 'weekdays are required weekly' });
}
if (value.mode !== 'weekly' && value.weekdays) {
context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'],
message: 'weekdays are only valid weekly' });
}
});
const socketClearTaskSchema = contextSchema.extend({
taskNum: z.number().int().min(0).max(20)
slotNum: z.number().int().min(1).max(20).default(1),
taskNum: z.number().int().min(0).max(20),
dangerConfirmation: z.string().max(64).optional()
});
const dangerSchema = contextSchema.extend({
dangerConfirmation: z.string().max(64)
});
const socketResetSchema = dangerSchema.extend({
[vendorPasswordField]: z.string().min(1).max(32)
});
const socketReturnKeySchema = dangerSchema.extend({
old: z.string().min(1).max(32),
new: z.string().min(1).max(32)
});
const socketProtectionSchema = contextSchema.extend({
maxPower: z.number().positive(),
maxCurrent: z.number().positive(),
maxTemperature: z.number().min(1).max(125),
pullOutStop: z.union([z.literal(0), z.literal(1)]),
pullOutPower: z.number().nonnegative(),
pullOutSec: z.number().int().min(1).max(86400),
chargeFullStop: z.union([z.literal(0), z.literal(1)]),
chargeFullPower: z.number().nonnegative(),
chargeFullSec: z.number().int().min(1).max(86400)
});
const socketParameterSchema = contextSchema.extend({
resetHold: z.union([z.literal(0), z.literal(1)]),
keyLock: z.union([z.literal(0), z.literal(1)]),
keyOff: z.union([z.literal(0), z.literal(1)])
});
const orderParams = z.object({ orderId: id });
const customerOpenDoorSchema = z.object({
@@ -89,7 +141,9 @@ export interface DeviceControlRouteOptions {
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
| 'clearSmartSocketTask'>;
| 'clearSmartSocketTask' | 'rebootSmartSocket' | 'emptySmartSocketEnergy'
| 'resetSmartSocket' | 'changeSmartSocketReturnKey'
| 'configureSmartSocketProtection' | 'configureSmartSocketParameter'>;
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
@@ -99,34 +153,48 @@ export interface DeviceControlRouteOptions {
export async function registerDeviceControlRoutes(
app: FastifyInstance, options: DeviceControlRouteOptions
) {
register('/admin-api/device-control/power', powerSchema,
registerManagement('/power', powerSchema,
(context, body) => options.service.controlPower(context, body));
register('/admin-api/device-control/door', doorSchema,
registerManagement('/door', doorSchema,
(context, body) => options.service.controlDoor(context, body));
register('/admin-api/device-control/tts', ttsSchema,
registerManagement('/tts', ttsSchema,
(context, body) => options.service.playTts(context, body));
register('/admin-api/device-control/tts/stop', contextSchema,
registerManagement('/tts/stop', contextSchema,
(context) => options.service.stopTts(context));
register('/admin-api/device-control/led', minuteSchema,
registerManagement('/led', minuteSchema,
(context, body) => options.service.controlLed(context, body.minute));
register('/admin-api/device-control/task/start', taskSchema,
registerManagement('/task/start', taskSchema,
(context, body) => options.service.startTask(context, body));
register('/admin-api/device-control/task/extend', extendSchema,
registerManagement('/task/extend', extendSchema,
(context, body) => options.service.extendTask(context, body.addminute));
register('/admin-api/device-control/task/cancel', contextSchema,
registerManagement('/task/cancel', contextSchema,
(context) => options.service.cancelTask(context));
register('/admin-api/device-control/sub-lock/pair', pairSchema,
registerManagement('/sub-lock/pair', pairSchema,
(context, body) => options.service.pairSubLock(context, body.timeout));
register('/admin-api/device-control/sub-lock/action', subLockSchema,
registerManagement('/sub-lock/action', subLockSchema,
(context, body) => options.service.controlSubLock(context, body));
register('/admin-api/device-control/socket/read', socketReadSchema,
(context, body) => options.service.readSmartSocket(context, body.target));
register('/admin-api/device-control/socket/switch', socketSwitchSchema,
registerManagement('/socket/read', socketReadSchema,
(context, body) => options.service.readSmartSocket(context, body));
registerManagement('/socket/switch', socketSwitchSchema,
(context, body) => options.service.switchSmartSocket(context, body));
register('/admin-api/device-control/socket/task', socketTaskSchema,
registerManagement('/socket/task', socketTaskSchema,
(context, body) => options.service.scheduleSmartSocket(context, body));
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
registerManagement('/socket/task/clear', socketClearTaskSchema,
(context, body) => options.service.clearSmartSocketTask(context, body));
registerManagement('/socket/reboot', dangerSchema,
(context, body) => options.service.rebootSmartSocket(context, body.dangerConfirmation));
registerManagement('/socket/energy/clear', dangerSchema,
(context, body) => options.service.emptySmartSocketEnergy(
context, body.dangerConfirmation
));
registerManagement('/socket/factory-reset', socketResetSchema,
(context, body) => options.service.resetSmartSocket(context, body));
registerManagement('/socket/return-key', socketReturnKeySchema,
(context, body) => options.service.changeSmartSocketReturnKey(context, body));
registerManagement('/socket/protection', socketProtectionSchema,
(context, body) => options.service.configureSmartSocketProtection(context, body));
registerManagement('/socket/parameter', socketParameterSchema,
(context, body) => options.service.configureSmartSocketParameter(context, body));
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
if (!options.customerAccess) {
@@ -160,6 +228,10 @@ export async function registerDeviceControlRoutes(
roomId: order.roomId,
orderId: order.orderId,
traceId: request.traceId,
actorType: 'CUSTOMER',
actorId: auth.session.user.id,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
access: {
roles: ['CUSTOMER'],
capabilities: ['device.write'],
@@ -216,6 +288,15 @@ export async function registerDeviceControlRoutes(
}
});
}
function registerManagement<T extends z.ZodTypeAny>(
suffix: string,
schema: T,
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
) {
register(`/admin-api/device-control${suffix}`, schema, handler);
register(`/app-api/management/device-control${suffix}`, schema, handler);
}
}
async function requireContext(
@@ -235,12 +316,19 @@ async function requireContext(
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
const actorType: CommandContext['actorType'] = access.roles.some((role) =>
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
) ? 'ADMIN' : 'STAFF';
return {
tenantId: auth.session.tenantId,
storeId: input.storeId,
roomId: input.roomId,
orderId: input.orderId,
traceId: request.traceId,
actorType,
actorId: auth.session.user.id,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? '',
access
};
}
+6 -2
View File
@@ -64,7 +64,8 @@ export interface DeviceRouteOptions {
}
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
app.get('/admin-api/devices', async (request, reply) => {
for (const path of ['/admin-api/devices', '/app-api/management/devices']) {
app.get(path, async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
@@ -74,6 +75,7 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
traceId: request.traceId
}));
});
}
app.post('/admin-api/devices', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = assetSchema.safeParse(request.body);
@@ -83,7 +85,8 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
traceId: request.traceId
}));
});
app.get('/admin-api/device-topology', async (request, reply) => {
for (const path of ['/admin-api/device-topology', '/app-api/management/device-topology']) {
app.get(path, async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const query = z.object({ storeId: id }).safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
@@ -93,6 +96,7 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
traceId: request.traceId
}));
});
}
app.post('/admin-api/device-channels', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = channelSchema.safeParse(request.body);
+148
View File
@@ -0,0 +1,148 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { FranchiseError, type FranchiseRepository } from '../franchise/franchise-repository.js';
import { AmbiguousAppTenantError } from '../tenancy/platform-config-repository.js';
import type { PlatformConfigResolver } from './platform-bootstrap.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const status = z.enum(['NEW', 'CONTACTED', 'QUALIFIED', 'REJECTED', 'CONVERTED']);
const contextHeaders = z.object({
'x-wechat-appid': z.string().trim().min(6).max(64),
'tenant-id': id.optional()
});
const applicationSchema = z.object({
city: z.string().trim().min(1).max(64),
contactName: z.string().trim().min(1).max(64),
contactPhone: z.string().trim().regex(/^\+?[0-9 -]{6,32}$/),
message: z.string().trim().max(1000).default(''),
clientRequestId: z.string().trim().min(8).max(128)
});
const listSchema = z.object({
tenantId: id.optional(), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20), status: status.optional(),
assigneeUserId: id.optional(), search: z.string().trim().max(64).optional()
});
const tenantQuery = z.object({ tenantId: id.optional() });
const assignmentSchema = z.object({ tenantId: id.optional(), assigneeUserId: id.nullable() });
const followUpSchema = z.object({
tenantId: id.optional(), followUpType: z.enum(['CALL', 'WECHAT', 'MEETING', 'NOTE']),
note: z.string().trim().min(1).max(1000), nextFollowUpAt: z.coerce.date().nullable().optional(),
status: status.optional()
});
export interface FranchiseRouteOptions {
repository: Pick<FranchiseRepository, 'submitApplication' | 'listApplications'
| 'getApplication' | 'assignApplication' | 'addFollowUp'>;
platformConfig: PlatformConfigResolver;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerFranchiseRoutes(app: FastifyInstance, options: FranchiseRouteOptions) {
app.post('/app-api/franchise-applications', async (request, reply) => {
const headers = contextHeaders.safeParse(request.headers);
const body = applicationSchema.safeParse(request.body);
if (!headers.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => {
let bootstrap;
try {
bootstrap = await options.platformConfig.resolveBootstrap(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
} catch (error) {
if (error instanceof AmbiguousAppTenantError) throw new FranchiseError('FRANCHISE_TENANT_SELECTION_REQUIRED');
throw error;
}
if (!bootstrap) throw new FranchiseError('FRANCHISE_TENANT_NOT_FOUND');
const auth = request.headers.authorization
? await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret)
: null;
if (request.headers.authorization && !auth) throw new FranchiseError('FRANCHISE_SESSION_INVALID');
const submittedUserId = auth?.session.tenantId === bootstrap.tenantId ? auth.session.user.id : null;
return reply.status(201).send({ code: 0, data: await options.repository.submitApplication({
tenantId: bootstrap.tenantId, submittedUserId, ...body.data, source: 'MINIAPP',
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
}), traceId: request.traceId });
});
});
app.get('/admin-api/franchise-applications', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const query = listSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, query.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.listApplications({ ...query.data, tenantId: targetTenantId }),
traceId: request.traceId }));
});
app.get('/admin-api/franchise-applications/:id', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const query = tenantQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, query.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.getApplication(targetTenantId, params.data.id), traceId: request.traceId }));
});
app.patch('/admin-api/franchise-applications/:id/assignee', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = assignmentSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, body.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({ code: 0,
data: await options.repository.assignApplication(actor, targetTenantId, params.data.id,
body.data.assigneeUserId), traceId: request.traceId }));
});
app.post('/admin-api/franchise-applications/:id/follow-ups', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = followUpSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
const targetTenantId = resolveTenant(actor, body.data.tenantId);
if (!targetTenantId) return forbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({ code: 0,
data: await options.repository.addFollowUp(actor, targetTenantId, params.data.id, body.data),
traceId: request.traceId }));
});
}
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: FranchiseRouteOptions) {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({ code: 'FRANCHISE_MANAGEMENT_FORBIDDEN', message: 'Franchise management permission is required.', traceId: request.traceId }); return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try { return await work(); } catch (error) {
if (!(error instanceof FranchiseError)) throw error;
const statusCode = error.code === 'FRANCHISE_SESSION_INVALID' ? 401
: error.code.endsWith('_NOT_FOUND') ? 404
: error.code.includes('FORBIDDEN') ? 403
: error.code.endsWith('_SELECTION_REQUIRED') ? 409 : 400;
return reply.status(statusCode).send({ code: error.code, message: 'The franchise request is invalid or not allowed.', traceId });
}
}
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_FRANCHISE_REQUEST', message: 'The franchise request is invalid.', traceId }); }
function forbidden(reply: FastifyReply, traceId: string) { return reply.status(403).send({ code: 'FRANCHISE_TENANT_FORBIDDEN', message: 'The tenant is outside the allowed scope.', traceId }); }
+251
View File
@@ -0,0 +1,251 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
const version = z.number().int().min(1).max(4_294_967_295);
const createVersion = z.number().int().min(0).max(4_294_967_295);
const quantity = z.number().int().min(1).max(1_000_000_000);
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
const reason = z.string().trim().min(1).max(512);
const stockParams = z.object({ skuId: id }).strict();
const ledgerParams = z.object({ inventoryId: id }).strict();
const stockListQuery = z.object({
storeId: id,
page,
pageSize,
skuId: id.optional(),
productId: id.optional(),
search: z.string().trim().max(64).optional()
}).strict();
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
const mutationBase = {
storeId: id,
requestId,
reason,
expectedVersion: version.optional()
};
const policyBody = z.object({
...mutationBase,
expectedVersion: createVersion,
policyType: z.enum(['TRACKED', 'UNLIMITED']),
lowStockThreshold: nonNegativeQuantity
}).strict();
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
const adjustmentBody = z.object({
...mutationBase,
availableDelta: delta,
lossDelta: delta.default(0)
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
const stocktakeBody = z.object({
...mutationBase,
expectedVersion: version,
availableQuantity: nonNegativeQuantity,
lossQuantity: nonNegativeQuantity
}).strict();
const lossBody = z.object({ ...mutationBase, quantity }).strict();
export interface InventoryRouteOptions {
service: Pick<InventoryService,
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
| 'stocktake' | 'recordLoss'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerInventoryRoutes(
app: FastifyInstance,
options: InventoryRouteOptions
) {
app.get('/admin-api/inventory/stocks', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const query = stockListQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listStocks(actor, query.data),
traceId: request.traceId
}));
});
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, false);
const params = ledgerParams.safeParse(request.params);
const query = ledgerQuery.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listLedger(actor, {
inventoryId: params.data.inventoryId,
...query.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = policyBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.configurePolicy(actor, {
skuId: params.data.skuId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = inboundBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = adjustmentBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = stocktakeBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
const actor = await requireInventoryActor(request, reply, options, true);
const params = stockParams.safeParse(request.params);
const body = lossBody.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return inventoryResponse(reply, request.traceId, async () => ({
code: 0,
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
traceId: request.traceId
}));
});
}
async function requireInventoryActor(
request: FastifyRequest,
reply: FastifyReply,
options: InventoryRouteOptions,
write: boolean
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
const manager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const allowed = manager || (write
? access.capabilities.includes('inventory.adjust')
: access.capabilities.includes('inventory.read')
|| access.capabilities.includes('inventory.adjust'));
if (!allowed) {
reply.status(403).send({
code: 'INVENTORY_OPERATION_FORBIDDEN',
message: 'Inventory permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function inventoryResponse(
reply: FastifyReply,
traceId: string,
work: () => Promise<unknown>
) {
try {
return await work();
} catch (error) {
if (!(error instanceof InventoryError)) throw error;
const status = inventoryErrorStatus(error.code);
return reply.status(status).send({
code: error.code,
message: 'The inventory operation is not allowed.',
traceId
});
}
}
function inventoryErrorStatus(code: string) {
if (code.endsWith('_FORBIDDEN')) return 403;
if (code.endsWith('_NOT_FOUND')) return 404;
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
return 400;
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_INVENTORY_REQUEST',
message: 'The inventory request is invalid.',
traceId
});
}
+6 -2
View File
@@ -54,7 +54,8 @@ export async function registerMemberRoutes(
}));
});
app.get('/admin-api/members', async (request, reply) => {
for (const path of ['/admin-api/members', '/app-api/management/members']) {
app.get(path, async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
@@ -65,8 +66,10 @@ export async function registerMemberRoutes(
traceId: request.traceId
}));
});
}
app.get('/admin-api/members/:id', async (request, reply) => {
for (const path of ['/admin-api/members/:id', '/app-api/management/members/:id']) {
app.get(path, async (request, reply) => {
const actor = await requireReader(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
@@ -78,6 +81,7 @@ export async function registerMemberRoutes(
}));
});
}
}
async function requireProfileReader(
request: FastifyRequest,
+144
View File
@@ -0,0 +1,144 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { NotificationError, type NotificationService } from '../notifications/notification-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const code = z.string().trim().regex(/^[A-Za-z][A-Za-z0-9._:-]{0,127}$/);
const channel = z.enum(['IN_APP', 'WECHAT_SUBSCRIBE', 'WE_COM', 'WEBHOOK', 'CLOUD_SPEAKER']);
const listQuery = z.object({ storeId: id.optional(), eventType: code.optional() }).strict();
const deliveryQuery = z.object({ storeId: id.optional(), status: z.string().trim().max(32).optional(),
channel: channel.optional(), page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20) }).strict();
const templateBody = z.object({ id: id.optional(), storeId: id.nullable().optional(),
templateCode: code, eventType: code, channel,
titleTemplate: z.string().trim().min(1).max(256),
bodyTemplate: z.string().trim().min(1).max(2000),
externalTemplateId: z.string().trim().max(128).optional(),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
expectedVersion: z.number().int().min(1).optional() }).strict()
.refine((value) => !value.id || value.expectedVersion !== undefined);
const routeBody = z.object({ storeId: id.nullable().optional(), eventType: code,
templateId: id, recipientType: z.enum(['CUSTOMER', 'USER', 'ROLE', 'STORE_WEBHOOK']),
recipientValue: z.string().trim().max(191).optional(),
quietStart: z.string().trim().nullable().optional(),
quietEnd: z.string().trim().nullable().optional() }).strict();
const subscriptionBody = z.object({ channel: z.literal('WECHAT_SUBSCRIBE'), templateCode: code,
status: z.enum(['AUTHORIZED', 'REVOKED']) }).strict();
const inboxQuery = z.object({ page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20) }).strict();
export interface NotificationRouteOptions {
service: Pick<NotificationService, 'listTemplates' | 'saveTemplate' | 'listRoutes'
| 'createRoute' | 'listDeliveries' | 'manualRetry' | 'setSubscription'
| 'listInbox' | 'markInboxRead'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerNotificationRoutes(app: FastifyInstance, options: NotificationRouteOptions) {
for (const prefix of ['/admin-api', '/app-api/management']) {
app.get(`${prefix}/notifications/templates`, async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
const query = listQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, () => options.service.listTemplates(actor, query.data));
});
app.put(`${prefix}/notifications/templates`, async (request, reply) => {
const actor = await requireActor(request, reply, options, true);
const body = templateBody.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, () => options.service.saveTemplate(actor, body.data));
});
app.get(`${prefix}/notifications/routes`, async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
const query = listQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, () => options.service.listRoutes(actor, query.data));
});
app.post(`${prefix}/notifications/routes`, async (request, reply) => {
const actor = await requireActor(request, reply, options, true);
const body = routeBody.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, () => options.service.createRoute(actor, body.data), 201);
});
app.get(`${prefix}/notifications/deliveries`, async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
const query = deliveryQuery.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, () => options.service.listDeliveries(actor, query.data));
});
app.post(`${prefix}/notifications/deliveries/:deliveryId/retry`, async (request, reply) => {
const actor = await requireActor(request, reply, options, true);
const params = z.object({ deliveryId: id }).strict().safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, () => options.service.manualRetry(actor, params.data.deliveryId));
});
}
app.put('/app-api/notifications/subscriptions', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization,
options.authRepository, options.jwtSecret);
const body = subscriptionBody.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, () => options.service.setSubscription(
auth.session.tenantId, auth.session.user.id, body.data
));
});
app.get('/app-api/notifications/inbox', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization,
options.authRepository, options.jwtSecret);
const query = inboxQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, () => options.service.listInbox(
auth.session.tenantId, auth.session.user.id, query.data
));
});
app.post('/app-api/notifications/inbox/:deliveryId/read', async (request, reply) => {
const auth = await authenticateAccessToken(request.headers.authorization,
options.authRepository, options.jwtSecret);
const params = z.object({ deliveryId: id }).strict().safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, () => options.service.markInboxRead(
auth.session.tenantId, auth.session.user.id, params.data.deliveryId
));
});
}
async function requireActor(request: FastifyRequest, reply: FastifyReply,
options: NotificationRouteOptions, write: boolean): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(request.headers.authorization,
options.authRepository, options.jwtSecret);
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
const manager = access.roles.includes('PLATFORM_ADMIN')
|| access.capabilities.some((item) => ['tenant.manage', 'platform.manage'].includes(item));
const allowed = manager || (write ? access.capabilities.includes('notification.manage')
: access.capabilities.some((item) => ['notification.read', 'notification.manage'].includes(item)));
if (!allowed) { reply.status(403).send({ code: 'NOTIFICATION_FORBIDDEN', message: 'Notification permission is required.', traceId: request.traceId }); return null; }
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>, status = 200) {
try { return reply.status(status).send({ code: 0, data: await work(), traceId }); }
catch (error) {
if (!(error instanceof NotificationError)) throw error;
const http = error.code.endsWith('_FORBIDDEN') ? 403 : error.code.endsWith('_NOT_FOUND') ? 404
: error.code.includes('CONFLICT') || error.code.endsWith('_NOT_ALLOWED') ? 409 : 400;
return reply.status(http).send({ code: error.code, message: 'The notification operation is not allowed.', traceId });
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({ code: 'INVALID_NOTIFICATION_REQUEST', message: 'The notification request is invalid.', traceId });
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId });
}
+3 -1
View File
@@ -91,7 +91,8 @@ export async function registerOrderManagementRoutes(
] as const;
for (const [path, schema, execute] of adminActions) {
app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => {
for (const prefix of ['/admin-api/orders', '/app-api/management/orders']) {
app.post(`${prefix}/:orderId/${path}`, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = schema.safeParse(request.body);
@@ -110,6 +111,7 @@ export async function registerOrderManagementRoutes(
});
}
}
}
function customerActor(
auth: { tenantId: string; userId: string; access: AccessProfile },
+6 -3
View File
@@ -16,7 +16,8 @@ const orderStatuses = [
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: z.enum(orderStatuses).optional()
status: z.enum(orderStatuses).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
@@ -31,7 +32,8 @@ export async function registerOrderQueryRoutes(
app: FastifyInstance,
options: OrderQueryRouteOptions
) {
app.get('/app-api/orders', async (request, reply) => {
for (const prefix of ['/app-api/orders', '/admin-api/orders', '/app-api/management/orders']) {
app.get(prefix, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const query = listSchema.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
@@ -43,7 +45,7 @@ export async function registerOrderQueryRoutes(
};
});
app.get('/app-api/orders/:orderId', async (request, reply) => {
app.get(`${prefix}/:orderId`, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
@@ -55,6 +57,7 @@ export async function registerOrderQueryRoutes(
}));
});
}
}
async function authenticate(
authorization: string | undefined,
+13 -2
View File
@@ -27,7 +27,12 @@ export interface OrderStateRouteOptions {
export async function registerOrderStateRoutes(
app: FastifyInstance, options: OrderStateRouteOptions
) {
app.get('/app-api/orders/:orderId/history', async (request, reply) => {
for (const path of [
'/app-api/orders/:orderId/history',
'/admin-api/orders/:orderId/history',
'/app-api/management/orders/:orderId/history'
]) {
app.get(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
@@ -40,6 +45,7 @@ export async function registerOrderStateRoutes(
traceId: request.traceId
}));
});
}
app.post('/app-api/orders/:orderId/cancel', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
@@ -62,7 +68,11 @@ export async function registerOrderStateRoutes(
});
});
app.post('/admin-api/orders/:orderId/actions', async (request, reply) => {
for (const path of [
'/admin-api/orders/:orderId/actions',
'/app-api/management/orders/:orderId/actions'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = transitionSchema.safeParse(request.body);
@@ -79,6 +89,7 @@ export async function registerOrderStateRoutes(
}));
});
}
}
async function authenticate(
authorization: string | undefined, options: OrderStateRouteOptions
+91
View File
@@ -0,0 +1,91 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import { PlatformAdminError, type PlatformAdminRepository } from '../tenancy/platform-admin-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const configSchema = z.object({
brandName: z.string().trim().min(1).max(128),
logoUrl: z.string().trim().max(512).default(''),
themeColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
servicePhone: z.string().trim().max(32).default(''),
franchisePhone: z.string().trim().max(32).default(''),
shareTitle: z.string().trim().max(128).default(''),
shareImageUrl: z.string().trim().max(512).default(''),
defaultStoreId: id.nullable().default(null),
extraConfig: z.record(z.unknown()).default({}),
bindingStatus: z.enum(['ACTIVE', 'DISABLED']),
isDefault: z.boolean()
});
const bindSchema = z.object({
appId: z.string().regex(/^[A-Za-z0-9_-]{6,64}$/),
appName: z.string().trim().min(1).max(128),
appStatus: z.enum(['ACTIVE', 'DISABLED']),
config: configSchema
});
export interface PlatformManagementRouteOptions {
repository: Pick<PlatformAdminRepository, 'listTenantApps' | 'updateTenantConfig' | 'bindApplication'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerPlatformManagementRoutes(app: FastifyInstance, options: PlatformManagementRouteOptions) {
app.get('/admin-api/platform-apps', async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0, data: await options.repository.listTenantApps(actor.tenantId), traceId: request.traceId
}));
});
app.put('/admin-api/platform-apps/:id/config', async (request, reply) => {
const actor = await requireActor(request, reply, options, false);
const params = z.object({ id }).safeParse(request.params);
const body = configSchema.safeParse(request.body);
if (!actor) return;
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateTenantConfig(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
app.post('/admin-api/platform-apps/bind', async (request, reply) => {
const actor = await requireActor(request, reply, options, true);
const body = bindSchema.safeParse(request.body);
if (!actor) return;
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.bindApplication(actor, body.data), traceId: request.traceId
}));
});
}
async function requireActor(request: FastifyRequest, reply: FastifyReply, options: PlatformManagementRouteOptions, platformOnly: boolean): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
const platform = access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
if ((platformOnly && !platform) || (!platformOnly && !platform && !access.capabilities.includes('tenant.manage'))) {
reply.status(403).send({ code: platformOnly ? 'PLATFORM_MANAGEMENT_FORBIDDEN' : 'TENANT_MANAGEMENT_FORBIDDEN', message: 'Management permission is required.', traceId: request.traceId });
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access, traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try { return await work(); }
catch (error) {
if (!(error instanceof PlatformAdminError)) throw error;
const missing = error.code.endsWith('_NOT_FOUND');
return reply.status(missing ? 404 : 400).send({ code: error.code, message: 'The platform application operation is invalid.', traceId });
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({ code: 'INVALID_PLATFORM_APP_REQUEST', message: 'The platform application request is invalid.', traceId });
}
+17 -4
View File
@@ -80,7 +80,11 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
};
});
app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => {
for (const path of [
'/admin-api/orders/reserve-on-behalf',
'/app-api/management/orders/reserve-on-behalf'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = adminReserveSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
@@ -104,12 +108,20 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
startAt: body.data.startAt,
endAt: body.data.endAt,
pricingMode: body.data.pricingMode,
allowedStoreIds: unrestricted ? null : access.storeIds
allowedStoreIds: unrestricted ? null : access.storeIds,
actor: {
userId: auth.userId,
source: 'ADMIN',
traceId: request.traceId,
ip: request.ip,
userAgent: String(request.headers['user-agent'] ?? '')
}
}),
traceId: request.traceId
}));
});
}
}
async function authenticate(
authorization: string | undefined,
@@ -131,8 +143,9 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
} catch (error) {
if (!(error instanceof PricingError)) throw error;
const conflict = error.code === 'TIME_SLOT_CONFLICT';
const notFound = error.code === 'ROOM_NOT_FOUND';
return reply.status(conflict ? 409 : notFound ? 404 : 400).send({
const forbidden = error.code === 'STORE_SCOPE_FORBIDDEN';
const notFound = error.code === 'ROOM_NOT_FOUND' || error.code === 'USER_NOT_FOUND';
return reply.status(conflict ? 409 : forbidden ? 403 : notFound ? 404 : 400).send({
code: error.code,
message: 'The requested price or time slot is not available.',
traceId
+308
View File
@@ -0,0 +1,308 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
ProductOrderError,
productOrderManagementActions,
productOrderStatuses,
type ProductOrderActor,
type ProductOrderService
} from '../products/product-order-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
const orderParams = z.object({ orderId: id }).strict();
const paymentParams = z.object({ paymentId: id }).strict();
const refundParams = z.object({ refundId: id }).strict();
const createSchema = z.object({
storeId: id,
requestId,
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE']),
roomOrderId: id.nullable().optional(),
note: z.string().trim().max(512).default(''),
items: z.array(z.object({
skuId: id,
quantity: z.number().int().min(1).max(1_000_000),
note: z.string().trim().max(256).default('')
}).strict()).min(1).max(100)
}).strict().superRefine((value, context) => {
if (value.fulfillmentMode === 'DELIVERY' && !value.roomOrderId) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['roomOrderId'],
message: 'roomOrderId is required for delivery orders'
});
}
if (value.fulfillmentMode === 'SELF_SERVICE' && value.roomOrderId) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['roomOrderId'],
message: 'roomOrderId is not allowed for self-service orders'
});
}
});
const customerListSchema = z.object({
storeId: id.optional(), status: z.enum(productOrderStatuses).optional(), page, pageSize
}).strict();
const managementListSchema = z.object({
storeId: id, status: z.enum(productOrderStatuses).optional(), page, pageSize
}).strict();
const cancelSchema = z.object({
requestId, reason: z.string().trim().min(1).max(512)
}).strict();
const actionSchema = z.object({
requestId,
action: z.enum(productOrderManagementActions),
reason: z.string().trim().max(512).default('')
}).strict();
const testPaymentSchema = z.object({ requestId, provider: z.literal('TEST') }).strict();
const testCallbackSchema = z.object({
callbackId: requestId,
amountCents: z.number().int().min(1).max(100_000_000_000_000)
}).strict();
export interface ProductOrderRouteOptions {
service: Pick<ProductOrderService,
'create' | 'listForCustomer' | 'getForCustomer' | 'cancelForCustomer'
| 'listForManagement' | 'getForManagement' | 'reconcileForManagement'
| 'managementAction'
| 'createPaymentForCustomer' | 'completeTestPaymentForCustomer'
| 'completeTestRefundForManagement'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
testAdapterEnabled?: boolean;
}
export async function registerProductOrderRoutes(
app: FastifyInstance,
options: ProductOrderRouteOptions
) {
app.post('/app-api/product-orders', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const body = createSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.service.create(actor, body.data),
traceId: request.traceId
}));
});
app.get('/app-api/product-orders', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const query = customerListSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listForCustomer(actor, query.data),
traceId: request.traceId
}));
});
app.get('/app-api/product-orders/:orderId', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = orderParams.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getForCustomer(actor, params.data.orderId),
traceId: request.traceId
}));
});
app.post('/app-api/product-orders/:orderId/cancel', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = orderParams.safeParse(request.params);
const body = cancelSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.cancelForCustomer(
actor, params.data.orderId, body.data
),
traceId: request.traceId
}));
});
if (options.testAdapterEnabled) {
app.post('/app-api/product-orders/:orderId/payments', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = orderParams.safeParse(request.params);
const body = testPaymentSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.service.createPaymentForCustomer(
actor, params.data.orderId, body.data
),
traceId: request.traceId
}));
});
app.post('/app-api/product-payments/:paymentId/test-complete', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = paymentParams.safeParse(request.params);
const body = testCallbackSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.completeTestPaymentForCustomer(
actor, params.data.paymentId, body.data
),
traceId: request.traceId
}));
});
}
for (const prefix of ['/admin-api', '/app-api/management']) {
app.get(`${prefix}/product-orders`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, false);
const query = managementListSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listForManagement(actor, query.data),
traceId: request.traceId
}));
});
app.get(`${prefix}/product-orders/:orderId`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, false);
const params = orderParams.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getForManagement(actor, params.data.orderId),
traceId: request.traceId
}));
});
app.get(`${prefix}/product-orders/:orderId/reconciliation`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, false);
const params = orderParams.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.reconcileForManagement(actor, params.data.orderId),
traceId: request.traceId
}));
});
app.post(`${prefix}/product-orders/:orderId/actions`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, true);
const params = orderParams.safeParse(request.params);
const body = actionSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.managementAction(actor, params.data.orderId, body.data),
traceId: request.traceId
}));
});
if (options.testAdapterEnabled) {
app.post(`${prefix}/product-refunds/:refundId/test-complete`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, true);
const params = refundParams.safeParse(request.params);
const body = testCallbackSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.completeTestRefundForManagement(
actor, params.data.refundId, body.data
),
traceId: request.traceId
}));
});
}
}
}
async function authenticate(
request: FastifyRequest,
reply: FastifyReply,
options: ProductOrderRouteOptions,
management: boolean,
write = false
): Promise<ProductOrderActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
if (management) {
const manager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const capability = write ? 'goods.order.manage' : 'goods.order.read';
if (!manager && !access.capabilities.includes(capability)
&& !(write && access.capabilities.includes('goods.order.manage'))) {
reply.status(403).send({
code: 'PRODUCT_ORDER_OPERATION_FORBIDDEN',
message: 'Product order permission is required.',
traceId: request.traceId
});
return null;
}
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
source: management ? 'MANAGEMENT' : 'CUSTOMER',
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ProductOrderError)) throw error;
const status = error.code === 'PRODUCT_ORDER_NOT_FOUND'
|| error.code === 'PRODUCT_PAYMENT_NOT_FOUND'
|| error.code === 'PRODUCT_REFUND_NOT_FOUND' ? 404
: error.code.includes('FORBIDDEN') ? 403
: error.code.includes('CONFLICT') || error.code.includes('NOT_ALLOWED')
|| error.code.includes('IN_PROGRESS') ? 409
: 400;
return reply.status(status).send({
code: error.code,
message: 'The requested product order operation is not available.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'PRODUCT_ORDER_INPUT_INVALID',
message: 'The product order input is invalid.',
traceId
});
}
+278
View File
@@ -0,0 +1,278 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import {
ProductStorageError,
productStorageStatuses,
type ProductStorageActor,
type ProductStorageService
} from '../products/product-storage-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,128}$/);
const expiresAt = z.coerce.date();
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
const storageParams = z.object({ storageId: id }).strict();
const orderItem = z.object({ orderItemId: id, quantity: z.number().int().min(1).max(1_000_000_000) }).strict();
const skuItem = z.object({ skuId: id, quantity: z.number().int().min(1).max(1_000_000_000) }).strict();
const storageItem = z.object({
storageItemId: id, quantity: z.number().int().min(1).max(1_000_000_000)
}).strict();
const fromOrderSchema = z.object({
requestId, sourceOrderId: id, expiresAt,
items: z.array(orderItem).min(1).max(100).optional()
}).strict();
const managementCreateSchema = z.discriminatedUnion('sourceType', [
z.object({
sourceType: z.literal('ORDER'), requestId, sourceOrderId: id, expiresAt,
items: z.array(orderItem).min(1).max(100).optional()
}).strict(),
z.object({
sourceType: z.literal('MANUAL'), requestId, storeId: id, memberId: id,
expiresAt, items: z.array(skuItem).min(1).max(100)
}).strict()
]);
const customerListSchema = z.object({
storeId: id.optional(), status: z.enum(productStorageStatuses).optional(), page, pageSize
}).strict();
const managementListSchema = z.object({
storeId: id, memberId: id.optional(),
status: z.enum(productStorageStatuses).optional(), page, pageSize
}).strict();
const retrieveSchema = z.object({
requestId,
claimCredential: z.string().regex(/^[A-Za-z0-9_-]{20,128}$/),
items: z.array(storageItem).min(1).max(100)
}).strict();
const rotateSchema = z.object({ requestId }).strict();
const cancelSchema = z.object({
requestId, reason: z.string().trim().min(1).max(512)
}).strict();
const expireSchema = z.object({
storeId: id, limit: z.number().int().min(1).max(100).default(100)
}).strict();
export interface ProductStorageRouteOptions {
service: Pick<ProductStorageService,
'createFromOrder' | 'createManual' | 'listForCustomer' | 'getForCustomer'
| 'listForManagement' | 'getForManagement' | 'retrieve'
| 'rotateCredential' | 'cancel' | 'expireDueForManagement'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerProductStorageRoutes(
app: FastifyInstance, options: ProductStorageRouteOptions
) {
app.post('/app-api/product-storages', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const body = fromOrderSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.service.createFromOrder(actor, body.data),
traceId: request.traceId
}));
});
app.get('/app-api/product-storages', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const query = customerListSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0, data: await options.service.listForCustomer(actor, query.data),
traceId: request.traceId
}));
});
app.get('/app-api/product-storages/:storageId', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = storageParams.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0, data: await options.service.getForCustomer(actor, params.data.storageId),
traceId: request.traceId
}));
});
app.post('/app-api/product-storages/:storageId/retrieve', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = storageParams.safeParse(request.params);
const body = retrieveSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.retrieve(actor, params.data.storageId, body.data),
traceId: request.traceId
}));
});
app.post('/app-api/product-storages/:storageId/credential/rotate', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = storageParams.safeParse(request.params);
const body = rotateSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.rotateCredential(actor, params.data.storageId, body.data),
traceId: request.traceId
}));
});
app.post('/app-api/product-storages/:storageId/cancel', async (request, reply) => {
const actor = await authenticate(request, reply, options, false);
const params = storageParams.safeParse(request.params);
const body = cancelSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.cancel(actor, params.data.storageId, body.data),
traceId: request.traceId
}));
});
for (const prefix of ['/admin-api', '/app-api/management']) {
app.post(`${prefix}/product-storages`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, true);
const body = managementCreateSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: body.data.sourceType === 'ORDER'
? await options.service.createFromOrder(actor, body.data)
: await options.service.createManual(actor, body.data),
traceId: request.traceId
}));
});
app.get(`${prefix}/product-storages`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, false);
const query = managementListSchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0, data: await options.service.listForManagement(actor, query.data),
traceId: request.traceId
}));
});
app.get(`${prefix}/product-storages/:storageId`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, false);
const params = storageParams.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getForManagement(actor, params.data.storageId),
traceId: request.traceId
}));
});
for (const action of ['retrieve', 'credential/rotate', 'cancel'] as const) {
app.post(`${prefix}/product-storages/:storageId/${action}`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, true);
const params = storageParams.safeParse(request.params);
const schema = action === 'retrieve' ? retrieveSchema
: action === 'cancel' ? cancelSchema : rotateSchema;
const body = schema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => {
const data = action === 'retrieve'
? await options.service.retrieve(
actor, params.data.storageId, body.data as z.infer<typeof retrieveSchema>
) : action === 'cancel'
? await options.service.cancel(
actor, params.data.storageId, body.data as z.infer<typeof cancelSchema>
) : await options.service.rotateCredential(
actor, params.data.storageId, body.data as z.infer<typeof rotateSchema>
);
return { code: 0, data, traceId: request.traceId };
});
});
}
app.post(`${prefix}/product-storages/expire-due`, async (request, reply) => {
const actor = await authenticate(request, reply, options, true, true);
const body = expireSchema.safeParse(request.body ?? {});
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0, data: await options.service.expireDueForManagement(actor, body.data),
traceId: request.traceId
}));
});
}
}
async function authenticate(
request: FastifyRequest,
reply: FastifyReply,
options: ProductStorageRouteOptions,
management: boolean,
write = false
): Promise<ProductStorageActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
if (management) {
const privileged = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const capability = write ? 'goods.storage.manage' : 'goods.storage.read';
if (!privileged && !access.capabilities.includes(capability)
&& !access.capabilities.includes('goods.storage.manage')) {
reply.status(403).send({
code: 'PRODUCT_STORAGE_OPERATION_FORBIDDEN',
message: 'Product storage permission is required.', traceId: request.traceId
});
return null;
}
}
return {
tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
source: management ? 'MANAGEMENT' : 'CUSTOMER', traceId: request.traceId,
ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ProductStorageError)) throw error;
const status = error.code.includes('NOT_FOUND') ? 404
: error.code.includes('FORBIDDEN') ? 403
: error.code.includes('CONFLICT') || error.code.includes('ALREADY')
|| error.code.includes('NOT_ACTIVE') ? 409 : 400;
return reply.status(status).send({
code: error.code,
message: 'The requested product storage operation is not available.', traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'PRODUCT_STORAGE_INPUT_INVALID',
message: 'The product storage input is invalid.', traceId
});
}
+508
View File
@@ -0,0 +1,508 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import {
ProductCatalogError,
type ProductCatalogRepository
} from '../products/product-catalog-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const version = z.coerce.number().int().min(0).max(0xffffffff);
const code = z.string().trim().min(1).max(64)
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
const storeParamsSchema = z.object({ storeId: id }).strict();
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
const productParamsSchema = z.object({ productId: id }).strict();
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
const includeInactiveQuerySchema = z.object({
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
}).strict();
const categoryFields = {
parentId: id.nullable().optional(),
categoryCode: code,
name: z.string().trim().min(1).max(128),
description: z.string().trim().max(1024).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createCategorySchema = z.object(categoryFields).strict();
const updateCategorySchema = z.object({
...categoryFields,
expectedVersion: version.min(1)
}).strict();
const productFields = {
productCode: code,
name: z.string().trim().min(1).max(128),
unitName: z.string().trim().min(1).max(32),
description: z.string().trim().max(10000).default(''),
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
images: z.array(z.string().url().max(512)).max(20).default([]),
deliveryEnabled: z.boolean().default(true),
storageEnabled: z.boolean().default(false),
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
};
const createProductSchema = z.object(productFields).strict();
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
const productListQuerySchema = z.object({
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
search: z.string().trim().min(1).max(128).optional()
}).strict();
const attributesSchema = z.record(
z.string().trim().min(1).max(64),
z.string().trim().max(128)
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
const skuFields = {
skuCode: code,
name: z.string().trim().min(1).max(128),
attributes: attributesSchema.default({}),
barcode: z.string().trim().max(64).default(''),
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
salePriceCents: z.number().int().min(0).max(100000000),
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
costPriceCents: z.number().int().min(0).max(100000000).default(0),
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
};
const createSkuSchema = z.object(skuFields).strict();
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
const listingSchema = z.object({
expectedVersion: version,
categoryId: id,
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
salesStartAt: z.coerce.date().nullable().optional(),
salesEndAt: z.coerce.date().nullable().optional(),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|| value.salesEndAt > value.salesStartAt, {
message: 'salesEndAt must be later than salesStartAt'
});
const hourSchema = z.object({
weekday: z.number().int().min(1).max(7),
slotNo: z.number().int().min(1).max(8),
openMinute: z.number().int().min(0).max(1439),
closeMinute: z.number().int().min(0).max(1439),
crossesMidnight: z.boolean()
}).strict();
const settingsSchema = z.object({
expectedVersion: version,
salesStatus: z.enum(['OPEN', 'CLOSED']),
manualPaused: z.boolean(),
manualPausedUntil: z.coerce.date().nullable().optional(),
manualPauseReason: z.string().trim().max(512).default(''),
hours: z.array(hourSchema).max(56).default([])
}).strict().superRefine((value, context) => {
if (value.manualPaused && value.manualPauseReason.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['manualPauseReason'],
message: 'manualPauseReason is required while manually paused'
});
}
});
export interface ProductRouteOptions {
repository: Pick<ProductCatalogRepository,
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
| 'listListings' | 'putListing' | 'archiveListing'
| 'getStoreSettings' | 'putStoreSettings' | 'listStoreCatalog'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
app.get('/app-api/stores/:storeId/product-catalog', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
const params = storeParamsSchema.safeParse(request.params);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return;
}
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listStoreCatalog({
tenantId: auth.session.tenantId, storeId: params.data.storeId
}),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-categories',
'/app-api/management/stores/:storeId/product-categories'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listCategories(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = createCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const body = updateCategorySchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateCategory(
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = categoryParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveCategory(
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const query = productListQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listProducts(actor, query.data),
traceId: request.traceId
}));
});
app.post('/admin-api/products', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const body = createProductSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createProduct(actor, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = updateProductSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateProduct(
actor, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveProduct(
actor, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
const params = productParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSkus(
actor, params.data.productId, query.data.includeInactive
),
traceId: request.traceId
}));
});
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = productParamsSchema.safeParse(request.params);
const body = createSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.createSku(actor, params.data.productId, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const body = updateSkuSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateSku(
actor, params.data.productId, params.data.skuId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = skuParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveSku(
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-listings',
'/app-api/management/stores/:storeId/product-listings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
const query = includeInactiveQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listListings(
actor, params.data.storeId, query.data.includeInactive
),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const body = listingSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putListing(
actor, params.data.storeId, params.data.productId, expectedVersion, input
),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = listingParamsSchema.safeParse(request.params);
const query = expectedVersionQuerySchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveListing(
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
),
traceId: request.traceId
}));
});
for (const path of [
'/admin-api/stores/:storeId/product-sales-settings',
'/app-api/management/stores/:storeId/product-sales-settings'
]) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
const params = storeParamsSchema.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getStoreSettings(actor, params.data.storeId),
traceId: request.traceId
}));
});
}
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
const params = storeParamsSchema.safeParse(request.params);
const body = settingsSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const { expectedVersion, ...input } = body.data;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.putStoreSettings(
actor, params.data.storeId, expectedVersion, input
),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: ProductRouteOptions,
mode: 'read' | 'write',
scope: 'store' | 'global-catalog' = 'store'
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
const allowed = access.capabilities.includes(capability)
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|| access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
if (!allowed) {
reply.status(403).send({
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
message: 'Product catalog permission is required.',
traceId: request.traceId
});
return null;
}
if (scope === 'global-catalog') {
const tenantManager = access.capabilities.includes('tenant.manage')
|| access.capabilities.includes('platform.manage')
|| access.roles.includes('PLATFORM_ADMIN');
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
&& access.storeIds.length > 0
&& (access.capabilities.includes('product.catalog.read')
|| access.capabilities.includes('product.catalog.write'));
if (!tenantManager && !authorizedStoreAdmin) {
reply.status(403).send({
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
message: 'Tenant management or an authorized store administrator is required.',
traceId: request.traceId
});
return null;
}
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ProductCatalogError)) throw error;
const code = error.code;
const status = code.endsWith('_FORBIDDEN') ? 403
: code.endsWith('_NOT_FOUND') ? 404
: code.includes('INVALID') ? 400
: 409;
return reply.status(status).send({
code,
message: 'The product catalog request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
});
}
+41 -2
View File
@@ -29,7 +29,7 @@ const storeSchema = z.object({
timezone: z.string().trim().min(1).max(64).default('Asia/Shanghai'),
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).default('OPEN'),
wifiSsid: z.string().trim().max(128).default(''),
wifiPassword: z.string().max(255).default(''),
wifiPassword: z.string().max(255).optional(),
notificationUrl: z.union([z.string().url(), z.literal('')]).default(''),
sortOrder: z.number().int().min(-100000).max(100000).default(0),
businessHours: hoursSchema.default([])
@@ -64,11 +64,21 @@ const disabledPeriodSchema = z.object({
endsAt: z.coerce.date(),
reason: z.string().trim().max(255).default('')
}).refine((value) => value.endsAt > value.startsAt);
const roomStatusSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
configurationStatus: z.enum(['ENABLED', 'DISABLED']).optional(),
operationalStatus: z.enum([
'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED'
]).optional(),
reason: z.string().trim().min(1).max(255)
}).strict().refine((value) => value.configurationStatus || value.operationalStatus, {
message: 'at least one room status is required'
});
export interface StoreRoomRouteOptions {
repository: Pick<StoreRoomRepository,
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
| 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -80,6 +90,11 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
if (!actor) return;
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
});
app.get('/app-api/management/stores', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
if (!actor) return;
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
});
app.post('/admin-api/stores', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const body = storeSchema.safeParse(request.body);
@@ -119,6 +134,15 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
traceId: request.traceId
}));
});
app.get('/app-api/management/stores/:storeId/rooms', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => ({
code: 0, data: await options.repository.listRooms(actor, params.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/rooms', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const body = roomSchema.safeParse(request.body);
@@ -140,6 +164,21 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
traceId: request.traceId
}));
});
for (const path of ['/admin-api/rooms/:id/status', '/app-api/management/rooms/:id/status']) {
app.patch(path, async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = roomStatusSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateRoomStatus(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
}
app.delete('/admin-api/rooms/:id', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
+183
View File
@@ -0,0 +1,183 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import {
SystemOperationsError,
type SystemOperationsRepository
} from '../operations/system-operations-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const auditQuerySchema = z.object({
tenantId: id.optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
action: z.string().trim().min(1).max(128).optional(),
resourceType: z.string().trim().min(1).max(64).optional(),
actorId: id.optional(),
search: z.string().trim().min(1).max(128).optional(),
from: z.coerce.date().optional(),
to: z.coerce.date().optional()
}).strict();
const tenantQuerySchema = z.object({ tenantId: id.optional() }).strict();
const updateTenantSchema = z.object({
tenantId: id.optional(),
name: z.string().trim().min(1).max(128),
timezone: z.string().trim().min(1).max(64),
status: z.enum(['ACTIVE', 'DISABLED']).optional()
}).strict();
export interface SystemOperationsRouteOptions {
repository: Pick<SystemOperationsRepository, 'listAuditLogs' | 'getSystemOverview' | 'updateTenant'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerSystemOperationsRoutes(
app: FastifyInstance,
options: SystemOperationsRouteOptions
) {
app.get('/admin-api/audit-logs', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const query = auditQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const tenantId = resolveTenant(actor, query.data.tenantId);
if (!tenantId) return tenantForbidden(reply, request.traceId);
const to = query.data.to ?? new Date();
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
if (to.getTime() <= from.getTime() || to.getTime() - from.getTime() > 366 * 86400000) {
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAuditLogs({ ...query.data, tenantId, from, to }),
traceId: request.traceId
}));
});
app.get('/admin-api/system/overview', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const query = tenantQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
const tenantId = resolveTenant(actor, query.data.tenantId);
if (!tenantId) return tenantForbidden(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getSystemOverview(tenantId),
traceId: request.traceId
}));
});
app.put('/admin-api/system/tenant', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const body = updateTenantSchema.safeParse(request.body);
if (!actor || !body.success || !isValidTimeZone(body.data.timezone)) {
return actor ? invalid(reply, request.traceId) : undefined;
}
const tenantId = resolveTenant(actor, body.data.tenantId);
if (!tenantId) return tenantForbidden(reply, request.traceId);
if (body.data.status && !isPlatform(actor.access)) {
return reply.status(403).send({
code: 'SYSTEM_STATUS_FORBIDDEN',
message: 'Platform management permission is required to change tenant status.',
traceId: request.traceId
});
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateTenant(actor, tenantId, body.data),
traceId: request.traceId
}));
});
}
async function requireManager(
request: FastifyRequest,
reply: FastifyReply,
options: SystemOperationsRouteOptions
): Promise<ManagementActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
reply.status(403).send({
code: 'SYSTEM_OPERATIONS_FORBIDDEN',
message: 'Tenant management permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
};
}
function resolveTenant(actor: ManagementActor, requested?: string) {
if (!requested || requested === actor.tenantId) return actor.tenantId;
return isPlatform(actor.access) ? requested : null;
}
function isPlatform(access: AccessProfile) {
return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
}
function isValidTimeZone(value: string) {
try {
new Intl.DateTimeFormat('en-US', { timeZone: value }).format();
return true;
} catch {
return false;
}
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof SystemOperationsError)) throw error;
const statusCode = error.code.endsWith('_NOT_FOUND') ? 404 : 400;
return reply.status(statusCode).send({
code: error.code,
message: 'The system operations request is invalid.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_SYSTEM_OPERATIONS_REQUEST',
message: 'The system operations request is invalid.',
traceId
});
}
function tenantForbidden(reply: FastifyReply, traceId: string) {
return reply.status(403).send({
code: 'SYSTEM_TENANT_FORBIDDEN',
message: 'The tenant is outside the allowed scope.',
traceId
});
}
+67 -3
View File
@@ -36,8 +36,10 @@ const bookingSchema = z.object({
const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) });
const recordsQuery = z.object({
provider: providerSchema.optional(),
status: z.string().min(1).max(32).optional()
status: z.string().min(1).max(32).optional(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const setupQuery = z.object({ provider: providerSchema.optional() });
const configSchema = z.object({
provider: providerSchema,
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
@@ -82,7 +84,33 @@ export async function registerThirdPartyRoutes(
}));
});
app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => {
for (const path of [
'/admin-api/group-vouchers/redeem',
'/app-api/management/group-vouchers/redeem'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = redeemSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherAsManager({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
}
for (const path of [
'/admin-api/group-vouchers/redeem-manual',
'/app-api/management/group-vouchers/redeem-manual'
]) {
app.post(path, async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = manualSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
@@ -98,6 +126,7 @@ export async function registerThirdPartyRoutes(
traceId: request.traceId
}));
});
}
app.post(
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
@@ -150,7 +179,42 @@ export async function registerThirdPartyRoutes(
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined,
status: query.data.status
status: query.data.status,
storeId: query.data.storeId
}),
traceId: request.traceId
}));
});
app.get('/admin-api/third-party/setup', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = setupQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listSetup({
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined
}),
traceId: request.traceId
}));
});
app.get('/app-api/management/third-party/records', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const query = recordsQuery.safeParse(request.query);
if (!auth) return unauthorized(reply, request.traceId);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listRecords({
tenantId: auth.tenantId,
access: auth.access!,
provider: query.data.provider as ThirdPartyProvider | undefined,
status: query.data.status,
storeId: query.data.storeId
}),
traceId: request.traceId
}));
+13 -2
View File
@@ -31,7 +31,9 @@ const updateSchema = z.object({
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
userType: z.enum(['STAFF']).optional(),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
search: z.string().trim().max(128).optional()
});
@@ -46,14 +48,23 @@ export async function registerUserManagementRoutes(
app: FastifyInstance,
options: UserManagementRouteOptions
): Promise<void> {
app.get('/admin-api/users', async (request, reply) => {
for (const [path, staffOnly] of [
['/admin-api/users', false],
['/app-api/management/staff', true]
] as const) {
app.get(path, async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
const data = await options.repository.listUsers({ actor, ...query.data });
const data = await options.repository.listUsers({
actor,
...query.data,
...(staffOnly ? { userType: 'STAFF' as const } : {})
});
return { code: 0, data, traceId: request.traceId };
});
}
app.post('/admin-api/staff', async (request, reply) => {
const actor = await requireManager(request, reply, options);

Some files were not shown because too many files have changed in this diff Show More