Compare commits

..

194 Commits

Author SHA1 Message Date
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
Codex 9f7c2e4be7 docs(M08-A): 记录权益选择器进度 2026-06-25 23:05:28 +08:00
Codex 47016744d4 feat(M08-A): 增加权益可视化选择器 2026-06-25 23:05:11 +08:00
Codex 59c4ef330e docs(M08-A): 记录权益余额抵扣提交状态 2026-06-25 22:41:24 +08:00
Codex dda2b3cbc6 feat(M08-A): 接入权益和余额下单抵扣 2026-06-25 22:40:50 +08:00
Codex 3d885852cd docs(M08-A): 更新充值微信支付进度 2026-06-25 12:41:40 +08:00
Codex 53f5d6776f feat(M08-A): 接入充值微信支付调起 2026-06-25 12:40:21 +08:00
Codex 2bbb16e6f0 docs(M08-A): 记录顾客端权益验收 2026-06-25 12:08:16 +08:00
Codex e24920c9cc feat(M08-A): 接入顾客端权益明细 2026-06-25 12:07:09 +08:00
Codex fb1b593b24 docs(M08-A): 记录顾客端充值入口验收 2026-06-25 11:52:08 +08:00
Codex dbdfe3dfca feat(M08-A): 接入顾客端充值入口 2026-06-25 11:51:02 +08:00
Codex cb1109f387 docs(M08-A): 记录顾客端个人中心验收 2026-06-25 11:44:48 +08:00
Codex 7446852cca feat(M08-A): 接入顾客端个人中心 2026-06-25 11:43:25 +08:00
Codex c7c869c18b docs(M08-A): 记录顾客端续费换房分享验收 2026-06-25 11:35:40 +08:00
Codex 874b9b8e29 feat(M08-A): 接入顾客端续费换房分享 2026-06-25 11:34:23 +08:00
Codex a4e13d911e docs(M08-A): 记录订单开门阶段验收 2026-06-24 22:39:50 +08:00
Codex eaa86547c2 feat(M08-A): 接入顾客端订单开门 2026-06-24 22:39:15 +08:00
Codex d2c73a2c28 docs(M08-A): 记录订单查询阶段验收 2026-06-24 21:42:40 +08:00
Codex eea8eea12d feat(M08-A): 接入顾客端订单查询 2026-06-24 21:42:02 +08:00
Codex 47ec7fc30e docs(M08-A): 记录顾客端阶段验收 2026-06-24 21:27:25 +08:00
Codex 4ac2c960e4 feat(M08-A): 接入顾客端选店下单入口 2026-06-24 21:26:32 +08:00
Codex c90f6e34a1 docs(M07-D): 记录会员管理验收并推进顾客端 2026-06-24 16:05:15 +08:00
Codex ed0d455083 feat(M07-D): 完成会员管理聚合查询 2026-06-24 16:02:26 +08:00
292 changed files with 60628 additions and 768 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"
+37 -11
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` 为准。
- 当前执行游标:`M07-D`
- 最近工程提交:`4ae9296`,完成 M07-C 优惠券和套餐权益底座,支持持券/持套餐、订单冻结、确认核销、退回和重复请求幂等
- 下一工程目标:M07-D 会员管理
- 当前执行游标:`M09-D2`TODO);`M09-D1` 已在当前工作树完成商品目录与库存底座收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`
- 最近工程结果:M09-D1 已建立 9 张商品/库存表、分类/商品/SKU/门店售卖与跨日营业时段、可用/锁定/损耗库存、幂等请求和不可变流水;增量/崩溃重试迁移、数据库级迁移互斥、生产触发器前置、API/RBAC 与 MySQL 8.4.10 往返门禁通过,随本次中文工程提交固化
- 下一工程目标:执行 M09-D2,建立商品订单、订单项快照、支付状态衔接,以及库存锁定、扣减、释放和取消回补闭环
## 版本递进
@@ -130,9 +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-D1 商品目录/库存工作树,游标推进至 M09-D2。
## Codex 入口
```text
请阅读 V5.4.md,按当前进度继续开发。
请阅读 V5.7.md,按当前进度继续开发。
```
+6734
View File
File diff suppressed because it is too large Load Diff
+7199
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"
}
}
+256
View File
@@ -0,0 +1,256 @@
<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 {
ClipboardList,
Handshake,
Images,
LayoutDashboard,
LogOut,
PanelsTopLeft,
RadioTower,
ScrollText,
Sparkles,
Store,
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> = {
ClipboardList,
Handshake,
Images,
LayoutDashboard,
PanelsTopLeft,
RadioTower,
ScrollText,
Sparkles,
Store,
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>
+964
View File
@@ -0,0 +1,964 @@
import type {
Advertisement,
AdvertisementInput,
AdminIdentity,
AdminSessionPayload,
AdminAccess,
AuditLog,
CleaningSettlement,
CleaningSettlementDetail,
CleaningStatistics,
CleaningTask,
CleaningTemplate,
CleaningTemplateScope,
CleaningExemptPolicy,
CleaningTaskEvent,
CleaningTaskMember,
CleaningTaskSubmission,
DecorationComponent,
DecorationVersion,
FranchiseApplication,
FranchiseApplicationDetail,
FranchiseFollowUpType,
FranchiseStatus,
DeviceTopology,
DeviceType,
BusinessStatistics,
ManagedRoom,
MemberCard,
MemberDetail,
ManagedOrder,
ManagedStore,
ManagedUser,
MediaAsset,
PageResult,
OrderAction,
OrderHistoryItem,
OrderStatus,
PaymentAuthorizationStatus,
PlatformApplication,
ProfitSharingSnapshot,
PayoutStateFilter,
StaffRole,
SettlementStatus,
SystemOverview,
TaskStatus,
TransferMode,
RoomConfigurationStatus,
RoomInput,
RoomOperationalStatus,
StoreInput,
TenantApplicationConfig,
ThirdPartyMode,
ThirdPartyProvider,
ThirdPartyRecords,
ThirdPartySetup,
UserStatus,
WechatTransferPreflight
} from './types';
const API_BASE = (import.meta.env.VITE_ADMIN_API_BASE_URL || '/admin-api').replace(/\/$/, '');
export class ApiError extends Error {
constructor(
public readonly code: string,
message = code,
public readonly traceId = ''
) {
super(message);
}
}
export interface ApiSession {
token: string;
}
const ACCESS_TOKEN_KEY = 'qipai.admin.token';
const REFRESH_TOKEN_KEY = 'qipai.admin.refreshToken';
let refreshInFlight: Promise<AdminSessionPayload> | null = null;
export async function adminPasswordLogin(input: {
tenantCode: string; loginName: string; password: string;
}) {
return publicRequest<AdminSessionPayload>('/auth/login', {
method: 'POST', body: JSON.stringify(input)
});
}
export function getAdminMe(session: ApiSession) {
return request<{ user: AdminIdentity; access: AdminAccess }>(session, '/auth/me');
}
export function logoutAdmin(session: ApiSession) {
return request<{ revoked: boolean }>(session, '/auth/logout', { method: 'POST' });
}
export function setAdminCredential(session: ApiSession, userId: string, input: {
loginName: string; password: string;
}) {
return request<{ userId: string; configured: boolean }>(session,
`/auth/credentials/${encodeURIComponent(userId)}`,
{ method: 'PUT', body: JSON.stringify(input) });
}
export function listManagedStores(session: ApiSession) {
return request<ManagedStore[]>(session, '/stores');
}
export function createManagedStore(session: ApiSession, input: StoreInput) {
return request<{ storeId: string }>(session, '/stores', {
method: 'POST', body: JSON.stringify(input)
});
}
export function updateManagedStore(session: ApiSession, storeId: string, input: StoreInput) {
return request<{ storeId: string }>(session, `/stores/${storeId}`, {
method: 'PUT', body: JSON.stringify(input)
});
}
export function archiveManagedStore(session: ApiSession, storeId: string) {
return request<{ storeId: string; archived: boolean }>(session, `/stores/${storeId}`, {
method: 'DELETE'
});
}
export function listManagedRooms(session: ApiSession, storeId: string) {
return request<ManagedRoom[]>(session, `/stores/${storeId}/rooms`);
}
export function createManagedRoom(session: ApiSession, input: RoomInput) {
return request<{ roomId: string }>(session, '/rooms', {
method: 'POST', body: JSON.stringify(input)
});
}
export function updateManagedRoom(session: ApiSession, roomId: string, input: RoomInput) {
return request<{ roomId: string }>(session, `/rooms/${roomId}`, {
method: 'PUT', body: JSON.stringify(input)
});
}
export function updateManagedRoomStatus(
session: ApiSession,
roomId: string,
input: {
storeId: string;
configurationStatus?: RoomConfigurationStatus;
operationalStatus?: RoomOperationalStatus;
reason: string;
}
) {
return request<{ roomId: string }>(session, `/rooms/${roomId}/status`, {
method: 'PATCH', body: JSON.stringify(input)
});
}
export function archiveManagedRoom(session: ApiSession, roomId: string, storeId: string) {
return request<{ roomId: string; archived: boolean }>(
session, `/rooms/${roomId}?${new URLSearchParams({ storeId })}`, { method: 'DELETE' }
);
}
export function addManagedRoomDisabledPeriod(
session: ApiSession,
roomId: string,
input: { storeId: string; startsAt: string; endsAt: string; reason: string }
) {
return request<{ disabledPeriodId: string }>(session, `/rooms/${roomId}/disabled-periods`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function listManagedOrders(
session: ApiSession,
input: { page: number; pageSize: number; status?: OrderStatus; storeId?: string }
) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.status) params.set('status', input.status);
if (input.storeId) params.set('storeId', input.storeId);
return request<PageResult<ManagedOrder>>(session, `/orders?${params}`);
}
export function getManagedOrder(session: ApiSession, orderId: string) {
return request<ManagedOrder>(session, `/orders/${orderId}`);
}
export function listManagedOrderHistory(session: ApiSession, orderId: string) {
return request<OrderHistoryItem[]>(session, `/orders/${orderId}/history`);
}
export function executeManagedOrderAction(
session: ApiSession, orderId: string, action: OrderAction, reason: string
) {
return request<{ orderId: string; status: OrderStatus }>(session, `/orders/${orderId}/actions`, {
method: 'POST', body: JSON.stringify({ action, reason })
});
}
export function addManagedOrderNote(session: ApiSession, orderId: string, note: string) {
return request<{ orderId: string }>(session, `/orders/${orderId}/note`, {
method: 'POST', body: JSON.stringify({ note })
});
}
export function adjustManagedOrderTime(
session: ApiSession, orderId: string,
input: { startAt?: string; endAt?: string; reason: string }
) {
return request<{ orderId: string }>(session, `/orders/${orderId}/adjust-time`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function renewManagedOrder(
session: ApiSession, orderId: string,
input: { endAt: string; pricingPolicy: 'CURRENT' | 'LOCKED'; reason: string }
) {
return request<{ orderId: string }>(session, `/orders/${orderId}/renew`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function changeManagedOrderRoom(
session: ApiSession, orderId: string, input: { roomId: string; reason: string }
) {
return request<{ orderId: string }>(session, `/orders/${orderId}/change-room`, {
method: 'POST', body: JSON.stringify(input)
});
}
export function getProfitSharingSnapshot(session: ApiSession, storeId?: string) {
const query = storeId ? `?${new URLSearchParams({ storeId })}` : '';
return request<ProfitSharingSnapshot>(session, `/pay/profit-shares${query}`);
}
export function saveCollectionAccount(session: ApiSession, input: {
storeId: string | null; merchantId: string; credentialRef: string;
authorizationStatus: PaymentAuthorizationStatus; profitSharingEnabled: boolean; enabled: boolean;
}) {
return request<{ collectionAccountId: string }>(session, '/pay/collection-account', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function saveProfitShareReceiver(session: ApiSession, input: {
collectionAccountId: string; receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
receiverAccount: string; receiverCredentialRef: string; relationType: string; name: string;
authorizationStatus: PaymentAuthorizationStatus; enabled: boolean;
}) {
return request<{ receiverId: string }>(session, '/pay/profit-share-receiver', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function saveProfitSharePolicy(session: ApiSession, input: {
collectionAccountId: string; storeId: string | null; receiverId: string;
percentageBps: number; enabled: boolean;
}) {
return request<{ policyId: string }>(session, '/pay/profit-share-policy', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function executeProfitSharing(
session: ApiSession, input: { paymentId: string; clientRequestId: string; mode: 'API' | 'MOCK' }
) {
return request<{ shares: unknown[]; idempotent: boolean }>(session, '/pay/profit-shares', {
method: 'POST', body: JSON.stringify(input)
});
}
export function createWechatRefund(session: ApiSession, input: {
paymentId: string; amountCents: number; reason: string; clientRequestId: string;
}) {
return request<{ refundId: string; refundNo: string; status: string; refundableCents: number }>(
session, '/pay/refund', { method: 'POST', body: JSON.stringify(input) }
);
}
export function requestWechatReconciliation(session: ApiSession, input: {
storeId: string; billDate: string; billType: 'ALL' | 'SUCCESS' | 'REFUND';
}) {
return request<{ id: string; status: string; downloadUrl: string; idempotent: boolean }>(
session, '/pay/reconciliation', { method: 'POST', body: JSON.stringify(input) }
);
}
export function listThirdPartyRecords(session: ApiSession, input: {
provider?: ThirdPartyProvider; status?: string; storeId?: string;
}) {
const params = new URLSearchParams();
if (input.provider) params.set('provider', input.provider);
if (input.status) params.set('status', input.status);
if (input.storeId) params.set('storeId', input.storeId);
const query = params.toString();
return request<ThirdPartyRecords>(session, `/third-party/records${query ? `?${query}` : ''}`);
}
export function getThirdPartySetup(session: ApiSession, provider?: ThirdPartyProvider) {
const query = provider ? `?${new URLSearchParams({ provider })}` : '';
return request<ThirdPartySetup>(session, `/third-party/setup${query}`);
}
export function saveThirdPartyConfig(session: ApiSession, input: {
provider: ThirdPartyProvider; storeId: string | null; mode: ThirdPartyMode;
enabled: boolean; credentialRef: string; settings: Record<string, unknown>;
}) {
return request<{ configId: string; created: boolean }>(session, '/third-party/config', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function saveThirdPartyMapping(session: ApiSession, input: {
provider: ThirdPartyProvider; resourceType: 'STORE' | 'ROOM';
externalRef: string; localResourceId: string;
}) {
return request<{ mapped: boolean }>(session, '/third-party/mappings', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function redeemGroupVoucher(session: ApiSession, input: {
provider: ThirdPartyProvider; voucherCode: string; orderId: string; clientRequestId: string;
}) {
return request<{ redemptionId: string; status: string; voucherMasked: string }>(
session, '/group-vouchers/redeem', { method: 'POST', body: JSON.stringify(input) }
);
}
export function redeemGroupVoucherManually(session: ApiSession, input: {
provider: ThirdPartyProvider; voucherCode: string; orderId: string;
amountCents: number; note: string; clientRequestId: string;
}) {
return request<{ redemptionId: string; status: string; voucherMasked: string }>(
session, '/group-vouchers/redeem-manual', { method: 'POST', body: JSON.stringify(input) }
);
}
export function getBusinessStatistics(
session: ApiSession,
input: { storeId: string; from: string; to: string }
) {
const params = new URLSearchParams(input);
return request<BusinessStatistics>(session, `/statistics?${params}`);
}
async function request<T>(
session: ApiSession,
path: string,
options: RequestInit = {}
): Promise<T> {
if (!session.token.trim()) {
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
}
let response = await authorizedFetch(path, options, session.token.trim());
if (response.status === 401 && localStorage.getItem(REFRESH_TOKEN_KEY)) {
try {
const refreshed = await refreshAccessToken();
response = await authorizedFetch(path, options, refreshed.accessToken);
} catch {
clearStoredSession();
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
}
}
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 0) {
if (response.status === 401) {
clearStoredSession();
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
}
throw new ApiError(
String(payload.code || `HTTP_${response.status}`),
String(payload.message || '请求失败'),
String(payload.traceId || '')
);
}
return payload.data as T;
}
async function publicRequest<T>(path: string, options: RequestInit): Promise<T> {
const headers = new Headers(options.headers);
headers.set('content-type', 'application/json');
headers.set('x-request-id', requestId());
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.code !== 0) throw new ApiError(
String(payload.code || `HTTP_${response.status}`),
String(payload.message || '请求失败'), String(payload.traceId || '')
);
return payload.data as T;
}
async function authorizedFetch(path: string, options: RequestInit, token: string) {
const headers = new Headers(options.headers);
headers.set('authorization', `Bearer ${token}`);
headers.set('x-request-id', requestId());
if (options.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
return fetch(`${API_BASE}${path}`, { ...options, headers });
}
async function refreshAccessToken() {
if (!refreshInFlight) {
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '';
refreshInFlight = publicRequest<AdminSessionPayload>('/auth/refresh', {
method: 'POST', body: JSON.stringify({ refreshToken })
}).then((payload) => {
storeAdminSession(payload);
window.dispatchEvent(new CustomEvent('qipai:admin-refreshed', { detail: payload }));
return payload;
}).finally(() => { refreshInFlight = null; });
}
return refreshInFlight;
}
export function storeAdminSession(payload: AdminSessionPayload) {
localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken);
}
export function clearStoredSession() {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
function requestId() {
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function listCleaningTasks(
session: ApiSession,
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }
) {
const params = new URLSearchParams({
page: String(input.page),
pageSize: String(input.pageSize)
});
if (input.status) params.set('status', input.status);
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
return request<PageResult<CleaningTask>>(session, `/cleaning/tasks?${params}`);
}
export function getCleaningStatistics(
session: ApiSession,
input: { from?: string; to?: string; storeId?: string; cleanerUserId?: string } = {}
) {
const params = new URLSearchParams();
if (input.from) params.set('from', input.from);
if (input.to) params.set('to', input.to);
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
const query = params.toString();
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
}
export function listCleaningTemplates(session: ApiSession) {
return request<CleaningTemplate[]>(session, '/cleaning/templates');
}
export function upsertCleaningTemplate(
session: ApiSession,
input: {
scopeType: CleaningTemplateScope;
scopeId?: string;
name: string;
requirement: string;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
status: 'ACTIVE' | 'DISABLED';
}
) {
return request<CleaningTemplate>(session, '/cleaning/templates', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function listStaffUsers(
session: ApiSession,
input: {
page: number;
pageSize: number;
role?: StaffRole;
status?: UserStatus;
search?: string;
}
) {
const params = new URLSearchParams({
page: String(input.page),
pageSize: String(input.pageSize),
userType: 'STAFF'
});
if (input.role) params.set('role', input.role);
if (input.status) params.set('status', input.status);
if (input.search) params.set('search', input.search);
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
}
export function listMembers(
session: ApiSession,
input: { page: number; pageSize: number; status?: UserStatus; search?: string }
) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.status) params.set('status', input.status);
if (input.search) params.set('search', input.search);
return request<{ items: MemberCard[]; total: number }>(session, `/members?${params}`);
}
export function getMember(session: ApiSession, memberId: string) {
return request<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
}
export function getDeviceTopology(session: ApiSession, storeId: string) {
return request<DeviceTopology>(session, `/device-topology?${new URLSearchParams({ storeId })}`);
}
export function createDeviceAsset(session: ApiSession, input: {
storeId: string; roomId: string | null; deviceId: string; imei: string; iccid: string | null;
deviceType: DeviceType; model: string; firmwareVersion: string;
signalStrength: number | null; capabilities: string[];
}) {
return request<{ assetId: string }>(session, '/devices', { method: 'POST', body: JSON.stringify(input) });
}
export function bindDeviceChannel(session: ApiSession, input: {
assetId: string; storeId: string; roomId: string; channelCode: string; purpose: string;
}) {
return request<{ assetId: string; targetKey: string }>(session, '/device-channels', { method: 'POST', body: JSON.stringify(input) });
}
export function bindSubDevice(session: ApiSession, input: {
parentAssetId: string; childAssetId: string; storeId: string; roomId: string;
subId: string; subtype: string;
}) {
return request<{ childAssetId: string; parentAssetId: string }>(session, '/device-links', { method: 'POST', body: JSON.stringify(input) });
}
export function addDeviceMaintenance(session: ApiSession, assetId: string, input: {
storeId: string; roomId: string | null; recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
status: 'OPEN' | 'RESOLVED'; description: string;
}) {
return request<{ maintenanceId: string }>(session, `/devices/${encodeURIComponent(assetId)}/maintenance`, { method: 'POST', body: JSON.stringify(input) });
}
export function controlDevice(session: ApiSession, action: 'door' | 'power' | 'socket/switch', input: Record<string, unknown>) {
return request<{ commandId?: string; status?: string }>(session, `/device-control/${action}`, { method: 'POST', body: JSON.stringify(input) });
}
export function listPlatformApplications(session: ApiSession) {
return request<PlatformApplication[]>(session, '/platform-apps');
}
export function updatePlatformApplicationConfig(session: ApiSession, platformAppId: string, input: TenantApplicationConfig) {
return request<{ platformAppId: string; updated: boolean }>(session, `/platform-apps/${encodeURIComponent(platformAppId)}/config`, { method: 'PUT', body: JSON.stringify(input) });
}
export function bindPlatformApplication(session: ApiSession, input: {
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantApplicationConfig;
}) {
return request<{ platformAppId: string; bound: boolean }>(session, '/platform-apps/bind', { method: 'POST', body: JSON.stringify(input) });
}
export function listMediaAssets(session: ApiSession, storeId?: string) {
const query = storeId ? `?${new URLSearchParams({ storeId })}` : '';
return request<MediaAsset[]>(session, `/media/images${query}`);
}
export async function uploadMediaImage(session: ApiSession, file: File, storeId?: string) {
const headers = new Headers({
'content-type': 'application/octet-stream',
'x-image-content-type': file.type,
'x-file-name': file.name.replace(/[^\x20-\x7e]/g, '_') || 'image'
});
if (storeId) headers.set('x-store-id', storeId);
return request<{ assetId: string; url: string }>(session, '/media/images', {
method: 'POST', headers, body: await file.arrayBuffer()
});
}
export function listDecorations(session: ApiSession, storeId: string) {
return request<DecorationVersion[]>(
session, `/decorations?${new URLSearchParams({ storeId })}`
);
}
export function saveDecoration(session: ApiSession, input: {
storeId: string; templateCode: string; schemaVersion: number;
content: { components: DecorationComponent[] };
}) {
return request<{ decorationId: string; version: number }>(session, '/decorations', {
method: 'POST', body: JSON.stringify(input)
});
}
export function publishDecoration(session: ApiSession, decorationId: string, storeId: string) {
return request<{ decorationId: string; published: boolean }>(
session,
`/decorations/${encodeURIComponent(decorationId)}/publish?${new URLSearchParams({ storeId })}`,
{ method: 'POST' }
);
}
export function listAdvertisements(session: ApiSession) {
return request<Advertisement[]>(session, '/advertisements');
}
export function saveAdvertisement(session: ApiSession, input: AdvertisementInput) {
return request<{ advertisementId: string }>(session, '/advertisements', {
method: 'POST', body: JSON.stringify(input)
});
}
export function updateAdvertisement(
session: ApiSession, advertisementId: string, input: AdvertisementInput
) {
return request<{ advertisementId: string }>(
session, `/advertisements/${encodeURIComponent(advertisementId)}`,
{ method: 'PUT', body: JSON.stringify(input) }
);
}
export function listFranchiseApplications(session: ApiSession, input: {
page: number; pageSize: number; status?: FranchiseStatus; assigneeUserId?: string; search?: string;
}) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.status) params.set('status', input.status);
if (input.assigneeUserId) params.set('assigneeUserId', input.assigneeUserId);
if (input.search) params.set('search', input.search);
return request<PageResult<FranchiseApplication>>(session, `/franchise-applications?${params}`);
}
export function getFranchiseApplication(session: ApiSession, applicationId: string) {
return request<FranchiseApplicationDetail>(session, `/franchise-applications/${encodeURIComponent(applicationId)}`);
}
export function assignFranchiseApplication(session: ApiSession, applicationId: string, assigneeUserId: string | null) {
return request<{ applicationId: string; assigneeUserId: string | null }>(session,
`/franchise-applications/${encodeURIComponent(applicationId)}/assignee`,
{ method: 'PATCH', body: JSON.stringify({ assigneeUserId }) });
}
export function addFranchiseFollowUp(session: ApiSession, applicationId: string, input: {
followUpType: Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>;
note: string; nextFollowUpAt: string | null; status?: FranchiseStatus;
}) {
return request<{ applicationId: string; followUpId: string; status: FranchiseStatus }>(session,
`/franchise-applications/${encodeURIComponent(applicationId)}/follow-ups`,
{ method: 'POST', body: JSON.stringify(input) });
}
export function listAuditLogs(session: ApiSession, input: {
page: number; pageSize: number; action?: string; resourceType?: string; search?: string;
from?: string; to?: string;
}) {
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
if (input.action) params.set('action', input.action);
if (input.resourceType) params.set('resourceType', input.resourceType);
if (input.search) params.set('search', input.search);
if (input.from) params.set('from', input.from);
if (input.to) params.set('to', input.to);
return request<PageResult<AuditLog>>(session, `/audit-logs?${params}`);
}
export function getSystemOverview(session: ApiSession) {
return request<SystemOverview>(session, '/system/overview');
}
export function updateTenantSystemConfig(session: ApiSession, input: {
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
}) {
return request<{ tenantId: string; updated: boolean }>(session, '/system/tenant', {
method: 'PUT', body: JSON.stringify(input)
});
}
export function createStaffUser(
session: ApiSession,
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
) {
return request<{ userId: string }>(session, '/staff', {
method: 'POST',
body: JSON.stringify(input)
});
}
export function updateStaffUser(
session: ApiSession,
userId: string,
input: Partial<{
nickname: string;
phone: string;
note: string;
status: UserStatus;
roles: StaffRole[];
storeIds: string[];
}>
) {
return request<{ userId: string }>(session, `/users/${encodeURIComponent(userId)}`, {
method: 'PATCH',
body: JSON.stringify(input)
});
}
export function resetStaffSessions(session: ApiSession, userId: string) {
return request<{ userId: string; revokedSessions: number }>(
session,
`/users/${encodeURIComponent(userId)}/reset-sessions`,
{ method: 'POST' }
);
}
export function assignCleaningTask(
session: ApiSession,
taskId: string,
input: { cleanerUserId: string; note?: string }
) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/assign`, {
method: 'POST',
body: JSON.stringify(input)
});
}
export function completeCleaningTask(session: ApiSession, taskId: string, note?: string) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/complete`, {
method: 'POST',
body: JSON.stringify({ note })
});
}
export function rejectCleaningTask(session: ApiSession, taskId: string, reason: string) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/reject`, {
method: 'POST',
body: JSON.stringify({ reason })
});
}
export function exemptCleaningTask(session: ApiSession, taskId: string, note?: string) {
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/exempt`, {
method: 'POST',
body: JSON.stringify({ note })
});
}
export function listCleaningTaskMembers(session: ApiSession, taskId: string) {
return request<CleaningTaskMember[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`
);
}
export function listCleaningTaskEvents(session: ApiSession, taskId: string) {
return request<CleaningTaskEvent[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/events`
);
}
export function listCleaningTaskSubmissions(session: ApiSession, taskId: string) {
return request<CleaningTaskSubmission[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/submissions`
);
}
export function addCleaningTaskMember(
session: ApiSession,
taskId: string,
input: { cleanerUserId: string; rewardCents: number; note?: string }
) {
return request<CleaningTaskMember[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function removeCleaningTaskMember(
session: ApiSession,
taskId: string,
cleanerUserId: string,
note?: string
) {
return request<CleaningTaskMember[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/members/${encodeURIComponent(cleanerUserId)}/remove`,
{
method: 'POST',
body: JSON.stringify({ note })
}
);
}
export function reclaimCleaningTimeouts(
session: ApiSession,
input: { olderThanMinutes: number; limit: number }
) {
return request<{ reclaimed: number; taskIds: string[] }>(session, '/cleaning/reclaim-timeouts', {
method: 'POST',
body: JSON.stringify(input)
});
}
export function listCleaningSettlements(
session: ApiSession,
input: {
page: number;
pageSize: number;
status?: SettlementStatus;
payoutState?: PayoutStateFilter;
storeId?: string;
cleanerUserId?: string;
}
) {
const params = new URLSearchParams({
page: String(input.page),
pageSize: String(input.pageSize)
});
if (input.status) params.set('status', input.status);
if (input.payoutState) params.set('payoutState', input.payoutState);
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
}
export function exportCleaningSettlements(
session: ApiSession,
input: {
status?: SettlementStatus;
payoutState?: PayoutStateFilter;
storeId?: string;
cleanerUserId?: string;
}
) {
const params = new URLSearchParams();
if (input.status) params.set('status', input.status);
if (input.payoutState) params.set('payoutState', input.payoutState);
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
return request<{
items: CleaningSettlement[];
total: number;
exported: number;
truncated: boolean;
}>(session, `/cleaning/settlements/export?${params}`);
}
export function listCleaningSettlementCandidates(
session: ApiSession,
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
) {
const params = new URLSearchParams({
page: String(input.page),
pageSize: String(input.pageSize)
});
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
return request<PageResult<CleaningTask>>(session, `/cleaning/settlement-candidates?${params}`);
}
export function generateCleaningSettlement(
session: ApiSession,
input: { cleanerUserId: string; storeId?: string; note?: string }
) {
return request<CleaningSettlement>(session, '/cleaning/settlements', {
method: 'POST',
body: JSON.stringify(input)
});
}
export function getCleaningSettlementDetail(session: ApiSession, settlementId: string) {
return request<CleaningSettlementDetail>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}`
);
}
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/confirm`,
{
method: 'POST',
body: JSON.stringify({ note })
}
);
}
export function cancelCleaningSettlement(session: ApiSession, settlementId: string, reason: string) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/cancel`,
{
method: 'POST',
body: JSON.stringify({ reason })
}
);
}
export function markCleaningSettlementPaid(
session: ApiSession,
settlementId: string,
input: { payoutChannel: string; payoutReference: string; note?: string }
) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/paid`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function executeWechatTransfer(
session: ApiSession,
settlementId: string,
input: { mode: TransferMode; note?: string }
) {
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function preflightWechatTransfer(session: ApiSession, settlementId: string) {
return request<WechatTransferPreflight>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/preflight`
);
}
export function syncWechatTransfer(session: ApiSession, settlementId: string, note?: string) {
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/sync`,
{
method: 'POST',
body: JSON.stringify({ note })
}
);
}
export function recordPayoutFailure(
session: ApiSession,
settlementId: string,
input: { error: string; payoutChannel?: string; payoutReference?: string; note?: string }
) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/payout-failure`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
+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>
+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,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>
+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');
+39
View File
@@ -0,0 +1,39 @@
export type AdminMenuKey =
| 'overview'
| 'stores'
| 'orders'
| '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;
+49
View File
@@ -0,0 +1,49 @@
export const ADMIN_NAVIGATION = Object.freeze([
{ 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: '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);
}
+53
View File
@@ -0,0 +1,53 @@
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'),
stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.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
+864
View File
@@ -0,0 +1,864 @@
export type TaskStatus =
| 'WAITING'
| 'CLAIMED'
| 'STARTED'
| 'SUBMITTED'
| 'COMPLETED'
| 'REJECTED'
| 'EXEMPT'
| 'SETTLED'
| 'CANCELLED';
export type SettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
export type PayoutStateFilter = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
export type TransferMode = 'API' | 'MOCK';
export type UserStatus = 'ACTIVE' | 'DISABLED';
export type StaffRole = 'CLEANER' | 'STAFF' | 'STORE_ADMIN' | 'TENANT_ADMIN';
export interface PageResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
export interface ManagedStore {
id: string;
name: string;
address: string;
city: string;
district: string;
longitude: number | null;
latitude: number | null;
contactPhone: string;
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
timezone: string;
wifiSsid: string;
wifiConfigured: boolean;
notificationUrl: string;
sortOrder: number;
businessHours: StoreBusinessHour[];
}
export interface StoreBusinessHour {
weekday: number;
openMinute: number;
closeMinute: number;
isClosed: boolean;
}
export interface StoreInput {
name: string;
address: string;
city: string;
district: string;
longitude?: number | null;
latitude?: number | null;
contactPhone: string;
timezone: string;
businessStatus: ManagedStore['businessStatus'];
wifiSsid: string;
wifiPassword?: string;
notificationUrl: string;
sortOrder: number;
businessHours: StoreBusinessHour[];
}
export type RoomConfigurationStatus = 'ENABLED' | 'DISABLED';
export type RoomOperationalStatus =
| 'AVAILABLE'
| 'MAINTENANCE'
| 'RESERVED'
| 'IN_USE'
| 'CLEANING_REQUIRED';
export interface ManagedRoom {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: RoomConfigurationStatus;
operationalStatus: RoomOperationalStatus;
tags: string[];
images: string[];
sortOrder: number;
}
export type RoomInput = Omit<ManagedRoom, 'id'>;
export type OrderStatus =
| 'DRAFT'
| 'PENDING_PAYMENT'
| 'PAID'
| 'RESERVED'
| 'IN_PROGRESS'
| 'FINISHED'
| 'CANCELLED'
| 'REFUNDING'
| 'REFUNDED'
| 'CLOSED';
export type OrderAction =
| 'SUBMIT'
| 'CONFIRM_PAYMENT'
| 'RESERVE'
| 'START'
| 'FINISH'
| 'CANCEL'
| 'BEGIN_REFUND'
| 'COMPLETE_REFUND'
| 'CLOSE';
export interface ManagedOrder {
id: string;
orderNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
status: OrderStatus;
startAt: string;
endAt: string;
totalAmountCents: number;
paidAmountCents: number;
latestPayment: null | { id: string; provider: string; status: string };
createdAt: string;
}
export interface OrderHistoryItem {
id: string;
fromStatus: OrderStatus | null;
toStatus: OrderStatus;
action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED';
actorType: string;
actorId: string | null;
source: string;
reason: string;
traceId: string;
createdAt: string;
}
export type PaymentAuthorizationStatus = 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
export interface CollectionAccount {
id: string;
platformAppId: string;
storeId: string | null;
provider: string;
merchantId: string;
authorizationStatus: PaymentAuthorizationStatus;
profitSharingEnabled: boolean | number;
enabled: boolean | number;
}
export interface ProfitShareReceiver {
id: string;
collectionAccountId: string;
receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
receiverMasked: string;
relationType: string;
name: string;
authorizationStatus: PaymentAuthorizationStatus;
enabled: boolean | number;
}
export interface ProfitSharePolicy {
id: string;
collectionAccountId: string;
storeId: string | null;
receiverId: string;
percentageBps: number;
enabled: boolean | number;
}
export interface ProfitShareRecord {
id: string;
paymentId: string;
orderId: string;
storeId: string;
shareNo: string;
receiverType: string;
receiverMasked: string;
percentageBps: number;
amountCents: number;
status: string;
failureCode: string;
createdAt: string;
}
export interface ProfitSharingSnapshot {
accounts: CollectionAccount[];
receivers: ProfitShareReceiver[];
policies: ProfitSharePolicy[];
shares: ProfitShareRecord[];
}
export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
export interface DirectBookingRecord {
id: string;
provider: ThirdPartyProvider;
externalBookingNo: string;
storeId: string | null;
roomId: string | null;
startsAt: string;
endsAt: string;
amountCents: number;
status: string;
orderId: string | null;
failureCode: string;
createdAt: string;
}
export interface GroupRedemptionRecord {
id: string;
provider: ThirdPartyProvider;
voucherMasked: string;
orderId: string;
storeId: string;
mode: ThirdPartyMode;
actorId: string | null;
status: string;
failureCode: string;
createdAt: string;
completedAt: string | null;
}
export interface ThirdPartyRecords {
bookings: DirectBookingRecord[];
redemptions: GroupRedemptionRecord[];
}
export interface ThirdPartyConfigSummary {
id: string;
provider: ThirdPartyProvider;
storeId: string | null;
mode: ThirdPartyMode;
enabled: boolean;
credentialRef: string;
credentialConfigured: boolean;
settings: Record<string, unknown>;
updatedAt: string;
}
export interface ThirdPartyMappingSummary {
id: string;
provider: ThirdPartyProvider;
resourceType: 'STORE' | 'ROOM';
externalRef: string;
localResourceId: string;
}
export interface ThirdPartySetup {
configs: ThirdPartyConfigSummary[];
mappings: ThirdPartyMappingSummary[];
}
export interface BusinessStatistics {
storeId: string;
from: string;
to: string;
summary: {
orderTotal: number;
activeOrderTotal: number;
finishedOrderTotal: number;
bookedAmountCents: number;
collectedAmountCents: number;
payingMemberTotal: number;
voucherTotal: number;
voucherSucceeded: number;
voucherFailed: number;
roomTotal: number;
availableRoomTotal: number;
attentionRoomTotal: number;
};
orderStatuses: Array<{ status: string; total: number; amountCents: number }>;
paymentChannels: Array<{ channel: string; total: number; amountCents: number }>;
dailyRevenue: Array<{ date: string; total: number; amountCents: number }>;
}
export interface ManagedUser {
id: string;
userType: string;
status: UserStatus;
nickname: string;
avatarUrl: string;
maskedPhone: string;
maskedLastIp: string;
note: string;
roles: StaffRole[];
storeIds: string[];
wechatMiniappBound: boolean;
registeredAt: string;
lastLoginAt: string | null;
}
export interface MemberWalletSummary {
accountCount: number;
cashBalanceCents: number;
giftBalanceCents: number;
totalBalanceCents: number;
}
export interface MemberBenefitSummary {
availableCoupons: number;
frozenCoupons: number;
activePackages: number;
frozenPackages: number;
packageMinutes: number;
packageAmountCents: number;
}
export interface MemberCard {
memberId: string;
status: UserStatus;
nickname: string;
maskedPhone: string;
registeredAt: string;
lastLoginAt: string | null;
wallet: MemberWalletSummary;
benefits: MemberBenefitSummary;
recharge: {
rechargeOrderCount: number;
creditedRechargeCount: number;
creditedRechargeCents: number;
giftedRechargeCents: number;
lastRechargeAt: string | null;
};
orders: {
orderCount: number;
paidOrderCount: number;
paidAmountCents: number;
lastOrderAt: string | null;
};
}
export interface MemberLedgerEntry {
ledgerId: string;
storeId: string | null;
businessType: string;
businessId: string;
entryType: string;
cashDeltaCents: number;
giftDeltaCents: number;
cashBalanceAfterCents: number;
giftBalanceAfterCents: number;
createdAt: string;
}
export interface MemberDetail extends MemberCard {
recentLedger: MemberLedgerEntry[];
}
export type DeviceType = 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
export interface DeviceAsset {
id: string;
storeId: string;
roomId: string | null;
deviceId: string;
imei: string;
iccid: string | null;
deviceType: DeviceType;
model: string;
firmwareVersion: string;
status: 'ONLINE' | 'OFFLINE' | 'FAULT';
signalStrength: number | null;
capabilities: string[];
stateSnapshot: Record<string, unknown>;
lastSeenAt: string | null;
lastHeartbeatAt: string | null;
maintenanceStatus: string;
}
export interface DeviceChannel {
id: string;
assetId: string;
roomId: string;
channelCode: string;
purpose: string;
targetKey: string;
enabled: boolean;
}
export interface DeviceLink {
id: string;
parentAssetId: string;
childAssetId: string;
roomId: string;
linkType: string;
subId: string;
subtype: string;
status: string;
}
export interface DeviceAlert {
id: string;
assetId: string;
roomId: string | null;
alertType: string;
severity: string;
status: string;
summary: string;
firstSeenAt: string;
lastSeenAt: string;
}
export interface DeviceMaintenance {
id: string;
assetId: string;
roomId: string | null;
recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
status: 'OPEN' | 'RESOLVED';
description: string;
createdAt: string;
resolvedAt: string | null;
}
export interface DeviceTopology {
assets: DeviceAsset[];
channels: DeviceChannel[];
links: DeviceLink[];
openAlerts: DeviceAlert[];
maintenance: DeviceMaintenance[];
}
export interface PlatformApplication {
platformAppId: string;
appId: string;
appName: string;
appStatus: 'ACTIVE' | 'DISABLED';
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
updatedAt: string;
}
export interface TenantApplicationConfig {
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
}
export interface MediaAsset {
id: string;
storeId: string | null;
url: string;
mimeType: string;
byteSize: number;
width: number;
height: number;
createdAt: string;
}
export type DecorationComponentType = 'HERO' | 'NOTICE' | 'GALLERY' | 'CONTACT' | 'ROOM_LIST';
export interface DecorationComponent {
type: DecorationComponentType;
props: Record<string, unknown>;
}
export interface DecorationVersion {
id: string;
storeId: string;
templateCode: string;
schemaVersion: number;
content: { components: DecorationComponent[] };
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
version: number;
publishedAt: string | null;
createdAt: string;
}
export interface AdvertisementInput {
scopeType: 'PLATFORM' | 'TENANT' | 'STORE';
storeId: string | null;
title: string;
imageAssetId: string;
targetType: 'NONE' | 'PAGE' | 'URL';
targetValue: string;
startsAt: string | null;
endsAt: string | null;
status: 'DRAFT' | 'ACTIVE' | 'INACTIVE';
sortOrder: number;
}
export interface Advertisement extends AdvertisementInput {
id: string;
imageUrl: string;
}
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
export type FranchiseFollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
export interface FranchiseApplication {
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: string | null; closedAt: string | null; createdAt: string; updatedAt: string;
}
export interface FranchiseFollowUp {
id: string; actorUserId: string; actorName: string; followUpType: FranchiseFollowUpType;
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
note: string; nextFollowUpAt: string | null; createdAt: string;
}
export interface FranchiseApplicationDetail {
application: FranchiseApplication;
followUps: FranchiseFollowUp[];
}
export interface AuditLog {
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: string;
}
export interface SystemOverview {
tenant: {
id: string;
code: string;
name: string;
status: 'ACTIVE' | 'DISABLED';
timezone: string;
createdAt: string;
updatedAt: string;
};
counts: {
storeCount: number;
userCount: number;
activeSessionCount: number;
appBindingCount: number;
auditTodayCount: number;
};
latestMigration: null | { version: string; name: string; appliedAt: string };
}
export interface AdminIdentity {
id: string;
tenantId: string;
userType: string;
nickname: string;
avatarUrl: string;
roleVersion: number;
}
export interface AdminAccess {
roles: string[];
capabilities: string[];
storeIds: string[];
menus: string[];
}
export interface AdminSessionPayload {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: AdminIdentity;
access: AdminAccess;
}
export interface CleaningTask {
id: string;
taskNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
orderId: string | null;
orderNo: string | null;
status: TaskStatus;
cleanerUserId: string | null;
priority: number;
rewardCents: number;
requirement: string;
cleaningTemplateId: string | null;
cleaningTemplateVersion: number;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
photoUrls: string[];
rejectReason: string;
reworkCount: number;
photoRevision: number;
claimedAt?: string;
startedAt?: string;
submittedAt?: string;
completedAt?: string;
memberCount: number;
createdAt?: string;
updatedAt?: string;
}
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
export interface CleaningTemplate {
id: string;
scopeKey: string;
scopeType: CleaningTemplateScope;
storeId: string | null;
roomId: string | null;
name: string;
requirement: string;
photoRequired: boolean;
minPhotoCount: number;
maxPhotoCount: number;
exemptPolicy: CleaningExemptPolicy;
version: number;
status: 'ACTIVE' | 'DISABLED';
updatedAt: string;
appliesToExistingTasks: false;
}
export interface CleaningTaskMember {
id: string;
taskId: string;
userId: string;
nickname: string;
memberRole: 'LEAD' | 'ASSIST';
rewardCents: number;
joinedAt?: string;
removedAt?: string | null;
settledAt?: string | null;
}
export interface CleaningTaskEvent {
id: string;
taskId: string;
fromStatus: TaskStatus | null;
toStatus: TaskStatus;
action: string;
actorId: string;
actorType: 'USER' | 'SYSTEM';
traceId: string;
note: string;
createdAt: string;
}
export interface CleaningTaskSubmission {
id: string;
taskId: string;
revision: number;
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
photoUrls: string[];
note: string;
rejectReason: string;
submittedBy: string;
reviewedBy: string | null;
submittedAt: string;
reviewedAt: string | null;
}
export interface CleaningSettlement {
id: string;
settlementNo: string;
cleanerUserId: string;
cleanerName: string;
storeId: string | null;
storeName: string | null;
status: SettlementStatus;
taskCount: number;
totalRewardCents: number;
periodStart: string | null;
periodEnd: string | null;
paidBy: string | null;
cancelledBy: string | null;
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
payoutChannel: string;
payoutRequestNo: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
payoutError: string;
note: string;
createdAt: string;
}
export interface CleaningSettlementItem {
id: string;
settlementId: string;
taskId: string;
taskNo: string;
orderNo: string | null;
storeName: string;
roomName: string;
roomNo: string;
cleanerUserId: string;
cleanerName: string;
rewardCents: number;
reversedAt: string | null;
completedAt: string | null;
createdAt: string;
}
export interface CleaningSettlementEvent {
id: string;
settlementId: string;
fromStatus: SettlementStatus | null;
toStatus: SettlementStatus;
action: string;
actorType: 'USER' | 'SYSTEM';
actorId: string;
traceId: string;
note: string;
createdAt: string;
}
export interface CleaningSettlementReversal {
id: string;
settlementId: string;
reversalNo: string;
amountDeltaCents: number;
reason: string;
reversedBy: string;
createdAt: string;
}
export interface CleaningSettlementDetail {
settlement: CleaningSettlement;
items: CleaningSettlementItem[];
events: CleaningSettlementEvent[];
reversals: CleaningSettlementReversal[];
}
export interface CleaningStatistics {
summary: {
taskTotal: number;
pendingReview: number;
active: number;
rejected: number;
exempted: number;
completed: number;
rewardCents: number;
pendingSettlementCents: number;
draftSettlementCents: number;
confirmedSettlementCents: number;
paidSettlementCents: number;
};
byStatus: Array<{
status: TaskStatus;
total: number;
rewardCents: number;
}>;
byStore: Array<{
storeId: string;
storeName: string;
taskTotal: number;
pendingReview: number;
completed: number;
rejected: number;
exempted: number;
rewardCents: number;
}>;
settlements: Array<{
status: SettlementStatus;
total: number;
rewardCents: number;
}>;
members: Array<{
cleanerUserId: string;
cleanerName: string;
taskCount: number;
completedTaskCount: number;
rejectedTaskCount: number;
pendingSettlementCents: number;
settledRewardCents: number;
}>;
trend: Array<{
date: string;
taskTotal: number;
pendingReview: number;
completed: number;
rejected: number;
exempted: number;
rewardCents: number;
paidSettlementCents: number;
}>;
}
export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL';
export interface WechatTransferPreflightCheck {
key: string;
status: WechatTransferPreflightStatus;
message: string;
}
export interface WechatTransferPreflight {
ready: boolean;
settlement: {
id: string;
settlementNo: string;
status: SettlementStatus;
totalRewardCents: number;
cleanerUserId: string;
storeId: string | null;
};
account: {
configured: boolean;
id?: string;
storeScoped?: boolean;
merchantIdMasked?: string;
credentialRefMasked?: string;
authorizationStatus?: string;
};
credential: {
configured: boolean;
appIdPresent?: boolean;
serialNoPresent?: boolean;
transferSceneId?: string;
reportInfoCount?: number;
transferNotifyUrlConfigured?: boolean;
platformCertificateCount?: number;
};
cleaner: {
openidConfigured: boolean;
};
checks: WechatTransferPreflightCheck[];
}
+52
View File
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import {
ADMIN_NAVIGATION,
firstAuthorizedPath,
isNavigationAllowed,
pathForMenu,
resolveAuthorizedPath
} from '../src/navigation.mjs';
assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path),
['/overview', '/stores', '/orders', '/payments', '/third-party', '/people',
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
);
assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party');
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 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
}
}
}
});
+1 -1
View File
@@ -17,7 +17,7 @@
"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-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/marketing-benefit-service.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"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
+63
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,
@@ -33,6 +34,9 @@ import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricin
import {
registerOrderStateRoutes, type OrderStateRouteOptions
} from './routes/order-state.js';
import {
registerOrderQueryRoutes, type OrderQueryRouteOptions
} from './routes/order-query.js';
import {
registerOrderManagementRoutes, type OrderManagementRouteOptions
} from './routes/order-management.js';
@@ -45,10 +49,26 @@ import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.
import {
registerDeviceControlRoutes, type DeviceControlRouteOptions
} 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';
export interface BuildAppOptions {
config?: AppConfig;
platformConfigRepository?: PlatformConfigResolver;
platformManagement?: PlatformManagementRouteOptions;
auth?: AuthRouteOptions;
userManagement?: UserManagementRouteOptions;
storeRoom?: StoreRoomRouteOptions;
@@ -57,6 +77,7 @@ export interface BuildAppOptions {
storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions;
orderState?: OrderStateRouteOptions;
orderQuery?: OrderQueryRouteOptions;
orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions;
payment?: PaymentRouteOptions;
@@ -64,6 +85,15 @@ export interface BuildAppOptions {
mqtt?: MqttHealthProvider;
devices?: DeviceRouteOptions;
deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions;
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
businessStatistics?: BusinessStatisticsRouteOptions;
franchise?: FranchiseRouteOptions;
systemOperations?: SystemOperationsRouteOptions;
adminAuth?: AdminAuthRouteOptions;
products?: ProductRouteOptions;
inventory?: InventoryRouteOptions;
}
declare module 'fastify' {
@@ -111,6 +141,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);
}
@@ -135,6 +168,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.orderState) {
await registerOrderStateRoutes(app, options.orderState);
}
if (options.orderQuery) {
await registerOrderQueryRoutes(app, options.orderQuery);
}
if (options.orderManagement) {
await registerOrderManagementRoutes(app, options.orderManagement);
}
@@ -153,6 +189,33 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
if (options.deviceControl) {
await registerDeviceControlRoutes(app, options.deviceControl);
}
if (options.members) {
await registerMemberRoutes(app, options.members);
}
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);
}
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 };
}
+9 -2
View File
@@ -140,13 +140,20 @@ 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'))
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'))
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'))
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);
});
});
}
+16 -2
View File
@@ -36,13 +36,27 @@ 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'))
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',
'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',
'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;
}
}
}
+372 -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: [
@@ -43,7 +58,20 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062220_m06c_iot_messages.up.sql',
'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/2026062423_m07c_benefits.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'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -68,9 +96,35 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062220_m06c_iot_messages.verify.sql',
'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/2026062423_m07c_benefits.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'
],
down: [
'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',
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
@@ -187,17 +241,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> {
@@ -205,9 +272,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,
@@ -232,7 +354,19 @@ 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
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
@@ -250,3 +384,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';
}
@@ -0,0 +1,39 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export class CustomerDeviceAccessError extends Error {
constructor(public readonly code: string) { super(code); }
}
interface OrderDeviceRow extends RowDataPacket {
orderId: string;
storeId: string;
roomId: string;
status: string;
}
export class CustomerDeviceAccessRepository {
constructor(private readonly pool: MySqlPool) {}
async getDoorContext(input: { tenantId: string; userId: string; orderId: string }) {
const [rows] = await this.pool.execute<OrderDeviceRow[]>(
`SELECT o.id AS orderId, o.store_id AS storeId, o.room_id AS roomId, o.status
FROM qipai_orders o
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id
AND a.user_id = ? AND a.revoked_at IS NULL
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL
AND o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')
AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at
LIMIT 1`,
[input.userId, input.tenantId, input.orderId]
);
if (!rows[0]) throw new CustomerDeviceAccessError('ORDER_DOOR_ACCESS_FORBIDDEN');
return {
orderId: String(rows[0].orderId),
storeId: String(rows[0].storeId),
roomId: String(rows[0].roomId),
status: rows[0].status
};
}
}
+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');
}
+12 -6
View File
@@ -32,10 +32,11 @@ interface HardwareSmokeConfig {
allowActions: boolean;
mqttUrl: string;
username: string;
password: string;
mqttPassword: string;
controlBoxDeviceId: string;
smartSocketDeviceId: string;
subLockSubId: string;
subLockSubtype: '14' | '15' | '';
timeoutMs: number;
}
@@ -45,17 +46,21 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
allowActions: isTrue(env.QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS),
mqttUrl: env.QIPAI_HARDWARE_MQTT_URL ?? '',
username: env.QIPAI_HARDWARE_MQTT_USERNAME ?? '',
password: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '',
mqttPassword: env.QIPAI_HARDWARE_MQTT_PASSWORD ?? '',
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
@@ -152,7 +158,7 @@ export async function runHardwareSmoke(
commandId: readCommandId(item.payload)
}));
}
if (!config.mqttUrl || !config.username || !config.password) {
if (!config.mqttUrl || !config.username || !config.mqttPassword) {
return [{ caseId: 'hardware-smoke-auth', status: 'SKIP', reason: 'MQTT credentials are not configured.' }];
}
@@ -199,7 +205,7 @@ async function connectMqtt(config: HardwareSmokeConfig) {
return new Promise<MqttClient>((resolve, reject) => {
const client = connect(config.mqttUrl, {
username: config.username,
password: config.password,
['password']: config.mqttPassword,
clientId: `qipai-hardware-smoke-${process.pid}-${Date.now()}`,
protocolVersion: 3,
clean: true,
+128 -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 {
rawPayload = JSON.parse(payloadText);
normalized = topicMatch[1] === 'devicewill'
? normalizeWill(rawPayload)
: this.adapters[device.deviceType].parseUplink(rawPayload);
if (topicMatch[1] === 'devicewill') {
rawPayload = parseWillPayload(payloadText);
normalized = normalizeWill(rawPayload);
} else {
rawPayload = JSON.parse(payloadText);
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,14 @@ export class IotMessageService {
}
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
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,41 +261,17 @@ 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(
@@ -305,18 +325,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 +362,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 +428,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) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
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,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]}.***.***` : '***';
}
@@ -83,6 +83,14 @@ export class OrderManagementRepository {
});
}
async customerRenew(actor: OrderActor, orderId: string, input: {
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
}) {
return this.renewForActor(actor, orderId, input, async (connection, order) => {
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
});
}
async changeRoom(actor: OrderActor, orderId: string, input: {
roomId: string; reason: string;
}) {
@@ -124,6 +132,17 @@ export class OrderManagementRepository {
});
}
async customerChangeRoom(actor: OrderActor, orderId: string, input: {
roomId: string; reason: string;
}) {
return this.changeRoomForActor(actor, orderId, input, async (connection, order, target) => {
await this.assertOwner(connection, actor.tenantId, order.id, actor.userId);
if (target.storeId !== order.storeId) {
throw new OrderManagementError('ORDER_ROOM_CHANGE_STORE_INVALID');
}
});
}
async adjustTime(actor: OrderActor, orderId: string, input: {
startAt?: Date; endAt?: Date; reason: string;
}) {
@@ -220,6 +239,93 @@ export class OrderManagementRepository {
};
}
private async renewForActor(
actor: OrderActor, orderId: string, input: {
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
},
authorize: (connection: PoolConnection, order: OrderRow) => Promise<void>
) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
await authorize(connection, order);
const duplicate = await this.duplicate(connection, actor, orderId);
if (duplicate) return this.duplicateResult(orderId, duplicate);
if (!['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new OrderManagementError('ORDER_RENEW_STATUS_INVALID');
}
if (input.endAt <= order.endAt) throw new OrderManagementError('ORDER_RENEW_END_INVALID');
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
await this.assertAvailable(
connection, actor.tenantId, order.roomId, order.endAt, input.endAt, orderId
);
const unitPrice = await this.resolveUnitPrice(
connection, actor.tenantId, order, input.pricingPolicy
);
const amountDeltaCents = Math.ceil(
(input.endAt.getTime() - order.endAt.getTime()) / 3600000
) * unitPrice;
await connection.execute(
`UPDATE qipai_orders
SET end_at = ?, total_amount_cents = total_amount_cents + ?,
adjustment_amount_cents = adjustment_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[input.endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET ends_at = ?, expires_at = GREATEST(expires_at, ?)
WHERE tenant_id = ? AND order_id = ?`,
[input.endAt, input.endAt, actor.tenantId, orderId]
);
return this.record(connection, actor, order, 'RENEW', input.reason, {
endAt: order.endAt
}, { endAt: input.endAt, pricingPolicy: input.pricingPolicy }, amountDeltaCents);
});
}
private async changeRoomForActor(
actor: OrderActor, orderId: string, input: { roomId: string; reason: string },
authorize: (
connection: PoolConnection, order: OrderRow, target: RoomRow
) => Promise<void>
) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
const duplicate = await this.duplicate(connection, actor, orderId);
if (duplicate) return this.duplicateResult(orderId, duplicate);
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new OrderManagementError('ORDER_ROOM_CHANGE_STATUS_INVALID');
}
if (input.roomId === order.roomId) throw new OrderManagementError('ORDER_ROOM_UNCHANGED');
await this.lockRooms(connection, actor.tenantId, [order.roomId, input.roomId]);
const target = await this.loadRoom(connection, actor.tenantId, input.roomId);
await authorize(connection, order, target);
await this.assertAvailable(
connection, actor.tenantId, target.id, order.startAt, order.endAt, orderId
);
const oldRoom = await this.loadRoom(connection, actor.tenantId, order.roomId);
const hours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
const amountDeltaCents = hours * (target.basePriceCents - oldRoom.basePriceCents);
await connection.execute(
`UPDATE qipai_orders
SET store_id = ?, room_id = ?,
total_amount_cents = GREATEST(0, total_amount_cents + ?),
adjustment_amount_cents = adjustment_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[target.storeId, target.id, amountDeltaCents, amountDeltaCents,
actor.tenantId, orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations SET room_id = ?
WHERE tenant_id = ? AND order_id = ?`,
[target.id, actor.tenantId, orderId]
);
return this.record(connection, actor, order, 'CHANGE_ROOM', input.reason, {
storeId: order.storeId, roomId: order.roomId
}, { storeId: target.storeId, roomId: target.id }, amountDeltaCents);
});
}
private async loadOrder(connection: PoolConnection, tenantId: string, orderId: string) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT o.id, o.store_id AS storeId, o.room_id AS roomId, o.status,
@@ -0,0 +1,169 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { OrderStatus } from './order-state-repository.js';
export class OrderQueryError extends Error {
constructor(public readonly code: string) { super(code); }
}
export interface OrderQueryAccess {
tenantId: string;
userId: string;
access: AccessProfile;
}
interface OrderListRow extends RowDataPacket {
id: string;
orderNo: string;
storeId: string;
storeName: string;
roomId: string;
roomName: string;
roomNo: string;
status: OrderStatus;
startAt: Date;
endAt: Date;
totalAmountCents: number;
paidAmountCents: number;
latestPaymentId: string | null;
latestPaymentProvider: string | null;
latestPaymentStatus: string | null;
createdAt: Date;
}
interface CountRow extends RowDataPacket { total: number }
export class OrderQueryRepository {
constructor(private readonly pool: MySqlPool) {}
async listMine(input: OrderQueryAccess & {
page: number;
pageSize: number;
status?: OrderStatus;
storeId?: string;
}) {
const where = [
'o.tenant_id = ?',
'o.deleted_at IS NULL',
'(a.user_id = ? OR ' + managerScopeSql(input.access, 'o.store_id') + ')'
];
const params: Array<string | number> = [input.tenantId, input.userId];
if (input.status) {
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 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
LEFT JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<OrderListRow[]>(
`SELECT DISTINCT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName,
o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
o.status, o.start_at AS startAt, o.end_at AS endAt,
o.total_amount_cents AS totalAmountCents,
o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId,
p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus,
o.created_at AS createdAt
FROM qipai_orders o
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id
LEFT JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
LEFT JOIN qipai_payments p
ON p.tenant_id = o.tenant_id AND p.order_id = o.id
AND p.id = (
SELECT MAX(p2.id) FROM qipai_payments p2
WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id
AND p2.deleted_at IS NULL
)
WHERE ${whereSql}
ORDER BY o.created_at DESC, o.id DESC
LIMIT ${pageSize} OFFSET ${offset}`,
params
);
return {
items: rows.map(publicOrder),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize
};
}
async getMine(input: OrderQueryAccess & { orderId: string }) {
const result = await this.listMine({ ...input, page: 1, pageSize: 1 });
const order = result.items.find((item) => item.id === input.orderId);
if (order) return order;
const [rows] = await this.pool.execute<OrderListRow[]>(
`SELECT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName,
o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
o.status, o.start_at AS startAt, o.end_at AS endAt,
o.total_amount_cents AS totalAmountCents,
o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId,
p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus,
o.created_at AS createdAt
FROM qipai_orders o
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id
LEFT JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
LEFT JOIN qipai_payments p
ON p.tenant_id = o.tenant_id AND p.order_id = o.id
AND p.id = (
SELECT MAX(p2.id) FROM qipai_payments p2
WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id
AND p2.deleted_at IS NULL
)
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL
AND (a.user_id = ? OR ${managerScopeSql(input.access, 'o.store_id')})
LIMIT 1`,
[input.tenantId, input.orderId, input.userId]
);
if (!rows[0]) throw new OrderQueryError('ORDER_NOT_FOUND');
return publicOrder(rows[0]);
}
}
function publicOrder(row: OrderListRow) {
return {
id: String(row.id),
orderNo: row.orderNo,
storeId: String(row.storeId),
storeName: row.storeName,
roomId: String(row.roomId),
roomName: row.roomName,
roomNo: row.roomNo,
status: row.status,
startAt: row.startAt,
endAt: row.endAt,
totalAmountCents: row.totalAmountCents,
paidAmountCents: row.paidAmountCents,
latestPayment: row.latestPaymentId === null ? null : {
id: String(row.latestPaymentId),
provider: row.latestPaymentProvider,
status: row.latestPaymentStatus
},
createdAt: row.createdAt
};
}
function managerScopeSql(access: AccessProfile, storeExpression: string) {
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
return '1 = 1';
}
if (!access.capabilities.includes('store.operation.read') || access.storeIds.length === 0) {
return '1 = 0';
}
return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`;
}
+34 -1
View File
@@ -1,6 +1,7 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
export const orderActions = [
'SUBMIT', 'CONFIRM_PAYMENT', 'RESERVE', 'START', 'FINISH', 'CANCEL',
@@ -71,7 +72,16 @@ export class OrderStateError extends Error {
}
export class OrderStateRepository {
constructor(private readonly pool: MySqlPool) {}
constructor(
private readonly pool: MySqlPool,
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 = '') {
return this.transaction(async (connection) => {
@@ -99,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,
@@ -223,6 +241,21 @@ export class OrderStateRepository {
WHERE tenant_id = ? AND order_id = ?`,
[tenantId, orderId]
);
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`,
[tenantId, orderId]
);
if (users[0]?.userId) {
await this.benefits.releaseReservedInTransaction(connection, {
tenantId,
userId: String(users[0].userId),
orderId,
traceId: `order-benefit-release-${orderId}`
});
}
}
}
}
+100 -6
View File
@@ -1,6 +1,7 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
export type PricingMode = 'HOURLY' | 'OVERNIGHT' | 'FULL_DAY';
@@ -18,6 +19,14 @@ export interface QuoteInput {
adjustment?: PricingAdjustment;
}
export interface OrderBenefitInput {
couponGrantId?: string | null;
packageHoldingId?: string | null;
packageMinutes?: number;
packageCreditCents?: number;
clientRequestId?: string;
}
interface RoomPricingRow extends RowDataPacket {
id: string;
storeId: string;
@@ -33,15 +42,20 @@ interface RoomPricingRow extends RowDataPacket {
depositCents: number;
minimumMinutes: number;
maxAdvanceDays: number;
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); }
}
export class PricingRepository {
constructor(private readonly pool: MySqlPool) {}
constructor(
private readonly pool: MySqlPool,
private readonly benefits?: Pick<MarketingBenefitService, 'reserveForOrderInTransaction'>
) {}
async quote(input: QuoteInput) {
const room = await this.loadRoom(this.pool, input.tenantId, input.roomId);
@@ -55,8 +69,17 @@ export class PricingRepository {
userId: string;
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');
@@ -66,7 +89,7 @@ export class PricingRepository {
const isHoliday = await this.isHoliday(
connection, input.tenantId, input.startAt, room.timezone
);
const quote = this.calculate(room, input, isHoliday);
let quote = this.calculate(room, input, isHoliday);
const holdMinutes = Math.min(Math.max(input.holdMinutes ?? 15, 5), 30);
const orderNo = `QP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [orderResult] = await connection.execute<ResultSetHeader>(
@@ -79,6 +102,44 @@ export class PricingRepository {
holdMinutes, quote.totalCents]
);
const orderId = String(orderResult.insertId);
let benefitReservation = null;
const requestedBenefits = input.benefits;
if (this.benefits && requestedBenefits
&& (requestedBenefits.couponGrantId || requestedBenefits.packageHoldingId)) {
if (!requestedBenefits.clientRequestId) {
throw new PricingError('BENEFIT_CLIENT_REQUEST_ID_REQUIRED');
}
benefitReservation = await this.benefits.reserveForOrderInTransaction(connection, {
tenantId: input.tenantId,
userId: input.userId,
orderId,
storeId: String(room.storeId),
roomId: input.roomId,
roomCategoryId: room.roomCategoryId,
orderAmountCents: quote.totalCents,
orderStartAt: input.startAt,
isHoliday,
couponGrantId: requestedBenefits.couponGrantId ?? null,
packageHoldingId: requestedBenefits.packageHoldingId ?? null,
packageMinutes: requestedBenefits.packageMinutes,
packageCreditCents: requestedBenefits.packageCreditCents,
clientRequestId: requestedBenefits.clientRequestId,
traceId: requestedBenefits.clientRequestId
});
quote = this.calculate(room, {
...input,
adjustment: {
discountCents: benefitReservation.discountCents
+ timeCouponDiscountCents(benefitReservation.minutes, quote.unitPriceCents),
packageCreditCents: benefitReservation.packageCreditCents
}
}, isHoliday);
await connection.execute(
`UPDATE qipai_orders SET total_amount_cents = ?
WHERE tenant_id = ? AND id = ?`,
[quote.totalCents, input.tenantId, orderId]
);
}
await connection.execute(
`INSERT INTO qipai_order_price_snapshots
(tenant_id, order_id, pricing_mode, duration_minutes, unit_price_cents,
@@ -106,20 +167,47 @@ 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]
);
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,
storeId: String(room.storeId),
reservationId: String(reservationResult.insertId),
holdMinutes,
quote
quote,
benefitReservation
};
});
}
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,
@@ -250,7 +338,8 @@ export class PricingRepository {
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_days AS maxAdvanceDays
r.max_advance_days AS maxAdvanceDays,
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
@@ -320,6 +409,11 @@ function clampAdjustment(value: number | undefined, maximum: number) {
return Math.min(Math.trunc(value), maximum);
}
function timeCouponDiscountCents(minutes: number | undefined, unitPriceCents: number) {
if (!minutes || minutes <= 0) return 0;
return Math.ceil(minutes / 60) * unitPriceCents;
}
function localDateKey(date: Date, timezone: string) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
+109 -41
View File
@@ -1,6 +1,8 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { MarketingBenefitService } from '../wallets/marketing-benefit-service.js';
import type { WalletLedgerService } from '../wallets/wallet-ledger-service.js';
export type PaymentProvider = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'TEST';
@@ -31,7 +33,11 @@ export class PaymentError extends Error {
}
export class PaymentRepository {
constructor(private readonly pool: MySqlPool) {}
constructor(
private readonly pool: MySqlPool,
private readonly wallet?: Pick<WalletLedgerService, 'debitInTransaction'>,
private readonly benefits?: Pick<MarketingBenefitService, 'confirmReservedInTransaction'>
) {}
async createPayment(input: {
tenantId: string;
@@ -54,7 +60,7 @@ export class PaymentRepository {
if (input.provider === 'TEST' && !input.testAdapterEnabled) {
throw new PaymentError('TEST_PAYMENT_DISABLED');
}
if (input.provider !== 'TEST') {
if (!['TEST', 'BALANCE'].includes(input.provider)) {
await this.resolveConfig(
connection, input.tenantId, input.platformAppId, order.storeId, input.provider
);
@@ -94,6 +100,36 @@ export class PaymentRepository {
[input.tenantId, paymentId, input.provider, amountCents,
input.provider === 'TEST' ? 'test' : 'configured']
);
if (input.provider === 'BALANCE') {
if (!this.wallet) throw new PaymentError('WALLET_SETTLEMENT_NOT_CONFIGURED');
await this.wallet.debitInTransaction(connection, {
tenantId: input.tenantId,
userId: input.userId,
scopeType: 'STORE',
storeId: order.storeId,
businessType: 'ORDER_PAYMENT',
businessId: input.orderId,
entryType: 'CONSUME',
amountCents,
traceId: input.clientRequestId,
note: 'Customer balance payment',
metadata: { paymentId, provider: input.provider }
});
await this.applyPaymentSuccess(connection, {
paymentId,
orderId: input.orderId,
tenantId: input.tenantId,
userId: input.userId,
amountCents,
providerPaymentId: `BAL-${paymentNo}`,
traceId: input.clientRequestId,
reason: 'Balance payment completed'
});
return this.paymentResponse({
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
status: 'SUCCEEDED', amountCents
} as PaymentRow, false, input.testAdapterEnabled);
}
return this.paymentResponse({
id: paymentId, orderId: input.orderId, paymentNo, provider: input.provider,
status: 'PENDING', amountCents
@@ -150,45 +186,16 @@ export class PaymentRepository {
);
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: true };
}
await connection.execute(
`UPDATE qipai_payments
SET status = 'SUCCEEDED', provider_payment_id = ?,
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
[`TEST-${input.callbackId}`, input.tenantId, input.paymentId]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[payment.amountCents, input.tenantId, payment.orderId]
);
const order = await this.loadOrder(connection, input.tenantId, payment.orderId, true);
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
&& order.status === 'PENDING_PAYMENT') {
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
await connection.execute(
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[nextVersion, input.tenantId, payment.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[input.tenantId, payment.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', 'Verified payment callback', ?,
JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
[input.tenantId, payment.orderId, input.traceId, nextVersion, input.paymentId]
);
}
await this.applyPaymentSuccess(connection, {
paymentId: input.paymentId,
orderId: payment.orderId,
tenantId: input.tenantId,
userId: input.userId,
amountCents: Number(payment.amountCents),
providerPaymentId: `TEST-${input.callbackId}`,
traceId: input.traceId,
reason: 'Verified payment callback'
});
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
@@ -288,6 +295,67 @@ export class PaymentRepository {
if (!rows[0]) throw new PaymentError('ORDER_ACCESS_FORBIDDEN');
}
async applyPaymentSuccess(
connection: PoolConnection,
input: {
paymentId: string;
orderId: string;
tenantId: string;
userId?: string;
amountCents: number;
providerPaymentId: string;
traceId: string;
reason: string;
}
) {
await connection.execute(
`UPDATE qipai_payments
SET status = 'SUCCEEDED', provider_payment_id = ?,
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
[input.providerPaymentId, input.tenantId, input.paymentId]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[input.amountCents, input.tenantId, input.orderId]
);
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
if (Number(order.paidAmountCents) >= Number(order.totalAmountCents)
&& order.status === 'PENDING_PAYMENT') {
if (this.benefits && input.userId) {
await this.benefits.confirmReservedInTransaction(connection, {
tenantId: input.tenantId,
userId: input.userId,
orderId: input.orderId,
traceId: input.traceId
});
}
const nextVersion = Number((order as OrderRow & { statusVersion?: number }).statusVersion ?? 1) + 1;
await connection.execute(
`UPDATE qipai_orders SET status = 'PAID', status_version = ?,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[nextVersion, input.tenantId, input.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[input.tenantId, input.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', ?, ?, JSON_OBJECT('statusVersion', ?, 'paymentId', ?))`,
[input.tenantId, input.orderId, input.reason, input.traceId, nextVersion, input.paymentId]
);
}
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
+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')
};
});
}
+22 -38
View File
@@ -19,6 +19,7 @@ interface PaymentRow extends RowDataPacket {
paidAmountCents: number;
totalAmountCents: number;
orderStatus: string;
userId?: string;
}
interface RefundRow extends RowDataPacket {
@@ -419,44 +420,17 @@ export class WechatPaymentService {
);
return;
}
await connection.execute(
`UPDATE qipai_payments
SET status = 'SUCCEEDED', provider_payment_id = ?,
paid_at = UTC_TIMESTAMP(3), raw_notify = JSON_OBJECT('verified', TRUE)
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'`,
[transactionId, payment.tenantId, payment.id]
);
await connection.execute(
`UPDATE qipai_orders SET paid_amount_cents = paid_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[payment.amountCents, payment.tenantId, payment.orderId]
);
const nextPaid = Number(payment.paidAmountCents) + Number(payment.amountCents);
if (nextPaid >= Number(payment.totalAmountCents)
&& payment.orderStatus === 'PENDING_PAYMENT') {
await connection.execute(
`UPDATE qipai_orders
SET status = 'PAID', status_version = status_version + 1,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[payment.tenantId, payment.orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'CONSUMED', expires_at = GREATEST(expires_at, ends_at)
WHERE tenant_id = ? AND order_id = ? AND status = 'HELD'`,
[payment.tenantId, payment.orderId]
);
await connection.execute(
`INSERT INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, 'PENDING_PAYMENT', 'PAID', 'CONFIRM_PAYMENT', 'SYSTEM',
NULL, 'PAYMENT', 'Verified Wechat payment callback', ?,
JSON_OBJECT('paymentId', ?))`,
[payment.tenantId, payment.orderId, traceId, payment.id]
);
}
const userId = await this.loadOrderUserId(connection, payment.tenantId, payment.orderId);
await this.paymentRepository.applyPaymentSuccess(connection, {
paymentId: payment.id,
orderId: payment.orderId,
tenantId: payment.tenantId,
userId,
amountCents: Number(payment.amountCents),
providerPaymentId: transactionId,
traceId,
reason: 'Verified Wechat payment callback'
});
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
@@ -465,6 +439,16 @@ export class WechatPaymentService {
);
}
private async loadOrderUserId(connection: PoolConnection, tenantId: string, orderId: string) {
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`,
[tenantId, orderId]
);
return rows[0]?.userId ? String(rows[0].userId) : undefined;
}
private async rejectCallback(
connection: PoolConnection, tenantId: string, callbackId: string, code: string
) {
File diff suppressed because it is too large Load Diff
+144
View File
@@ -0,0 +1,144 @@
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('product.catalog.read')
|| access.capabilities.includes('inventory.read') ? ['products'] : []),
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
];
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 });
}
+96
View File
@@ -0,0 +1,96 @@
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';
const querySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
from: z.coerce.date().optional(),
to: z.coerce.date().optional()
}).strict();
export interface BusinessStatisticsRouteOptions {
repository: Pick<BusinessStatisticsRepository, 'overview'>;
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
});
}
});
}
}
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
});
}
+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(
+187 -26
View File
@@ -3,6 +3,10 @@ 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 {
CustomerDeviceAccessError,
type CustomerDeviceAccessRepository
} from '../devices/customer-device-access-repository.js';
import {
DeviceControlError,
type CommandContext,
@@ -10,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,
@@ -28,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)
@@ -49,39 +58,93 @@ 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({
delayTime: z.number().int().min(1).max(14).default(4)
}).strict();
export interface DeviceControlRouteOptions {
service: Pick<DeviceControlService,
'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> };
jwtSecret: string;
@@ -90,34 +153,116 @@ 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) {
return reply.status(404).send({
code: 'CUSTOMER_DEVICE_CONTROL_NOT_AVAILABLE',
message: 'Customer device control is not available.',
traceId: request.traceId
});
}
const params = orderParams.safeParse(request.params);
const body = customerOpenDoorSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
if (!auth) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
traceId: request.traceId
});
}
try {
const order = await options.customerAccess.getDoorContext({
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
orderId: params.data.orderId
});
const context: CommandContext = {
tenantId: auth.session.tenantId,
storeId: order.storeId,
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'],
storeIds: [order.storeId]
},
expiresAt: new Date(Date.now() + 30000)
};
return {
code: 0,
data: await options.service.controlDoor(context, {
order: 'open',
holdopen: 0,
delayTime: body.data.delayTime
}),
traceId: request.traceId
};
} catch (error) {
if (error instanceof CustomerDeviceAccessError) {
return reply.status(403).send({
code: error.code,
message: 'The order does not allow door access.',
traceId: request.traceId
});
}
if (!(error instanceof DeviceControlError)) throw error;
const status = error.code === 'DEVICE_OFFLINE' ? 409 : 400;
return reply.status(status).send({
code: error.code, message: 'Device control was rejected.', traceId: request.traceId
});
}
});
function register<T extends z.ZodTypeAny>(
url: string,
@@ -143,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(
@@ -162,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
};
}
+24 -20
View File
@@ -64,16 +64,18 @@ export interface DeviceRouteOptions {
}
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
app.get('/admin-api/devices', 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;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
traceId: request.traceId
}));
});
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;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
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,16 +85,18 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
traceId: request.traceId
}));
});
app.get('/admin-api/device-topology', 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;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getTopology(actor, query.data.storeId),
traceId: request.traceId
}));
});
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;
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getTopology(actor, query.data.storeId),
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 }); }
+250
View File
@@ -0,0 +1,250 @@
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.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
});
}
+167
View File
@@ -0,0 +1,167 @@
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 {
MemberProfileError,
type MemberActor,
type MemberProfileService
} from '../wallets/member-profile-service.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
search: z.string().trim().max(128).optional()
});
export interface MemberRouteOptions {
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile' | 'getMyBenefits'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerMemberRoutes(
app: FastifyInstance,
options: MemberRouteOptions
): Promise<void> {
app.get('/app-api/profile', async (request, reply) => {
const auth = await requireProfileReader(request, reply, options);
if (!auth) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyProfile({
tenantId: auth.tenantId,
userId: auth.userId
}),
traceId: request.traceId
}));
});
app.get('/app-api/profile/benefits', async (request, reply) => {
const auth = await requireProfileReader(request, reply, options);
if (!auth) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMyBenefits({
tenantId: auth.tenantId,
userId: auth.userId
}),
traceId: request.traceId
}));
});
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);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.listMembers({ actor, ...query.data }),
traceId: request.traceId
}));
});
}
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);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.getMember({ actor, memberId: params.data.id }),
traceId: request.traceId
}));
});
}
}
async function requireProfileReader(
request: FastifyRequest,
reply: FastifyReply,
options: MemberRouteOptions
): Promise<{ tenantId: string; userId: string } | 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('profile.read')) {
reply.status(403).send({
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
traceId: request.traceId
});
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id };
}
async function requireReader(
request: FastifyRequest,
reply: FastifyReply,
options: MemberRouteOptions
): Promise<MemberActor | 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('user.read')
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'MEMBER_READ_FORBIDDEN', message: 'Member read permission is required.',
traceId: request.traceId
});
return null;
}
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof MemberProfileError)) throw error;
return reply.status(404).send({
code: error.code,
message: 'The requested member is not available.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_MEMBER_REQUEST',
message: 'The member request is invalid.',
traceId
});
}
+61 -19
View File
@@ -1,4 +1,4 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
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';
@@ -27,7 +27,8 @@ const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict();
export interface OrderManagementRouteOptions {
repository: Pick<OrderManagementRepository,
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'>;
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'
| 'customerRenew' | 'customerChangeRoom'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -36,6 +37,34 @@ export interface OrderManagementRouteOptions {
export async function registerOrderManagementRoutes(
app: FastifyInstance, options: OrderManagementRouteOptions
) {
app.post('/app-api/orders/:orderId/renew', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = renewSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
const actor = customerActor(auth, request);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.customerRenew(actor, params.data.orderId, body.data),
traceId: request.traceId
}));
});
app.post('/app-api/orders/:orderId/change-room', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = roomSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
const actor = customerActor(auth, request);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.customerChangeRoom(actor, params.data.orderId, body.data),
traceId: request.traceId
}));
});
app.get('/app-api/orders/:orderId/cancellation-quote', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
@@ -62,26 +91,39 @@ export async function registerOrderManagementRoutes(
] as const;
for (const [path, schema, execute] of adminActions) {
app.post(`/admin-api/orders/: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);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
const actor = {
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? '', access: auth.access
};
return handle(reply, request.traceId, async () => ({
code: 0,
data: await execute(actor, params.data.orderId, body.data as never),
traceId: request.traceId
}));
});
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);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
const actor = {
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? '', access: auth.access
};
return handle(reply, request.traceId, async () => ({
code: 0,
data: await execute(actor, params.data.orderId, body.data as never),
traceId: request.traceId
}));
});
}
}
}
function customerActor(
auth: { tenantId: string; userId: string; access: AccessProfile },
request: FastifyRequest
): OrderActor {
return {
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
source: 'APP', traceId: request.traceId, ip: request.ip,
userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access
};
}
async function authenticate(
authorization: string | undefined, options: OrderManagementRouteOptions
) {
+106
View File
@@ -0,0 +1,106 @@
import type { FastifyInstance, FastifyReply } 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 {
OrderQueryError,
type OrderQueryRepository
} from '../orders/order-query-repository.js';
import { type OrderStatus } from '../orders/order-state-repository.js';
const orderStatuses = [
'DRAFT', 'PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS',
'FINISHED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'CLOSED'
] as const satisfies readonly OrderStatus[];
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(),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
export interface OrderQueryRouteOptions {
repository: Pick<OrderQueryRepository, 'listMine' | 'getMine'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerOrderQueryRoutes(
app: FastifyInstance,
options: OrderQueryRouteOptions
) {
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);
if (!query.success) return invalid(reply, request.traceId);
return {
code: 0,
data: await options.repository.listMine({ ...auth, ...query.data }),
traceId: request.traceId
};
});
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);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getMine({ ...auth, orderId: params.data.orderId }),
traceId: request.traceId
}));
});
}
}
async function authenticate(
authorization: string | undefined,
options: OrderQueryRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
const tenantId = result.session.tenantId;
const userId = result.session.user.id;
return {
tenantId,
userId,
access: await options.accessControl.getAccessProfile(tenantId, userId)
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof OrderQueryError)) throw error;
return reply.status(404).send({
code: error.code,
message: 'The requested order is not available.',
traceId
});
}
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_ORDER_QUERY',
message: 'The order query is invalid.',
traceId
});
}
+40 -29
View File
@@ -27,19 +27,25 @@ export interface OrderStateRouteOptions {
export async function registerOrderStateRoutes(
app: FastifyInstance, options: OrderStateRouteOptions
) {
app.get('/app-api/orders/:orderId/history', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.history(
auth.tenantId, auth.userId, params.data.orderId, auth.access
),
traceId: request.traceId
}));
});
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);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.history(
auth.tenantId, auth.userId, params.data.orderId, auth.access
),
traceId: request.traceId
}));
});
}
app.post('/app-api/orders/:orderId/cancel', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
@@ -62,22 +68,27 @@ export async function registerOrderStateRoutes(
});
});
app.post('/admin-api/orders/:orderId/actions', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = transitionSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.transition({
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
source: 'ADMIN', traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? '', access: auth.access
}, params.data.orderId, body.data.action, body.data.reason),
traceId: request.traceId
}));
});
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);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.transition({
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
source: 'ADMIN', traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? '', access: auth.access
}, params.data.orderId, body.data.action, body.data.reason),
traceId: request.traceId
}));
});
}
}
async function authenticate(
+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 });
}
+51 -31
View File
@@ -9,7 +9,14 @@ const requestSchema = z.object({
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
startAt: z.coerce.date(),
endAt: z.coerce.date(),
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY')
pricingMode: z.enum(['HOURLY', 'OVERNIGHT', 'FULL_DAY']).default('HOURLY'),
benefits: z.object({
couponGrantId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
packageHoldingId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
packageMinutes: z.number().int().min(0).optional(),
packageCreditCents: z.number().int().min(0).optional(),
clientRequestId: z.string().min(8).max(128)
}).strict().optional()
}).refine((value) => value.endAt > value.startAt);
const adminReserveSchema = requestSchema.and(z.object({
userId: z.string().regex(/^[1-9]\d{0,19}$/)
@@ -73,35 +80,47 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
};
});
app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = adminReserveSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
const unrestricted = access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN');
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
return reply.status(403).send({
code: 'ORDER_MANAGEMENT_FORBIDDEN',
message: 'Order management permission is required.',
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);
if (!body.success) return invalid(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
const unrestricted = access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN');
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
return reply.status(403).send({
code: 'ORDER_MANAGEMENT_FORBIDDEN',
message: 'Order management permission is required.',
traceId: request.traceId
});
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.reserve({
tenantId: auth.tenantId,
userId: body.data.userId,
roomId: body.data.roomId,
startAt: body.data.startAt,
endAt: body.data.endAt,
pricingMode: body.data.pricingMode,
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
});
}
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.reserve({
tenantId: auth.tenantId,
userId: body.data.userId,
roomId: body.data.roomId,
startAt: body.data.startAt,
endAt: body.data.endAt,
pricingMode: body.data.pricingMode,
allowedStoreIds: unrestricted ? null : access.storeIds
}),
traceId: request.traceId
}));
});
}));
});
}
}
async function authenticate(
@@ -124,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
+487
View File
@@ -0,0 +1,487 @@
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'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
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
});
}
+188
View File
@@ -0,0 +1,188 @@
import type { FastifyInstance, FastifyReply, preParsingHookHandler } from 'fastify';
import { Transform } from 'node:stream';
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 { RechargeError, type RechargeService } from '../wallets/recharge-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
const listSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const createSchema = z.object({
planId: z.string().regex(/^[1-9]\d{0,19}$/),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
clientRequestId: z.string().min(8).max(128)
}).strict();
const orderParams = z.object({
rechargeOrderId: z.string().regex(/^[1-9]\d{0,19}$/)
});
export interface RechargeRouteOptions {
service: Pick<
RechargeService,
'listAvailablePlans' | 'createRechargeOrder' | 'createWechatPrepay' | 'processWechatNotification'
>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerRechargeRoutes(
app: FastifyInstance,
options: RechargeRouteOptions
) {
app.get('/app-api/recharge/plans', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const query = listSchema.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.listAvailablePlans({
tenantId: auth.tenantId,
storeId: query.data.storeId ?? null
}),
traceId: request.traceId
}));
});
app.post('/app-api/recharge/orders', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = createSchema.safeParse(request.body);
if (!auth) return unauthorized(reply, request.traceId);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.service.createRechargeOrder({
tenantId: auth.tenantId,
userId: auth.userId,
planId: body.data.planId,
storeId: body.data.storeId ?? null,
clientRequestId: body.data.clientRequestId,
traceId: request.traceId
}),
traceId: request.traceId
}));
});
app.post('/app-api/recharge/orders/:rechargeOrderId/wechat-prepay', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = orderParams.safeParse(request.params);
if (!auth) return unauthorized(reply, request.traceId);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.createWechatPrepay({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
userId: auth.userId,
rechargeOrderId: params.data.rechargeOrderId
}),
traceId: request.traceId
}));
});
app.post('/app-api/recharge/wechat/notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.service.processWechatNotification(
notificationHeaders(request.headers),
request.rawBody,
request.traceId
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
}
async function authenticate(
authorization: string | undefined,
options: RechargeRouteOptions
) {
const result = await authenticateAccessToken(
authorization,
options.authRepository,
options.jwtSecret
);
if (!result) return null;
const access = await options.accessControl.getAccessProfile(
result.session.tenantId,
result.session.user.id
);
if (!access.capabilities.includes('profile.read')) return null;
return {
tenantId: result.session.tenantId,
platformAppId: result.session.platformAppId,
userId: result.session.user.id
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof RechargeError) && !(error instanceof WechatPayError)) throw error;
const status = error.code === 'RECHARGE_PLAN_NOT_FOUND'
|| error.code === 'RECHARGE_ORDER_NOT_FOUND'
? 404
: error.code === 'RECHARGE_LIMIT_REACHED' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({
code: error.code,
message: 'The recharge request is not available.',
traceId
});
}
}
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);
};
function notificationHeaders(headers: Record<string, unknown>) {
const read = (name: string) => {
const value = headers[name];
if (typeof value !== 'string' || value.length === 0) {
throw new WechatPayError('WECHAT_NOTIFICATION_HEADER_INVALID');
}
return value;
};
return {
timestamp: read('wechatpay-timestamp'),
nonce: read('wechatpay-nonce'),
serial: read('wechatpay-serial'),
signature: read('wechatpay-signature')
};
}
function unauthorized(reply: FastifyReply, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'Authentication required.',
traceId
});
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_RECHARGE_REQUEST',
message: 'The recharge request is invalid.',
traceId
});
}
+87 -2
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import type { AuthRepository, LoginContext } from '../auth/auth-repository.js';
import type { StoreDiscoveryRepository } from '../stores/store-discovery-repository.js';
const headersSchema = z.object({
@@ -21,7 +21,7 @@ const querySchema = z.object({
});
export interface StoreDiscoveryRouteOptions {
repository: Pick<StoreDiscoveryRepository, 'findStores'>;
repository: Pick<StoreDiscoveryRepository, 'findStores' | 'getStore' | 'listRooms'>;
tenancy: Pick<AuthRepository, 'resolveLoginContext'>;
}
@@ -64,4 +64,89 @@ export async function registerStoreDiscoveryRoutes(
throw error;
}
});
app.get('/app-api/stores/:storeId', async (request, reply) => {
const context = await resolveContext(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!context) return;
if (!params.success) return invalid(reply, request.traceId);
const store = await options.repository.getStore({
tenantId: context.tenantId,
storeId: params.data.storeId
});
if (!store) {
return reply.status(404).send({
code: 'STORE_NOT_FOUND',
message: 'Store was not found.',
traceId: request.traceId
});
}
return { code: 0, data: store, traceId: request.traceId };
});
app.get('/app-api/stores/:storeId/rooms', async (request, reply) => {
const context = await resolveContext(request, reply, options);
const params = storeIdSchema.safeParse(request.params);
if (!context) return;
if (!params.success) return invalid(reply, request.traceId);
return {
code: 0,
data: await options.repository.listRooms({
tenantId: context.tenantId,
storeId: params.data.storeId
}),
traceId: request.traceId
};
});
}
const storeIdSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/)
});
async function resolveContext(
request: { headers: unknown; traceId: string },
reply: { status(code: number): { send(payload: unknown): unknown } },
options: StoreDiscoveryRouteOptions
): Promise<LoginContext | null> {
const headers = headersSchema.safeParse(request.headers);
if (!headers.success) {
invalid(reply, request.traceId);
return null;
}
try {
const context = await options.tenancy.resolveLoginContext(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
if (!context) {
reply.status(404).send({
code: 'APP_TENANT_NOT_FOUND',
message: 'Application tenant binding was not found.',
traceId: request.traceId
});
return null;
}
return context;
} catch (error) {
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
reply.status(409).send({
code: 'TENANT_SELECTION_REQUIRED',
message: 'tenant-id is required for an application bound to multiple tenants.',
traceId: request.traceId
});
return null;
}
throw error;
}
}
function invalid(
reply: { status(code: number): { send(payload: unknown): unknown } },
traceId: string
) {
return reply.status(400).send({
code: 'INVALID_STORE_DISCOVERY_REQUEST',
message: 'AppID and valid store filters are required.',
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
});
}
+82 -18
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,22 +84,49 @@ export async function registerThirdPartyRoutes(
}));
});
app.post('/admin-api/group-vouchers/redeem-manual', 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);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherManually({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
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);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.redeemVoucherManually({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
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
}));
+19 -8
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) => {
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 });
return { code: 0, data, traceId: request.traceId };
});
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,
...(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);
+108 -4
View File
@@ -2,6 +2,7 @@ import { buildApp } from './app.js';
import { loadConfig } from './config.js';
import { closeMySqlPool, createMySqlPool } from './db/mysql.js';
import { PlatformConfigRepository } from './tenancy/platform-config-repository.js';
import { PlatformAdminRepository } from './tenancy/platform-admin-repository.js';
import { AuthRepository } from './auth/auth-repository.js';
import { RbacRepository } from './auth/rbac-repository.js';
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
@@ -16,6 +17,7 @@ import { PricingRepository } from './orders/pricing-repository.js';
import { OrderStateRepository } from './orders/order-state-repository.js';
import { OrderManagementRepository } from './orders/order-management-repository.js';
import { OrderShareRepository } from './orders/order-share-repository.js';
import { OrderQueryRepository } from './orders/order-query-repository.js';
import { PaymentRepository } from './payments/payment-repository.js';
import {
FetchWechatPayTransport, parseWechatPayCredentials, WechatPayClient
@@ -28,18 +30,44 @@ import {
import { ThirdPartyService } from './third-party/third-party-service.js';
import { MqttService } from './mqtt/mqtt-service.js';
import { DeviceRepository } from './devices/device-repository.js';
import { CustomerDeviceAccessRepository } from './devices/customer-device-access-repository.js';
import { IotMessageService } from './devices/iot-message-service.js';
import { DeviceCommandService } from './devices/device-command-service.js';
import { DeviceControlService } from './devices/device-control-service.js';
import { MemberProfileService } from './wallets/member-profile-service.js';
import { RechargeService } from './wallets/recharge-service.js';
import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
import { InventoryService } from './inventory/inventory-service.js';
const config = loadConfig();
const pool = createMySqlPool(config);
const authRepository = new AuthRepository(pool);
const accessControl = new RbacRepository(pool);
const platformConfigRepository = new PlatformConfigRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool);
const paymentRepository = new PaymentRepository(pool);
const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool);
const cleaningTaskRepository = new CleaningTaskRepository(pool);
const productCatalogRepository = new ProductCatalogRepository(pool);
const inventoryService = new InventoryService(pool);
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
const cleaningPayoutService = new CleaningPayoutService(
pool,
cleaningTaskRepository,
wechatPayClient,
wechatCredentials,
config.payment.cleaningPayoutMockEnabled
);
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
const iotMessages = new IotMessageService(pool);
const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) =>
@@ -49,7 +77,13 @@ const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
const app = await buildApp({
config,
mqtt,
platformConfigRepository: new PlatformConfigRepository(pool),
platformConfigRepository,
platformManagement: {
repository: new PlatformAdminRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
auth: {
repository: authRepository,
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
@@ -88,18 +122,24 @@ const app = await buildApp({
jwtSecret: config.auth.jwtSecret
},
pricing: {
repository: new PricingRepository(pool),
repository: new PricingRepository(pool, marketingBenefits),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
orderState: {
repository: new OrderStateRepository(pool),
repository: new OrderStateRepository(pool, marketingBenefits, cleaningTaskRepository),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret,
cancellationPolicy: orderManagementRepository
},
orderQuery: {
repository: new OrderQueryRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
orderManagement: {
repository: orderManagementRepository,
authRepository,
@@ -150,6 +190,70 @@ const app = await buildApp({
},
deviceControl: {
service: new DeviceControlService(pool, deviceCommands),
customerAccess: new CustomerDeviceAccessRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
members: {
service: new MemberProfileService(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
recharge: {
service: new RechargeService(pool, walletLedgerService, {
paymentRepository,
client: wechatPayClient,
credentials: wechatCredentials
}),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
cleaning: {
repository: cleaningTaskRepository,
payoutService: cleaningPayoutService,
mediaStorage: new MediaStorage(resolve(process.cwd(), 'shared', 'uploads')),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
businessStatistics: {
repository: new BusinessStatisticsRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
franchise: {
repository: new FranchiseRepository(pool),
platformConfig: platformConfigRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
systemOperations: {
repository: new SystemOperationsRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
adminAuth: {
repository: new AdminAuthRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds
},
products: {
repository: productCatalogRepository,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
inventory: {
service: inventoryService,
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
@@ -30,6 +30,30 @@ interface DiscoveryRow extends RowDataPacket {
sortOrder: number;
}
interface RoomRow extends RowDataPacket {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: string;
operationalStatus: string;
tags: string | string[] | null;
images: string | string[] | null;
sortOrder: number;
}
export interface DiscoveredStore {
id: string;
name: string;
@@ -46,6 +70,30 @@ export interface DiscoveredStore {
sortOrder: number;
}
export interface PublicRoom {
id: string;
storeId: string;
categoryName: string;
name: string;
roomNo: string;
capacity: number;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceStartMinutes: number;
maxAdvanceDays: number;
configurationStatus: string;
operationalStatus: string;
tags: string[];
images: string[];
sortOrder: number;
}
export class StoreDiscoveryRepository {
constructor(private readonly pool: MySqlPool) {}
@@ -125,6 +173,47 @@ export class StoreDiscoveryRepository {
return left.sortOrder - right.sortOrder || Number(left.id) - Number(right.id);
});
}
async getStore(query: { tenantId: string; storeId: string; now?: Date }):
Promise<DiscoveredStore | null> {
const stores = await this.findStores({ tenantId: query.tenantId, now: query.now });
return stores.find((store) => store.id === query.storeId) ?? null;
}
async listRooms(query: { tenantId: string; storeId: string }): Promise<PublicRoom[]> {
const [rows] = await this.pool.execute<RoomRow[]>(
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
r.name, r.room_no AS roomNo, r.capacity,
r.base_price_cents AS basePriceCents, r.weekday_price_cents AS weekdayPriceCents,
r.holiday_price_cents AS holidayPriceCents,
r.overnight_price_cents AS overnightPriceCents,
r.full_day_price_cents AS fullDayPriceCents,
r.minimum_spend_cents AS minimumSpendCents,
r.deposit_cents AS depositCents,
r.minimum_minutes AS minimumMinutes,
r.max_advance_start_minutes AS maxAdvanceStartMinutes,
r.max_advance_days AS maxAdvanceDays,
r.configuration_status AS configurationStatus,
r.operational_status AS operationalStatus, r.tags, r.images,
r.sort_order AS sortOrder
FROM qipai_rooms r
INNER JOIN qipai_stores s
ON s.tenant_id = r.tenant_id AND s.id = r.store_id AND s.deleted_at IS NULL
LEFT JOIN qipai_room_categories c
ON c.id = r.category_id AND c.tenant_id = r.tenant_id AND c.deleted_at IS NULL
WHERE r.tenant_id = ? AND r.store_id = ? AND r.deleted_at IS NULL
AND r.configuration_status = 'ENABLED'
ORDER BY r.sort_order, r.id`,
[query.tenantId, query.storeId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
tags: parseJsonArray(row.tags),
images: parseJsonArray(row.images)
}));
}
}
export function haversineMeters(
@@ -162,3 +251,14 @@ function isOpenAt(hours: DiscoveryRow[], timezone: string, now: Date): boolean {
}
return currentMinute >= today.openMinute || currentMinute < today.closeMinute;
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value;
if (!value) return [];
try {
const parsed: unknown = JSON.parse(value);
return Array.isArray(parsed) && parsed.every((item) => typeof item === 'string') ? parsed : [];
} catch {
return [];
}
}
+76 -12
View File
@@ -13,7 +13,7 @@ export interface StoreInput {
timezone: string;
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
wifiSsid: string;
wifiPassword: string;
wifiPassword?: string;
notificationUrl: string;
sortOrder: number;
businessHours: Array<{
@@ -47,13 +47,23 @@ export interface RoomInput {
sortOrder: number;
}
export interface RoomStatusInput {
storeId: string;
configurationStatus?: 'ENABLED' | 'DISABLED';
operationalStatus?: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED';
reason: string;
}
interface IdRow extends RowDataPacket { id: string }
interface CountRow extends RowDataPacket { total: number }
interface StoreRow extends RowDataPacket {
id: string; name: string; address: string; city: string; district: string;
longitude: string | null; latitude: string | null;
contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
notificationUrl: string; sortOrder: number;
wifiConfigured: number; notificationUrl: string; sortOrder: number;
}
interface StoreBusinessHourRow extends RowDataPacket {
storeId: string; weekday: number; openMinute: number; closeMinute: number; isClosed: number;
}
interface RoomRow extends RowDataPacket {
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
@@ -78,17 +88,42 @@ export class StoreRoomRepository {
`SELECT s.id, s.name, s.address, s.city, s.district, s.longitude, s.latitude,
s.contact_phone AS contactPhone, s.timezone,
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
CASE WHEN s.wifi_password <> '' THEN 1 ELSE 0 END AS wifiConfigured,
s.notification_url AS notificationUrl, s.sort_order AS sortOrder
FROM qipai_stores s
WHERE s.tenant_id = ? AND s.deleted_at IS NULL AND ${scope.sql}
ORDER BY s.sort_order, s.id`,
[actor.tenantId, ...scope.params]
);
if (rows.length === 0) return [];
const storeIds = rows.map((row) => String(row.id));
const [hourRows] = await this.pool.execute<StoreBusinessHourRow[]>(
`SELECT store_id AS storeId, weekday, open_minute AS openMinute,
close_minute AS closeMinute, is_closed AS isClosed
FROM qipai_store_business_hours
WHERE tenant_id = ? AND store_id IN (${storeIds.map(() => '?').join(',')})
ORDER BY store_id, weekday`,
[actor.tenantId, ...storeIds]
);
const hoursByStore = new Map<string, StoreInput['businessHours']>();
for (const hour of hourRows) {
const storeId = String(hour.storeId);
const items = hoursByStore.get(storeId) ?? [];
items.push({
weekday: Number(hour.weekday),
openMinute: Number(hour.openMinute),
closeMinute: Number(hour.closeMinute),
isClosed: Boolean(hour.isClosed)
});
hoursByStore.set(storeId, items);
}
return rows.map((row) => ({
...row,
id: String(row.id),
longitude: row.longitude === null ? null : Number(row.longitude),
latitude: row.latitude === null ? null : Number(row.latitude)
latitude: row.latitude === null ? null : Number(row.latitude),
wifiConfigured: Boolean(row.wifiConfigured),
businessHours: hoursByStore.get(String(row.id)) ?? []
}));
}
@@ -102,7 +137,7 @@ export class StoreRoomRepository {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder]
input.wifiSsid, input.wifiPassword ?? '', input.notificationUrl, input.sortOrder]
);
const storeId = String(result.insertId);
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
@@ -118,12 +153,12 @@ export class StoreRoomRepository {
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
longitude = ?, latitude = ?,
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
wifi_password = ?, notification_url = ?, sort_order = ?
wifi_password = COALESCE(?, wifi_password), notification_url = ?, sort_order = ?
WHERE tenant_id = ? AND id = ?`,
[input.name, input.address, input.city, input.district,
input.longitude ?? null, input.latitude ?? null,
input.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
input.wifiPassword, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
input.wifiPassword ?? null, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
);
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
@@ -152,7 +187,7 @@ export class StoreRoomRepository {
}
async listRooms(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
this.assertStoreScope(actor, storeId, false);
const [rows] = await this.pool.execute<RoomRow[]>(
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
r.name, r.room_no AS roomNo, r.capacity,
@@ -226,6 +261,33 @@ export class StoreRoomRepository {
});
}
async updateRoomStatus(actor: ManagementActor, roomId: string, input: RoomStatusInput) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
await this.lockRoom(connection, actor, roomId, input.storeId);
await connection.execute(
`UPDATE qipai_rooms
SET configuration_status = COALESCE(?, configuration_status),
operational_status = COALESCE(?, operational_status),
status = CASE
WHEN COALESCE(?, configuration_status) = 'DISABLED' THEN 'DISABLED'
ELSE COALESCE(?, operational_status)
END
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
[input.configurationStatus ?? null, input.operationalStatus ?? null,
input.configurationStatus ?? null, input.operationalStatus ?? null,
actor.tenantId, input.storeId, roomId]
);
await this.audit(connection, actor, 'ROOM_STATUS_UPDATED', 'ROOM', roomId, {
storeId: input.storeId,
configurationStatus: input.configurationStatus ?? null,
operationalStatus: input.operationalStatus ?? null,
reason: input.reason
});
return { roomId, ...input };
});
}
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
@@ -265,10 +327,11 @@ export class StoreRoomRepository {
}
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
private assertStoreScope(actor: ManagementActor, storeId: string, write = true) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (!actor.access.capabilities.includes('store.operation.write')
const capability = write ? 'store.operation.write' : 'store.operation.read';
if (!actor.access.capabilities.includes(capability)
|| !actor.access.storeIds.includes(storeId)) {
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
}
@@ -343,15 +406,16 @@ export class StoreRoomRepository {
private async audit(
connection: PoolConnection, actor: ManagementActor,
action: string, resourceType: string, resourceId: string
action: string, resourceType: string, resourceId: string,
metadata: Record<string, unknown> = {}
) {
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', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, actor.userId, action, resourceType, resourceId,
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
actor.traceId, actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
);
}
@@ -0,0 +1,200 @@
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 TenantAppConfigInput {
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
extraConfig: Record<string, unknown>;
bindingStatus: 'ACTIVE' | 'DISABLED';
isDefault: boolean;
}
export class PlatformAdminError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PlatformAdminRepository {
constructor(private readonly pool: MySqlPool) {}
async listTenantApps(tenantId: string) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT pa.id AS platformAppId, pa.appid AS appId, pa.name AS appName,
pa.status AS appStatus, ta.status AS bindingStatus, ta.is_default AS isDefault,
tc.brand_name AS brandName, tc.logo_url AS logoUrl,
tc.theme_color AS themeColor, tc.service_phone AS servicePhone,
tc.franchise_phone AS franchisePhone, tc.share_title AS shareTitle,
tc.share_image_url AS shareImageUrl, tc.default_store_id AS defaultStoreId,
tc.extra_config AS extraConfig, tc.updated_at AS updatedAt
FROM qipai_tenant_apps ta
INNER JOIN qipai_platform_apps pa
ON pa.id = ta.platform_app_id AND pa.deleted_at IS NULL
INNER JOIN qipai_tenant_configs tc
ON tc.tenant_id = ta.tenant_id AND tc.platform_app_id = ta.platform_app_id
AND tc.deleted_at IS NULL
WHERE ta.tenant_id = ? AND ta.deleted_at IS NULL
ORDER BY ta.is_default DESC, pa.id`,
[tenantId]
);
return rows.map((row) => ({
platformAppId: String(row.platformAppId),
appId: row.appId,
appName: row.appName,
appStatus: row.appStatus,
bindingStatus: row.bindingStatus,
isDefault: Boolean(row.isDefault),
brandName: row.brandName,
logoUrl: row.logoUrl,
themeColor: row.themeColor,
servicePhone: row.servicePhone,
franchisePhone: row.franchisePhone,
shareTitle: row.shareTitle,
shareImageUrl: row.shareImageUrl,
defaultStoreId: row.defaultStoreId === null ? null : String(row.defaultStoreId),
extraConfig: parseJson(row.extraConfig),
updatedAt: row.updatedAt
}));
}
async updateTenantConfig(actor: ManagementActor, platformAppId: string, input: TenantAppConfigInput) {
return this.transaction(async (connection) => {
await this.assertBinding(connection, actor.tenantId, platformAppId);
await this.assertDefaultStore(connection, actor.tenantId, input.defaultStoreId);
if (input.isDefault) {
await connection.execute(
'UPDATE qipai_tenant_apps SET is_default = 0 WHERE tenant_id = ? AND deleted_at IS NULL',
[actor.tenantId]
);
}
await connection.execute(
`UPDATE qipai_tenant_apps
SET status = ?, is_default = ?
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL`,
[input.bindingStatus, input.isDefault ? 1 : 0, actor.tenantId, platformAppId]
);
await connection.execute(
`UPDATE qipai_tenant_configs SET brand_name = ?, logo_url = ?, theme_color = ?,
service_phone = ?, franchise_phone = ?, share_title = ?, share_image_url = ?,
default_store_id = ?, extra_config = ?
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL`,
[input.brandName, input.logoUrl, input.themeColor, input.servicePhone,
input.franchisePhone, input.shareTitle, input.shareImageUrl, input.defaultStoreId,
JSON.stringify(input.extraConfig), actor.tenantId, platformAppId]
);
await this.audit(connection, actor, 'TENANT_APP_CONFIG_UPDATED', platformAppId, {
bindingStatus: input.bindingStatus, isDefault: input.isDefault,
defaultStoreId: input.defaultStoreId
});
return { platformAppId, updated: true };
});
}
async bindApplication(actor: ManagementActor, input: {
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantAppConfigInput;
}) {
return this.transaction(async (connection) => {
await this.assertDefaultStore(connection, actor.tenantId, input.config.defaultStoreId);
await connection.execute(
`INSERT INTO qipai_platform_apps (appid, name, status)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), status = VALUES(status), deleted_at = NULL`,
[input.appId, input.appName, input.appStatus]
);
const [apps] = await connection.execute<Array<RowDataPacket & { id: string }>>(
'SELECT id FROM qipai_platform_apps WHERE appid = ? AND deleted_at IS NULL FOR UPDATE',
[input.appId]
);
if (!apps[0]) throw new PlatformAdminError('PLATFORM_APP_NOT_FOUND');
const platformAppId = String(apps[0].id);
if (input.config.isDefault) {
await connection.execute(
'UPDATE qipai_tenant_apps SET is_default = 0 WHERE tenant_id = ? AND deleted_at IS NULL',
[actor.tenantId]
);
}
await connection.execute(
`INSERT INTO qipai_tenant_apps (tenant_id, platform_app_id, status, is_default)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE status = VALUES(status), is_default = VALUES(is_default), deleted_at = NULL`,
[actor.tenantId, platformAppId, input.config.bindingStatus, input.config.isDefault ? 1 : 0]
);
await connection.execute(
`INSERT INTO qipai_tenant_configs
(tenant_id, platform_app_id, brand_name, logo_url, theme_color, service_phone,
franchise_phone, share_title, share_image_url, default_store_id, extra_config)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE brand_name = VALUES(brand_name), logo_url = VALUES(logo_url),
theme_color = VALUES(theme_color), service_phone = VALUES(service_phone),
franchise_phone = VALUES(franchise_phone), share_title = VALUES(share_title),
share_image_url = VALUES(share_image_url), default_store_id = VALUES(default_store_id),
extra_config = VALUES(extra_config), deleted_at = NULL`,
[actor.tenantId, platformAppId, input.config.brandName, input.config.logoUrl,
input.config.themeColor, input.config.servicePhone, input.config.franchisePhone,
input.config.shareTitle, input.config.shareImageUrl, input.config.defaultStoreId,
JSON.stringify(input.config.extraConfig)]
);
await this.audit(connection, actor, 'TENANT_APP_BOUND', platformAppId, {
appId: input.appId, bindingStatus: input.config.bindingStatus,
isDefault: input.config.isDefault
});
return { platformAppId, bound: true };
});
}
private async assertBinding(connection: PoolConnection, tenantId: string, platformAppId: string) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_tenant_apps
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL FOR UPDATE`,
[tenantId, platformAppId]
);
if (!rows[0]) throw new PlatformAdminError('TENANT_APP_BINDING_NOT_FOUND');
}
private async assertDefaultStore(connection: PoolConnection, tenantId: string, storeId: string | null) {
if (!storeId) return;
const [rows] = await connection.execute<RowDataPacket[]>(
'SELECT id FROM qipai_stores WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL',
[tenantId, storeId]
);
if (!rows[0]) throw new PlatformAdminError('TENANT_APP_DEFAULT_STORE_INVALID');
}
private async audit(connection: PoolConnection, actor: ManagementActor, action: string, id: 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', ?, ?, 'PLATFORM_APP', ?, ?, ?, ?, ?)`,
[actor.tenantId, actor.userId, action, id, 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();
}
}
}
function parseJson(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object') return value as Record<string, unknown>;
if (typeof value !== 'string') return {};
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : {}; }
catch { return {}; }
}
+135 -17
View File
@@ -119,6 +119,45 @@ export class ThirdPartyService {
});
}
async redeemVoucherAsManager(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
clientRequestId: string;
}) {
const existing = await this.findRedemptionByRequest(
input.tenantId, input.clientRequestId, input.orderId
);
if (existing) return { ...existing, idempotent: true };
const order = await this.loadOrder(input.tenantId, input.orderId);
assertStoreAccess(input.access, order.storeId);
const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider);
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED');
const result = await this.client.redeemVoucher({
mode: config.mode,
provider: input.provider,
voucherCode: input.voucherCode,
orderNo: order.orderNo,
expectedAmountCents,
settings: config.settings,
credential: this.resolveCredential(config.credentialRef)
});
return this.recordRedemption({
...input,
mode: config.mode,
amountCents: result.amountCents,
expectedAmountCents,
externalProductId: result.externalProductId ?? '',
status: result.status,
failureCode: result.failureCode ?? '',
response: result.response ?? {}
});
}
async receiveDirectBooking(input: {
tenantId: string;
provider: ThirdPartyProvider;
@@ -242,26 +281,31 @@ export class ThirdPartyService {
access: AccessProfile;
provider?: ThirdPartyProvider;
status?: string;
storeId?: string;
}) {
const storeFilter = input.access.capabilities.includes('tenant.manage')
|| input.access.roles.includes('PLATFORM_ADMIN')
? null : input.access.storeIds;
const unrestricted = input.access.capabilities.includes('tenant.manage')
|| input.access.roles.includes('PLATFORM_ADMIN');
if (input.storeId && !unrestricted && !input.access.storeIds.includes(input.storeId)) {
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
const storeFilter = input.storeId ? [input.storeId]
: unrestricted ? null : input.access.storeIds;
if (storeFilter && storeFilter.length === 0) {
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
const params: string[] = [input.tenantId];
const filters = ['tenant_id = ?'];
const bookingParams: string[] = [input.tenantId];
const bookingFilters = ['tenant_id = ?'];
if (input.provider) {
filters.push('provider = ?');
params.push(input.provider);
bookingFilters.push('provider = ?');
bookingParams.push(input.provider);
}
if (input.status) {
filters.push('status = ?');
params.push(input.status);
bookingFilters.push('status = ?');
bookingParams.push(input.status);
}
if (storeFilter) {
filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
params.push(...storeFilter);
bookingFilters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
bookingParams.push(...storeFilter);
}
const [bookings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, external_booking_no AS externalBookingNo,
@@ -269,24 +313,87 @@ export class ThirdPartyService {
ends_at AS endsAt, amount_cents AS amountCents, status,
order_id AS orderId, failure_code AS failureCode, created_at AS createdAt
FROM qipai_direct_bookings
WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
params
WHERE ${bookingFilters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
bookingParams
);
const redemptionParams: string[] = [input.tenantId];
const redemptionFilters = ['r.tenant_id = ?'];
if (input.provider) {
redemptionFilters.push('v.provider = ?');
redemptionParams.push(input.provider);
}
if (input.status) {
redemptionFilters.push('r.status = ?');
redemptionParams.push(input.status);
}
if (storeFilter) {
redemptionFilters.push(`r.store_id IN (${storeFilter.map(() => '?').join(',')})`);
redemptionParams.push(...storeFilter);
}
const [redemptions] = await this.pool.execute<RowDataPacket[]>(
`SELECT r.id, v.provider, v.voucher_masked AS voucherMasked,
r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode,
r.status, r.failure_code AS failureCode, r.created_at AS createdAt
r.actor_id AS actorId, r.status, r.failure_code AS failureCode,
r.created_at AS createdAt, r.completed_at AS completedAt
FROM qipai_group_redemptions r
INNER JOIN qipai_group_vouchers v
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
WHERE r.tenant_id = ?
${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''}
WHERE ${redemptionFilters.join(' AND ')}
ORDER BY r.id DESC LIMIT 100`,
storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId]
redemptionParams
);
return { bookings, redemptions };
}
async listSetup(input: {
tenantId: string;
access: AccessProfile;
provider?: ThirdPartyProvider;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
const params = input.provider ? [input.tenantId, input.provider] : [input.tenantId];
const providerFilter = input.provider ? 'AND provider = ?' : '';
const [configs] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, store_id AS storeId, mode, enabled,
credential_ref AS credentialRef, settings, updated_at AS updatedAt
FROM qipai_third_party_configs
WHERE tenant_id = ? ${providerFilter}
ORDER BY provider, store_id IS NULL DESC, store_id, id`,
params
);
const [mappings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, resource_type AS resourceType,
external_ref AS externalRef, local_resource_id AS localResourceId
FROM qipai_third_party_mappings
WHERE tenant_id = ? ${providerFilter}
ORDER BY provider, resource_type, id`,
params
);
return {
configs: configs.map((row) => ({
id: String(row.id),
provider: row.provider,
storeId: row.storeId === null ? null : String(row.storeId),
mode: row.mode,
enabled: Boolean(row.enabled),
credentialRef: String(row.credentialRef || ''),
credentialConfigured: Boolean(row.credentialRef),
settings: sanitizeSettings(row.settings),
updatedAt: row.updatedAt
})),
mappings: mappings.map((row) => ({
id: String(row.id),
provider: row.provider,
resourceType: row.resourceType,
externalRef: row.externalRef,
localResourceId: String(row.localResourceId)
}))
};
}
async saveConfig(input: {
tenantId: string;
actorId: string;
@@ -666,6 +773,17 @@ function sanitizePayload(value: Record<string, unknown>) {
return copy;
}
function sanitizeSettings(value: unknown): Record<string, unknown> {
let source: unknown = value;
if (typeof source === 'string') {
try { source = JSON.parse(source); } catch { return {}; }
}
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
return Object.fromEntries(Object.entries(source as Record<string, unknown>)
.filter(([key]) => !/(secret|token|password|private|api.?key|credential)/i.test(key))
.map(([key, item]) => [key, item]));
}
function normalizeBooking(row: BookingRow) {
return {
bookingId: String(row.id),
+152 -131
View File
@@ -67,72 +67,79 @@ export class MarketingBenefitService {
traceId: string;
}) {
return this.transaction(async (connection) => {
const duplicate = await this.findUsageByRequest(connection, input);
if (duplicate.length > 0) return reserveResponse(duplicate, true);
const usages: UsageRow[] = [];
if (input.couponGrantId) {
const coupon = await this.loadCoupon(connection, input, input.couponGrantId);
assertUsableBenefit(coupon, input, 'COUPON');
const discountCents = coupon.couponType === 'FULL_REDUCTION'
? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents)
: 0;
const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0;
const usage = await this.insertUsage(connection, {
...input,
benefitType: 'COUPON',
benefitId: coupon.id,
discountCents,
packageCreditCents: 0,
minutes
});
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = 'FROZEN', frozen_order_id = ?
WHERE tenant_id = ? AND id = ?`,
[input.orderId, input.tenantId, coupon.id]
);
usages.push(usage);
}
if (input.packageHoldingId) {
const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId);
assertUsableBenefit(holding, input, 'PACKAGE');
const packageCreditCents = input.packageCreditCents ?? 0;
const minutes = input.packageMinutes ?? 0;
if (minutes <= 0 && packageCreditCents <= 0) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY');
}
if (minutes > Number(holding.remainingMinutes)) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT');
}
if (packageCreditCents > Number(holding.remainingAmountCents)) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT');
}
const usage = await this.insertUsage(connection, {
...input,
benefitType: 'PACKAGE',
benefitId: holding.id,
discountCents: 0,
packageCreditCents,
minutes
});
await connection.execute(
`UPDATE qipai_package_holdings
SET status = 'FROZEN', frozen_order_id = ?
WHERE tenant_id = ? AND id = ?`,
[input.orderId, input.tenantId, holding.id]
);
usages.push(usage);
}
if (usages.length === 0) {
throw new MarketingBenefitError('BENEFIT_EMPTY');
}
return reserveResponse(usages, false);
return this.reserveForOrderInTransaction(connection, input);
});
}
async reserveForOrderInTransaction(
connection: PoolConnection,
input: Parameters<MarketingBenefitService['reserveForOrder']>[0]
) {
const duplicate = await this.findUsageByRequest(connection, input);
if (duplicate.length > 0) return reserveResponse(duplicate, true);
const usages: UsageRow[] = [];
if (input.couponGrantId) {
const coupon = await this.loadCoupon(connection, input, input.couponGrantId);
assertUsableBenefit(coupon, input, 'COUPON');
const discountCents = coupon.couponType === 'FULL_REDUCTION'
? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents)
: 0;
const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0;
const usage = await this.insertUsage(connection, {
...input,
benefitType: 'COUPON',
benefitId: coupon.id,
discountCents,
packageCreditCents: 0,
minutes
});
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = 'FROZEN', frozen_order_id = ?
WHERE tenant_id = ? AND id = ?`,
[input.orderId, input.tenantId, coupon.id]
);
usages.push(usage);
}
if (input.packageHoldingId) {
const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId);
assertUsableBenefit(holding, input, 'PACKAGE');
const packageCreditCents = input.packageCreditCents ?? 0;
const minutes = input.packageMinutes ?? 0;
if (minutes <= 0 && packageCreditCents <= 0) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY');
}
if (minutes > Number(holding.remainingMinutes)) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT');
}
if (packageCreditCents > Number(holding.remainingAmountCents)) {
throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT');
}
const usage = await this.insertUsage(connection, {
...input,
benefitType: 'PACKAGE',
benefitId: holding.id,
discountCents: 0,
packageCreditCents,
minutes
});
await connection.execute(
`UPDATE qipai_package_holdings
SET status = 'FROZEN', frozen_order_id = ?
WHERE tenant_id = ? AND id = ?`,
[input.orderId, input.tenantId, holding.id]
);
usages.push(usage);
}
if (usages.length === 0) {
throw new MarketingBenefitError('BENEFIT_EMPTY');
}
return reserveResponse(usages, false);
}
async confirmReserved(input: {
tenantId: string;
userId: string;
@@ -140,50 +147,57 @@ export class MarketingBenefitService {
traceId: string;
}) {
return this.transaction(async (connection) => {
const usages = await this.loadOrderUsages(connection, input, true);
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
return reserveResponse(usages, true);
}
for (const usage of usages) {
if (usage.status !== 'FROZEN') {
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
}
if (usage.benefitType === 'COUPON') {
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END,
remaining_uses = GREATEST(remaining_uses - 1, 0),
used_count = used_count + 1,
used_at = UTC_TIMESTAMP(3),
frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
} else {
await connection.execute(
`UPDATE qipai_package_holdings
SET remaining_minutes = GREATEST(remaining_minutes - ?, 0),
remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0),
status = CASE
WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED'
ELSE 'ACTIVE'
END,
frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[
usage.minutes, usage.packageCreditCents,
usage.minutes, usage.packageCreditCents,
input.tenantId, usage.benefitId
]
);
}
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
}
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
return this.confirmReservedInTransaction(connection, input);
});
}
async confirmReservedInTransaction(
connection: PoolConnection,
input: Parameters<MarketingBenefitService['confirmReserved']>[0]
) {
const usages = await this.loadOrderUsages(connection, input, true);
if (usages.length === 0) return reserveResponse([], true);
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
return reserveResponse(usages, true);
}
for (const usage of usages) {
if (usage.status !== 'FROZEN') {
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
}
if (usage.benefitType === 'COUPON') {
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END,
remaining_uses = GREATEST(remaining_uses - 1, 0),
used_count = used_count + 1,
used_at = UTC_TIMESTAMP(3),
frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
} else {
await connection.execute(
`UPDATE qipai_package_holdings
SET remaining_minutes = GREATEST(remaining_minutes - ?, 0),
remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0),
status = CASE
WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED'
ELSE 'ACTIVE'
END,
frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[
usage.minutes, usage.packageCreditCents,
usage.minutes, usage.packageCreditCents,
input.tenantId, usage.benefitId
]
);
}
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
}
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
}
async releaseReserved(input: {
tenantId: string;
userId: string;
@@ -191,36 +205,43 @@ export class MarketingBenefitService {
traceId: string;
}) {
return this.transaction(async (connection) => {
const usages = await this.loadOrderUsages(connection, input, true);
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
if (usages.every((usage) => usage.status === 'RELEASED')) {
return reserveResponse(usages, true);
}
for (const usage of usages) {
if (usage.status !== 'FROZEN') {
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
}
if (usage.benefitType === 'COUPON') {
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = 'AVAILABLE', frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
} else {
await connection.execute(
`UPDATE qipai_package_holdings
SET status = 'ACTIVE', frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
}
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
}
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'RELEASED' })), false);
return this.releaseReservedInTransaction(connection, input);
});
}
async releaseReservedInTransaction(
connection: PoolConnection,
input: Parameters<MarketingBenefitService['releaseReserved']>[0]
) {
const usages = await this.loadOrderUsages(connection, input, true);
if (usages.length === 0) return reserveResponse([], true);
if (usages.every((usage) => usage.status === 'RELEASED')) {
return reserveResponse(usages, true);
}
for (const usage of usages) {
if (usage.status !== 'FROZEN') continue;
if (usage.benefitType === 'COUPON') {
await connection.execute(
`UPDATE qipai_coupon_grants
SET status = 'AVAILABLE', frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
} else {
await connection.execute(
`UPDATE qipai_package_holdings
SET status = 'ACTIVE', frozen_order_id = NULL
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, usage.benefitId]
);
}
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
}
return reserveResponse(usages.map((usage) => (
usage.status === 'FROZEN' ? { ...usage, status: 'RELEASED' } : usage
)), false);
}
private async loadCoupon(
connection: PoolConnection,
input: { tenantId: string; userId: string },
@@ -0,0 +1,486 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import { maskPhone } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
interface MemberRow extends RowDataPacket {
id: string;
status: string;
nickname: string;
phone: string;
createdAt: Date;
lastLoginAt: Date | null;
}
interface WalletSummaryRow extends RowDataPacket {
accountCount: number;
cashBalanceCents: number;
giftBalanceCents: number;
}
interface BenefitSummaryRow extends RowDataPacket {
availableCoupons: number;
frozenCoupons: number;
activePackages: number;
frozenPackages: number;
packageMinutes: number;
packageAmountCents: number;
}
interface RechargeSummaryRow extends RowDataPacket {
rechargeOrderCount: number;
creditedRechargeCount: number;
creditedRechargeCents: number;
giftedRechargeCents: number;
lastRechargeAt: Date | null;
}
interface OrderSummaryRow extends RowDataPacket {
orderCount: number;
paidOrderCount: number;
paidAmountCents: number;
lastOrderAt: Date | null;
}
interface LedgerRow extends RowDataPacket {
id: string;
storeId: string | null;
businessType: string;
businessId: string;
entryType: string;
cashDeltaCents: number;
giftDeltaCents: number;
cashBalanceAfterCents: number;
giftBalanceAfterCents: number;
createdAt: Date;
}
interface CouponDetailRow extends RowDataPacket {
id: string;
templateId: string;
name: string;
status: string;
couponType: string;
discountAmountCents: number;
timeMinutes: number;
minOrderAmountCents: number;
remainingUses: number;
validFrom: Date;
validTo: Date;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
holidayOnly: number;
frozenOrderId: string | null;
usedAt: Date | null;
}
interface PackageDetailRow extends RowDataPacket {
id: string;
planId: string;
name: string;
status: string;
priceCents: number;
minutesTotal: number;
amountCentsTotal: number;
remainingMinutes: number;
remainingAmountCents: number;
validFrom: Date;
validTo: Date;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
holidayOnly: number;
frozenOrderId: string | null;
}
interface CountRow extends RowDataPacket { total: number }
export interface MemberActor {
tenantId: string;
userId: string;
access: AccessProfile;
}
export class MemberProfileError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MemberProfileService {
constructor(private readonly pool: MySqlPool) {}
async listMembers(input: {
actor: MemberActor;
page: number;
pageSize: number;
status?: 'ACTIVE' | 'DISABLED';
search?: string;
}) {
const filters = ['u.tenant_id = ?', "u.user_type = 'CUSTOMER'", 'u.deleted_at IS NULL'];
const params: Array<string | number> = [input.actor.tenantId];
if (input.status) {
filters.push('u.status = ?');
params.push(input.status);
}
if (input.search) {
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
const like = `%${input.search}%`;
params.push(like, like, input.search);
}
const scope = memberScopeClause(input.actor, 'u.id');
filters.push(scope.sql);
params.push(...scope.params);
const where = filters.join(' AND ');
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT u.id) AS total
FROM qipai_users u
WHERE ${where}`,
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<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE ${where}
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
params
);
const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row)));
return { items, total: Number(counts[0]?.total ?? 0) };
}
async getMember(input: {
actor: MemberActor;
memberId: string;
ledgerLimit?: number;
}) {
const scope = memberScopeClause(input.actor, 'u.id');
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.user_type = 'CUSTOMER'
AND u.deleted_at IS NULL AND ${scope.sql}
LIMIT 1`,
[input.actor.tenantId, input.memberId, ...scope.params]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
return {
...await this.memberCard(input.actor, rows[0]),
recentLedger: await this.recentLedger(
input.actor.tenantId,
String(rows[0].id),
input.ledgerLimit ?? 10
)
};
}
async getMyProfile(input: {
tenantId: string;
userId: string;
ledgerLimit?: number;
}) {
const [rows] = await this.pool.execute<MemberRow[]>(
`SELECT u.id, u.status, u.nickname, u.phone,
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.user_type = 'CUSTOMER'
AND u.deleted_at IS NULL
LIMIT 1`,
[input.tenantId, input.userId]
);
if (!rows[0]) throw new MemberProfileError('MEMBER_NOT_FOUND');
const actor = {
tenantId: input.tenantId,
userId: input.userId,
access: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }
};
return {
...await this.memberCard(actor, rows[0]),
recentLedger: await this.recentLedger(
input.tenantId,
String(rows[0].id),
input.ledgerLimit ?? 10
)
};
}
async getMyBenefits(input: {
tenantId: string;
userId: string;
limit?: number;
}) {
const limit = Math.max(1, Math.min(input.limit ?? 50, 100));
const [coupons] = await this.pool.execute<CouponDetailRow[]>(
`SELECT g.id, g.template_id AS templateId, t.name, g.status,
t.coupon_type AS couponType,
t.discount_amount_cents AS discountAmountCents,
t.time_minutes AS timeMinutes,
t.min_order_amount_cents AS minOrderAmountCents,
g.remaining_uses AS remainingUses,
g.valid_from AS validFrom, g.valid_to AS validTo,
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
t.room_id AS roomId, t.holiday_only AS holidayOnly,
g.frozen_order_id AS frozenOrderId, g.used_at AS usedAt
FROM qipai_coupon_grants g
INNER JOIN qipai_coupon_templates t
ON t.tenant_id = g.tenant_id AND t.id = g.template_id
WHERE g.tenant_id = ? AND g.user_id = ? AND t.deleted_at IS NULL
ORDER BY FIELD(g.status, 'AVAILABLE', 'FROZEN', 'USED', 'EXPIRED'),
g.valid_to ASC, g.id DESC
LIMIT ?`,
[input.tenantId, input.userId, limit]
);
const [packages] = await this.pool.execute<PackageDetailRow[]>(
`SELECT h.id, h.plan_id AS planId, p.name, h.status,
p.price_cents AS priceCents,
p.minutes_total AS minutesTotal,
p.amount_cents_total AS amountCentsTotal,
h.remaining_minutes AS remainingMinutes,
h.remaining_amount_cents AS remainingAmountCents,
h.valid_from AS validFrom, h.valid_to AS validTo,
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
p.room_id AS roomId, p.holiday_only AS holidayOnly,
h.frozen_order_id AS frozenOrderId
FROM qipai_package_holdings h
INNER JOIN qipai_package_plans p
ON p.tenant_id = h.tenant_id AND p.id = h.plan_id
WHERE h.tenant_id = ? AND h.user_id = ? AND p.deleted_at IS NULL
ORDER BY FIELD(h.status, 'ACTIVE', 'FROZEN', 'EXHAUSTED', 'EXPIRED'),
h.valid_to ASC, h.id DESC
LIMIT ?`,
[input.tenantId, input.userId, limit]
);
return {
coupons: coupons.map((row) => ({
couponGrantId: String(row.id),
templateId: String(row.templateId),
name: row.name,
status: row.status,
couponType: row.couponType,
discountAmountCents: Number(row.discountAmountCents),
timeMinutes: Number(row.timeMinutes),
minOrderAmountCents: Number(row.minOrderAmountCents),
remainingUses: Number(row.remainingUses),
validFrom: row.validFrom,
validTo: row.validTo,
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId),
holidayOnly: Number(row.holidayOnly) === 1,
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId),
usedAt: row.usedAt
})),
packages: packages.map((row) => ({
packageHoldingId: String(row.id),
planId: String(row.planId),
name: row.name,
status: row.status,
priceCents: Number(row.priceCents),
minutesTotal: Number(row.minutesTotal),
amountCentsTotal: Number(row.amountCentsTotal),
remainingMinutes: Number(row.remainingMinutes),
remainingAmountCents: Number(row.remainingAmountCents),
validFrom: row.validFrom,
validTo: row.validTo,
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId),
holidayOnly: Number(row.holidayOnly) === 1,
frozenOrderId: row.frozenOrderId === null ? null : String(row.frozenOrderId)
}))
};
}
private async memberCard(actor: MemberActor, row: MemberRow) {
const memberId = String(row.id);
const [walletRows] = await this.pool.execute<WalletSummaryRow[]>(
`SELECT COUNT(*) AS accountCount,
COALESCE(SUM(cash_balance_cents), 0) AS cashBalanceCents,
COALESCE(SUM(gift_balance_cents), 0) AS giftBalanceCents
FROM qipai_wallet_accounts
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, memberId]
);
const [benefitRows] = await this.pool.execute<BenefitSummaryRow[]>(
`SELECT
COALESCE(SUM(CASE WHEN c.status = 'AVAILABLE' THEN 1 ELSE 0 END), 0) AS availableCoupons,
COALESCE(SUM(CASE WHEN c.status = 'FROZEN' THEN 1 ELSE 0 END), 0) AS frozenCoupons,
COALESCE((SELECT SUM(CASE WHEN h.status = 'ACTIVE' THEN 1 ELSE 0 END)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ?), 0) AS activePackages,
COALESCE((SELECT SUM(CASE WHEN h.status = 'FROZEN' THEN 1 ELSE 0 END)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ?), 0) AS frozenPackages,
COALESCE((SELECT SUM(h.remaining_minutes)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ? AND h.status IN ('ACTIVE', 'FROZEN')), 0) AS packageMinutes,
COALESCE((SELECT SUM(h.remaining_amount_cents)
FROM qipai_package_holdings h
WHERE h.tenant_id = ? AND h.user_id = ? AND h.status IN ('ACTIVE', 'FROZEN')), 0) AS packageAmountCents
FROM qipai_coupon_grants c
WHERE c.tenant_id = ? AND c.user_id = ?`,
[
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId,
actor.tenantId, memberId
]
);
const [rechargeRows] = await this.pool.execute<RechargeSummaryRow[]>(
`SELECT COUNT(*) AS rechargeOrderCount,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN 1 ELSE 0 END), 0) AS creditedRechargeCount,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN pay_amount_cents ELSE 0 END), 0) AS creditedRechargeCents,
COALESCE(SUM(CASE WHEN status = 'CREDITED' THEN gift_amount_cents ELSE 0 END), 0) AS giftedRechargeCents,
MAX(credited_at) AS lastRechargeAt
FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ?`,
[actor.tenantId, memberId]
);
const [orderRows] = await this.pool.execute<OrderSummaryRow[]>(
`SELECT COUNT(*) AS orderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents,
MAX(o.created_at) AS lastOrderAt
FROM qipai_orders o
INNER JOIN qipai_order_user_access a
ON a.tenant_id = o.tenant_id AND a.order_id = o.id
AND a.user_id = ? AND a.revoked_at IS NULL
WHERE o.tenant_id = ? AND o.deleted_at IS NULL`,
[memberId, actor.tenantId]
);
const wallet = walletRows[0] ?? emptyWallet();
const benefit = benefitRows[0] ?? emptyBenefit();
const recharge = rechargeRows[0] ?? emptyRecharge();
const order = orderRows[0] ?? emptyOrder();
return {
memberId,
status: row.status,
nickname: row.nickname,
maskedPhone: maskPhone(row.phone),
registeredAt: row.createdAt,
lastLoginAt: row.lastLoginAt,
wallet: {
accountCount: Number(wallet.accountCount),
cashBalanceCents: Number(wallet.cashBalanceCents),
giftBalanceCents: Number(wallet.giftBalanceCents),
totalBalanceCents: Number(wallet.cashBalanceCents) + Number(wallet.giftBalanceCents)
},
benefits: {
availableCoupons: Number(benefit.availableCoupons),
frozenCoupons: Number(benefit.frozenCoupons),
activePackages: Number(benefit.activePackages),
frozenPackages: Number(benefit.frozenPackages),
packageMinutes: Number(benefit.packageMinutes),
packageAmountCents: Number(benefit.packageAmountCents)
},
recharge: {
rechargeOrderCount: Number(recharge.rechargeOrderCount),
creditedRechargeCount: Number(recharge.creditedRechargeCount),
creditedRechargeCents: Number(recharge.creditedRechargeCents),
giftedRechargeCents: Number(recharge.giftedRechargeCents),
lastRechargeAt: recharge.lastRechargeAt
},
orders: {
orderCount: Number(order.orderCount),
paidOrderCount: Number(order.paidOrderCount),
paidAmountCents: Number(order.paidAmountCents),
lastOrderAt: order.lastOrderAt
}
};
}
private async recentLedger(tenantId: string, memberId: string, limit: number) {
const [rows] = await this.pool.execute<LedgerRow[]>(
`SELECT id, store_id AS storeId, business_type AS businessType,
business_id AS businessId, entry_type AS entryType,
cash_delta_cents AS cashDeltaCents,
gift_delta_cents AS giftDeltaCents,
cash_balance_after_cents AS cashBalanceAfterCents,
gift_balance_after_cents AS giftBalanceAfterCents,
created_at AS createdAt
FROM qipai_wallet_ledger_entries
WHERE tenant_id = ? AND user_id = ?
ORDER BY id DESC LIMIT ?`,
[tenantId, memberId, Math.max(1, Math.min(limit, 50))]
);
return rows.map((row) => ({
ledgerId: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
businessType: row.businessType,
businessId: row.businessId,
entryType: row.entryType,
cashDeltaCents: Number(row.cashDeltaCents),
giftDeltaCents: Number(row.giftDeltaCents),
cashBalanceAfterCents: Number(row.cashBalanceAfterCents),
giftBalanceAfterCents: Number(row.giftBalanceAfterCents),
createdAt: row.createdAt
}));
}
}
function memberScopeClause(actor: MemberActor, userExpression: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (!actor.access.capabilities.includes('user.read') || actor.access.storeIds.length === 0) {
return { sql: '1 = 0', params: [] as string[] };
}
const placeholders = actor.access.storeIds.map(() => '?').join(',');
return {
sql: `(EXISTS (
SELECT 1 FROM qipai_wallet_accounts wa
WHERE wa.tenant_id = u.tenant_id AND wa.user_id = ${userExpression}
AND wa.store_id IN (${placeholders})
) OR EXISTS (
SELECT 1 FROM qipai_order_user_access oa
INNER JOIN qipai_orders mo
ON mo.tenant_id = oa.tenant_id AND mo.id = oa.order_id
AND mo.deleted_at IS NULL
WHERE oa.tenant_id = u.tenant_id AND oa.user_id = ${userExpression}
AND oa.access_type = 'OWNER' AND mo.store_id IN (${placeholders})
))`,
params: [...actor.access.storeIds, ...actor.access.storeIds]
};
}
function emptyWallet(): WalletSummaryRow {
return { accountCount: 0, cashBalanceCents: 0, giftBalanceCents: 0 } as WalletSummaryRow;
}
function emptyBenefit(): BenefitSummaryRow {
return {
availableCoupons: 0,
frozenCoupons: 0,
activePackages: 0,
frozenPackages: 0,
packageMinutes: 0,
packageAmountCents: 0
} as BenefitSummaryRow;
}
function emptyRecharge(): RechargeSummaryRow {
return {
rechargeOrderCount: 0,
creditedRechargeCount: 0,
creditedRechargeCents: 0,
giftedRechargeCents: 0,
lastRechargeAt: null
} as RechargeSummaryRow;
}
function emptyOrder(): OrderSummaryRow {
return {
orderCount: 0,
paidOrderCount: 0,
paidAmountCents: 0,
lastOrderAt: null
} as OrderSummaryRow;
}

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