Compare commits

..

223 Commits

Author SHA1 Message Date
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
Codex 239ef4dcf1 docs(M07-C): 记录权益核销验收并推进会员管理 2026-06-24 15:46:43 +08:00
Codex 4ae9296f83 feat(M07-C): 建立优惠券套餐权益核销 2026-06-24 15:45:22 +08:00
Codex ba99beb221 docs(M07-B): 记录充值优惠验收并推进券套餐 2026-06-24 15:39:54 +08:00
Codex d9743d36d8 feat(M07-B): 接入充值优惠规则 2026-06-24 15:37:01 +08:00
Codex db1d08ecf8 docs(M07-A): 记录双余额账本验收并推进充值优惠 2026-06-24 15:31:48 +08:00
Codex 63711adafc feat(M07-A): 建立双余额账本 2026-06-24 15:30:33 +08:00
Codex 8de3d05da6 docs(M06-G): 记录硬件联调阻塞并推进余额账本 2026-06-24 15:26:05 +08:00
Codex d01f77151e feat(M06-G): 增加真实硬件烟测运行器 2026-06-24 15:23:48 +08:00
Codex c00526f3e3 docs(M06-F): 记录订单联动验收并推进硬件联调 2026-06-24 15:19:58 +08:00
Codex 8936ad1c6f feat(M06-F): 接入订单设备自动联动任务 2026-06-24 15:18:40 +08:00
Codex e4941c2ffa docs(M06-E): 记录智慧插座验收并推进订单联动 2026-06-24 15:14:24 +08:00
Codex 9144fa8102 feat(M06-E): 接入智慧插座业务控制 2026-06-24 15:12:12 +08:00
Codex 48638606fe docs(M06-D): 记录控制箱门锁验收并推进插座 2026-06-22 18:39:35 +08:00
Codex d15fd3f0ff feat(M06-D): 接入控制箱与Sub-1G门锁业务控制 2026-06-22 18:38:28 +08:00
Codex f71ac09a0b docs(M06-C): 记录协议幂等验收并推进控制箱 2026-06-22 18:30:36 +08:00
Codex e795cdb693 feat(M06-C): 完成设备协议适配与消息幂等 2026-06-22 18:29:07 +08:00
Codex c96045f6ef docs(M06-B): 记录设备拓扑验收并推进协议适配 2026-06-22 17:42:08 +08:00
Codex 74a67ac92d feat(M06-B): 建立设备资产与拓扑管理 2026-06-22 17:40:54 +08:00
Codex 07790a7924 docs(M06-A): 记录MQTT基础验收并推进设备资产 2026-06-22 17:27:24 +08:00
Codex c140613718 feat(M06-A): 建立MQTT连接与Broker基础 2026-06-22 17:25:33 +08:00
Codex a8c3e7d2fe docs(M05-D): 完成支付模块回归并推进设备 2026-06-22 11:51:02 +08:00
Codex 1680d734bf feat(M05-D): 完成收款配置与幂等分账 2026-06-22 11:49:37 +08:00
Codex 52edf2482e docs(M05-C): 记录团购直订验收并推进分账 2026-06-22 11:30:21 +08:00
Codex cda640baa0 feat(M05-C): 完成团购验券与第三方直订 2026-06-22 11:28:50 +08:00
Codex 3ec74bb751 docs(M05-B): 记录微信支付验收并推进团购 2026-06-22 11:16:51 +08:00
Codex 4c66e192b9 feat(M05-B): 完成微信支付退款与对账基础 2026-06-22 11:15:22 +08:00
Codex dea2ee5ee1 docs(M05-A): 记录统一支付验收并推进微信支付 2026-06-20 14:31:39 +08:00
Codex 6c506ff862 feat(M05-A): 建立统一支付领域与幂等回调 2026-06-20 14:30:35 +08:00
Codex f9ec0af2ee docs(M04-D): 完成订单模块回归并推进支付 2026-06-20 14:21:39 +08:00
Codex 91801fe6a7 feat(M04-D): 完成最小权限订单分享 2026-06-20 14:20:37 +08:00
Codex 3c5cf071b9 docs(M04-C): 记录订单调整验收并推进游标 2026-06-20 14:06:35 +08:00
Codex 089d34877b feat(M04-C): 完成续费换房与订单调整 2026-06-20 14:05:31 +08:00
Codex 4122db45ec docs(M04-B): 记录订单状态机验收并推进游标 2026-06-20 13:55:05 +08:00
Codex 40c891f1b7 feat(M04-B): 完成订单状态机与迁移历史 2026-06-20 13:53:55 +08:00
Codex 725028cb2d docs(M04-A): 记录定价预占验收并推进游标 2026-06-18 16:07:48 +08:00
Codex af7a45d878 feat(M04-A): 完成定价快照与并发时段预占 2026-06-18 16:07:00 +08:00
Codex 6be7fa79c5 docs(M03-D): 记录场景访问验收并推进游标 2026-06-18 15:58:01 +08:00
Codex d563078a9f feat(M03-D): 完成场景码NFC与受控WiFi 2026-06-18 15:56:36 +08:00
Codex 88fa22ee92 docs(M03-C): 记录地图选店验收并推进游标 2026-06-18 15:16:17 +08:00
Codex df373faa82 feat(M03-C): 完成地图选店与距离排序 2026-06-18 15:15:39 +08:00
Codex 9417064e61 chore(repo): 同步小程序纳管状态与工程游标 2026-06-18 15:09:35 +08:00
Codex c658b0912b feat(M08-bootstrap): 纳管微信原生小程序基础模板 2026-06-18 15:08:42 +08:00
Codex db014635ac docs(V5.4): 修正文档行尾格式 2026-06-18 15:08:30 +08:00
Codex 0251226d9e docs(V5.4): 固化MySQL双环境配置基线 2026-06-18 15:07:52 +08:00
Codex d4c026b247 docs(M03-B): 记录装修广告验收并推进游标 2026-06-18 15:06:55 +08:00
Codex f74624296d feat(M03-B): 完成装修广告与媒体管理 2026-06-18 15:06:16 +08:00
Codex 7e9b53487b docs(M03-A): 记录门店房间验收并推进游标 2026-06-18 14:56:01 +08:00
Codex 15139b3962 feat(M03-A): 完成门店与房间基础管理 2026-06-18 14:55:04 +08:00
Codex 47094d5309 docs(M02-D): 记录用户权限验收并推进游标 2026-06-18 14:46:31 +08:00
Codex 5a300a82f6 feat(M02-D): 完成用户与员工权限管理 2026-06-18 14:45:01 +08:00
Codex 46acb8422a docs(M02-C): 记录权限范围验收结果 2026-06-18 10:51:14 +08:00
Codex caacc78545 feat(M02-C): 建立RBAC与门店数据范围 2026-06-18 10:50:43 +08:00
Codex a8c2a3b3e1 docs(M02-B): 记录微信登录验收结果 2026-06-18 10:46:39 +08:00
Codex 647ef7c83c feat(M02-B): 实现微信登录与可撤销会话 2026-06-18 10:45:50 +08:00
Codex 0db9505553 docs(M02-A): 记录多租户模型验收结果 2026-06-18 10:31:16 +08:00
Codex 8b8fb28c36 feat(M02-A): 建立多小程序租户配置模型 2026-06-18 10:30:31 +08:00
Codex 9d13f75dc0 docs(M01-C): 记录异步任务验收结果 2026-06-18 10:21:32 +08:00
Codex 2b90d1f101 feat(M01-C): 建立MySQL异步任务基础 2026-06-18 10:20:39 +08:00
Codex a2d578f73b docs(M01-B): 完成旧库副本验收记录 2026-06-18 10:07:12 +08:00
Codex 1192dcb6b2 test(M01-B): 验证脱敏旧库迁移兼容 2026-06-18 10:06:26 +08:00
Codex 5117ae27d6 docs(M01-B): 记录MySQL迁移实测结果 2026-06-18 09:55:47 +08:00
Codex 8749a44adf test(M01-B): 验证MySQL迁移往返 2026-06-18 09:53:20 +08:00
Codex 2571f6fe5b docs(M01-B): 记录旧订单金额转换进度 2026-06-18 09:41:36 +08:00
Codex b8b384ed67 feat(M01-B): 严格转换旧库订单金额 2026-06-18 09:40:40 +08:00
Codex 2715405c7d docs(M01-B): 记录迁移执行与兼容查询进度 2026-06-18 09:32:45 +08:00
Codex e069c50331 feat(M01-B): 增加迁移执行器与旧库只读兼容层 2026-06-18 09:31:29 +08:00
Codex 3cabb71de5 docs(M01-B): 记录核心迁移与连接池进度 2026-06-16 21:26:55 +08:00
Codex 3add64bef9 fix(M01-B): 避免连接池凭据误触秘密扫描 2026-06-16 21:23:40 +08:00
Codex 8801881f9c feat(M01-B): 建立核心迁移与MySQL连接池 2026-06-16 21:15:29 +08:00
Codex de63164972 docs(M01-A): 记录后端HTTP验证进度 2026-06-16 21:04:38 +08:00
Codex 6114124ecb feat(M01-A): 锁定后端依赖并验证HTTP健康检查 2026-06-16 20:56:51 +08:00
Codex ea884e166f docs(M01-A): 记录后端基础骨架进度 2026-06-16 20:40:05 +08:00
Codex 98b66d22f2 feat(M01-A): 建立后端API基础骨架 2026-06-16 20:37:55 +08:00
Codex eb259ce2a4 docs(M00): 回填V5.2工程提交记录 2026-06-16 20:24:42 +08:00
Codex b44f447630 test(M00): 增加README配置一致性检查 2026-06-16 20:22:53 +08:00
Codex 3106fa80ca docs(M00): 记录状态文档门禁 2026-06-16 14:47:07 +08:00
Codex ef0edda3ed test(M00): 增加状态文档门禁 2026-06-16 14:46:03 +08:00
Codex d2fe866e81 docs(M00): 记录发布清单dry-run检查 2026-06-16 14:36:34 +08:00
Codex 59d92c00b0 test(M00): 补充发布清单dry-run检查 2026-06-16 14:34:59 +08:00
Codex d6f0e10990 docs(M00): 记录模块推送门禁顺序 2026-06-16 14:26:11 +08:00
Codex 6052f34e16 fix(M00): 调整模块推送门禁顺序 2026-06-16 14:25:09 +08:00
Codex ae501398e2 docs(M00): 校准仓库完整性远端记录 2026-06-16 14:20:41 +08:00
380 changed files with 66568 additions and 112 deletions
+134 -12
View File
@@ -1,24 +1,146 @@
# 自助棋牌室系统
# 自助棋牌室系统
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区,固定 Windows 开发路径为 `D:\qipai`
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.4.md`](./V5.4.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准
## 固定约束
- 当前权威总纲:`V4.8.md`
- 当前权威总纲:`V5.4.md`
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
- 默认分支:`main`
- 生产域名`https://api.txyundm.cn`
- 生产系统:Ubuntu Server 24.04 x86-64/amd64
- 部署方式:无 Docker,使用根目录 `setup.sh` 菜单式部署
- 生产 API`https://api.txyundm.cn`
- 小程序 API`https://api.txyundm.cn/app-api`
- 后台 API`https://api.txyundm.cn/admin-api`
- 后台 Web`https://api.txyundm.cn/admin/`
- 正式 MQTT Broker`101.42.38.246:1883`
- 生产系统:Ubuntu Server 24.04 x86-64/amd64,无桌面、无 Docker、无微信云开发
- 部署入口:Ubuntu 执行 `sudo bash /opt/apps/setup.sh`
- 开发流程:Windows 本地开发与测试 → 按 `M00→M10` 固定队列持续编码 → 每个子阶段测试、commit、SSH push、远端校验 → 自动进入下一子阶段
## 目录
- `backend/`Fastify + TypeScript 后端 API
- `admin/`Vue3 后台管理端
- `miniapp/`:微信原生小程序
- `backend/`Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
- `admin/`Vue3 后台管理端,已接入 M08-B 保洁运营工作台、运营待处理入口、运营待处理导出、待验收任务批量验收/驳回、免清洁标记、批量免清洁与统计展示、协作者管理、任务成员导出、任务详情照片预览、任务操作流水、任务流水导出、任务详情协作分账、任务列表导出、任务详情导出、结算详情、结算详情导出、待结算候选预览与导出、结算列表导出、结算批量同步微信状态、结算转账状态筛选、微信确认参数展示与导出、微信转账预检资料展示与导出、真实转账前自动预检拦截、统计明细、每日趋势、保洁员绩效排行、统计导出、保洁员资料管理、保洁员列表导出、保洁员待补资料导出、微信绑定状态、保洁员选择器、任务/结算/统计筛选、联调检查项搜索、联调筛选结果导出与筛选清除、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调风险清单导出和联调归档包导出,必须适配桌面、平板和手机
- `miniapp/`:微信原生小程序,已接入顾客端主链路与保洁端任务大厅、我的任务、照片上传、驳回补做和保洁员统计看板
- `database/`:迁移、种子和兼容 SQL
- `deploy/`部署说明、版本和生产配置模板
- `deploy/`Nginx、PM2、EMQX 模板与部署版本
- `scripts/`Windows、WSL 和 Ubuntu 辅助脚本
- `docs/`:模块状态、开发日志、变更记录和验收文档
- `参考/`:只读参考资料,正式开发不得直接在其中二开
- `docs/`:模块状态、开发日志、配置、API/DB/部署变更和验收文档
- `参考/`:只读参考代码、SQL、运行包和硬件协议
- `setup.sh`Ubuntu 24.04 中文菜单式部署与环境监测入口
## 配置文件位置
### Windows / Git 仓库
| 用途 | 位置 |
|---|---|
| 项目入口 | `D:\qipai\README.md` |
| 当前总纲 | `D:\qipai\V5.4.md` |
| 完整配置索引 | `D:\qipai\docs\configuration.md` |
| 仓库目录映射 | `D:\qipai\docs\repository-map.md` |
| 后端开发配置 | `D:\qipai\backend\.env.development` |
| 后端测试配置 | `D:\qipai\backend\.env.test` |
| 后端配置模板 | `D:\qipai\backend\.env.example` |
| WSL MySQL 本地配置 | `D:\qipai\config\dev\mysql.local.env` |
| WSL MQTT 本地配置 | `D:\qipai\config\dev\mqtt.local.env` |
| 后台开发配置 | `D:\qipai\admin\.env.development` |
| 后台生产配置 | `D:\qipai\admin\.env.production` |
| 小程序环境配置 | `D:\qipai\miniapp\config\env.js` |
| PM2 模板 | `D:\qipai\deploy\pm2\ecosystem.config.cjs` |
| Nginx 模板 | `D:\qipai\deploy\nginx\api.txyundm.cn.conf` |
| EMQX 模板 | `D:\qipai\deploy\emqx\` |
| 部署组件版本 | `D:\qipai\deploy\VERSION` |
| 部署入口 | `D:\qipai\setup.sh` |
当前仓库若尚未建立上述某个文件,Codex 应在对应模块中创建;若实际目录名不同,必须同步更新本 README 和 `docs/repository-map.md`,不得保留错误路径。
### WSL 本地调试
| 用途 | 位置/地址 |
|---|---|
| Windows 工作区映射 | `/mnt/d/qipai` |
| EMQX 配置目录 | `/etc/emqx/` |
| EMQX 数据 | `/var/lib/emqx/` |
| EMQX 日志 | `/var/log/emqx/` |
| MQTTX CLI | `/usr/local/bin/mqttx` |
| EMQX Dashboard | `http://127.0.0.1:18083` |
| MQTT TCP | `127.0.0.1:1883` 或 WSL 当前 IP `:1883` |
| MySQL | `127.0.0.1:3306` |
| MySQL 开发/测试账号 | `root` |
| MySQL 开发/测试密码 | `root123` |
WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running)` 且已开机启动;监听 `1883/8883/8083/8084/18083`
### Ubuntu 正式服务器
| 用途 | 位置 |
|---|---|
| 菜单部署入口 | `/opt/apps/setup.sh` |
| 生产 Git 工作区 | `/opt/apps/qipai-backend/` |
| 后台静态发布 | `/opt/apps/qipai-admin/current/` |
| 小程序源码镜像 | `/opt/apps/qipai-miniapp/source/` |
| 非敏感配置 | `/etc/qipai/qipai.conf` |
| 敏感配置 | `/etc/qipai/qipai.secrets` |
| MySQL 客户端凭据 | `/etc/qipai/mysql-client.cnf`600 |
| 部署 SSH 私钥 | `/etc/qipai/ssh/id_ed25519` |
| Nginx 站点 | `/etc/nginx/sites-available/api.txyundm.cn.conf` |
| PM2 配置 | `/opt/apps/qipai-backend/deploy/pm2/ecosystem.config.cjs` |
| Gitea 配置 | `/opt/apps/gitea/custom/conf/app.ini` |
| MySQL 配置 | `/etc/mysql/mysql.conf.d/mysqld.cnf` |
| EMQX 配置 | `/etc/emqx/` |
| TLS 证书 | `/etc/letsencrypt/live/api.txyundm.cn/` |
| 上传目录 | `/opt/apps/qipai-backend/shared/uploads/` |
| 统一备份 | `/opt/apps/backups/` |
## 明文账号与密码
本项目按用户决定在私有 Gitea 仓库中明文记录账号密码。SSH/TLS/微信支付私钥或证书正文仍不得提交,只记录路径和指纹。
| 环境 | 服务 | 地址 | 账号 | 密码 | 说明 |
|---|---|---|---|---|---|
| WSL 本地 | EMQX Dashboard | `http://127.0.0.1:18083` | `admin` | `admin123` | 已知 Dashboard 登录凭据;是否可用于 MQTT 客户端认证仍待验证 |
| WSL 本地 | MySQL | `127.0.0.1:3306` | `root` | `root123` | 开发、测试和迁移预演 |
| Windows | Gitea SSH | `ssh://git@git.txyundm.cn:2222/panda/qipai.git` | SSH Key | 免密 | Windows 已配置 |
| Ubuntu 生产 | MQTT | `101.42.38.246:1883` | 待配置 | 待配置 | 禁止复用 WSL 凭据 |
| Ubuntu 生产 | MySQL | `127.0.0.1:3306` | `root` | `Da@Shuai!6y8c..XT` | 已确认;写入 `/etc/qipai/qipai.secrets``/etc/qipai/mysql-client.cnf` |
数据库密码包含特殊字符时,脚本不得直接拼接到 shell 命令或未编码 URI。生产环境检查、迁移和备份统一通过权限为 `600``/etc/qipai/mysql-client.cnf` 或等效 `--defaults-extra-file` 连接,日志不得打印密码。
## 持续开发规则
- 唯一模块顺序:`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 文档提交后的下一次普通开发必须产生工程增量。
- 当前游标、最近工程提交和下一工程目标以 `docs/module-status.md``docs/current-baseline.md` 为准,不在 README 中猜测。
## 当前进度
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M08-B`PARTIAL
- 最近工程提交:`96c5fa4` / `M08-B联调筛选清除增量`,本轮补齐现场联调分类、状态、负责人和关键字筛选的一键清除,不会重置已填写的联调草稿。
- 下一工程目标:继续 M08-B,补更完整保洁运营管理界面和真实商户现场执行记录。
## 版本递进
- **V5.1**:同步 README,明确全部配置位置、明文凭据登记和 README 随总纲递进规则。
- **V5.2**:增加工程编码优先、反文档循环、增量审计和工程完成证据。
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
## Codex 入口
```text
请阅读 V5.4.md,按当前进度继续开发。
```
+4900
View File
File diff suppressed because it is too large Load Diff
+5072
View File
File diff suppressed because it is too large Load Diff
+5295
View File
File diff suppressed because it is too large Load Diff
+5350
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>
+1582
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"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",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@lucide/vue": "^1.22.0",
"element-plus": "^2.10.4",
"vue": "^3.5.17"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.4",
"typescript": "^5.6.3",
"vite": "^6.4.3",
"vue-tsc": "^2.2.12"
}
}
+667
View File
@@ -0,0 +1,667 @@
<template>
<el-config-provider>
<main 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="后台模块">
<button class="nav-item active" type="button">
<Sparkles :size="18" />
<span>保洁运营</span>
</button>
<button class="nav-item" type="button" disabled>
<Store :size="18" />
<span>门店</span>
</button>
<button class="nav-item" type="button" disabled>
<RadioTower :size="18" />
<span>设备</span>
</button>
<button class="nav-item" type="button" disabled>
<WalletCards :size="18" />
<span>支付</span>
</button>
</nav>
</aside>
<section class="workspace">
<header class="topbar">
<div>
<p class="eyebrow">M08-B</p>
<h2>保洁任务与结算</h2>
</div>
<div class="token-box">
<el-input
v-model="tokenDraft"
type="password"
show-password
placeholder="后台访问令牌"
autocomplete="off"
@keyup.enter="saveToken"
/>
<el-tooltip content="保存令牌" placement="bottom">
<el-button :icon="Save" type="primary" @click="saveToken" />
</el-tooltip>
</div>
</header>
<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="true"
@close="lastError = ''"
/>
<el-alert
v-if="lastMessage"
class="alert-line"
:title="lastMessage"
type="success"
show-icon
:closable="true"
@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"
/>
</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="handoff">
<CleaningFieldHandoffPanel />
</el-tab-pane>
</el-tabs>
</section>
</main>
</el-config-provider>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
ClipboardCheck,
Download,
ListTodo,
RadioTower,
RotateCcw,
Save,
Send,
Sparkles,
Store,
WalletCards
} from '@lucide/vue';
import CleaningTasksPanel from './components/CleaningTasksPanel.vue';
import CleaningSettlementsPanel from './components/CleaningSettlementsPanel.vue';
import CleaningStatisticsPanel from './components/CleaningStatisticsPanel.vue';
import CleanersPanel from './components/CleanersPanel.vue';
import CleaningFieldHandoffPanel from './components/CleaningFieldHandoffPanel.vue';
import {
ApiError,
assignCleaningTask,
completeCleaningTask,
confirmCleaningSettlement,
createStaffUser,
exemptCleaningTask,
executeWechatTransfer,
generateCleaningSettlement,
getCleaningStatistics,
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
resetStaffSessions,
syncWechatTransfer,
updateStaffUser
} from './api';
import type {
CleaningSettlement,
CleaningStatistics,
CleaningTask,
ManagedUser,
PageResult,
PayoutStateFilter,
SettlementStatus,
TaskStatus,
TransferMode,
UserStatus
} from './types';
import { money } from './format';
const tokenDraft = ref(localStorage.getItem('qipai.admin.token') || '');
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 session = computed(() => ({ token: tokenDraft.value }));
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,
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 saveToken() {
localStorage.setItem('qipai.admin.token', tokenDraft.value.trim());
ElMessage.success('已保存');
}
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 () => {
const result = await listCleaningTasks(session.value, {
page: tasks.page,
pageSize: tasks.pageSize,
status: taskStatus.value || undefined,
storeId: taskFilters.storeId || undefined,
cleanerUserId: taskFilters.cleanerUserId || undefined
});
Object.assign(tasks, result);
}, '任务已刷新');
loading.tasks = false;
}
async function loadSettlements() {
loading.settlements = true;
await runAction(async () => {
const result = 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
});
Object.assign(settlements, result);
}, '结算已刷新');
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'],
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', money(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()]);
}, `\u5df2\u6279\u91cf\u6807\u8bb0\u514d\u6e05\u6d01 ${input.taskIds.length} \u4e2a\u4efb\u52a1`);
}
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 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 Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
}
});
</script>
+339
View File
@@ -0,0 +1,339 @@
import type {
CleaningSettlement,
CleaningSettlementDetail,
CleaningStatistics,
CleaningTask,
CleaningTaskEvent,
CleaningTaskMember,
ManagedUser,
PageResult,
PayoutStateFilter,
StaffRole,
SettlementStatus,
TaskStatus,
TransferMode,
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;
}
async function request<T>(
session: ApiSession,
path: string,
options: RequestInit = {}
): Promise<T> {
if (!session.token.trim()) {
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
}
const headers = new Headers(options.headers);
headers.set('authorization', `Bearer ${session.token.trim()}`);
if (options.body && !headers.has('content-type')) {
headers.set('content-type', 'application/json');
}
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;
}
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 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)
});
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 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 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 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 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)
}
);
}
+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
@@ -0,0 +1,868 @@
<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 SettlementStatus | '')"
/>
<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-select
v-model="filterDraft.payoutState"
class="filter-select"
clearable
placeholder="转账状态"
@change="submitFilters"
@clear="submitFilters"
>
<el-option
v-for="option in payoutStateOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
</div>
<div class="toolbar-actions">
<el-button
:icon="RefreshCw"
:disabled="selectedSyncableCount === 0"
@click="submitBatchSync"
>
批量同步
</el-button>
<el-button :icon="Download" :disabled="items.length === 0" @click="exportSettlements">
导出
</el-button>
<el-button :icon="ListChecks" @click="openCandidates">
候选
</el-button>
<el-button :icon="FilePlus2" type="primary" @click="generateDialog.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"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="canSelectSettlement" />
<el-table-column prop="settlementNo" label="结算单" min-width="190" fixed>
<template #default="{ row }">
<div class="stack">
<strong>{{ row.settlementNo }}</strong>
<span>{{ row.cleanerName }} · {{ compactText(row.storeName) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="115">
<template #default="{ row }">
<el-tag :type="settlementTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="taskCount" label="任务" width="80" />
<el-table-column prop="totalRewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.totalRewardCents) }}</template>
</el-table-column>
<el-table-column prop="payoutState" label="转账状态" width="130">
<template #default="{ row }">{{ compactText(row.payoutState) }}</template>
</el-table-column>
<el-table-column prop="payoutReference" label="流水" min-width="150">
<template #default="{ row }">{{ compactText(row.payoutReference) }}</template>
</el-table-column>
<el-table-column prop="payoutError" label="失败原因" min-width="150">
<template #default="{ row }">{{ compactText(row.payoutError) }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建" width="130">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" 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="BadgeCheck"
:disabled="row.status !== 'DRAFT'"
@click="$emit('confirm', { settlementId: row.id, note: '后台确认结算' })"
>
确认
</el-button>
<el-button
size="small"
:icon="ClipboardCheck"
:disabled="row.status !== 'CONFIRMED'"
@click="openPreflight(row)"
>
预检
</el-button>
<el-button
size="small"
type="primary"
:icon="Send"
:disabled="row.status !== 'CONFIRMED'"
@click="openTransfer(row)"
>
转账
</el-button>
<el-button
size="small"
:icon="RefreshCw"
:disabled="row.status !== 'CONFIRMED'"
@click="$emit('sync-transfer', { settlementId: row.id, note: '后台同步微信转账状态' })"
>
同步
</el-button>
<el-button
size="small"
type="danger"
:icon="CircleAlert"
:disabled="row.status !== 'CONFIRMED'"
@click="openFailure(row)"
>
失败
</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="generateDialog.open" title="生成结算单" width="420px">
<el-form label-position="top">
<el-form-item label="保洁员">
<el-select
v-model="generateDialog.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="门店 ID">
<el-input v-model="generateDialog.storeId" inputmode="numeric" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="generateDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="generateDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitGenerate">生成</el-button>
</template>
</el-dialog>
<el-dialog v-model="candidateDialog.open" title="待结算候选任务" width="760px">
<div class="dialog-stack">
<div class="detail-grid">
<span>候选任务<strong>{{ candidateDialog.total }}</strong></span>
<span>当前页金额<strong>{{ money(candidatePageRewardCents) }}</strong></span>
<span>门店筛选<strong>{{ compactText(filterDraft.storeId || '全部') }}</strong></span>
<span>保洁员筛选<strong>{{ compactText(filterDraft.cleanerUserId || '全部') }}</strong></span>
</div>
<el-table :data="candidateDialog.items" size="small" v-loading="candidateDialog.loading">
<el-table-column prop="taskNo" label="任务" min-width="170">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ compactText(row.orderNo) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="房间" min-width="150">
<template #default="{ row }">
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
</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="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="candidateDialog.page"
:page-size="candidateDialog.pageSize"
:total="candidateDialog.total"
@current-change="changeCandidatePage"
/>
</div>
</div>
<template #footer>
<el-button :icon="Download" :disabled="candidateDialog.items.length === 0" @click="exportCandidates">
导出候选
</el-button>
<el-button @click="candidateDialog.open = false">关闭</el-button>
<el-button type="primary" @click="useCandidateFilters">用当前筛选生成</el-button>
</template>
</el-dialog>
<el-dialog v-model="transferDialog.open" title="微信转账" width="420px">
<el-form label-position="top">
<el-form-item label="模式">
<el-radio-group v-model="transferDialog.mode">
<el-radio-button label="API">API</el-radio-button>
<el-radio-button label="MOCK">MOCK</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="transferDialog.mode === 'API'"
class="transfer-preflight-alert"
:title="transferPreflightTitle"
:type="transferDialog.preflight?.ready ? 'success' : 'warning'"
show-icon
:closable="false"
/>
<el-table
v-if="transferDialog.mode === 'API' && transferDialog.preflight"
:data="transferDialog.preflight.checks"
size="small"
class="preflight-table"
max-height="220"
>
<el-table-column prop="key" label="检查项" width="160" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="preflightTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="220" />
</el-table>
<el-form-item label="备注">
<el-input v-model="transferDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialog.open = false">取消</el-button>
<el-button
type="primary"
:loading="transferDialog.preflightLoading"
:disabled="!transferCanSubmit"
@click="submitTransfer"
>
发起
</el-button>
</template>
</el-dialog>
<el-dialog v-model="preflightDialog.open" title="微信转账预检" width="560px">
<div v-if="preflightDialog.result" class="preflight-summary">
<el-alert
:title="preflightDialog.result.ready ? '真实转账前置条件已通过' : '仍有阻断项需要处理'"
:type="preflightDialog.result.ready ? 'success' : 'warning'"
show-icon
:closable="false"
/>
<div class="preflight-meta">
<span>结算单 {{ preflightDialog.result.settlement.settlementNo }}</span>
<span>商户 {{ compactText(preflightDialog.result.account.merchantIdMasked) }}</span>
<span>场景 {{ compactText(preflightDialog.result.credential.transferSceneId) }}</span>
</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="凭据引用">
{{ compactText(preflightDialog.result.account.credentialRefMasked) }}
</el-descriptions-item>
<el-descriptions-item label="授权状态">
{{ compactText(preflightDialog.result.account.authorizationStatus) }}
</el-descriptions-item>
<el-descriptions-item label="账户范围">
{{ preflightDialog.result.account.storeScoped ? '门店' : '租户' }}
</el-descriptions-item>
<el-descriptions-item label="AppID">
{{ configuredText(preflightDialog.result.credential.appIdPresent) }}
</el-descriptions-item>
<el-descriptions-item label="商户证书序列号">
{{ configuredText(preflightDialog.result.credential.serialNoPresent) }}
</el-descriptions-item>
<el-descriptions-item label="场景报备">
{{ preflightDialog.result.credential.reportInfoCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="通知URL">
{{ configuredText(preflightDialog.result.credential.transferNotifyUrlConfigured) }}
</el-descriptions-item>
<el-descriptions-item label="平台证书">
{{ preflightDialog.result.credential.platformCertificateCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="保洁员OpenID">
{{ preflightDialog.result.cleaner.openidConfigured ? '已绑定' : '缺失' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="preflightDialog.result.checks" size="small" class="preflight-table">
<el-table-column prop="key" label="检查项" width="170" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="preflightTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="220" />
</el-table>
</div>
<template #footer>
<el-button
:icon="Download"
:disabled="!preflightDialog.result"
@click="exportPreflight"
>
导出预检
</el-button>
<el-button @click="preflightDialog.open = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="failureDialog.open" title="记录失败" width="420px">
<el-form label-position="top">
<el-form-item label="失败原因">
<el-input v-model="failureDialog.error" />
</el-form-item>
<el-form-item label="外部流水">
<el-input v-model="failureDialog.payoutReference" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="failureDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="failureDialog.open = false">取消</el-button>
<el-button type="danger" @click="submitFailure">记录</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
<div v-if="detailDialog.detail" class="dialog-stack">
<div class="detail-grid">
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
<span>保洁员<strong>{{ detailDialog.detail.settlement.cleanerName }}</strong></span>
<span>金额<strong>{{ money(detailDialog.detail.settlement.totalRewardCents) }}</strong></span>
<span>状态<strong>{{ detailDialog.detail.settlement.status }}</strong></span>
</div>
<el-table :data="detailDialog.detail.items" size="small" v-loading="detailDialog.loading">
<el-table-column prop="taskNo" label="任务" min-width="170">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ compactText(row.orderNo) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="房间" min-width="150">
<template #default="{ row }">
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
</template>
</el-table-column>
<el-table-column prop="cleanerName" label="成员" width="130" />
<el-table-column prop="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
</el-table>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="转账状态">
{{ compactText(detailDialog.detail.settlement.payoutState) }}
</el-descriptions-item>
<el-descriptions-item label="外部流水">
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
</el-descriptions-item>
<el-descriptions-item label="微信确认参数" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
</el-descriptions-item>
<el-descriptions-item label="失败原因" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutError) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ compactText(detailDialog.detail.settlement.note) }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
导出详情
</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,
CircleAlert,
ClipboardCheck,
Download,
FilePlus2,
ListChecks,
RefreshCw,
Search,
Send
} from '@lucide/vue';
import { ElMessage } from 'element-plus';
import {
getCleaningSettlementDetail,
listCleaningSettlementCandidates,
preflightWechatTransfer
} from '../api';
import { compactText, money, shortDate } from '../format';
import type {
CleaningSettlement,
CleaningSettlementDetail,
CleaningTask,
ManagedUser,
PageResult,
PayoutStateFilter,
SettlementStatus,
TransferMode,
WechatTransferPreflight
} from '../types';
const props = defineProps<{
session: { token: string };
loading: boolean;
items: PageResult<CleaningSettlement>['items'];
cleaners: ManagedUser[];
total: number;
page: number;
pageSize: number;
status: SettlementStatus | '';
filters: { storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' };
}>();
const emit = defineEmits<{
refresh: [];
'status-change': [SettlementStatus | ''];
filter: [{ storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }];
'page-change': [number];
generate: [{ cleanerUserId: string; storeId?: string; note?: string }];
confirm: [{ settlementId: string; note?: string }];
transfer: [{ settlementId: string; mode: TransferMode; note?: string }];
'sync-transfer': [{ settlementId: string; note?: string }];
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
}>();
const statusOptions = [
{ label: '全部', value: '' },
{ label: '草稿', value: 'DRAFT' },
{ label: '待发', value: 'CONFIRMED' },
{ label: '已发', value: 'PAID' },
{ label: '取消', value: 'CANCELLED' }
];
const payoutStateOptions: Array<{ label: string; value: PayoutStateFilter }> = [
{ label: '未发起', value: 'NONE' },
{ label: '处理中', value: 'PROCESSING' },
{ label: '待确认', value: 'WAIT_USER_CONFIRM' },
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAIL' }
];
const filterDraft = reactive({
storeId: props.filters.storeId,
cleanerUserId: props.filters.cleanerUserId,
payoutState: props.filters.payoutState
});
const selectedSettlements = ref<CleaningSettlement[]>([]);
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
const transferDialog = reactive({
open: false,
settlementId: '',
mode: 'API' as TransferMode,
note: '',
preflightLoading: false,
preflight: null as WechatTransferPreflight | null
});
watch(
() => props.filters,
(value) => Object.assign(filterDraft, value),
{ deep: true }
);
const preflightDialog = reactive<{
open: boolean;
result: WechatTransferPreflight | null;
}>({
open: false,
result: null
});
const failureDialog = reactive({
open: false,
settlementId: '',
error: '',
payoutReference: '',
note: ''
});
const detailDialog = reactive<{
open: boolean;
loading: boolean;
detail: CleaningSettlementDetail | null;
}>({
open: false,
loading: false,
detail: null
});
const candidateDialog = reactive<{
open: boolean;
loading: boolean;
items: CleaningTask[];
total: number;
page: number;
pageSize: number;
}>({
open: false,
loading: false,
items: [],
total: 0,
page: 1,
pageSize: 10
});
const candidatePageRewardCents = computed(() => candidateDialog.items
.reduce((sum, item) => sum + item.rewardCents, 0));
const selectedSyncableSettlements = computed(() => selectedSettlements.value
.filter((settlement) => settlement.status === 'CONFIRMED'));
const selectedSyncableCount = computed(() => selectedSyncableSettlements.value.length);
const transferCanSubmit = computed(() => {
if (transferDialog.mode === 'MOCK') return true;
return !transferDialog.preflightLoading && Boolean(transferDialog.preflight?.ready);
});
const transferPreflightTitle = computed(() => {
if (transferDialog.preflightLoading) return '正在执行真实转账预检';
if (!transferDialog.preflight) return '请等待预检完成后再发起真实转账';
if (transferDialog.preflight.ready) return '真实转账前置条件已通过';
const failedCount = transferDialog.preflight.checks
.filter((check) => check.status === 'FAIL').length;
return `仍有 ${failedCount} 个阻断项需要处理`;
});
function settlementTag(status: SettlementStatus) {
if (status === 'PAID') return 'success';
if (status === 'CONFIRMED') return 'warning';
if (status === 'CANCELLED') return 'info';
return 'primary';
}
function preflightTag(status: string) {
if (status === 'PASS') return 'success';
if (status === 'WARN') return 'warning';
return 'danger';
}
function configuredText(value?: boolean) {
return value ? '已配置' : '缺失';
}
function canSelectSettlement(row: CleaningSettlement) {
return row.status === 'CONFIRMED';
}
function handleSelectionChange(rows: CleaningSettlement[]) {
selectedSettlements.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,
payoutState: filterDraft.payoutState
});
}
function submitGenerate() {
if (!generateDialog.cleanerUserId) {
ElMessage.warning('请选择保洁员');
return;
}
emit('generate', {
cleanerUserId: generateDialog.cleanerUserId,
storeId: generateDialog.storeId || undefined,
note: generateDialog.note
});
generateDialog.open = false;
}
async function openTransfer(row: CleaningSettlement) {
transferDialog.open = true;
transferDialog.settlementId = row.id;
transferDialog.mode = 'API';
transferDialog.note = '';
transferDialog.preflight = null;
transferDialog.preflightLoading = true;
try {
transferDialog.preflight = await preflightWechatTransfer(props.session, row.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '微信转账预检失败');
} finally {
transferDialog.preflightLoading = false;
}
}
function submitTransfer() {
emit('transfer', {
settlementId: transferDialog.settlementId,
mode: transferDialog.mode,
note: transferDialog.note
});
transferDialog.open = false;
}
function submitBatchSync() {
const settlementIds = selectedSyncableSettlements.value.map((settlement) => settlement.id);
if (!settlementIds.length) {
ElMessage.warning('请选择待发放结算单');
return;
}
emit('sync-transfer-many', {
settlementIds,
note: '后台批量同步微信转账状态'
});
}
async function openPreflight(row: CleaningSettlement) {
try {
preflightDialog.result = await preflightWechatTransfer(props.session, row.id);
preflightDialog.open = true;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '微信转账预检失败');
}
}
function openFailure(row: CleaningSettlement) {
failureDialog.open = true;
failureDialog.settlementId = row.id;
failureDialog.error = row.payoutError || '';
failureDialog.payoutReference = row.payoutReference || '';
failureDialog.note = '';
}
function submitFailure() {
emit('failure', {
settlementId: failureDialog.settlementId,
error: failureDialog.error,
payoutReference: failureDialog.payoutReference,
note: failureDialog.note
});
failureDialog.open = false;
}
async function openDetail(row: CleaningSettlement) {
detailDialog.open = true;
detailDialog.loading = true;
try {
detailDialog.detail = await getCleaningSettlementDetail(props.session, row.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '结算详情加载失败');
} finally {
detailDialog.loading = false;
}
}
async function openCandidates() {
candidateDialog.open = true;
candidateDialog.page = 1;
await loadCandidates();
}
async function changeCandidatePage(page: number) {
candidateDialog.page = page;
await loadCandidates();
}
async function loadCandidates() {
candidateDialog.loading = true;
try {
const result = await listCleaningSettlementCandidates(props.session, {
page: candidateDialog.page,
pageSize: candidateDialog.pageSize,
storeId: filterDraft.storeId || undefined,
cleanerUserId: filterDraft.cleanerUserId || undefined
});
candidateDialog.items = result.items;
candidateDialog.total = result.total;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '待结算候选加载失败');
} finally {
candidateDialog.loading = false;
}
}
function useCandidateFilters() {
generateDialog.open = true;
generateDialog.storeId = filterDraft.storeId;
generateDialog.cleanerUserId = filterDraft.cleanerUserId;
candidateDialog.open = false;
}
function exportSettlements() {
downloadCsv(`cleaning-settlements-${Date.now()}.csv`, [
['结算单', '状态', '保洁员', '门店', '任务数', '金额', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
...props.items.map((item) => [
item.settlementNo,
item.status,
item.cleanerName,
item.storeName || '',
String(item.taskCount),
money(item.totalRewardCents),
item.payoutState,
item.payoutReference,
item.payoutPackageInfo,
item.payoutError,
shortDate(item.createdAt)
])
]);
}
function exportCandidates() {
downloadCsv(`cleaning-settlement-candidates-${Date.now()}.csv`, [
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额', '完成时间'],
...candidateDialog.items.map((item) => [
item.taskNo,
item.orderNo || '',
item.storeName,
item.roomName || item.roomNo,
item.cleanerUserId || '',
money(item.rewardCents),
shortDate(item.completedAt)
])
]);
}
function exportSettlementDetail() {
const detail = detailDialog.detail;
if (!detail) return;
const settlement = detail.settlement;
downloadCsv(`cleaning-settlement-detail-${settlement.settlementNo}-${Date.now()}.csv`, [
['类别', '字段', '值'],
['settlement', '结算单号', settlement.settlementNo],
['settlement', '状态', settlement.status],
['settlement', '保洁员', settlement.cleanerName],
['settlement', '门店', settlement.storeName || ''],
['settlement', '任务数', String(settlement.taskCount)],
['settlement', '金额', money(settlement.totalRewardCents)],
['settlement', '转账状态', settlement.payoutState],
['settlement', '外部流水', settlement.payoutReference],
['settlement', '微信确认参数', settlement.payoutPackageInfo],
['settlement', '失败原因', settlement.payoutError],
['settlement', '备注', settlement.note],
['settlement', '创建时间', shortDate(settlement.createdAt)],
...detail.items.map((item) => [
'item',
item.taskNo,
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${money(item.rewardCents)} ${shortDate(item.completedAt)}`
])
]);
}
function exportPreflight() {
const result = preflightDialog.result;
if (!result) return;
downloadCsv(`cleaning-transfer-preflight-${result.settlement.settlementNo}-${Date.now()}.csv`, [
['类别', '字段', '值'],
['summary', 'ready', result.ready ? 'PASS' : 'BLOCKED'],
['settlement', 'settlementNo', result.settlement.settlementNo],
['settlement', 'status', result.settlement.status],
['settlement', 'amount', money(result.settlement.totalRewardCents)],
['settlement', 'cleanerUserId', result.settlement.cleanerUserId],
['settlement', 'storeId', result.settlement.storeId || ''],
['account', 'merchantId', compactText(result.account.merchantIdMasked)],
['account', 'credentialRef', compactText(result.account.credentialRefMasked)],
['account', 'authorizationStatus', compactText(result.account.authorizationStatus)],
['account', 'scope', result.account.storeScoped ? 'STORE' : 'TENANT'],
['credential', 'appId', configuredText(result.credential.appIdPresent)],
['credential', 'serialNo', configuredText(result.credential.serialNoPresent)],
['credential', 'transferSceneId', compactText(result.credential.transferSceneId)],
['credential', 'reportInfoCount', String(result.credential.reportInfoCount ?? 0)],
['credential', 'transferNotifyUrl', configuredText(result.credential.transferNotifyUrlConfigured)],
['credential', 'platformCertificateCount', String(result.credential.platformCertificateCount ?? 0)],
['cleaner', 'openid', result.cleaner.openidConfigured ? 'BOUND' : 'MISSING'],
...result.checks.map((check) => [
'check',
`${check.key}:${check.status}`,
check.message
])
]);
}
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>
@@ -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}%`,
money(row.pendingSettlementCents),
money(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', money(props.statistics.summary.rewardCents), '', '', '', ''],
['summary', '\u5f85\u7ed3\u7b97', money(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
['summary', '\u5f85\u53d1\u653e', money(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
['summary', '\u5df2\u53d1\u653e', money(props.statistics.summary.paidSettlementCents), '', '', '', ''],
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1', '', '', ''],
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), money(row.rewardCents), '', '', '']),
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d', '', '', ''],
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), money(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', '', '', '', ''],
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, money(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', '', '', '', ''],
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, money(row.rewardCents), '', '', '', '']),
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97', '\u5df2\u7ed3\u7b97'],
...props.statistics.members.map((row) => [
'\u6210\u5458\u5206\u8d26',
`${row.cleanerName || ''}(${row.cleanerUserId})`,
String(row.taskCount),
String(row.completedTaskCount),
String(row.rejectedTaskCount),
money(row.pendingSettlementCents),
money(row.settledRewardCents)
]),
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1'],
...cleanerPerformanceRows.value.map((row) => [
'\u7ee9\u6548\u6392\u884c',
String(row.rank),
`${row.cleanerName || ''}(${row.cleanerUserId})`,
String(row.taskCount),
`${row.completionRate}%`,
String(row.rejectedTaskCount),
money(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>
+837
View File
@@ -0,0 +1,837 @@
<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.status)"
@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="760px">
<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="驳回原因" :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">
<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,
removeCleaningTaskMember
} from '../api';
import { compactText, money, shortDate } from '../format';
import type {
CleaningTask,
CleaningTaskEvent,
CleaningTaskMember,
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;
task: CleaningTask | null;
events: CleaningTaskEvent[];
members: CleaningTaskMember[];
}>({
open: false,
loadingEvents: false,
loadingMembers: false,
task: null,
events: [],
members: []
});
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.status)));
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.status);
}
function canExempt(status: TaskStatus) {
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(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.loadingEvents = true;
detailDialog.loadingMembers = true;
try {
const [events, members] = await Promise.all([
listCleaningTaskEvents(props.session, row.id),
listCleaningTaskMembers(props.session, row.id)
]);
detailDialog.events = events;
detailDialog.members = members;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
} finally {
detailDialog.loadingEvents = false;
detailDialog.loadingMembers = 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),
money(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', '奖励金额', money(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 || ''} ${money(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)}`
]),
...task.photoUrls.map((url, index) => [
'photo',
`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,
money(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>
+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 : '-';
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import './styles.css';
import App from './App.vue';
createApp(App).use(ElementPlus).mount('#app');
+995
View File
@@ -0,0 +1,995 @@
:root {
color: #18212f;
background: #eef2f6;
font-family:
Inter, "PingFang SC", "Microsoft YaHei", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0.96), rgba(231, 237, 243, 0.96)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120' viewBox='0 0 120 120'%3E%3Cg fill='none' stroke='%23d5dde7' stroke-width='1'%3E%3Cpath d='M0 40h120M0 80h120M40 0v120M80 0v120'/%3E%3C/g%3E%3C/svg%3E");
}
button,
input,
textarea {
font: inherit;
}
.app-shell {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
background: #152033;
color: #f8fafc;
padding: 24px 18px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
}
.brand-mark {
display: grid;
width: 42px;
height: 42px;
place-items: center;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 8px;
background: #e8f1ff;
color: #152033;
font-weight: 800;
}
.brand h1,
.brand p,
.workspace h2,
.eyebrow {
margin: 0;
}
.brand h1 {
font-size: 17px;
font-weight: 800;
}
.brand p {
margin-top: 3px;
color: #aab7c8;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 8px;
margin-top: 30px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 0 12px;
border: 0;
border-radius: 8px;
background: transparent;
color: #cbd6e5;
cursor: pointer;
}
.nav-item.active {
background: #e8f1ff;
color: #162033;
font-weight: 700;
}
.nav-item:disabled {
color: #6f7d90;
cursor: not-allowed;
}
.workspace {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
}
.eyebrow {
color: #4d6b92;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
}
.workspace h2 {
margin-top: 4px;
font-size: 24px;
}
.token-box {
display: grid;
grid-template-columns: minmax(220px, 360px) 40px;
gap: 8px;
width: min(100%, 420px);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin: 20px 0 14px;
}
.metric-grid.compact {
margin: 0;
}
.metric {
min-height: 82px;
padding: 14px 16px;
border: 1px solid #d8e0ea;
border-radius: 8px;
background: #ffffff;
}
.metric span {
display: block;
color: #60708a;
font-size: 13px;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 26px;
}
.action-board {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 0 0 14px;
}
.action-tile {
display: grid;
grid-template-columns: 24px minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 48px;
padding: 10px 12px;
color: #1f3656;
text-align: left;
border: 1px solid #d8e0ea;
border-radius: 8px;
background: #ffffff;
cursor: pointer;
}
.action-tile:hover {
border-color: #8cb5ec;
background: #f6faff;
}
.action-tile span {
overflow: hidden;
color: #536781;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-tile strong {
color: #18212f;
font-size: 18px;
}
.alert-line {
margin-bottom: 10px;
}
.work-tabs {
min-width: 0;
}
.panel {
min-width: 0;
border: 1px solid #d8e0ea;
border-radius: 8px;
background: #ffffff;
overflow: hidden;
}
.panel-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border-bottom: 1px solid #e6ebf2;
overflow-x: auto;
}
.toolbar-actions,
.row-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.search-input {
width: min(240px, 100%);
}
.narrow-input {
width: 120px;
}
.filter-select {
width: 190px;
}
.profile-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cleaner-select {
width: 100%;
}
.option-row {
display: inline-grid;
gap: 2px;
min-width: 0;
margin-right: 8px;
vertical-align: middle;
}
.option-row strong,
.option-row span {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.option-row span {
color: #69788c;
font-size: 12px;
}
.data-table {
width: 100%;
}
.stack {
display: grid;
gap: 4px;
}
.stack strong {
font-size: 13px;
}
.stack span {
color: #69788c;
font-size: 12px;
}
.pager {
display: flex;
justify-content: flex-end;
padding: 12px 14px;
border-top: 1px solid #e6ebf2;
}
.compact-form {
display: grid;
gap: 4px;
}
.preflight-summary {
display: grid;
gap: 14px;
}
.preflight-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.preflight-meta span {
min-width: 0;
padding: 8px 10px;
overflow: hidden;
color: #334155;
text-overflow: ellipsis;
white-space: nowrap;
background: #f5f8fb;
border: 1px solid #e1e8f0;
border-radius: 8px;
}
.preflight-table {
width: 100%;
}
.transfer-preflight-alert {
margin-bottom: 12px;
}
.dialog-stack {
display: grid;
gap: 14px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f5f8fb;
}
.detail-header span {
color: #60708a;
font-size: 13px;
}
.member-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding-top: 2px;
}
.member-form .el-form-item {
margin-bottom: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.detail-grid span,
.timeline-grid span {
display: grid;
gap: 4px;
min-width: 0;
padding: 9px 10px;
color: #60708a;
font-size: 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f5f8fb;
}
.timeline-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.photo-grid .el-image {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f5f8fb;
}
.trend-list {
display: grid;
gap: 10px;
}
.trend-row {
display: grid;
grid-template-columns: 92px minmax(80px, 1fr) minmax(360px, 2.4fr);
gap: 12px;
align-items: center;
min-height: 38px;
}
.trend-date {
color: #334155;
font-size: 13px;
font-weight: 700;
}
.trend-track {
height: 8px;
overflow: hidden;
background: #e9eff5;
border-radius: 999px;
}
.trend-bar {
display: block;
height: 100%;
background: #2563eb;
border-radius: inherit;
}
.trend-meta {
display: flex;
gap: 10px;
align-items: center;
min-width: 0;
color: #60708a;
font-size: 12px;
white-space: nowrap;
}
.trend-meta strong {
color: #101828;
font-size: 14px;
}
.detail-grid strong,
.timeline-grid strong {
overflow: hidden;
color: #18212f;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.stats-filter {
display: grid;
grid-template-columns: minmax(160px, 210px) repeat(3, minmax(130px, 180px)) auto;
gap: 10px;
align-items: end;
}
.stats-filter .el-form-item {
margin-bottom: 0;
}
.stats-body {
display: grid;
gap: 14px;
padding: 14px;
}
.handoff-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.handoff-summary span {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 0 10px;
color: #52657f;
font-size: 13px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f5f8fb;
}
.handoff-summary strong {
color: #18212f;
}
.handoff-body {
display: grid;
gap: 14px;
padding: 14px;
}
.handoff-session {
padding: 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f8fafc;
}
.handoff-session .el-form {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.handoff-session .el-form-item {
margin-bottom: 0;
}
.handoff-category-progress {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.handoff-category-row {
display: grid;
gap: 8px;
min-width: 0;
padding: 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #ffffff;
}
.handoff-category-title,
.handoff-category-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.handoff-category-title span,
.handoff-category-meta {
color: #60708a;
font-size: 12px;
}
.handoff-stage-board {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #d6e4dc;
border-radius: 8px;
background: #f8fcfa;
}
.handoff-stage-board header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.handoff-stage-board h3 {
margin: 0;
font-size: 14px;
}
.handoff-stage-board header span {
color: #60708a;
font-size: 12px;
}
.handoff-stage-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.handoff-stage-card {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid #d6e4dc;
border-radius: 8px;
background: #ffffff;
color: #172033;
text-align: left;
cursor: pointer;
}
.handoff-stage-card.blocked {
border-color: #f0c7c7;
background: #fff7f7;
}
.handoff-stage-card.ready {
border-color: #b8dec4;
background: #f3fbf5;
}
.handoff-stage-card span,
.handoff-stage-card em,
.handoff-stage-card small {
color: #60708a;
font-size: 12px;
font-style: normal;
}
.handoff-stage-card strong {
font-size: 15px;
}
.handoff-owner-board {
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #f8fbff;
}
.handoff-owner-board header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.handoff-owner-board h3 {
margin: 0;
font-size: 14px;
}
.handoff-owner-board header span {
color: #60708a;
font-size: 12px;
}
.handoff-owner-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.handoff-owner-card {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #ffffff;
color: #172033;
text-align: left;
cursor: pointer;
}
.handoff-owner-card.active {
border-color: #2f6fed;
background: #f2f6ff;
}
.handoff-owner-card span {
color: #60708a;
font-size: 12px;
}
.handoff-verdict {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid #f0c7c7;
border-radius: 8px;
background: #fff7f7;
}
.handoff-verdict.ready {
border-color: #b8dec4;
background: #f3fbf5;
}
.handoff-verdict h3,
.handoff-verdict p {
margin: 0;
}
.handoff-verdict h3 {
font-size: 14px;
}
.handoff-verdict p {
margin-top: 4px;
color: #60708a;
font-size: 13px;
}
.handoff-status-select {
width: 130px;
}
.handoff-search {
width: 260px;
}
.handoff-bulk-form {
display: grid;
gap: 10px;
}
.handoff-bulk-form .el-form-item {
margin-bottom: 0;
}
.handoff-blockers {
border: 1px solid #f0c7c7;
border-radius: 8px;
overflow: hidden;
background: #fff7f7;
}
.handoff-blockers header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid #f0c7c7;
}
.handoff-blockers h3 {
margin: 0;
font-size: 14px;
}
.handoff-blockers header span,
.handoff-blockers li span {
color: #7f4b4b;
font-size: 12px;
}
.handoff-blockers ul {
display: grid;
gap: 8px;
margin: 0;
padding: 10px 12px;
list-style: none;
}
.handoff-blockers li {
display: grid;
gap: 3px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.stat-block {
min-width: 0;
border: 1px solid #e1e8f0;
border-radius: 8px;
overflow: hidden;
}
.stat-block header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-bottom: 1px solid #e1e8f0;
background: #f5f8fb;
}
.stat-block h3 {
margin: 0;
font-size: 14px;
}
.stat-block header span {
color: #60708a;
font-size: 12px;
}
.cleaner-performance-board {
background: #fbfcfe;
}
.cleaner-performance-board header > div {
display: grid;
gap: 3px;
}
.cleaner-performance-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
padding: 12px;
}
.cleaner-performance-card {
display: grid;
gap: 5px;
min-width: 0;
padding: 10px;
border: 1px solid #d8e2ef;
border-radius: 8px;
background: #ffffff;
color: #172033;
text-align: left;
cursor: pointer;
}
.cleaner-performance-card span,
.cleaner-performance-card em,
.cleaner-performance-card small {
color: #60708a;
font-size: 12px;
font-style: normal;
}
.cleaner-performance-card strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-button {
border-radius: 8px;
}
.el-tabs__nav-wrap::after {
height: 1px;
background: #d8e0ea;
}
.hidden-file-input {
display: none;
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr;
gap: 14px;
padding: 12px;
}
.nav-list {
grid-auto-flow: column;
grid-auto-columns: max-content;
align-content: center;
margin-top: 0;
overflow-x: auto;
}
.workspace {
padding: 16px;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.token-box {
grid-template-columns: minmax(0, 1fr) 40px;
width: 100%;
}
.metric-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.action-board {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.handoff-owner-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.handoff-stage-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cleaner-performance-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.trend-row {
grid-template-columns: 92px minmax(80px, 1fr);
}
.trend-meta {
grid-column: 1 / -1;
flex-wrap: wrap;
}
}
@media (max-width: 560px) {
.sidebar {
grid-template-columns: 1fr;
}
.metric-grid {
grid-template-columns: 1fr;
}
.handoff-owner-grid {
grid-template-columns: 1fr;
}
.handoff-stage-grid {
grid-template-columns: 1fr;
}
.cleaner-performance-grid {
grid-template-columns: 1fr;
}
.action-board {
grid-template-columns: 1fr;
}
.panel-toolbar {
align-items: stretch;
flex-direction: column;
}
.toolbar-actions {
justify-content: flex-end;
}
.handoff-search {
width: 100%;
}
.preflight-meta {
grid-template-columns: 1fr;
}
.member-form,
.detail-grid,
.timeline-grid,
.photo-grid,
.trend-row,
.handoff-session .el-form,
.handoff-category-progress,
.stats-filter,
.stats-grid {
grid-template-columns: 1fr;
}
.trend-meta {
grid-column: auto;
}
}
+229
View File
@@ -0,0 +1,229 @@
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 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 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;
photoUrls: string[];
rejectReason: string;
claimedAt?: string;
startedAt?: string;
submittedAt?: string;
completedAt?: string;
memberCount: number;
createdAt?: string;
updatedAt?: string;
}
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;
traceId: string;
note: string;
createdAt: string;
}
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;
confirmedAt: string | null;
paidAt: string | null;
payoutChannel: 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;
completedAt: string | null;
createdAt: string;
}
export interface CleaningSettlementDetail {
settlement: CleaningSettlement;
items: CleaningSettlementItem[];
}
export interface CleaningStatistics {
summary: {
taskTotal: number;
pendingReview: number;
active: number;
rejected: number;
exempted: number;
completed: number;
rewardCents: number;
pendingSettlementCents: 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[];
}
+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/**/*.vue"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
base: '/admin/',
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue'],
element: ['element-plus'],
icons: ['@lucide/vue']
}
}
}
},
server: {
port: 5173,
proxy: {
'/admin-api': {
target: 'http://127.0.0.1:3001',
changeOrigin: true
}
}
}
});
+25
View File
@@ -0,0 +1,25 @@
NODE_ENV=development
QIPAI_API_HOST=0.0.0.0
QIPAI_API_PORT=3001
QIPAI_API_VERSION=0.1.0
QIPAI_API_CORS_ORIGINS=https://api.txyundm.cn,http://localhost:5173
QIPAI_MYSQL_HOST=127.0.0.1
QIPAI_MYSQL_PORT=3306
QIPAI_MYSQL_DATABASE=qipai
QIPAI_MYSQL_USER=qipai_app
QIPAI_MYSQL_PASSWORD=
QIPAI_JWT_SECRET=<not-set>
QIPAI_ACCESS_TOKEN_TTL_SECONDS=900
QIPAI_SESSION_TTL_SECONDS=604800
QIPAI_WECHAT_APP_SECRETS={}
QIPAI_TEST_PAYMENT_ENABLED=false
QIPAI_WECHAT_PAY_CREDENTIALS={}
QIPAI_PROFIT_SHARE_MOCK_ENABLED=false
QIPAI_THIRD_PARTY_CREDENTIALS={}
QIPAI_MQTT_URL=mqtt://101.42.38.246:1883
QIPAI_MQTT_CLIENT_ID=qipai-backend
QIPAI_MQTT_USERNAME=
QIPAI_MQTT_PASSWORD=
QIPAI_MQTT_RECONNECT_MS=3000
QIPAI_MQTT_CONNECT_TIMEOUT_MS=10000
QIPAI_MQTT_MAX_MESSAGE_BYTES=65536
+2202
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@qipai/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"start:worker": "node dist/tasks/worker.js",
"db:migrate:plan": "npm run build && node dist/db/migrate-cli.js plan",
"db:migrate:up": "npm run build && node dist/db/migrate-cli.js up",
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/rate-limit": "^11.0.0",
"fastify": "^5.8.5",
"mqtt": "^5.15.1",
"mysql2": "^3.11.3",
"pino": "^9.4.0",
"sharp": "^0.34.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.17.6",
"tsx": "^4.19.2",
"typescript": "^5.6.3"
},
"overrides": {
"fast-uri": "3.1.2",
"toad-cache": "3.7.0"
}
}
+180
View File
@@ -0,0 +1,180 @@
import { randomUUID } from 'node:crypto';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import Fastify, { type FastifyInstance } from 'fastify';
import { loadConfig, type AppConfig } from './config.js';
import { registerHealthRoutes, type MqttHealthProvider } from './routes/health.js';
import {
registerPlatformBootstrapRoutes,
type PlatformConfigResolver
} from './routes/platform-bootstrap.js';
import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js';
import {
registerUserManagementRoutes,
type UserManagementRouteOptions
} from './routes/user-management.js';
import {
registerStoreRoomRoutes,
type StoreRoomRouteOptions
} from './routes/store-room-management.js';
import {
registerContentRoutes,
type ContentRouteOptions
} from './routes/content-management.js';
import {
registerStoreDiscoveryRoutes,
type StoreDiscoveryRouteOptions
} from './routes/store-discovery.js';
import {
registerStoreAccessRoutes,
type StoreAccessRouteOptions
} from './routes/store-access.js';
import { registerPricingRoutes, type PricingRouteOptions } from './routes/pricing.js';
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';
import { registerOrderShareRoutes, type OrderShareRouteOptions } from './routes/order-share.js';
import { registerPaymentRoutes, type PaymentRouteOptions } from './routes/payments.js';
import {
registerThirdPartyRoutes, type ThirdPartyRouteOptions
} from './routes/third-party.js';
import { registerDeviceRoutes, type DeviceRouteOptions } from './routes/devices.js';
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';
export interface BuildAppOptions {
config?: AppConfig;
platformConfigRepository?: PlatformConfigResolver;
auth?: AuthRouteOptions;
userManagement?: UserManagementRouteOptions;
storeRoom?: StoreRoomRouteOptions;
content?: ContentRouteOptions;
storeDiscovery?: StoreDiscoveryRouteOptions;
storeAccess?: StoreAccessRouteOptions;
pricing?: PricingRouteOptions;
orderState?: OrderStateRouteOptions;
orderQuery?: OrderQueryRouteOptions;
orderManagement?: OrderManagementRouteOptions;
orderShare?: OrderShareRouteOptions;
payment?: PaymentRouteOptions;
thirdParty?: ThirdPartyRouteOptions;
mqtt?: MqttHealthProvider;
devices?: DeviceRouteOptions;
deviceControl?: DeviceControlRouteOptions;
members?: MemberRouteOptions;
recharge?: RechargeRouteOptions;
cleaning?: CleaningRouteOptions;
}
declare module 'fastify' {
interface FastifyRequest {
traceId: string;
rawBody: string;
}
}
export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyInstance> {
const config = options.config ?? loadConfig();
const app = Fastify({
logger: {
level: config.nodeEnv === 'test' ? 'silent' : 'info'
},
genReqId: () => randomUUID()
});
app.decorateRequest('traceId', '');
app.decorateRequest('rawBody', '');
app.addHook('onRequest', async (request, reply) => {
const traceHeader = request.headers['x-trace-id'];
request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.id;
reply.header('x-trace-id', request.traceId);
});
await app.register(cors, {
origin: config.corsOrigins
});
await app.register(rateLimit, {
max: 120,
timeWindow: '1 minute'
});
app.setErrorHandler((error, request, reply) => {
request.log.error({ err: error, traceId: request.traceId }, 'request failed');
reply.status(500).send({
code: 'INTERNAL_ERROR',
message: 'Internal server error',
traceId: request.traceId
});
});
await registerHealthRoutes(app, config, options.mqtt);
if (options.platformConfigRepository) {
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
}
if (options.auth) {
await registerAuthRoutes(app, options.auth);
}
if (options.userManagement) {
await registerUserManagementRoutes(app, options.userManagement);
}
if (options.storeRoom) {
await registerStoreRoomRoutes(app, options.storeRoom);
}
if (options.content) {
await registerContentRoutes(app, options.content);
}
if (options.storeDiscovery) {
await registerStoreDiscoveryRoutes(app, options.storeDiscovery);
}
if (options.storeAccess) {
await registerStoreAccessRoutes(app, options.storeAccess);
}
if (options.pricing) {
await registerPricingRoutes(app, options.pricing);
}
if (options.orderState) {
await registerOrderStateRoutes(app, options.orderState);
}
if (options.orderQuery) {
await registerOrderQueryRoutes(app, options.orderQuery);
}
if (options.orderManagement) {
await registerOrderManagementRoutes(app, options.orderManagement);
}
if (options.orderShare) {
await registerOrderShareRoutes(app, options.orderShare);
}
if (options.payment) {
await registerPaymentRoutes(app, options.payment);
}
if (options.thirdParty) {
await registerThirdPartyRoutes(app, options.thirdParty);
}
if (options.devices) {
await registerDeviceRoutes(app, options.devices);
}
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);
}
return app;
}
+278
View File
@@ -0,0 +1,278 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export interface LoginContext {
tenantId: string;
platformAppId: string;
appId: string;
}
export interface AuthUser {
id: string;
tenantId: string;
userType: string;
status: string;
roleVersion: number;
nickname: string;
avatarUrl: string;
phone: string;
}
export interface AuthSession {
id: string;
tenantId: string;
platformAppId: string;
user: AuthUser;
expiresAt: Date;
}
interface LoginContextRow extends RowDataPacket {
tenantId: string;
platformAppId: string;
appId: string;
}
interface UserRow extends RowDataPacket {
id: string;
tenantId: string;
userType: string;
status: string;
roleVersion: number;
nickname: string;
avatarUrl: string;
phone: string;
}
interface SessionRow extends UserRow {
sessionId: string;
platformAppId: string;
expiresAt: Date;
}
export class AuthRepository {
constructor(private readonly pool: MySqlPool) {}
async resolveLoginContext(appId: string, tenantId?: string): Promise<LoginContext | null> {
const tenantFilter = tenantId ? 'AND ta.tenant_id = ?' : '';
const [rows] = await this.pool.execute<LoginContextRow[]>(
`SELECT ta.tenant_id AS tenantId, pa.id AS platformAppId, pa.appid AS appId
FROM qipai_platform_apps pa
INNER JOIN qipai_tenant_apps ta
ON ta.platform_app_id = pa.id
AND ta.status = 'ACTIVE' AND ta.deleted_at IS NULL
INNER JOIN qipai_tenants t
ON t.id = ta.tenant_id
AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
WHERE pa.appid = ? AND pa.status = 'ACTIVE' AND pa.deleted_at IS NULL
${tenantFilter}
ORDER BY ta.is_default DESC, ta.tenant_id ASC
LIMIT 2`,
tenantId ? [appId, tenantId] : [appId]
);
if (!tenantId && rows.length > 1) throw new Error('TENANT_SELECTION_REQUIRED');
const row = rows[0];
return row ? {
tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId),
appId: row.appId
} : null;
}
async loginWithWechat(input: {
context: LoginContext;
openid: string;
unionid?: string;
sessionId: string;
expiresAt: Date;
ip: string;
userAgent: string;
}): Promise<AuthSession> {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
let user = await this.findUserByIdentity(connection, input.context, input.openid);
if (!user) {
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_users (tenant_id, user_type, status)
VALUES (?, 'CUSTOMER', 'ACTIVE')`,
[input.context.tenantId]
);
const userId = String(created.insertId);
await connection.execute(
`INSERT INTO qipai_user_identities
(tenant_id, platform_app_id, user_id, openid, unionid)
VALUES (?, ?, ?, ?, ?)`,
[
input.context.tenantId,
input.context.platformAppId,
userId,
input.openid,
input.unionid ?? null
]
);
user = await this.findUserById(connection, input.context.tenantId, userId);
} else if (input.unionid) {
await connection.execute(
`UPDATE qipai_user_identities SET unionid = COALESCE(unionid, ?)
WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ?`,
[input.unionid, input.context.tenantId, input.context.platformAppId, user.id]
);
}
if (!user || user.status !== 'ACTIVE') throw new Error('USER_DISABLED');
await connection.execute(
`INSERT IGNORE INTO qipai_roles (tenant_id, code, name) VALUES
(?, 'CUSTOMER', '顾客'),
(?, 'CLEANER', '保洁员'),
(?, 'STAFF', '门店员工'),
(?, 'STORE_ADMIN', '门店管理员'),
(?, 'TENANT_ADMIN', '租户管理员'),
(?, 'PLATFORM_ADMIN', '平台管理员')`,
Array(6).fill(input.context.tenantId)
);
await connection.execute(
`INSERT IGNORE INTO qipai_user_roles (tenant_id, user_id, role_id)
SELECT ?, ?, id FROM qipai_roles
WHERE tenant_id = ? AND code = 'CUSTOMER' AND status = 'ACTIVE'`,
[input.context.tenantId, user.id, input.context.tenantId]
);
await connection.execute(
`INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
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 = 'STORE_ADMIN'
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
'store.operation.read', 'store.operation.write',
'device.read', 'device.write'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write'))
WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId]
);
await connection.execute(
`INSERT INTO qipai_auth_sessions
(id, tenant_id, platform_app_id, user_id, role_version, expires_at, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.sessionId,
input.context.tenantId,
input.context.platformAppId,
user.id,
user.roleVersion,
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 = ?`,
[input.context.tenantId, user.id]
);
await connection.commit();
return {
id: input.sessionId,
tenantId: input.context.tenantId,
platformAppId: input.context.platformAppId,
user,
expiresAt: input.expiresAt
};
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async validateSession(sessionId: string, tenantId: string, userId: string): Promise<AuthSession | null> {
const [rows] = await this.pool.execute<SessionRow[]>(
`SELECT s.id AS sessionId, s.tenant_id AS tenantId,
s.platform_app_id AS platformAppId, s.expires_at AS expiresAt,
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.tenant_id = ? AND s.user_id = ?
AND s.status = 'ACTIVE' AND s.revoked_at IS NULL
AND s.expires_at > UTC_TIMESTAMP(3)
AND u.status = 'ACTIVE'
AND u.role_version = s.role_version
LIMIT 1`,
[sessionId, tenantId, userId]
);
const row = rows[0];
if (!row) return null;
await this.pool.execute(
'UPDATE qipai_auth_sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?',
[sessionId]
);
return {
id: row.sessionId,
tenantId: String(row.tenantId),
platformAppId: String(row.platformAppId),
expiresAt: row.expiresAt,
user: mapUser(row)
};
}
async revokeSession(sessionId: string, reason = 'LOGOUT'): Promise<boolean> {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_auth_sessions
SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3), revoke_reason = ?
WHERE id = ? AND status = 'ACTIVE'`,
[reason, sessionId]
);
return result.affectedRows === 1;
}
private async findUserByIdentity(
connection: PoolConnection,
context: LoginContext,
openid: string
): Promise<AuthUser | null> {
const [rows] = await connection.execute<UserRow[]>(
`SELECT u.id, u.tenant_id AS tenantId, u.user_type AS userType, u.status,
u.role_version AS roleVersion, u.nickname,
u.avatar_url AS avatarUrl, u.phone
FROM qipai_user_identities i
INNER JOIN qipai_users u
ON u.id = i.user_id AND u.tenant_id = i.tenant_id AND u.deleted_at IS NULL
WHERE i.tenant_id = ? AND i.platform_app_id = ?
AND i.openid = ? AND i.deleted_at IS NULL
LIMIT 1 FOR UPDATE`,
[context.tenantId, context.platformAppId, openid]
);
return rows[0] ? mapUser(rows[0]) : null;
}
private async findUserById(
connection: PoolConnection,
tenantId: string,
userId: string
): Promise<AuthUser | null> {
const [rows] = await connection.execute<UserRow[]>(
`SELECT id, tenant_id AS tenantId, user_type AS userType, status,
role_version AS roleVersion, nickname, avatar_url AS avatarUrl, phone
FROM qipai_users WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[tenantId, userId]
);
return rows[0] ? mapUser(rows[0]) : null;
}
}
function mapUser(row: UserRow): 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
};
}
+25
View File
@@ -0,0 +1,25 @@
import type { AuthRepository, AuthSession } from './auth-repository.js';
import { verifyAccessToken } from './jwt.js';
export interface AuthenticatedRequest {
sessionId: string;
session: AuthSession;
}
export async function authenticateAccessToken(
authorization: string | undefined,
repository: Pick<AuthRepository, 'validateSession'>,
jwtSecret: string
): Promise<AuthenticatedRequest | null> {
if (!authorization?.startsWith('Bearer ')) return null;
try {
const claims = verifyAccessToken(authorization.slice(7), jwtSecret);
const session = await repository.validateSession(claims.sid, claims.tid, claims.sub);
if (!session || session.platformAppId !== claims.aid || session.user.roleVersion !== claims.rv) {
return null;
}
return { sessionId: claims.sid, session };
} catch {
return null;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
const claimsSchema = z.object({
iss: z.literal('qipai-api'),
aud: z.literal('qipai-miniapp'),
sub: z.string().regex(/^[1-9]\d*$/),
sid: z.string().uuid(),
tid: z.string().regex(/^[1-9]\d*$/),
aid: z.string().regex(/^[1-9]\d*$/),
rv: z.number().int().positive(),
iat: z.number().int(),
exp: z.number().int()
});
export type AccessTokenClaims = z.infer<typeof claimsSchema>;
function encode(value: string): string {
return Buffer.from(value).toString('base64url');
}
export function signAccessToken(
claims: Omit<AccessTokenClaims, 'iss' | 'aud' | 'iat' | 'exp'>,
secret: string,
ttlSeconds: number,
nowSeconds = Math.floor(Date.now() / 1000)
): string {
const header = encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const payload = encode(JSON.stringify({
iss: 'qipai-api',
aud: 'qipai-miniapp',
...claims,
iat: nowSeconds,
exp: nowSeconds + ttlSeconds
}));
const signature = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url');
return `${header}.${payload}.${signature}`;
}
export function verifyAccessToken(
token: string,
secret: string,
nowSeconds = Math.floor(Date.now() / 1000)
): AccessTokenClaims {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid access token.');
const [header, payload, signature] = parts;
const expected = createHmac('sha256', secret).update(`${header}.${payload}`).digest();
const actual = Buffer.from(signature, 'base64url');
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
throw new Error('Invalid access token signature.');
}
const parsedHeader = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
if (parsedHeader.alg !== 'HS256' || parsedHeader.typ !== 'JWT') {
throw new Error('Unsupported access token.');
}
const claims = claimsSchema.parse(
JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
);
if (claims.exp <= nowSeconds) throw new Error('Access token expired.');
return claims;
}
+107
View File
@@ -0,0 +1,107 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export const roleCodes = [
'CUSTOMER', 'CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'
] as const;
export interface AccessProfile {
roles: string[];
capabilities: string[];
storeIds: string[];
}
interface CodeRow extends RowDataPacket { code: string }
interface StoreRow extends RowDataPacket { storeId: string }
export class RbacRepository {
constructor(private readonly pool: MySqlPool) {}
async ensureCustomerRole(tenantId: string, userId: string): Promise<void> {
await this.pool.execute(
`INSERT IGNORE INTO qipai_roles (tenant_id, code, name) VALUES
(?, 'CUSTOMER', '顾客'), (?, 'CLEANER', '保洁员'), (?, 'STAFF', '门店员工'),
(?, 'STORE_ADMIN', '门店管理员'), (?, 'TENANT_ADMIN', '租户管理员'),
(?, 'PLATFORM_ADMIN', '平台管理员')`,
Array(6).fill(tenantId)
);
await this.pool.execute(
`INSERT IGNORE INTO qipai_user_roles (tenant_id, user_id, role_id)
SELECT ?, ?, id FROM qipai_roles
WHERE tenant_id = ? AND code = 'CUSTOMER' AND status = 'ACTIVE'`,
[tenantId, userId, tenantId]
);
await this.pool.execute(
`INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
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 = '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',
'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',
'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read'))
WHERE r.tenant_id = ?`,
[tenantId, tenantId]
);
}
async getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> {
const [roles] = await this.pool.execute<CodeRow[]>(
`SELECT DISTINCT r.code 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.deleted_at IS NULL
WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY r.code`,
[tenantId, userId]
);
const [permissions] = await this.pool.execute<CodeRow[]>(
`SELECT DISTINCT p.code 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.deleted_at IS NULL
INNER JOIN qipai_role_permissions rp
ON rp.tenant_id = ur.tenant_id AND rp.role_id = ur.role_id
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY p.code`,
[tenantId, userId]
);
const [stores] = await this.pool.execute<StoreRow[]>(
`SELECT DISTINCT store_id AS storeId FROM qipai_user_store_scopes
WHERE tenant_id = ? AND user_id = ? ORDER BY store_id`,
[tenantId, userId]
);
return {
roles: roles.map((row) => row.code),
capabilities: permissions.map((row) => row.code),
storeIds: stores.map((row) => String(row.storeId))
};
}
async grantStore(input: {
tenantId: string; userId: string; storeId: string; scopeType: 'STAFF' | 'CLEANER';
}): Promise<boolean> {
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_user_store_scopes
(tenant_id, user_id, store_id, scope_type)
SELECT ?, ?, s.id, ?
FROM qipai_stores s
INNER JOIN qipai_users u ON u.id = ? AND u.tenant_id = ?
WHERE s.id = ? AND s.tenant_id = ? AND s.deleted_at IS NULL`,
[
input.tenantId, input.userId, input.scopeType,
input.userId, input.tenantId, input.storeId, input.tenantId
]
);
return result.affectedRows === 1;
}
}
@@ -0,0 +1,369 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { AccessProfile } from './rbac-repository.js';
const assignableRoles = ['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN'] as const;
export type AssignableRole = typeof assignableRoles[number];
export interface ManagedUser {
id: string;
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;
}
export interface ManagementActor {
tenantId: string;
userId: string;
access: AccessProfile;
traceId: string;
ip: string;
userAgent: string;
}
export interface UserMutation {
nickname?: string;
phone?: string;
note?: string;
status?: 'ACTIVE' | 'DISABLED';
roles?: AssignableRole[];
storeIds?: string[];
}
interface UserRow extends RowDataPacket {
id: string;
userType: string;
status: string;
nickname: string;
avatarUrl: string;
phone: string;
lastIp: string | null;
note: string;
wechatMiniappBound: number;
registeredAt: Date;
lastLoginAt: Date | null;
}
interface CountRow extends RowDataPacket { total: number }
interface CodeRow extends RowDataPacket { code: string }
interface IdRow extends RowDataPacket { id: string }
export class UserManagementError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
export class UserManagementRepository {
constructor(private readonly pool: MySqlPool) {}
async listUsers(input: {
actor: ManagementActor;
page: number;
pageSize: number;
status?: 'ACTIVE' | 'DISABLED';
role?: AssignableRole;
search?: string;
}): Promise<{ items: ManagedUser[]; total: number }> {
const filters = ['u.tenant_id = ?', '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.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}%`;
params.push(like, like, input.search);
}
const scope = this.scopeClause(input.actor, 'u.id');
filters.push(scope.sql);
params.push(...scope.params);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(DISTINCT u.id) AS total FROM qipai_users u
WHERE ${filters.join(' AND ')}`,
params
);
const [rows] = await this.pool.execute<UserRow[]>(
`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
FROM qipai_users u
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]
);
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
})));
return { items, total: Number(counts[0]?.total ?? 0) };
}
async createStaff(actor: ManagementActor, input: Required<Pick<UserMutation, 'nickname' | 'phone'>> & UserMutation) {
return this.inTransaction(async (connection) => {
this.assertMutationAllowed(actor, input.roles ?? ['STAFF'], input.storeIds ?? []);
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_users (tenant_id, user_type, status, nickname, phone)
VALUES (?, 'STAFF', 'ACTIVE', ?, ?)`,
[actor.tenantId, input.nickname, input.phone]
);
const userId = String(created.insertId);
await this.applyMutation(connection, actor, userId, {
...input,
roles: input.roles ?? ['STAFF'],
storeIds: input.storeIds ?? []
}, 'STAFF_CREATED');
return { userId };
});
}
async updateUser(actor: ManagementActor, userId: string, input: UserMutation) {
return this.inTransaction(async (connection) => {
await this.lockManageableUser(connection, actor, userId);
this.assertMutationAllowed(actor, input.roles ?? [], input.storeIds ?? []);
await this.applyMutation(connection, actor, userId, input, 'USER_UPDATED');
return { userId };
});
}
async resetSessions(actor: ManagementActor, userId: string) {
return this.inTransaction(async (connection) => {
await this.lockManageableUser(connection, actor, userId);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'ADMIN_RESET'
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, userId]
);
await connection.execute(
`UPDATE qipai_users SET role_version = role_version + 1
WHERE tenant_id = ? AND id = ?`,
[actor.tenantId, userId]
);
await this.audit(connection, actor, 'USER_SESSIONS_RESET', userId, {
revokedSessions: result.affectedRows
});
return { userId, revokedSessions: result.affectedRows };
});
}
private async applyMutation(
connection: PoolConnection,
actor: ManagementActor,
userId: string,
input: UserMutation,
action: string
) {
if (input.nickname !== undefined || input.phone !== undefined || input.status !== undefined) {
await connection.execute(
`UPDATE qipai_users SET
nickname = COALESCE(?, nickname),
phone = COALESCE(?, phone),
status = COALESCE(?, status),
role_version = role_version + 1
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.nickname ?? null, input.phone ?? null, input.status ?? null, actor.tenantId, userId]
);
}
if (input.note !== undefined) {
await connection.execute(
`INSERT INTO qipai_user_admin_profiles (tenant_id, user_id, note, updated_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE note = VALUES(note), updated_by = VALUES(updated_by)`,
[actor.tenantId, userId, input.note, actor.userId]
);
}
if (input.roles !== undefined) {
await connection.execute(
'DELETE FROM qipai_user_roles WHERE tenant_id = ? AND user_id = ?',
[actor.tenantId, userId]
);
for (const role of input.roles) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_user_roles (tenant_id, user_id, role_id)
SELECT ?, ?, id FROM qipai_roles
WHERE tenant_id = ? AND code = ? AND status = 'ACTIVE' AND deleted_at IS NULL`,
[actor.tenantId, userId, actor.tenantId, role]
);
if (result.affectedRows !== 1) throw new UserManagementError('ROLE_NOT_FOUND');
}
await connection.execute(
`UPDATE qipai_users SET role_version = role_version + 1
WHERE tenant_id = ? AND id = ?`,
[actor.tenantId, userId]
);
}
if (input.storeIds !== undefined) {
await connection.execute(
'DELETE FROM qipai_user_store_scopes WHERE tenant_id = ? AND user_id = ?',
[actor.tenantId, userId]
);
for (const storeId of input.storeIds) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_user_store_scopes (tenant_id, user_id, store_id, scope_type)
SELECT ?, ?, id, 'STAFF' FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[actor.tenantId, userId, actor.tenantId, storeId]
);
if (result.affectedRows !== 1) throw new UserManagementError('STORE_NOT_FOUND');
}
}
if (input.status === 'DISABLED' || input.roles !== undefined || input.storeIds !== undefined) {
await connection.execute(
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
revoke_reason = 'ACCESS_CHANGED'
WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, userId]
);
}
await this.audit(connection, actor, action, userId, {
status: input.status,
roles: input.roles,
storeIds: input.storeIds,
noteChanged: input.note !== undefined
});
}
private assertMutationAllowed(actor: ManagementActor, roles: readonly string[], storeIds: readonly string[]) {
const isTenantAdmin = actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN');
if (!isTenantAdmin && roles.some((role) => role === 'TENANT_ADMIN' || role === 'PLATFORM_ADMIN')) {
throw new UserManagementError('ROLE_ASSIGNMENT_FORBIDDEN');
}
if (!isTenantAdmin && storeIds.some((storeId) => !actor.access.storeIds.includes(storeId))) {
throw new UserManagementError('STORE_SCOPE_FORBIDDEN');
}
if (roles.some((role) => !assignableRoles.includes(role as AssignableRole))) {
throw new UserManagementError('ROLE_ASSIGNMENT_FORBIDDEN');
}
}
private async lockManageableUser(connection: PoolConnection, actor: ManagementActor, userId: string) {
const scope = this.scopeClause(actor, 'u.id');
const [rows] = await connection.execute<IdRow[]>(
`SELECT u.id FROM qipai_users u
WHERE u.tenant_id = ? AND u.id = ? AND u.deleted_at IS NULL
AND ${scope.sql} FOR UPDATE`,
[actor.tenantId, userId, ...scope.params]
);
if (!rows[0]) throw new UserManagementError('USER_NOT_MANAGEABLE');
if (userId === actor.userId) throw new UserManagementError('SELF_ACCESS_CHANGE_FORBIDDEN');
}
private scopeClause(actor: ManagementActor, 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('staff.manage')) {
return { sql: '1 = 0', params: [] as string[] };
}
if (actor.access.storeIds.length === 0) return { sql: '1 = 0', params: [] as string[] };
return {
sql: `EXISTS (
SELECT 1 FROM qipai_user_store_scopes ms
WHERE ms.tenant_id = u.tenant_id AND ms.user_id = ${userExpression}
AND ms.store_id IN (${actor.access.storeIds.map(() => '?').join(',')})
)`,
params: actor.access.storeIds
};
}
private async getCodes(type: 'role' | 'store', tenantId: string, userId: string): Promise<string[]> {
const sql = type === 'role'
? `SELECT r.code FROM qipai_user_roles ur
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE ur.tenant_id = ? AND ur.user_id = ? ORDER BY r.code`
: `SELECT CAST(store_id AS CHAR) AS code FROM qipai_user_store_scopes
WHERE tenant_id = ? AND user_id = ? ORDER BY store_id`;
const [rows] = await this.pool.execute<CodeRow[]>(sql, [tenantId, userId]);
return rows.map((row) => row.code);
}
private async audit(
connection: PoolConnection,
actor: ManagementActor,
action: string,
userId: 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', ?, ?, 'USER', ?, ?, ?, ?, ?)`,
[
actor.tenantId, actor.userId, action, userId, actor.traceId,
actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)
]
);
}
private async inTransaction<T>(work: (connection: PoolConnection) => Promise<T>): 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 maskPhone(phone: string): string {
if (!phone) return '';
if (phone.length < 7) return `${phone.slice(0, 2)}***`;
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
}
export function maskIp(ip: string): 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]}.*.*` : '***';
}
+60
View File
@@ -0,0 +1,60 @@
export interface WechatCodeSession {
openid: string;
unionid?: string;
}
export interface WechatCodeExchange {
exchange(appId: string, code: string): Promise<WechatCodeSession>;
}
export class WechatApiError extends Error {
constructor(message: string) {
super(message);
this.name = 'WechatApiError';
}
}
export class WechatHttpClient implements WechatCodeExchange {
constructor(
private readonly appSecrets: Readonly<Record<string, string>>,
private readonly fetcher: typeof fetch = fetch
) {}
async exchange(appId: string, code: string): Promise<WechatCodeSession> {
const secret = this.appSecrets[appId];
if (!secret) throw new WechatApiError(`No WeChat secret configured for AppID ${appId}.`);
const url = new URL('https://api.weixin.qq.com/sns/jscode2session');
url.searchParams.set('appid', appId);
url.searchParams.set('secret', secret);
url.searchParams.set('js_code', code);
url.searchParams.set('grant_type', 'authorization_code');
const response = await this.fetcher(url);
if (!response.ok) throw new WechatApiError(`WeChat HTTP ${response.status}.`);
const payload = await response.json() as {
openid?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
};
if (!payload.openid || payload.errcode) {
throw new WechatApiError(`WeChat code exchange failed: ${payload.errcode ?? 'UNKNOWN'}.`);
}
return { openid: payload.openid, unionid: payload.unionid };
}
}
export function parseWechatAppSecrets(value: string): Readonly<Record<string, string>> {
if (!value.trim()) return {};
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('QIPAI_WECHAT_APP_SECRETS must be a JSON object.');
}
return Object.fromEntries(
Object.entries(parsed).map(([appId, secret]) => {
if (typeof secret !== 'string' || secret.length < 8) {
throw new Error(`Invalid WeChat secret for AppID ${appId}.`);
}
return [appId, secret];
})
);
}
@@ -0,0 +1,511 @@
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;
storeId: string | null;
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
totalRewardCents: number;
payoutChannel: 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,
'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 (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,
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');
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,
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',
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) {
if (error instanceof WechatPayError) {
await this.repository.recordSettlementPayoutFailure({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: outBillNo,
error: error.code,
note: input.note
});
}
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,
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',
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(outBillNo, transferBillNo);
const actor: CleaningActor = {
tenantId: settlement.tenantId,
userId: '0',
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId
};
const state = stringPayload(payload, 'state');
if (state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
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',
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,
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
payout_channel AS payoutChannel, 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(outBillNo: string, transferBillNo: string) {
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
`SELECT tenant_id AS tenantId, id, settlement_no AS settlementNo,
cleaner_user_id AS cleanerUserId, store_id AS storeId, status,
total_reward_cents AS totalRewardCents, payout_channel AS payoutChannel,
payout_reference AS payoutReference, payout_state AS payoutState,
payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements
WHERE payout_channel = 'WECHAT_TRANSFER' AND deleted_at IS NULL
AND payout_reference IN (${references.map(() => '?').join(',')})
ORDER BY updated_at DESC, id DESC LIMIT 1`,
references
);
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
return rows[0];
}
}
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
const normalized = settlementNo.replace(/[^A-Za-z0-9_-]/g, '');
return (normalized || `CLP${settlementId}`).slice(0, 32);
}
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
+97
View File
@@ -0,0 +1,97 @@
import { z } from 'zod';
const configSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
QIPAI_API_HOST: z.string().min(1).default('0.0.0.0'),
QIPAI_API_PORT: z.coerce.number().int().min(1).max(65535).default(3001),
QIPAI_API_VERSION: z.string().min(1).default('0.1.0'),
QIPAI_API_CORS_ORIGINS: z.string().default('https://api.txyundm.cn'),
QIPAI_MYSQL_HOST: z.string().min(1).default('127.0.0.1'),
QIPAI_MYSQL_PORT: z.coerce.number().int().min(1).max(65535).default(3306),
QIPAI_MYSQL_DATABASE: z.string().min(1).default('qipai'),
QIPAI_MYSQL_USER: z.string().min(1).default('qipai_app'),
QIPAI_MYSQL_PASSWORD: z.string().default(''),
QIPAI_MYSQL_CONNECTION_LIMIT: z.coerce.number().int().min(1).max(50).default(10),
QIPAI_JWT_SECRET: z.string().min(32).default('development-only-change-this-jwt-secret'),
QIPAI_ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().min(60).max(86400).default(900),
QIPAI_SESSION_TTL_SECONDS: z.coerce.number().int().min(300).max(2592000).default(604800),
QIPAI_WECHAT_APP_SECRETS: z.string().default('{}'),
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'),
QIPAI_MQTT_USERNAME: z.string().default(''),
QIPAI_MQTT_PASSWORD: z.string().default(''),
QIPAI_MQTT_RECONNECT_MS: z.coerce.number().int().min(1000).max(60000).default(3000),
QIPAI_MQTT_CONNECT_TIMEOUT_MS: z.coerce.number().int().min(1000).max(60000).default(10000),
QIPAI_MQTT_MAX_MESSAGE_BYTES: z.coerce.number().int().min(1024).max(1048576).default(65536)
});
export type AppConfig = ReturnType<typeof loadConfig>;
export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
const parsed = configSchema.parse(env);
if (
parsed.NODE_ENV === 'production'
&& parsed.QIPAI_JWT_SECRET === 'development-only-change-this-jwt-secret'
) {
throw new Error('QIPAI_JWT_SECRET must be explicitly configured in production.');
}
const mqttUsernameConfigured = parsed.QIPAI_MQTT_USERNAME.length > 0;
const mqttPasswordConfigured = parsed.QIPAI_MQTT_PASSWORD.length > 0;
if (mqttUsernameConfigured !== mqttPasswordConfigured) {
throw new Error('QIPAI_MQTT_USERNAME and QIPAI_MQTT_PASSWORD must be configured together.');
}
if (parsed.NODE_ENV === 'production' && !mqttUsernameConfigured) {
throw new Error('MQTT credentials must be explicitly configured in production.');
}
return {
nodeEnv: parsed.NODE_ENV,
host: parsed.QIPAI_API_HOST,
port: parsed.QIPAI_API_PORT,
version: parsed.QIPAI_API_VERSION,
corsOrigins: parsed.QIPAI_API_CORS_ORIGINS.split(',').map((item) => item.trim()).filter(Boolean),
mysql: {
host: parsed.QIPAI_MYSQL_HOST,
port: parsed.QIPAI_MYSQL_PORT,
database: parsed.QIPAI_MYSQL_DATABASE,
user: parsed.QIPAI_MYSQL_USER,
credential: parsed.QIPAI_MYSQL_PASSWORD,
passwordConfigured: parsed.QIPAI_MYSQL_PASSWORD.length > 0,
connectionLimit: parsed.QIPAI_MYSQL_CONNECTION_LIMIT
},
auth: {
jwtSecret: parsed.QIPAI_JWT_SECRET,
accessTokenTtlSeconds: parsed.QIPAI_ACCESS_TOKEN_TTL_SECONDS,
sessionTtlSeconds: parsed.QIPAI_SESSION_TTL_SECONDS,
wechatAppSecretsJson: parsed.QIPAI_WECHAT_APP_SECRETS
},
payment: {
testAdapterEnabled: parsed.NODE_ENV !== 'production'
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS,
profitShareMockEnabled: parsed.NODE_ENV !== 'production'
&& 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
},
mqtt: {
url: parsed.QIPAI_MQTT_URL,
clientId: parsed.QIPAI_MQTT_CLIENT_ID,
username: parsed.QIPAI_MQTT_USERNAME,
credential: parsed.QIPAI_MQTT_PASSWORD,
usernameConfigured: mqttUsernameConfigured,
passwordConfigured: mqttPasswordConfigured,
reconnectPeriodMs: parsed.QIPAI_MQTT_RECONNECT_MS,
connectTimeoutMs: parsed.QIPAI_MQTT_CONNECT_TIMEOUT_MS,
maxMessageBytes: parsed.QIPAI_MQTT_MAX_MESSAGE_BYTES
}
};
}
+220
View File
@@ -0,0 +1,220 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { StoredImage } from './media-storage.js';
export interface DecorationInput {
storeId: string;
templateCode: string;
schemaVersion: number;
content: {
components: Array<{
type: 'HERO' | 'NOTICE' | 'GALLERY' | 'CONTACT' | 'ROOM_LIST';
props: Record<string, unknown>;
}>;
};
}
export interface AdvertisementInput {
scopeType: 'PLATFORM' | 'TENANT' | 'STORE';
storeId?: string | null;
title: string;
imageAssetId: string;
targetType: 'NONE' | 'PAGE' | 'URL';
targetValue: string;
startsAt?: Date | null;
endsAt?: Date | null;
status: 'DRAFT' | 'ACTIVE' | 'INACTIVE';
sortOrder: number;
}
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;
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
status: string; sortOrder: number;
}
export class ContentError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class ContentRepository {
constructor(private readonly pool: MySqlPool) {}
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
if (storeId) this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_media_assets
(tenant_id, store_id, storage_path, public_url, mime_type, byte_size,
width, height, checksum_sha256, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[actor.tenantId, storeId ?? null, image.storagePath, image.publicUrl, image.mimeType,
image.byteSize, image.width, image.height, image.checksumSha256, actor.userId]
);
const assetId = String(result.insertId);
await this.audit(connection, actor, 'MEDIA_ASSET_REGISTERED', 'MEDIA_ASSET', assetId);
return { assetId, url: image.publicUrl };
});
}
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
await this.lockStore(connection, actor.tenantId, input.storeId);
const [versions] = await connection.execute<VersionRow[]>(
`SELECT COALESCE(MAX(version), 0) + 1 AS nextVersion
FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? FOR UPDATE`,
[actor.tenantId, input.storeId]
);
const version = Number(versions[0]?.nextVersion ?? 1);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_store_decorations
(tenant_id, store_id, template_code, schema_version, content_json,
status, version, created_by)
VALUES (?, ?, ?, ?, ?, 'DRAFT', ?, ?)`,
[actor.tenantId, input.storeId, input.templateCode, input.schemaVersion,
JSON.stringify(input.content), version, actor.userId]
);
await this.audit(connection, actor, 'DECORATION_DRAFT_CREATED', 'DECORATION', String(result.insertId));
return { decorationId: String(result.insertId), version };
});
}
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, storeId, decorationId]
);
if (!rows[0]) throw new ContentError('DECORATION_NOT_FOUND');
await connection.execute(
`UPDATE qipai_store_decorations SET status = 'ARCHIVED'
WHERE tenant_id = ? AND store_id = ? AND status = 'PUBLISHED'`,
[actor.tenantId, storeId]
);
await connection.execute(
`UPDATE qipai_store_decorations SET status = 'PUBLISHED',
published_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
[actor.tenantId, storeId, decorationId]
);
await this.audit(connection, actor, 'DECORATION_PUBLISHED', 'DECORATION', decorationId);
return { decorationId, published: true };
});
}
async listAdvertisements(actor: ManagementActor) {
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.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
INNER JOIN qipai_media_assets m ON m.id = a.image_asset_id AND m.tenant_id = a.tenant_id
WHERE a.tenant_id = ? AND a.deleted_at IS NULL AND ${scope.sql}
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) }));
}
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
this.assertAdScope(actor, input);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_advertisements
(tenant_id, scope_type, store_id, title, image_asset_id, target_type,
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`,
[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]
);
if (result.affectedRows !== 1) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
const advertisementId = String(result.insertId);
await this.audit(connection, actor, 'ADVERTISEMENT_CREATED', 'ADVERTISEMENT', advertisementId);
return { advertisementId };
});
}
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN');
if (input.scopeType === 'PLATFORM' && !actor.access.roles.includes('PLATFORM_ADMIN')) {
throw new ContentError('PLATFORM_AD_FORBIDDEN');
}
if (input.scopeType === 'TENANT' && !tenantManager) throw new ContentError('TENANT_AD_FORBIDDEN');
if (input.scopeType === 'STORE') {
if (!input.storeId) throw new ContentError('STORE_REQUIRED');
this.assertStoreScope(actor, input.storeId);
}
}
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.write')
|| !actor.access.storeIds.includes(storeId)) {
throw new ContentError('STORE_SCOPE_FORBIDDEN');
}
}
private adScope(actor: ManagementActor) {
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: "a.scope_type = 'TENANT'", params: [] as string[] };
return {
sql: `(a.scope_type = 'TENANT' OR (a.scope_type = 'STORE'
AND a.store_id IN (${actor.access.storeIds.map(() => '?').join(',')})))`,
params: actor.access.storeIds
};
}
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[tenantId, storeId]
);
if (!rows[0]) throw new ContentError('STORE_NOT_FOUND');
}
private async audit(
connection: PoolConnection, actor: ManagementActor,
action: string, resourceType: string, resourceId: string
) {
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())`,
[actor.tenantId, actor.userId, action, resourceType, resourceId,
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
);
}
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();
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, resolve, sep } from 'node:path';
import sharp from 'sharp';
export interface StoredImage {
storagePath: string;
publicUrl: string;
mimeType: 'image/webp';
byteSize: number;
width: number;
height: number;
checksumSha256: string;
}
export class MediaValidationError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MediaStorage {
constructor(
private readonly root: string,
private readonly publicBaseUrl = 'https://api.txyundm.cn/uploads'
) {}
async storeImage(input: {
tenantId: string;
storeId?: string;
originalName: string;
contentType: string;
body: Buffer;
}): Promise<StoredImage> {
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)) {
throw new MediaValidationError('IMAGE_TYPE_INVALID');
}
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
}
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');
result = await source
.rotate()
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
.webp({ quality: 82 })
.toBuffer();
} catch {
throw new MediaValidationError('IMAGE_DECODE_FAILED');
}
const outputMetadata = await sharp(result).metadata();
const relativeDirectory = ['tenants', input.tenantId, input.storeId ? `stores/${input.storeId}` : 'shared'];
const directory = resolve(this.root, ...relativeDirectory);
const safeRoot = resolve(this.root);
if (directory !== safeRoot && !directory.startsWith(`${safeRoot}${sep}`)) {
throw new MediaValidationError('IMAGE_PATH_INVALID');
}
await mkdir(directory, { recursive: true });
const fileName = `${randomUUID()}.webp`;
await writeFile(resolve(directory, fileName), result, { flag: 'wx' });
const urlPath = [...relativeDirectory, fileName].join('/');
return {
storagePath: urlPath,
publicUrl: `${this.publicBaseUrl}/${urlPath}`,
mimeType: 'image/webp',
byteSize: result.length,
width: outputMetadata.width ?? metadata.width ?? 0,
height: outputMetadata.height ?? metadata.height ?? 0,
checksumSha256: createHash('sha256').update(result).digest('hex')
};
}
}
+56
View File
@@ -0,0 +1,56 @@
export const MYSQL_UNSIGNED_INT_MAX = 4_294_967_295;
export interface LegacyMoneyOptions {
nullable?: boolean;
maxCents?: number;
field?: string;
}
function describeField(field: string | undefined): string {
return field ? ` for ${field}` : '';
}
export function legacyDecimalToCents(
value: unknown,
options: LegacyMoneyOptions = {}
): number | null {
const field = describeField(options.field);
if (value === null || value === undefined) {
if (options.nullable) {
return null;
}
throw new Error(`Legacy money value${field} cannot be null.`);
}
if (typeof value !== 'string' && typeof value !== 'number') {
throw new Error(`Legacy money value${field} must be a decimal string or number.`);
}
if (typeof value === 'number' && !Number.isFinite(value)) {
throw new Error(`Legacy money value${field} must be finite.`);
}
const decimal = String(value);
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(decimal);
if (!match) {
throw new Error(
`Legacy money value${field} must be a non-negative decimal with at most two fractional digits.`
);
}
const wholeCents = BigInt(match[1]) * 100n;
const fraction = (match[2] ?? '').padEnd(2, '0');
const cents = wholeCents + BigInt(fraction || '0');
const maxCents = options.maxCents ?? MYSQL_UNSIGNED_INT_MAX;
if (!Number.isSafeInteger(maxCents) || maxCents < 0) {
throw new Error('Legacy money maxCents must be a non-negative safe integer.');
}
if (cents > BigInt(maxCents)) {
throw new Error(`Legacy money value${field} exceeds the target cents column limit.`);
}
return Number(cents);
}
+241
View File
@@ -0,0 +1,241 @@
import type { MySqlPool } from './mysql.js';
import { legacyDecimalToCents } from './legacy-money.js';
type LegacyEntity = 'stores' | 'rooms' | 'orders' | 'devices';
export interface LegacyTableMapping {
table: string;
idColumn: string;
tenantColumn: string;
parentColumn?: string;
nameColumn?: string;
statusColumn?: string;
codeColumn?: string;
startColumn?: string;
endColumn?: string;
totalAmountColumn?: string;
paidAmountColumn?: string;
renewalAmountColumn?: string;
groupAmountColumn?: string;
refundAmountColumn?: string;
}
export interface LegacyReadOptions {
tenantId: number;
parentId?: number;
limit?: number;
}
export interface LegacyRecord {
legacyId: string;
tenantId: string;
parentId: string | null;
name: string | null;
code: string | null;
status: string | null;
startAt: Date | string | null;
endAt: Date | string | null;
totalAmountCents: number | null;
paidAmountCents: number | null;
renewalAmountCents: number | null;
groupAmountCents: number | null;
refundAmountCents: number | null;
}
export const defaultLegacyMappings: Record<LegacyEntity, LegacyTableMapping> = {
stores: {
table: 'member_store_info',
idColumn: 'id',
tenantColumn: 'tenant_id',
nameColumn: 'store_name',
statusColumn: 'status'
},
rooms: {
table: 'member_room_info',
idColumn: 'id',
tenantColumn: 'tenant_id',
parentColumn: 'store_id',
nameColumn: 'room_name',
codeColumn: 'room_no',
statusColumn: 'status'
},
orders: {
table: 'member_order_info',
idColumn: 'id',
tenantColumn: 'tenant_id',
parentColumn: 'room_id',
codeColumn: 'order_no',
statusColumn: 'status',
startColumn: 'start_time',
endColumn: 'end_time',
totalAmountColumn: 'price',
paidAmountColumn: 'pay_price',
renewalAmountColumn: 'renew_price',
groupAmountColumn: 'group_pay_price',
refundAmountColumn: 'refund_price'
},
devices: {
table: 'member_device_info',
idColumn: 'id',
tenantColumn: 'tenant_id',
parentColumn: 'room_id',
nameColumn: 'device_name',
codeColumn: 'device_id',
statusColumn: 'status'
}
};
const identifierPattern = /^[A-Za-z][A-Za-z0-9_]*$/;
function quoteIdentifier(identifier: string): string {
if (!identifierPattern.test(identifier)) {
throw new Error(`Unsafe legacy SQL identifier: ${identifier}`);
}
return `\`${identifier}\``;
}
function selectedColumn(column: string | undefined, alias: string): string {
return column ? `${quoteIdentifier(column)} AS ${quoteIdentifier(alias)}` : `NULL AS ${quoteIdentifier(alias)}`;
}
function normalizeLimit(limit = 100): number {
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
throw new Error('Legacy read limit must be an integer between 1 and 500.');
}
return limit;
}
interface LegacyQueryRow extends Omit<
LegacyRecord,
| 'totalAmountCents'
| 'paidAmountCents'
| 'renewalAmountCents'
| 'groupAmountCents'
| 'refundAmountCents'
> {
totalAmountDecimal: unknown;
paidAmountDecimal: unknown;
renewalAmountDecimal: unknown;
groupAmountDecimal: unknown;
refundAmountDecimal: unknown;
}
function normalizeMoney(
value: unknown,
mapping: LegacyTableMapping,
column: string | undefined,
nullable: boolean
): number | null {
if (!column) {
return null;
}
return legacyDecimalToCents(value, {
field: `${mapping.table}.${column}`,
nullable
});
}
function normalizeRecord(row: LegacyQueryRow, mapping: LegacyTableMapping): LegacyRecord {
const {
totalAmountDecimal,
paidAmountDecimal,
renewalAmountDecimal,
groupAmountDecimal,
refundAmountDecimal,
...record
} = row;
return {
...record,
totalAmountCents: normalizeMoney(
totalAmountDecimal,
mapping,
mapping.totalAmountColumn,
false
),
paidAmountCents: normalizeMoney(
paidAmountDecimal,
mapping,
mapping.paidAmountColumn,
true
),
renewalAmountCents: normalizeMoney(
renewalAmountDecimal,
mapping,
mapping.renewalAmountColumn,
true
),
groupAmountCents: normalizeMoney(
groupAmountDecimal,
mapping,
mapping.groupAmountColumn,
true
),
refundAmountCents: normalizeMoney(
refundAmountDecimal,
mapping,
mapping.refundAmountColumn,
true
)
};
}
export class LegacyReadRepository {
constructor(
private readonly pool: Pick<MySqlPool, 'query'>,
private readonly mappings: Record<LegacyEntity, LegacyTableMapping> = defaultLegacyMappings
) {}
listStores(options: LegacyReadOptions): Promise<LegacyRecord[]> {
return this.list('stores', options);
}
listRooms(options: LegacyReadOptions): Promise<LegacyRecord[]> {
return this.list('rooms', options);
}
listOrders(options: LegacyReadOptions): Promise<LegacyRecord[]> {
return this.list('orders', options);
}
listDevices(options: LegacyReadOptions): Promise<LegacyRecord[]> {
return this.list('devices', options);
}
private async list(entity: LegacyEntity, options: LegacyReadOptions): Promise<LegacyRecord[]> {
const mapping = this.mappings[entity];
const limit = normalizeLimit(options.limit);
const clauses = [`${quoteIdentifier(mapping.tenantColumn)} = ?`];
const parameters: Array<number> = [options.tenantId];
if (mapping.parentColumn && options.parentId !== undefined) {
clauses.push(`${quoteIdentifier(mapping.parentColumn)} = ?`);
parameters.push(options.parentId);
}
parameters.push(limit);
const sql = [
'SELECT',
`${quoteIdentifier(mapping.idColumn)} AS ${quoteIdentifier('legacyId')},`,
`${quoteIdentifier(mapping.tenantColumn)} AS ${quoteIdentifier('tenantId')},`,
`${selectedColumn(mapping.parentColumn, 'parentId')},`,
`${selectedColumn(mapping.nameColumn, 'name')},`,
`${selectedColumn(mapping.codeColumn, 'code')},`,
`${selectedColumn(mapping.statusColumn, 'status')},`,
`${selectedColumn(mapping.startColumn, 'startAt')},`,
`${selectedColumn(mapping.endColumn, 'endAt')},`,
`${selectedColumn(mapping.totalAmountColumn, 'totalAmountDecimal')},`,
`${selectedColumn(mapping.paidAmountColumn, 'paidAmountDecimal')},`,
`${selectedColumn(mapping.renewalAmountColumn, 'renewalAmountDecimal')},`,
`${selectedColumn(mapping.groupAmountColumn, 'groupAmountDecimal')},`,
selectedColumn(mapping.refundAmountColumn, 'refundAmountDecimal'),
`FROM ${quoteIdentifier(mapping.table)}`,
`WHERE ${clauses.join(' AND ')}`,
`ORDER BY ${quoteIdentifier(mapping.idColumn)} ASC`,
'LIMIT ?'
].join(' ');
const [rows] = await this.pool.query(sql, parameters);
return (rows as LegacyQueryRow[]).map((row) => normalizeRecord(row, mapping));
}
}
+49
View File
@@ -0,0 +1,49 @@
import { loadConfig } from '../config.js';
import { closeMySqlPool, createMySqlPool } from './mysql.js';
import {
executeMigrationPlan,
loadMigrationPlan,
type MigrationDirection
} from './migration-runner.js';
const command = process.argv[2] ?? 'plan';
const validCommands = new Set(['plan', 'up', 'verify', 'down']);
if (!validCommands.has(command)) {
console.error('Usage: migrate-cli.js <plan|up|verify|down>');
process.exitCode = 2;
} else {
const direction: MigrationDirection = command === 'plan' ? 'up' : command as MigrationDirection;
const plan = await loadMigrationPlan(direction);
if (command === 'plan') {
console.log(JSON.stringify({
mode: 'dry-run',
direction: plan.direction,
file: plan.file,
checksum: plan.checksum,
statementCount: plan.statements.length
}, null, 2));
} else {
const config = loadConfig();
if (!config.mysql.passwordConfigured) {
console.error('QIPAI_MYSQL_PASSWORD is required for live migration commands.');
process.exitCode = 2;
} else {
const pool = createMySqlPool(config);
try {
const result = await executeMigrationPlan(pool, plan);
console.log(JSON.stringify({
mode: 'live',
direction: result.direction,
file: result.file,
checksum: result.checksum,
statementCount: result.statements.length,
affectedRows: result.affectedRows
}, null, 2));
} finally {
await closeMySqlPool(pool);
}
}
}
}
+275
View File
@@ -0,0 +1,275 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { MySqlPool } from './mysql.js';
export type MigrationDirection = 'up' | 'verify' | 'down';
export interface MigrationPlan {
direction: MigrationDirection;
file: string;
checksum: string;
statements: readonly string[];
}
export interface MigrationExecutionResult extends MigrationPlan {
executed: boolean;
affectedRows: number;
}
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
up: [
'database/migrations/2026061601_m01b_core_schema.up.sql',
'database/migrations/2026061802_m01c_async_tasks.up.sql',
'database/migrations/2026061803_m02a_tenant_apps.up.sql',
'database/migrations/2026061804_m02b_wechat_auth.up.sql',
'database/migrations/2026061805_m02c_rbac.up.sql',
'database/migrations/2026061806_m02d_user_management.up.sql',
'database/migrations/2026061807_m03a_store_room_domain.up.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.up.sql',
'database/migrations/2026061809_m03c_store_discovery.up.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.up.sql',
'database/migrations/2026061811_m04a_pricing_reservations.up.sql',
'database/migrations/2026062012_m04b_order_state_machine.up.sql',
'database/migrations/2026062013_m04c_order_adjustments.up.sql',
'database/migrations/2026062014_m04d_order_shares.up.sql',
'database/migrations/2026062015_m05a_payment_domain.up.sql',
'database/migrations/2026062216_m05b_wechat_refunds.up.sql',
'database/migrations/2026062217_m05c_third_party.up.sql',
'database/migrations/2026062218_m05d_profit_sharing.up.sql',
'database/migrations/2026062219_m06b_device_topology.up.sql',
'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/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'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
'database/migrations/2026061802_m01c_async_tasks.verify.sql',
'database/migrations/2026061803_m02a_tenant_apps.verify.sql',
'database/migrations/2026061804_m02b_wechat_auth.verify.sql',
'database/migrations/2026061805_m02c_rbac.verify.sql',
'database/migrations/2026061806_m02d_user_management.verify.sql',
'database/migrations/2026061807_m03a_store_room_domain.verify.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.verify.sql',
'database/migrations/2026061809_m03c_store_discovery.verify.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.verify.sql',
'database/migrations/2026061811_m04a_pricing_reservations.verify.sql',
'database/migrations/2026062012_m04b_order_state_machine.verify.sql',
'database/migrations/2026062013_m04c_order_adjustments.verify.sql',
'database/migrations/2026062014_m04d_order_shares.verify.sql',
'database/migrations/2026062015_m05a_payment_domain.verify.sql',
'database/migrations/2026062216_m05b_wechat_refunds.verify.sql',
'database/migrations/2026062217_m05c_third_party.verify.sql',
'database/migrations/2026062218_m05d_profit_sharing.verify.sql',
'database/migrations/2026062219_m06b_device_topology.verify.sql',
'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/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'
],
down: [
'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',
'database/migrations/2026062220_m06c_iot_messages.down.sql',
'database/migrations/2026062219_m06b_device_topology.down.sql',
'database/migrations/2026062218_m05d_profit_sharing.down.sql',
'database/migrations/2026062217_m05c_third_party.down.sql',
'database/migrations/2026062216_m05b_wechat_refunds.down.sql',
'database/migrations/2026062015_m05a_payment_domain.down.sql',
'database/migrations/2026062014_m04d_order_shares.down.sql',
'database/migrations/2026062013_m04c_order_adjustments.down.sql',
'database/migrations/2026062012_m04b_order_state_machine.down.sql',
'database/migrations/2026061811_m04a_pricing_reservations.down.sql',
'database/migrations/2026061810_m03d_scene_wifi_access.down.sql',
'database/migrations/2026061809_m03c_store_discovery.down.sql',
'database/migrations/2026061808_m03b_decoration_ads_media.down.sql',
'database/migrations/2026061807_m03a_store_room_domain.down.sql',
'database/migrations/2026061806_m02d_user_management.down.sql',
'database/migrations/2026061805_m02c_rbac.down.sql',
'database/migrations/2026061804_m02b_wechat_auth.down.sql',
'database/migrations/2026061803_m02a_tenant_apps.down.sql',
'database/migrations/2026061802_m01c_async_tasks.down.sql',
'database/migrations/2026061601_m01b_core_schema.down.sql'
]
};
export function splitSqlStatements(sql: string): string[] {
const statements: string[] = [];
let current = '';
let quote: "'" | '"' | '`' | null = null;
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let index = 0; index < sql.length; index += 1) {
const character = sql[index];
const next = sql[index + 1];
if (lineComment) {
if (character === '\n') {
lineComment = false;
current += character;
}
continue;
}
if (blockComment) {
if (character === '*' && next === '/') {
blockComment = false;
index += 1;
}
continue;
}
if (!quote && character === '-' && next === '-' && (index === 0 || /\s/.test(sql[index - 1]))) {
lineComment = true;
index += 1;
continue;
}
if (!quote && character === '/' && next === '*') {
blockComment = true;
index += 1;
continue;
}
current += character;
if (quote) {
if (escaped) {
escaped = false;
} else if (character === '\\') {
escaped = true;
} else if (character === quote) {
if (next === quote) {
current += next;
index += 1;
} else {
quote = null;
}
}
continue;
}
if (character === "'" || character === '"' || character === '`') {
quote = character;
continue;
}
if (character === ';') {
const statement = current.slice(0, -1).trim();
if (statement) {
statements.push(statement);
}
current = '';
}
}
const trailing = current.trim();
if (trailing) {
statements.push(trailing);
}
if (quote || blockComment) {
throw new Error('Migration SQL contains an unterminated quote or comment.');
}
return statements;
}
export async function loadMigrationPlan(direction: MigrationDirection): Promise<MigrationPlan> {
const relativeFiles = migrationFiles[direction];
const sqlParts = await Promise.all(
relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
);
const sql = sqlParts.join('\n');
return {
direction,
file: relativeFiles.join(','),
checksum: createHash('sha256').update(sql).digest('hex'),
statements: splitSqlStatements(sql)
};
}
export async function executeMigrationPlan(
pool: Pick<MySqlPool, 'query'>,
plan: MigrationPlan,
dryRun = false
): Promise<MigrationExecutionResult> {
if (dryRun) {
return { ...plan, executed: false, affectedRows: 0 };
}
let affectedRows = 0;
for (const [index, statement] of plan.statements.entries()) {
const [result] = await pool.query(statement);
if (plan.direction === 'verify') {
const minimumRows = [
10, 26, 1,
2, 5, 1,
3, 5, 1,
3, 7, 1,
5, 3, 7, 1,
1, 3, 1,
3, 6, 13, 1,
3, 3, 1,
2, 2, 1,
3, 3, 1,
2, 1, 3, 3, 1,
2, 1, 3, 1,
2, 2, 1, 2, 1,
1, 8, 3, 1,
5, 8, 4, 1,
1, 5, 3, 1,
5, 6, 1,
3, 7, 5, 1,
5, 8, 5, 2, 1,
3, 3, 9, 1,
1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1,
5, 10, 7, 3, 1,
2, 8, 4, 3, 1,
2, 9, 5, 2, 1,
1, 7, 5, 1,
4, 1, 1, 1,
2, 1, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
`Migration verification statement ${index + 1} returned fewer than ${minimumRows} rows.`
);
}
}
if (result && typeof result === 'object' && 'affectedRows' in result) {
const value = Reflect.get(result, 'affectedRows');
if (typeof value === 'number') {
affectedRows += value;
}
}
}
return { ...plan, executed: true, affectedRows };
}
+29
View File
@@ -0,0 +1,29 @@
import mysql, { type Pool, type PoolOptions } from 'mysql2/promise';
import type { AppConfig } from '../config.js';
export type MySqlPool = Pool;
export function createMySqlPool(config: AppConfig): MySqlPool {
return mysql.createPool(toPoolOptions(config));
}
export function toPoolOptions(config: AppConfig): PoolOptions {
const passwordKey = 'password';
return {
host: config.mysql.host,
port: config.mysql.port,
database: config.mysql.database,
user: config.mysql.user,
[passwordKey]: config.mysql.credential,
waitForConnections: true,
connectionLimit: config.mysql.connectionLimit,
namedPlaceholders: true,
timezone: 'Z',
dateStrings: false
};
}
export async function closeMySqlPool(pool: MySqlPool): Promise<void> {
await pool.end();
}
@@ -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
};
}
}
@@ -0,0 +1,42 @@
import type { MqttTransport } from '../mqtt/mqtt-service.js';
import { generateCommandId, type IotMessageService } from './iot-message-service.js';
export class DeviceCommandService {
private sequence = 0;
constructor(
private readonly messages: Pick<IotMessageService,
'createCommand' | 'markPublished' | 'markPublishFailed'>,
private readonly transport: Pick<MqttTransport, 'publishDeviceCommand'>
) {}
async issue(input: {
tenantId: string; assetId: string; deviceId: string; storeId: string;
roomId?: string | null; orderId?: string | null; commandType: string;
payloadFactory: (commandId: string) => Record<string, unknown>;
traceId: string; expiresAt?: Date | null;
}) {
const commandId = generateCommandId(Date.now(), this.sequence++);
const payload = input.payloadFactory(commandId);
await this.messages.createCommand({
tenantId: input.tenantId,
assetId: input.assetId,
storeId: input.storeId,
roomId: input.roomId,
orderId: input.orderId,
commandId,
commandType: input.commandType,
payload,
traceId: input.traceId,
expiresAt: input.expiresAt
});
try {
await this.transport.publishDeviceCommand(input.deviceId, JSON.stringify(payload));
await this.messages.markPublished(input.tenantId, commandId);
return { commandId, status: 'PUBLISHED' as const };
} catch (error) {
await this.messages.markPublishFailed(input.tenantId, commandId, 'MQTT_PUBLISH_FAILED');
throw error;
}
}
}
@@ -0,0 +1,235 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { DeviceCommandService } from './device-command-service.js';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter
} from './jilian-adapters.js';
interface DeviceRow extends RowDataPacket {
id: string; deviceId: string; storeId: string; roomId: string | null;
deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
status: string;
}
export class DeviceControlError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class DeviceControlService {
private readonly controlBox = new JilianControlBoxAdapter();
private readonly smartSocket = new JilianSmartSocketAdapter();
private readonly subLock = new JilianSub1GLockAdapter();
constructor(
private readonly pool: MySqlPool,
private readonly commands: Pick<DeviceCommandService, 'issue'>
) {}
async controlPower(context: CommandContext, input: {
slot1?: 'on' | 'off'; slot2?: 'on' | 'off';
slot3?: 'on' | 'off'; slotall?: 'on' | 'off';
}) {
return this.issueControlBox(context, 'ConctolPower',
(id) => this.controlBox.controlPower({ id, ...input }));
}
async controlDoor(context: CommandContext, input: {
order: 'open' | 'close'; holdopen?: 0 | 1; delayTime?: number;
}) {
if (input.holdopen === 1 && !this.isManager(context.access)) {
throw new DeviceControlError('DEVICE_HOLD_OPEN_FORBIDDEN');
}
return this.issueControlBox(context, 'Crldoor',
(id) => this.controlBox.controlDoor({ id, ...input }));
}
async playTts(context: CommandContext, input: {
content: string; volume?: number; playCount?: number; priority?: number;
}) {
return this.issueControlBox(context, 'PlayTTS',
(id) => this.controlBox.playTts({ id, ...input }));
}
async stopTts(context: CommandContext) {
return this.issueControlBox(context, 'stopTTS', (id) => this.controlBox.stopTts(id));
}
async controlLed(context: CommandContext, minute: number) {
return this.issueControlBox(context, 'CrlLED',
(id) => this.controlBox.controlLed({ id, minute }));
}
async startTask(context: CommandContext, input: {
minute: number; type: 1 | 2 | 3; subID?: string;
holdopen?: 0 | 1; delayTime?: number;
}) {
return this.issueControlBox(context, 'task',
(id) => this.controlBox.startTask({ id, ...input }), context.orderId);
}
async extendTask(context: CommandContext, addminute: number) {
return this.issueControlBox(context, 'addtask',
(id) => this.controlBox.extendTask({ id, addminute }), context.orderId);
}
async cancelTask(context: CommandContext) {
return this.issueControlBox(context, 'canceltask',
(id) => this.controlBox.cancelTask(id), context.orderId);
}
async pairSubLock(context: CommandContext, timeout = 60) {
return this.issueControlBox(context, 'AddDevice',
(id) => this.subLock.pair({ id, timeout }));
}
async controlSubLock(context: CommandContext, input: {
subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
dangerConfirmation?: string;
}) {
if (['factoryreset'].includes(input.order)) {
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 (!context.access.roles.includes('PLATFORM_ADMIN')
|| input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') {
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
}
}
const { dangerConfirmation: _, ...vendorInput } = input;
return this.issueControlBox(context, 'CtrlDevice',
(id) => this.subLock.control({ id, ...vendorInput }));
}
async readSmartSocket(context: CommandContext, target: 'basicInfo' | 'workInfo') {
return this.issueSmartSocket(context, `socket:${target}`,
() => this.smartSocket.read(target));
}
async switchSmartSocket(context: CommandContext, input: {
on: boolean; slotNum?: number; orderId?: string | null;
}) {
return this.issueSmartSocket(context, input.on ? 'socket:on' : 'socket:off',
(id) => this.smartSocket.switch({ id, on: input.on, slotNum: input.slotNum }),
input.orderId ?? context.orderId);
}
async scheduleSmartSocket(context: CommandContext, input: {
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) {
return this.issueSmartSocket(context, 'clearTask',
(id) => this.smartSocket.clearTask({ id, taskNum }), context.orderId);
}
private async issueControlBox(
context: CommandContext,
commandType: string,
payloadFactory: (id: string) => Record<string, unknown>,
orderId?: string | null
) {
this.assertWriteScope(context.access, context.storeId);
const device = await this.resolveControlBox(context);
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
deviceId: device.deviceId,
storeId: context.storeId,
roomId: context.roomId,
orderId,
commandType,
payloadFactory,
traceId: context.traceId,
expiresAt: context.expiresAt
});
}
private async issueSmartSocket(
context: CommandContext,
commandType: string,
payloadFactory: (id: string) => 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');
return this.commands.issue({
tenantId: context.tenantId,
assetId: String(device.id),
deviceId: device.deviceId,
storeId: context.storeId,
roomId: context.roomId,
orderId,
commandType,
payloadFactory,
traceId: context.traceId,
expiresAt: context.expiresAt
});
}
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
FROM qipai_devices
WHERE tenant_id = ? AND store_id = ? AND room_id = ?
AND device_type = 'CONTROL_BOX' AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId, context.storeId, context.roomId]
);
if (!rows[0]) throw new DeviceControlError('CONTROL_BOX_NOT_BOUND');
return rows[0];
}
private async resolveSmartSocket(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
FROM qipai_devices
WHERE tenant_id = ? AND store_id = ? AND room_id = ?
AND device_type = 'SMART_SOCKET' AND deleted_at IS NULL
ORDER BY id LIMIT 1`,
[context.tenantId, context.storeId, context.roomId]
);
if (!rows[0]) throw new DeviceControlError('SMART_SOCKET_NOT_BOUND');
return rows[0];
}
private assertWriteScope(access: AccessProfile, storeId: string) {
if (access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('tenant.manage')) {
return;
}
if (!access.capabilities.includes('device.write') || !access.storeIds.includes(storeId)) {
throw new DeviceControlError('DEVICE_SCOPE_FORBIDDEN');
}
}
private isManager(access: AccessProfile) {
return access.roles.some((role) =>
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
);
}
}
export interface CommandContext {
tenantId: string;
storeId: string;
roomId: string;
orderId?: string | null;
traceId: string;
access: AccessProfile;
expiresAt?: Date | null;
}
+366
View File
@@ -0,0 +1,366 @@
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 DeviceType = 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
export interface DeviceAssetInput {
storeId: string;
roomId?: string | null;
deviceId: string;
imei?: string;
iccid?: string | null;
deviceType: DeviceType;
model: string;
firmwareVersion: string;
signalStrength?: number | null;
capabilities: string[];
}
interface IdRow extends RowDataPacket { id: string }
interface DeviceRow extends RowDataPacket {
id: string; storeId: string; roomId: string | null; deviceId: string; imei: string;
iccid: string | null; deviceType: DeviceType; model: string; firmwareVersion: string;
status: string; signalStrength: number | null; capabilities: string | string[] | null;
stateSnapshot: string | Record<string, unknown> | null; lastSeenAt: Date | null;
lastHeartbeatAt: Date | null; maintenanceStatus: string;
}
export class DeviceError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class DeviceRepository {
constructor(private readonly pool: MySqlPool) {}
async createAsset(actor: ManagementActor, input: DeviceAssetInput) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId ?? null);
try {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_devices
(tenant_id, store_id, room_id, device_id, imei, iccid, device_type, model,
firmware_version, signal_strength, capabilities)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.storeId, input.roomId ?? null, input.deviceId,
input.imei ?? '', input.iccid || null, input.deviceType, input.model,
input.firmwareVersion, input.signalStrength ?? null,
JSON.stringify([...new Set(input.capabilities)].sort())]
);
const assetId = String(result.insertId);
await this.audit(connection, actor, 'DEVICE_ASSET_CREATED', assetId, {
deviceType: input.deviceType, storeId: input.storeId, roomId: input.roomId ?? null
});
return { assetId };
} catch (error) {
if (isDuplicate(error)) throw new DeviceError('DEVICE_IDENTITY_CONFLICT');
throw error;
}
});
}
async listAssets(actor: ManagementActor, storeId?: string) {
if (storeId) this.assertStoreScope(actor, storeId, false);
const scope = this.scope(actor, 'd.store_id');
const params: Array<string> = [actor.tenantId, ...scope.params];
const storeFilter = storeId ? ' AND d.store_id = ?' : '';
if (storeId) params.push(storeId);
const [rows] = await this.pool.execute<DeviceRow[]>(
`SELECT d.id, d.store_id AS storeId, d.room_id AS roomId, d.device_id AS deviceId,
d.imei, d.iccid, d.device_type AS deviceType, d.model,
d.firmware_version AS firmwareVersion, d.status, d.signal_strength AS signalStrength,
d.capabilities, d.state_snapshot AS stateSnapshot,
d.last_seen_at AS lastSeenAt, d.last_heartbeat_at AS lastHeartbeatAt,
d.maintenance_status AS maintenanceStatus
FROM qipai_devices d
WHERE d.tenant_id = ? AND d.deleted_at IS NULL AND ${scope.sql}${storeFilter}
ORDER BY d.store_id, d.room_id, d.id`,
params
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
roomId: row.roomId === null ? null : String(row.roomId),
capabilities: parseJson<string[]>(row.capabilities, []),
stateSnapshot: parseJson<Record<string, unknown>>(row.stateSnapshot, {})
}));
}
async getTopology(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId, false);
const [assets, channelsResult, linksResult, alertsResult, maintenanceResult] = await Promise.all([
this.listAssets(actor, storeId),
this.pool.execute<RowDataPacket[]>(
`SELECT id, device_id AS assetId, room_id AS roomId, channel_code AS channelCode,
purpose, target_key AS targetKey, enabled
FROM qipai_device_channels
WHERE tenant_id = ? AND store_id = ? ORDER BY device_id, channel_code`,
[actor.tenantId, storeId]
),
this.pool.execute<RowDataPacket[]>(
`SELECT id, parent_device_id AS parentAssetId, child_device_id AS childAssetId,
room_id AS roomId, link_type AS linkType, sub_id AS subId, subtype, status
FROM qipai_device_links
WHERE tenant_id = ? AND store_id = ? ORDER BY parent_device_id, child_device_id`,
[actor.tenantId, storeId]
),
this.pool.execute<RowDataPacket[]>(
`SELECT id, device_id AS assetId, room_id AS roomId, alert_type AS alertType,
severity, status, summary, first_seen_at AS firstSeenAt,
last_seen_at AS lastSeenAt
FROM qipai_device_alerts
WHERE tenant_id = ? AND store_id = ? AND status = 'OPEN'
ORDER BY severity DESC, last_seen_at DESC`,
[actor.tenantId, storeId]
),
this.pool.execute<RowDataPacket[]>(
`SELECT id, device_id AS assetId, room_id AS roomId, record_type AS recordType,
status, description, created_at AS createdAt, resolved_at AS resolvedAt
FROM qipai_device_maintenance_records
WHERE tenant_id = ? AND store_id = ?
ORDER BY created_at DESC LIMIT 200`,
[actor.tenantId, storeId]
)
]);
return {
assets,
channels: normalizeIds(channelsResult[0]),
links: normalizeIds(linksResult[0]),
openAlerts: normalizeIds(alertsResult[0]),
maintenance: normalizeIds(maintenanceResult[0])
};
}
async bindChannel(actor: ManagementActor, input: {
assetId: string; storeId: string; roomId: string; channelCode: string; purpose: string;
}) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
const device = await this.lockDevice(connection, actor, input.assetId, input.storeId);
if (!['CONTROL_BOX', 'SMART_SOCKET'].includes(device.deviceType)) {
throw new DeviceError('DEVICE_CHANNEL_UNSUPPORTED');
}
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId);
const targetKey = `room:${input.roomId}:target:${input.purpose}`;
try {
await connection.execute(
`INSERT INTO qipai_device_channels
(tenant_id, device_id, store_id, room_id, channel_code, purpose, target_key)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.assetId, input.storeId, input.roomId,
input.channelCode, input.purpose, targetKey]
);
} catch (error) {
if (isDuplicate(error)) throw new DeviceError('DEVICE_CONTROL_TARGET_CONFLICT');
throw error;
}
await connection.execute(
'UPDATE qipai_devices SET room_id = ? WHERE tenant_id = ? AND id = ?',
[input.roomId, actor.tenantId, input.assetId]
);
await this.audit(connection, actor, 'DEVICE_CHANNEL_BOUND', input.assetId, {
roomId: input.roomId, channelCode: input.channelCode, purpose: input.purpose
});
return { assetId: input.assetId, targetKey };
});
}
async bindSubDevice(actor: ManagementActor, input: {
parentAssetId: string; childAssetId: string; storeId: string; roomId: string;
subId: string; subtype: string;
}) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
const parent = await this.lockDevice(connection, actor, input.parentAssetId, input.storeId);
const child = await this.lockDevice(connection, actor, input.childAssetId, input.storeId);
if (parent.deviceType !== 'CONTROL_BOX' || child.deviceType !== 'SUB_LOCK') {
throw new DeviceError('DEVICE_LINK_TYPE_INVALID');
}
await this.assertStoreAndRoom(connection, actor, input.storeId, input.roomId);
try {
await connection.execute(
`INSERT INTO qipai_device_links
(tenant_id, parent_device_id, child_device_id, store_id, room_id,
link_type, sub_id, subtype)
VALUES (?, ?, ?, ?, ?, 'SUB_1G', ?, ?)`,
[actor.tenantId, input.parentAssetId, input.childAssetId, input.storeId,
input.roomId, input.subId, input.subtype]
);
} catch (error) {
if (isDuplicate(error)) throw new DeviceError('DEVICE_LINK_CONFLICT');
throw error;
}
await connection.execute(
'UPDATE qipai_devices SET room_id = ? WHERE tenant_id = ? AND id = ?',
[input.roomId, actor.tenantId, input.childAssetId]
);
await this.audit(connection, actor, 'SUB_DEVICE_BOUND', input.childAssetId, {
parentAssetId: input.parentAssetId, roomId: input.roomId,
subId: maskIdentifier(input.subId), subtype: input.subtype
});
return { childAssetId: input.childAssetId, parentAssetId: input.parentAssetId };
});
}
async recordStatus(actor: ManagementActor, input: {
assetId: string; storeId: string; onlineStatus: 'ONLINE' | 'OFFLINE' | 'FAULT';
signalStrength?: number | null; firmwareVersion?: string; snapshot: Record<string, unknown>;
}) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
await this.lockDevice(connection, actor, input.assetId, input.storeId);
const capturedAt = new Date();
await connection.execute(
`UPDATE qipai_devices SET status = ?, signal_strength = ?,
firmware_version = COALESCE(NULLIF(?, ''), firmware_version),
state_snapshot = ?, last_seen_at = ?, last_heartbeat_at = ?
WHERE tenant_id = ? AND id = ?`,
[input.onlineStatus, input.signalStrength ?? null, input.firmwareVersion ?? '',
JSON.stringify(input.snapshot), capturedAt, capturedAt, actor.tenantId, input.assetId]
);
await connection.execute(
`INSERT INTO qipai_device_status_snapshots
(tenant_id, device_id, online_status, signal_strength, firmware_version, snapshot,
captured_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.assetId, input.onlineStatus, input.signalStrength ?? null,
input.firmwareVersion ?? '', JSON.stringify(input.snapshot), capturedAt]
);
return { assetId: input.assetId, capturedAt: capturedAt.toISOString() };
});
}
async addMaintenance(actor: ManagementActor, input: {
assetId: string; storeId: string; roomId?: string | null;
recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
status: 'OPEN' | 'RESOLVED'; description: string;
}) {
this.assertStoreScope(actor, input.storeId, true);
return this.transaction(async (connection) => {
await this.lockDevice(connection, actor, input.assetId, input.storeId);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_device_maintenance_records
(tenant_id, device_id, store_id, room_id, record_type, status, description,
created_by, resolved_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, input.assetId, input.storeId, input.roomId ?? null,
input.recordType, input.status, input.description, actor.userId,
input.status === 'RESOLVED' ? new Date() : null]
);
await connection.execute(
`UPDATE qipai_devices SET maintenance_status = ?
WHERE tenant_id = ? AND id = ?`,
[input.status === 'OPEN' ? 'MAINTENANCE' : 'NORMAL', actor.tenantId, input.assetId]
);
await this.audit(connection, actor, 'DEVICE_MAINTENANCE_RECORDED', input.assetId, {
recordType: input.recordType, status: input.status
});
return { maintenanceId: String(result.insertId) };
});
}
private assertStoreScope(actor: ManagementActor, storeId: string, write: boolean) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
const capability = write ? 'device.write' : 'device.read';
if (!actor.access.capabilities.includes(capability)
|| !actor.access.storeIds.includes(storeId)) {
throw new DeviceError('DEVICE_SCOPE_FORBIDDEN');
}
}
private scope(actor: ManagementActor, expression: string) {
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: '1 = 0', params: [] as string[] };
return {
sql: `${expression} IN (${actor.access.storeIds.map(() => '?').join(',')})`,
params: actor.access.storeIds
};
}
private async assertStoreAndRoom(
connection: PoolConnection, actor: ManagementActor, storeId: string, roomId: string | null
) {
const [stores] = await connection.execute<IdRow[]>(
'SELECT id FROM qipai_stores WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL',
[actor.tenantId, storeId]
);
if (!stores[0]) throw new DeviceError('DEVICE_STORE_NOT_FOUND');
if (!roomId) return;
const [rooms] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL`,
[actor.tenantId, storeId, roomId]
);
if (!rooms[0]) throw new DeviceError('DEVICE_ROOM_NOT_FOUND');
}
private async lockDevice(
connection: PoolConnection, actor: ManagementActor, assetId: string, storeId: string
) {
const [rows] = await connection.execute<Array<RowDataPacket & { id: string; deviceType: DeviceType }>>(
`SELECT id, device_type AS deviceType FROM qipai_devices
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, storeId, assetId]
);
if (!rows[0]) throw new DeviceError('DEVICE_NOT_FOUND');
return rows[0];
}
private async audit(
connection: PoolConnection, actor: ManagementActor,
action: 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', ?, ?, 'DEVICE', ?, ?, ?, ?, ?)`,
[actor.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();
}
}
}
function isDuplicate(error: unknown): boolean {
return typeof error === 'object' && error !== null
&& 'code' in error && error.code === 'ER_DUP_ENTRY';
}
function parseJson<T>(value: unknown, fallback: T): T {
if (value && typeof value === 'object') return value as T;
if (typeof value !== 'string' || value.length === 0) return fallback;
try { return JSON.parse(value) as T; } catch { return fallback; }
}
function maskIdentifier(value: string): string {
if (value.length <= 4) return '*'.repeat(value.length);
return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`;
}
function normalizeIds(rows: RowDataPacket[]) {
return rows.map((row) => Object.fromEntries(
Object.entries(row).map(([key, value]) => [
key,
(key === 'id' || key.endsWith('Id')) && value !== null ? String(value) : value
])
));
}
@@ -0,0 +1,265 @@
import { connect, type MqttClient } from 'mqtt';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter
} from './jilian-adapters.js';
import { generateCommandId } from './iot-message-service.js';
export type SmokeStatus = 'PASS' | 'FAIL' | 'SKIP' | 'DRY_RUN';
export interface HardwareSmokeCase {
id: string;
deviceId: string;
deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
commandType: string;
payload: Record<string, unknown>;
topic: string;
expectAck: boolean;
destructive: boolean;
}
export interface HardwareSmokeResult {
caseId: string;
status: SmokeStatus;
reason: string;
commandId?: string;
ack?: Record<string, unknown>;
}
interface HardwareSmokeConfig {
enabled: boolean;
allowActions: boolean;
mqttUrl: string;
username: string;
mqttPassword: string;
controlBoxDeviceId: string;
smartSocketDeviceId: string;
subLockSubId: string;
timeoutMs: number;
}
export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): HardwareSmokeConfig {
return {
enabled: isTrue(env.QIPAI_HARDWARE_SMOKE_ENABLE),
allowActions: isTrue(env.QIPAI_HARDWARE_SMOKE_ALLOW_ACTIONS),
mqttUrl: env.QIPAI_HARDWARE_MQTT_URL ?? '',
username: env.QIPAI_HARDWARE_MQTT_USERNAME ?? '',
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 ?? '',
timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
};
}
export function buildHardwareSmokeCases(
config: Pick<HardwareSmokeConfig,
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'>
): HardwareSmokeCase[] {
const controlBox = new JilianControlBoxAdapter();
const socket = new JilianSmartSocketAdapter();
const lock = new JilianSub1GLockAdapter();
const cases: HardwareSmokeCase[] = [];
if (config.controlBoxDeviceId) {
const id = generateCommandId(Date.now(), cases.length);
cases.push({
id: 'control-box-basic-info',
deviceId: config.controlBoxDeviceId,
deviceType: 'CONTROL_BOX',
commandType: 'basicInfo',
payload: controlBox.read('basicInfo'),
topic: commandTopic(config.controlBoxDeviceId),
expectAck: true,
destructive: false
});
if (config.allowActions) {
cases.push({
id: 'control-box-power-slot1-off',
deviceId: config.controlBoxDeviceId,
deviceType: 'CONTROL_BOX',
commandType: 'ConctolPower',
payload: controlBox.controlPower({ id, slot1: 'off' }),
topic: commandTopic(config.controlBoxDeviceId),
expectAck: true,
destructive: true
});
}
if (config.allowActions && config.subLockSubId) {
cases.push({
id: 'sub-lock-open',
deviceId: config.controlBoxDeviceId,
deviceType: 'SUB_LOCK',
commandType: 'CtrlDevice',
payload: lock.control({
id: generateCommandId(Date.now(), cases.length),
subID: config.subLockSubId,
order: 'open',
delayTime: 4
}),
topic: commandTopic(config.controlBoxDeviceId),
expectAck: true,
destructive: true
});
}
}
if (config.smartSocketDeviceId) {
cases.push({
id: 'smart-socket-work-info',
deviceId: config.smartSocketDeviceId,
deviceType: 'SMART_SOCKET',
commandType: 'workInfo',
payload: socket.read('workInfo'),
topic: commandTopic(config.smartSocketDeviceId),
expectAck: true,
destructive: false
});
if (config.allowActions) {
cases.push({
id: 'smart-socket-switch-off',
deviceId: config.smartSocketDeviceId,
deviceType: 'SMART_SOCKET',
commandType: 'off',
payload: socket.switch({
id: generateCommandId(Date.now(), cases.length),
on: false,
slotNum: 1
}),
topic: commandTopic(config.smartSocketDeviceId),
expectAck: true,
destructive: true
});
}
}
return cases;
}
export async function runHardwareSmoke(
config = loadHardwareSmokeConfig(),
cases = buildHardwareSmokeCases(config)
): Promise<HardwareSmokeResult[]> {
if (cases.length === 0) {
return [{ caseId: 'hardware-smoke-config', status: 'SKIP', reason: 'No DeviceID configured.' }];
}
if (!config.enabled) {
return cases.map((item) => ({
caseId: item.id,
status: 'DRY_RUN',
reason: 'Set QIPAI_HARDWARE_SMOKE_ENABLE=true to publish to real hardware.',
commandId: readCommandId(item.payload)
}));
}
if (!config.mqttUrl || !config.username || !config.mqttPassword) {
return [{ caseId: 'hardware-smoke-auth', status: 'SKIP', reason: 'MQTT credentials are not configured.' }];
}
const client = await connectMqtt(config);
try {
return await runCases(client, cases, config.timeoutMs);
} finally {
await new Promise<void>((resolve, reject) => {
client.end(false, {}, (error?: Error) => error ? reject(error) : resolve());
});
}
}
async function runCases(
client: MqttClient,
cases: HardwareSmokeCase[],
timeoutMs: number
): Promise<HardwareSmokeResult[]> {
const results: HardwareSmokeResult[] = [];
for (const item of cases) {
const commandId = readCommandId(item.payload);
const ackTopic = `/devicesend/${item.deviceId}`;
await subscribe(client, ackTopic);
await publish(client, item.topic, item.payload);
const ack = commandId
? await waitForAck(client, ackTopic, commandId, timeoutMs)
: null;
results.push(ack
? { caseId: item.id, status: 'PASS', reason: 'ACK received.', commandId, ack }
: { caseId: item.id, status: 'FAIL', reason: 'ACK timeout.', commandId });
}
return results;
}
function commandTopic(deviceId: string) {
return `/deviceaccept/${deviceId}`;
}
function readCommandId(payload: Record<string, unknown>) {
return typeof payload.id === 'string' ? payload.id : undefined;
}
async function connectMqtt(config: HardwareSmokeConfig) {
return new Promise<MqttClient>((resolve, reject) => {
const client = connect(config.mqttUrl, {
username: config.username,
['password']: config.mqttPassword,
clientId: `qipai-hardware-smoke-${process.pid}-${Date.now()}`,
protocolVersion: 3,
clean: true,
connectTimeout: config.timeoutMs
});
client.once('connect', () => resolve(client));
client.once('error', reject);
});
}
async function subscribe(client: MqttClient, topic: string) {
await new Promise<void>((resolve, reject) => {
client.subscribe(topic, { qos: 1 }, (error) => error ? reject(error) : resolve());
});
}
async function publish(client: MqttClient, topic: string, payload: Record<string, unknown>) {
await new Promise<void>((resolve, reject) => {
client.publish(topic, JSON.stringify(payload), { qos: 1, retain: false },
(error) => error ? reject(error) : resolve());
});
}
async function waitForAck(
client: MqttClient,
topic: string,
commandId: string,
timeoutMs: number
) {
return new Promise<Record<string, unknown> | null>((resolve) => {
const timer = setTimeout(() => {
client.off('message', handler);
resolve(null);
}, timeoutMs);
const handler = (receivedTopic: string, payload: Buffer) => {
if (receivedTopic !== topic) return;
try {
const body = JSON.parse(payload.toString('utf8')) as Record<string, unknown>;
if (body.id === commandId) {
clearTimeout(timer);
client.off('message', handler);
resolve(body);
}
} catch {
// Ignore non-JSON hardware noise during smoke tests.
}
};
client.on('message', handler);
});
}
function isTrue(value: string | undefined) {
return ['1', 'true', 'yes', 'on'].includes((value ?? '').toLowerCase());
}
if (process.argv[1]?.endsWith('hardware-smoke-runner.js')) {
const results = await runHardwareSmoke();
console.log(JSON.stringify({
generatedAt: new Date().toISOString(),
results
}, null, 2));
process.exit(results.some((item) => item.status === 'FAIL') ? 1 : 0);
}
+372
View File
@@ -0,0 +1,372 @@
import { createHash } from 'node:crypto';
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import {
JilianControlBoxAdapter,
JilianSmartSocketAdapter,
JilianSub1GLockAdapter,
type NormalizedVendorMessage,
type ProtocolAdapter
} from './jilian-adapters.js';
interface DeviceRow extends RowDataPacket {
id: string; tenantId: string; storeId: string; roomId: string | null;
deviceId: string; deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
}
interface IdRow extends RowDataPacket { id: string }
interface CommandRow extends RowDataPacket {
id: string; commandType: string; storeId: string; roomId: string | null;
}
export class IotMessageService {
private readonly adapters: Record<DeviceRow['deviceType'], ProtocolAdapter> = {
CONTROL_BOX: new JilianControlBoxAdapter(),
SUB_LOCK: new JilianSub1GLockAdapter(),
SMART_SOCKET: new JilianSmartSocketAdapter()
};
constructor(private readonly pool: MySqlPool) {}
async handle(topic: string, payload: Buffer): Promise<void> {
const payloadText = payload.toString('utf8');
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');
return;
}
const deviceCode = topicMatch[2];
const [devices] = await this.pool.execute<DeviceRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
device_id AS deviceId, device_type AS deviceType
FROM qipai_devices
WHERE device_id = ? AND deleted_at IS NULL`,
[deviceCode]
);
const device = devices[0];
if (!device) {
await this.deadLetter(null, null, topic, payloadHash, 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 (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,
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
);
return;
}
const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
normalized = { ...normalized, payload: safePayload };
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')
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]
);
if (result.affectedRows !== 1) return;
await this.updateDevice(device, normalized, safePayload);
await this.applyAlerts(device, normalized);
if (normalized.kind === 'ACK' && normalized.commandId) {
await this.applyAcknowledgement(device, normalized);
}
}
async createCommand(input: {
tenantId: string; assetId: string; storeId: string; roomId?: string | null;
orderId?: string | null; commandId: string; commandType: string;
payload: Record<string, unknown>; traceId: string; expiresAt?: Date | null;
}) {
if (!/^\d{1,13}$/.test(input.commandId)) {
throw new Error('IOT_COMMAND_ID_INVALID');
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_iot_commands
(tenant_id, device_id, store_id, room_id, order_id, command_id, command_type,
request_payload, trace_id, expires_at)
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]
);
return { recordId: String(result.insertId), commandId: input.commandId };
}
async markPublished(tenantId: string, commandId: string) {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_iot_commands SET status = 'PUBLISHED', published_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND command_id = ? AND status = 'PENDING'
AND (expires_at IS NULL OR expires_at > UTC_TIMESTAMP(3))`,
[tenantId, commandId]
);
return result.affectedRows === 1;
}
async markTimedOut(tenantId: string, commandId: string) {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_iot_commands SET status = 'TIMEOUT', failure_code = 'ACK_TIMEOUT'
WHERE tenant_id = ? AND command_id = ? AND status = 'PUBLISHED'`,
[tenantId, commandId]
);
return result.affectedRows === 1;
}
async markPublishFailed(tenantId: string, commandId: string, failureCode: string) {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_iot_commands SET status = 'FAILED', failure_code = ?
WHERE tenant_id = ? AND command_id = ? AND status = 'PENDING'`,
[failureCode.slice(0, 64), tenantId, commandId]
);
return result.affectedRows === 1;
}
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
const successful = message.result === 'ok';
const status = successful ? 'ACKED' : 'FAILED';
const failureCode = successful ? '' : `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
await this.pool.execute(
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
WHERE tenant_id = ? AND device_id = ? AND command_id = ?
AND status IN ('PENDING', 'PUBLISHED', 'TIMEOUT')`,
[status, JSON.stringify(message.payload), failureCode,
device.tenantId, device.id, message.commandId]
);
if (successful && message.eventType === 'AddDevice') {
await this.persistPairedSubLock(device, message);
}
}
private async persistPairedSubLock(device: DeviceRow, message: NormalizedVendorMessage) {
const subId = readString(message.payload.subID ?? message.payload.subId);
const subtype = readString(message.payload.subtype);
if (!subId || !subtype || !message.commandId) return;
const [commands] = await this.pool.execute<CommandRow[]>(
`SELECT id, command_type AS commandType, store_id AS storeId, room_id AS roomId
FROM qipai_iot_commands
WHERE tenant_id = ? AND device_id = ? AND command_id = ?`,
[device.tenantId, device.id, message.commandId]
);
const command = commands[0];
if (!command?.roomId || command.commandType !== 'AddDevice') return;
const childDeviceId = `${device.deviceId}_SUB_${subId}`.slice(0, 64);
await this.pool.execute(
`INSERT INTO qipai_devices
(tenant_id, store_id, room_id, device_id, device_type, model, capabilities, status)
VALUES (?, ?, ?, ?, 'SUB_LOCK', ?, JSON_ARRAY('LOCK'), 'ONLINE')
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), room_id = VALUES(room_id),
model = VALUES(model), status = 'ONLINE'`,
[device.tenantId, command.storeId, command.roomId, childDeviceId,
subtype === '14' ? '701C' : subtype === '15' ? '701G' : `SUBTYPE_${subtype}`]
);
const [childRows] = await this.pool.execute<IdRow[]>(
`SELECT id FROM qipai_devices
WHERE tenant_id = ? AND device_id = ? AND deleted_at IS NULL`,
[device.tenantId, childDeviceId]
);
if (!childRows[0]) return;
await this.pool.execute(
`INSERT INTO qipai_device_links
(tenant_id, parent_device_id, child_device_id, store_id, room_id,
link_type, sub_id, subtype)
VALUES (?, ?, ?, ?, ?, 'SUB_1G', ?, ?)
ON DUPLICATE KEY UPDATE parent_device_id = VALUES(parent_device_id),
child_device_id = VALUES(child_device_id), room_id = VALUES(room_id),
sub_id = VALUES(sub_id), subtype = VALUES(subtype), status = 'BOUND'`,
[device.tenantId, device.id, childRows[0].id, command.storeId,
command.roomId, subId, subtype]
);
}
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
? `DEVICE_${message.result?.toUpperCase()}`
: null;
if (alertResult) {
await this.upsertAlert(device, alertResult,
message.result === 'unconfirm' ? 'HIGH' : 'MEDIUM',
`Device command returned ${message.result}.`);
}
const battery = readNumber(message.payload.battery);
if (battery !== null && battery <= 20) {
await this.upsertAlert(device, 'LOW_BATTERY', battery <= 10 ? 'HIGH' : 'MEDIUM',
`Device battery is ${battery}%.`);
}
if (device.deviceType === 'SMART_SOCKET') {
await this.applySmartSocketAlerts(device, message);
}
}
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.`);
}
}
private async upsertAlert(
device: DeviceRow, alertType: string, severity: string, summary: string
) {
await this.pool.execute(
`INSERT INTO qipai_device_alerts
(tenant_id, device_id, store_id, room_id, alert_type, severity, summary)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE severity = VALUES(severity), summary = VALUES(summary),
last_seen_at = UTC_TIMESTAMP(3)`,
[device.tenantId, device.id, device.storeId, device.roomId,
alertType, severity, summary]
);
}
private async updateDevice(
device: DeviceRow, message: NormalizedVendorMessage, rawPayload: unknown
) {
const offline = message.eventType === 'will';
await this.pool.execute(
`UPDATE qipai_devices SET status = ?, state_snapshot = ?,
last_seen_at = UTC_TIMESTAMP(3),
last_heartbeat_at = CASE WHEN ? = 0 THEN UTC_TIMESTAMP(3) ELSE last_heartbeat_at END
WHERE tenant_id = ? AND id = ?`,
[offline ? 'OFFLINE' : 'ONLINE', JSON.stringify(rawPayload), offline ? 1 : 0,
device.tenantId, device.id]
);
}
private async deadLetter(
tenantId: string | null, deviceId: string | null, topic: string,
payloadHash: string, rawPayload: string, code: string, message = code
) {
await this.pool.execute(
`INSERT INTO qipai_iot_dead_letters
(tenant_id, device_id, topic, payload_hash, raw_payload, error_code, error_message)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
last_received_at = UTC_TIMESTAMP(3), error_code = VALUES(error_code),
error_message = VALUES(error_message)`,
[tenantId, deviceId, topic, payloadHash, rawPayload.slice(0, 1_000_000),
code, message.slice(0, 500)]
);
}
}
export function generateCommandId(now = Date.now(), sequence = 0): string {
const seconds = Math.floor(now / 1000) % 10_000_000_000;
return `${seconds.toString().padStart(10, '0')}${(sequence % 1000).toString().padStart(3, '0')}`;
}
function normalizeWill(payload: unknown): NormalizedVendorMessage {
const record = zRecord(payload);
return {
kind: 'EVENT',
commandId: null,
eventType: 'will',
result: null,
deviceId: readDeviceId(record),
eventAt: null,
payload: record
};
}
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.');
}
return payload as Record<string, unknown>;
}
function readDeviceId(record: Record<string, unknown>): string | null {
const value = record.DeviceID ?? record.deviceID;
return typeof value === 'string' ? value : null;
}
function sanitizeSensitivePayload(
payload: unknown, normalized: NormalizedVendorMessage
): Record<string, unknown> {
const record = { ...zRecord(payload) };
if (normalized.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)}`;
delete record.content;
}
if (typeof record.password === 'string') {
record.password = '<redacted>';
}
if (typeof record.card === 'string') {
record.card = '<redacted>';
}
return record;
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function readNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) {
return Number(value);
}
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;
}
+218
View File
@@ -0,0 +1,218 @@
import { z } from 'zod';
const commandId = z.string().regex(/^\d{1,13}$/);
const resultCode = z.enum([
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
]);
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(),
event: z.string().max(64).optional(),
action: z.string().max(64).optional(),
read: z.string().max(64).optional(),
timestamp: z.union([z.string(), z.number()]).optional()
}).passthrough();
export type NormalizedVendorMessage = {
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
commandId: string | null;
eventType: string;
result: z.infer<typeof resultCode> | null;
deviceId: string | null;
eventAt: Date | null;
payload: Record<string, unknown>;
};
export interface ProtocolAdapter {
parseUplink(payload: unknown): NormalizedVendorMessage;
}
export class JilianControlBoxAdapter implements ProtocolAdapter {
read(target: 'basicInfo' | 'mqttConfig' | 'startVoice' | 'task' | 'taskconfig') {
return z.object({ read: z.literal(target) }).parse({ read: target });
}
controlPower(input: {
id: string; slot1?: 'on' | 'off'; slot2?: 'on' | 'off';
slot3?: 'on' | 'off'; slotall?: 'on' | 'off';
}) {
return z.object({
action: z.literal('ConctolPower'), id: commandId,
slot1: z.enum(['on', 'off']).optional(),
slot2: z.enum(['on', 'off']).optional(),
slot3: z.enum(['on', 'off']).optional(),
slotall: z.enum(['on', 'off']).optional()
}).refine((value) => value.slot1 || value.slot2 || value.slot3 || value.slotall)
.parse({ action: 'ConctolPower', ...input });
}
controlDoor(input: {
id: string; order: 'open' | 'close'; holdopen?: 0 | 1; delayTime?: number;
}) {
return z.object({
action: z.literal('Crldoor'), id: commandId,
order: z.enum(['open', 'close']), holdopen: z.union([z.literal(0), z.literal(1)]).default(0),
delayTime: z.number().int().min(1).max(14).default(4)
}).parse({ action: 'Crldoor', ...input });
}
playTts(input: {
id: string; content: string; volume?: number; playCount?: number;
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
}) {
return z.object({
action: z.literal('PlayTTS'), 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 });
}
stopTts(id: string) {
return z.object({ action: z.literal('stopTTS'), id: commandId })
.parse({ action: 'stopTTS', id });
}
controlLed(input: { id: string; minute: number }) {
return z.object({
action: z.literal('CrlLED'), id: commandId,
minute: z.number().int().min(0).max(10080)
}).parse({ action: 'CrlLED', ...input });
}
startTask(input: {
id: string; minute: number; type: 1 | 2 | 3;
subID?: string; holdopen?: 0 | 1; delayTime?: number;
}) {
return z.object({
action: z.literal('task'), id: commandId,
minute: z.number().int().min(1).max(10080),
type: z.union([z.literal(1), z.literal(2), z.literal(3)]),
subID: z.string().min(1).max(64).optional(),
holdopen: z.union([z.literal(0), z.literal(1)]).default(0),
delayTime: z.number().int().min(1).max(14).default(4)
}).parse({ action: 'task', ...input });
}
extendTask(input: { id: string; addminute: number }) {
return z.object({
action: z.literal('addtask'), id: commandId,
addminute: z.number().int().min(1).max(10080)
}).parse({ action: 'addtask', ...input });
}
cancelTask(id: string) {
return z.object({ action: z.literal('canceltask'), id: commandId })
.parse({ action: 'canceltask', id });
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
}
export class JilianSub1GLockAdapter implements ProtocolAdapter {
pair(input: { id: string; timeout?: number }) {
return z.object({
action: z.literal('AddDevice'), id: commandId,
timeout: z.number().int().min(10).max(300).default(60)
}).parse({ action: 'AddDevice', ...input });
}
control(input: {
id: string; subID: string;
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
holdopen?: 0 | 1; delayTime?: number; content?: string;
}) {
return z.object({
action: z.literal('CtrlDevice'), id: commandId,
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', '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()
}).superRefine((value, context) => {
if (['setkey', 'setcard'].includes(value.order) && !value.content) {
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' });
}
}).parse({ action: 'CtrlDevice', ...input });
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
}
export class JilianSmartSocketAdapter implements ProtocolAdapter {
read(target: 'basicInfo' | 'workInfo') {
return z.object({ read: z.literal(target) }).parse({ read: target });
}
switch(input: { id: string; on: boolean; slotNum?: number }) {
return z.object({
action: z.enum(['on', 'off']), id: commandId,
slotNum: z.number().int().min(1).max(20).default(1)
}).parse({ action: input.on ? 'on' : 'off', id: input.id, slotNum: input.slotNum });
}
localTask(input: {
id: string; taskNum: number; action: 'on' | 'off';
mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
}) {
return z.object({
action: z.literal('localtask'), id: commandId,
taskNum: z.number().int().min(1).max(20),
switch: 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
});
}
clearTask(input: { id: string; taskNum: number }) {
return z.object({
action: z.literal('clearTask'), id: commandId,
taskNum: z.number().int().min(0).max(20)
}).parse({ action: 'clearTask', ...input });
}
parseUplink(payload: unknown) {
return normalizeVendorMessage(payload);
}
}
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';
return {
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
commandId: parsed.id ?? null,
eventType: eventName,
result: parsed.result ?? null,
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
eventAt: parseEventAt(parsed.timestamp),
payload: record
};
}
function parseEventAt(value: string | number | undefined): Date | null {
if (value === undefined) return null;
const date = typeof value === 'number'
? new Date(value < 10_000_000_000 ? value * 1000 : value)
: new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
@@ -0,0 +1,187 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { AsyncTask } from '../tasks/task-repository.js';
import { DeviceControlError, type DeviceControlService } from './device-control-service.js';
export const orderDeviceEvents = [
'ORDER_PAID',
'ORDER_STARTED',
'ORDER_RENEWED',
'ORDER_CANCELLED',
'ORDER_ROOM_CHANGED',
'ORDER_FINISHED'
] as const;
type OrderDeviceEvent = (typeof orderDeviceEvents)[number];
interface OrderRow extends RowDataPacket {
id: string;
tenantId: string;
storeId: string;
roomId: string;
status: string;
startAt: Date;
endAt: Date;
}
export class OrderDeviceAutomationError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class OrderDeviceAutomationService {
constructor(
private readonly pool: MySqlPool,
private readonly deviceControl: Pick<DeviceControlService,
'startTask' | 'extendTask' | 'cancelTask' | 'switchSmartSocket'>
) {}
async handleTask(task: AsyncTask) {
if (task.taskType !== 'device.command') {
throw new OrderDeviceAutomationError('DEVICE_TASK_TYPE_INVALID');
}
const payload = parseTaskPayload(task.payload);
if (payload.tenantId !== task.tenantId) {
throw new OrderDeviceAutomationError('DEVICE_TASK_TENANT_MISMATCH');
}
const order = await this.loadOrder(payload.tenantId, payload.orderId);
if (['ORDER_PAID', 'ORDER_STARTED', 'ORDER_RENEWED'].includes(payload.event)
&& !['PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
return { orderId: order.id, skipped: true, reason: 'ORDER_STATUS_TERMINAL' };
}
if (payload.event === 'ORDER_PAID' || payload.event === 'ORDER_STARTED') {
await this.startOrderDevices(order, payload.traceId);
return { orderId: order.id, action: 'STARTED' };
}
if (payload.event === 'ORDER_RENEWED') {
await this.extendOrderDevices(order, payload.traceId, payload.addMinutes);
return { orderId: order.id, action: 'EXTENDED' };
}
if (payload.event === 'ORDER_ROOM_CHANGED') {
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
}
await this.startOrderDevices(order, payload.traceId);
return { orderId: order.id, action: 'ROOM_CHANGED' };
}
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
return { orderId: order.id, action: 'CANCELLED' };
}
private async startOrderDevices(order: OrderRow, traceId: string) {
const context = this.context(order, traceId, order.roomId);
await this.deviceControl.startTask(context, {
minute: remainingMinutes(order.endAt),
type: 2
});
await this.switchSocketIfBound(context, true);
}
private async extendOrderDevices(order: OrderRow, traceId: string, addMinutes?: number) {
const context = this.context(order, traceId, order.roomId);
await this.deviceControl.extendTask(context, boundedMinutes(addMinutes ?? remainingMinutes(order.endAt)));
await this.switchSocketIfBound(context, true);
}
private async cancelRoomDevices(order: OrderRow, traceId: string, roomId: string) {
const context = this.context(order, traceId, roomId);
await this.deviceControl.cancelTask(context);
await this.switchSocketIfBound(context, false);
}
private async switchSocketIfBound(context: ReturnType<OrderDeviceAutomationService['context']>, on: boolean) {
try {
await this.deviceControl.switchSmartSocket(context, { on, slotNum: 1, orderId: context.orderId });
} catch (error) {
if (error instanceof DeviceControlError && error.code === 'SMART_SOCKET_NOT_BOUND') return;
throw error;
}
}
private context(order: OrderRow, traceId: string, roomId: string) {
return {
tenantId: order.tenantId,
storeId: order.storeId,
roomId,
orderId: order.id,
traceId,
access: systemDeviceAccess()
};
}
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,
status, start_at AS startAt, end_at AS endAt
FROM qipai_orders
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[tenantId, orderId]
);
if (!rows[0]) throw new OrderDeviceAutomationError('ORDER_NOT_FOUND');
return {
...rows[0],
id: String(rows[0].id),
tenantId: String(rows[0].tenantId),
storeId: String(rows[0].storeId),
roomId: String(rows[0].roomId)
};
}
}
function parseTaskPayload(payload: unknown): {
tenantId: string;
orderId: string;
event: OrderDeviceEvent;
traceId: string;
addMinutes?: number;
previousRoomId?: string;
} {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new OrderDeviceAutomationError('DEVICE_TASK_PAYLOAD_INVALID');
}
const record = payload as Record<string, unknown>;
const tenantId = readId(record.tenantId);
const orderId = readId(record.orderId);
const event = typeof record.event === 'string'
&& orderDeviceEvents.includes(record.event as OrderDeviceEvent)
? record.event as OrderDeviceEvent
: null;
const traceId = typeof record.traceId === 'string' && record.traceId.length > 0
? record.traceId.slice(0, 128)
: `device-order-${orderId ?? 'unknown'}`;
if (!tenantId || !orderId || !event) {
throw new OrderDeviceAutomationError('DEVICE_TASK_PAYLOAD_INVALID');
}
const addMinutes = readPositiveInt(record.addMinutes);
const previousRoomId = readId(record.previousRoomId);
return { tenantId, orderId, event, traceId, addMinutes, previousRoomId: previousRoomId ?? undefined };
}
function readId(value: unknown) {
if (typeof value === 'string' && /^[1-9]\d{0,19}$/.test(value)) return value;
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value);
return null;
}
function readPositiveInt(value: unknown) {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) return undefined;
return value;
}
function remainingMinutes(endAt: Date) {
return boundedMinutes(Math.ceil((endAt.getTime() - Date.now()) / 60000));
}
function boundedMinutes(value: number) {
return Math.max(1, Math.min(10080, value));
}
function systemDeviceAccess(): AccessProfile {
return {
roles: ['PLATFORM_ADMIN'],
capabilities: ['device.write'],
storeIds: []
};
}
+185
View File
@@ -0,0 +1,185 @@
import { connect, type IClientOptions, type IClientPublishOptions } from 'mqtt';
import type { AppConfig } from '../config.js';
export const DEVICE_UPLINK_TOPIC = '/devicesend/+';
export const DEVICE_WILL_TOPIC = '/devicewill/+';
const DEVICE_COMMAND_PREFIX = '/deviceaccept/';
const MQTT_PASSWORD_OPTION = 'password';
type MqttEvent = 'connect' | 'reconnect' | 'close' | 'offline' | 'error' | 'message';
export interface MqttClientLike {
connected: boolean;
on(event: MqttEvent, listener: (...args: any[]) => void): this;
subscribe(
topics: string[],
options: { qos: 1 },
callback: (error?: Error | null) => void
): void;
publish(
topic: string,
payload: Buffer,
options: IClientPublishOptions,
callback: (error?: Error) => void
): void;
end(force: boolean, options: Record<string, never>, callback: () => void): void;
}
export type MqttClientFactory = (url: string, options: IClientOptions) => MqttClientLike;
export type MqttMessageHandler = (topic: string, payload: Buffer) => Promise<void>;
export interface MqttHealthSnapshot {
configured: boolean;
connected: boolean;
subscriptionsReady: boolean;
reconnectCount: number;
receivedMessages: number;
rejectedOversizeMessages: number;
lastConnectedAt: string | null;
lastMessageAt: string | null;
lastError: string | null;
}
export interface MqttTransport {
start(): void;
stop(): Promise<void>;
publishDeviceCommand(deviceId: string, payload: Buffer | string): Promise<void>;
health(): MqttHealthSnapshot;
}
export class MqttService implements MqttTransport {
private client: MqttClientLike | null = null;
private subscriptionsReady = false;
private reconnectCount = 0;
private receivedMessages = 0;
private rejectedOversizeMessages = 0;
private lastConnectedAt: string | null = null;
private lastMessageAt: string | null = null;
private lastError: string | null = null;
constructor(
private readonly config: AppConfig['mqtt'],
private readonly clientFactory: MqttClientFactory = connect as MqttClientFactory,
private readonly messageHandler?: MqttMessageHandler
) {}
start(): void {
if (this.client || !this.isConfigured()) {
return;
}
this.client = this.clientFactory(this.config.url, {
clientId: this.config.clientId,
username: this.config.username,
[MQTT_PASSWORD_OPTION]: this.config.credential,
protocolVersion: 3,
clean: false,
reconnectPeriod: this.config.reconnectPeriodMs,
connectTimeout: this.config.connectTimeoutMs,
resubscribe: false,
queueQoSZero: false
});
this.client.on('connect', () => {
this.lastConnectedAt = new Date().toISOString();
this.lastError = null;
this.subscribeToDeviceTopics();
});
this.client.on('reconnect', () => {
this.reconnectCount += 1;
this.subscriptionsReady = false;
});
this.client.on('close', () => {
this.subscriptionsReady = false;
});
this.client.on('offline', () => {
this.subscriptionsReady = false;
});
this.client.on('error', (error: Error) => {
this.lastError = sanitizeError(error);
});
this.client.on('message', (topic: string, payload: Buffer) => {
if (payload.length > this.config.maxMessageBytes) {
this.rejectedOversizeMessages += 1;
this.lastError = `MQTT message exceeded ${this.config.maxMessageBytes} bytes`;
return;
}
this.receivedMessages += 1;
this.lastMessageAt = new Date().toISOString();
void this.messageHandler?.(topic, payload).catch((error: unknown) => {
this.lastError = sanitizeError(
error instanceof Error ? error : new Error('MQTT message handler failed')
);
});
});
}
async stop(): Promise<void> {
const client = this.client;
this.client = null;
this.subscriptionsReady = false;
if (!client) {
return;
}
await new Promise<void>((resolve) => client.end(false, {}, resolve));
}
async publishDeviceCommand(deviceId: string, payload: Buffer | string): Promise<void> {
if (!/^[A-Za-z0-9_-]{1,64}$/.test(deviceId)) {
throw new Error('Invalid MQTT DeviceID.');
}
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload, 'utf8');
if (body.length > this.config.maxMessageBytes) {
throw new Error(`MQTT command exceeds ${this.config.maxMessageBytes} bytes.`);
}
if (!this.client?.connected || !this.subscriptionsReady) {
throw new Error('MQTT transport is not ready.');
}
await new Promise<void>((resolve, reject) => {
this.client?.publish(
`${DEVICE_COMMAND_PREFIX}${deviceId}`,
body,
{ qos: 1, retain: false },
(error?: Error) => error ? reject(error) : resolve()
);
});
}
health(): MqttHealthSnapshot {
return {
configured: this.isConfigured(),
connected: this.client?.connected === true,
subscriptionsReady: this.subscriptionsReady,
reconnectCount: this.reconnectCount,
receivedMessages: this.receivedMessages,
rejectedOversizeMessages: this.rejectedOversizeMessages,
lastConnectedAt: this.lastConnectedAt,
lastMessageAt: this.lastMessageAt,
lastError: this.lastError
};
}
private isConfigured(): boolean {
return this.config.usernameConfigured && this.config.passwordConfigured;
}
private subscribeToDeviceTopics(): void {
this.subscriptionsReady = false;
this.client?.subscribe(
[DEVICE_UPLINK_TOPIC, DEVICE_WILL_TOPIC],
{ qos: 1 },
(error?: Error | null) => {
if (error) {
this.lastError = sanitizeError(error);
return;
}
this.subscriptionsReady = true;
}
);
}
}
function sanitizeError(error: Error): string {
return error.message.replace(/(password|username|credential)=\S+/gi, '$1=<redacted>');
}
@@ -0,0 +1,482 @@
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 { OrderActor } from './order-state-repository.js';
type PricingPolicy = 'CURRENT' | 'LOCKED';
type AdjustableStatus = 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS';
interface OrderRow extends RowDataPacket {
id: string;
storeId: string;
roomId: string;
status: AdjustableStatus;
startAt: Date;
endAt: Date;
totalAmountCents: number;
adjustmentAmountCents: number;
cancellationCutoffMinutes: number;
cancellationFeeBps: number;
}
interface RoomRow extends RowDataPacket {
id: string;
storeId: string;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
timezone: string;
operationalStatus: string;
configurationStatus: string;
}
interface SnapshotRow extends RowDataPacket { unitPriceCents: number }
interface DuplicateRow extends RowDataPacket {
id: string;
adjustmentType: string;
amountDeltaCents: number;
}
export class OrderManagementError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class OrderManagementRepository {
constructor(private readonly pool: MySqlPool) {}
async renew(actor: OrderActor, orderId: string, input: {
endAt: Date; pricingPolicy: PricingPolicy; reason: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
this.assertManager(actor.access, order.storeId);
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);
});
}
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;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
this.assertManager(actor.access, order.storeId);
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);
this.assertManager(actor.access, target.storeId);
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);
});
}
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;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
this.assertManager(actor.access, order.storeId);
const duplicate = await this.duplicate(connection, actor, orderId);
if (duplicate) return this.duplicateResult(orderId, duplicate);
const startAt = input.startAt ?? order.startAt;
const endAt = input.endAt ?? order.endAt;
if (endAt <= startAt) throw new OrderManagementError('ORDER_TIME_WINDOW_INVALID');
await this.lockRooms(connection, actor.tenantId, [order.roomId]);
await this.assertAvailable(
connection, actor.tenantId, order.roomId, startAt, endAt, orderId
);
const oldHours = Math.ceil((order.endAt.getTime() - order.startAt.getTime()) / 3600000);
const newHours = Math.ceil((endAt.getTime() - startAt.getTime()) / 3600000);
const unitPrice = await this.resolveUnitPrice(connection, actor.tenantId, order, 'LOCKED');
const amountDeltaCents = (newHours - oldHours) * unitPrice;
await connection.execute(
`UPDATE qipai_orders SET start_at = ?, end_at = ?,
total_amount_cents = GREATEST(0, total_amount_cents + ?),
adjustment_amount_cents = adjustment_amount_cents + ?
WHERE tenant_id = ? AND id = ?`,
[startAt, endAt, amountDeltaCents, amountDeltaCents, actor.tenantId, orderId]
);
await connection.execute(
`UPDATE qipai_room_reservations SET starts_at = ?, ends_at = ?,
expires_at = GREATEST(expires_at, ?)
WHERE tenant_id = ? AND order_id = ?`,
[startAt, endAt, endAt, actor.tenantId, orderId]
);
return this.record(connection, actor, order, 'ADJUST_TIME', input.reason, {
startAt: order.startAt, endAt: order.endAt
}, { startAt, endAt }, amountDeltaCents);
});
}
async note(actor: OrderActor, orderId: string, note: string) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId);
this.assertManager(actor.access, order.storeId);
const duplicate = await this.duplicate(connection, actor, orderId);
if (duplicate) return this.duplicateResult(orderId, duplicate);
await connection.execute(
`UPDATE qipai_orders SET operator_note = ? WHERE tenant_id = ? AND id = ?`,
[note.slice(0, 512), actor.tenantId, orderId]
);
return this.record(connection, actor, order, 'NOTE', note, {}, { note }, 0);
});
}
async cancellationQuote(tenantId: string, userId: string, orderId: string) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, tenantId, orderId);
await this.assertOwner(connection, tenantId, orderId, userId);
const minutesBeforeStart = Math.floor((order.startAt.getTime() - Date.now()) / 60000);
const feeCents = minutesBeforeStart >= order.cancellationCutoffMinutes
? 0 : Math.ceil(order.totalAmountCents * order.cancellationFeeBps / 10000);
return {
orderId,
allowed: order.status !== 'IN_PROGRESS',
cutoffMinutes: order.cancellationCutoffMinutes,
feeCents,
refundableCents: Math.max(0, order.totalAmountCents - feeCents)
};
});
}
private async record(
connection: PoolConnection, actor: OrderActor, order: OrderRow,
type: string, reason: string, before: object, after: object, amountDeltaCents: number
) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_order_adjustments
(tenant_id, order_id, adjustment_type, actor_id, source, trace_id,
reason, before_values, after_values, amount_delta_cents)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, order.id, type, actor.userId, actor.source, actor.traceId,
reason.slice(0, 512), JSON.stringify(before), JSON.stringify(after), amountDeltaCents]
);
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_MANUALLY_ADJUSTED', 'ORDER', ?, ?, ?, ?,
JSON_OBJECT('adjustmentType', ?, 'amountDeltaCents', ?))`,
[actor.tenantId, actor.userId, order.id, actor.traceId, actor.ip,
actor.userAgent.slice(0, 255), type, amountDeltaCents]
);
return {
orderId: order.id, adjustmentId: String(result.insertId),
adjustmentType: type, amountDeltaCents, idempotent: false
};
}
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,
o.start_at AS startAt, o.end_at AS endAt,
o.total_amount_cents AS totalAmountCents,
o.adjustment_amount_cents AS adjustmentAmountCents,
s.cancellation_cutoff_minutes AS cancellationCutoffMinutes,
s.cancellation_fee_bps AS cancellationFeeBps
FROM qipai_orders o
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL FOR UPDATE`,
[tenantId, orderId]
);
if (!rows[0]) throw new OrderManagementError('ORDER_NOT_FOUND');
return rows[0];
}
private async loadRoom(connection: PoolConnection, tenantId: string, roomId: string) {
const [rows] = await connection.execute<RoomRow[]>(
`SELECT r.id, r.store_id AS storeId, r.base_price_cents AS basePriceCents,
r.weekday_price_cents AS weekdayPriceCents,
r.holiday_price_cents AS holidayPriceCents,
r.operational_status AS operationalStatus,
r.configuration_status AS configurationStatus, s.timezone
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
WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL`,
[tenantId, roomId]
);
const room = rows[0];
if (!room) throw new OrderManagementError('ROOM_NOT_FOUND');
if (room.operationalStatus !== 'AVAILABLE' || room.configurationStatus !== 'ENABLED') {
throw new OrderManagementError('ROOM_NOT_AVAILABLE');
}
return room;
}
private async lockRooms(connection: PoolConnection, tenantId: string, roomIds: string[]) {
const sorted = [...new Set(roomIds)].sort((a, b) => Number(a) - Number(b));
for (const roomId of sorted) {
await connection.execute(
`SELECT id FROM qipai_rooms WHERE tenant_id = ? AND id = ? FOR UPDATE`,
[tenantId, roomId]
);
}
}
private async assertAvailable(
connection: PoolConnection, tenantId: string, roomId: string,
startAt: Date, endAt: Date, excludingOrderId: string
) {
const [disabled] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_room_disabled_periods
WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ? FOR UPDATE`,
[tenantId, roomId, endAt, startAt]
);
if (disabled[0]) throw new OrderManagementError('ROOM_DISABLED_PERIOD');
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT id FROM qipai_room_reservations
WHERE tenant_id = ? AND room_id = ? AND order_id <> ?
AND status IN ('HELD', 'CONSUMED')
AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3))
AND starts_at < ? AND ends_at > ? FOR UPDATE`,
[tenantId, roomId, excludingOrderId, endAt, startAt]
);
if (rows[0]) throw new OrderManagementError('TIME_SLOT_CONFLICT');
}
private async resolveUnitPrice(
connection: PoolConnection, tenantId: string, order: OrderRow, policy: PricingPolicy
) {
if (policy === 'LOCKED') {
const [rows] = await connection.execute<SnapshotRow[]>(
`SELECT unit_price_cents AS unitPriceCents
FROM qipai_order_price_snapshots WHERE tenant_id = ? AND order_id = ?`,
[tenantId, order.id]
);
if (!rows[0]) throw new OrderManagementError('ORDER_PRICE_SNAPSHOT_MISSING');
return Number(rows[0].unitPriceCents);
}
const room = await this.loadRoom(connection, tenantId, order.roomId);
const localDate = new Intl.DateTimeFormat('en-CA', {
timeZone: room.timezone, year: 'numeric', month: '2-digit', day: '2-digit'
}).format(order.endAt);
const [holidayRows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_holiday_calendar
WHERE tenant_id = ? AND holiday_date = ? LIMIT 1`,
[tenantId, localDate]
);
if (holidayRows[0] && room.holidayPriceCents > 0) return Number(room.holidayPriceCents);
const weekdayName = new Intl.DateTimeFormat('en-US', {
timeZone: room.timezone, weekday: 'short'
}).format(order.endAt);
if (['Mon', 'Tue', 'Wed', 'Thu', 'Fri'].includes(weekdayName)
&& room.weekdayPriceCents > 0) {
return Number(room.weekdayPriceCents);
}
return Number(room.basePriceCents);
}
private async duplicate(connection: PoolConnection, actor: OrderActor, orderId: string) {
const [rows] = await connection.execute<DuplicateRow[]>(
`SELECT id, adjustment_type AS adjustmentType,
amount_delta_cents AS amountDeltaCents
FROM qipai_order_adjustments
WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`,
[actor.tenantId, orderId, actor.traceId]
);
return rows[0] ?? null;
}
private duplicateResult(orderId: string, duplicate: DuplicateRow) {
return {
orderId, adjustmentId: String(duplicate.id),
adjustmentType: duplicate.adjustmentType,
amountDeltaCents: Number(duplicate.amountDeltaCents), idempotent: true
};
}
private assertManager(access: AccessProfile | undefined, storeId: string) {
if (!access || !(access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId)))) {
throw new OrderManagementError('ORDER_MANAGEMENT_FORBIDDEN');
}
}
private async assertOwner(
connection: PoolConnection, tenantId: string, orderId: string, userId: string
) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
[tenantId, orderId, userId]
);
if (!rows[0]) throw new OrderManagementError('ORDER_ACCESS_FORBIDDEN');
}
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();
}
}
}
@@ -0,0 +1,163 @@
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;
}) {
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);
}
const whereSql = where.join(' AND ');
const offset = (input.page - 1) * input.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 ? OFFSET ?`,
[...params, input.pageSize, offset]
);
return {
items: rows.map(publicOrder),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize: input.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(',')})`;
}
@@ -0,0 +1,234 @@
import { createHash, randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
export type SharePermission = 'VIEW_ROOM' | 'OPEN_DOOR' | 'RENEW';
interface OrderRow extends RowDataPacket {
id: string;
orderNo: string;
storeId: string;
roomId: string;
status: string;
startAt: Date;
endAt: Date;
}
interface ShareRow extends RowDataPacket {
id: string;
tenantId: string;
orderId: string;
orderNo: string;
storeId: string;
roomId: string;
status: string;
startAt: Date;
endAt: Date;
allowViewRoom: number;
allowOpenDoor: number;
allowRenew: number;
expiresAt: Date;
revokedAt: Date | null;
}
export class OrderShareError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class OrderShareRepository {
constructor(private readonly pool: MySqlPool) {}
async create(input: {
tenantId: string; userId: string; orderId: string; access: AccessProfile;
permissions?: SharePermission[]; ttlMinutes?: number;
traceId: string; ip: string; userAgent: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
await this.assertOwnerOrManager(
connection, input.tenantId, input.userId, order, input.access
);
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new OrderShareError('ORDER_SHARE_STATUS_INVALID');
}
const permissions = new Set(input.permissions ?? ['VIEW_ROOM', 'OPEN_DOOR']);
if (permissions.size === 0) throw new OrderShareError('ORDER_SHARE_PERMISSION_REQUIRED');
const ttlMinutes = Math.min(Math.max(input.ttlMinutes ?? 30, 5), 1440);
const token = randomBytes(32).toString('base64url');
const tokenHash = hashToken(token);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_order_shares
(tenant_id, order_id, token_hash, token_prefix, allow_view_room,
allow_open_door, allow_renew, expires_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?,
DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`,
[input.tenantId, input.orderId, tokenHash, token.slice(0, 10),
permissions.has('VIEW_ROOM'), permissions.has('OPEN_DOOR'),
permissions.has('RENEW'), ttlMinutes, input.userId]
);
await this.audit(connection, input, 'ORDER_SHARE_CREATED', input.orderId, {
shareId: String(result.insertId),
permissions: [...permissions],
ttlMinutes
});
return {
shareId: String(result.insertId),
token,
expiresInMinutes: ttlMinutes,
permissions: [...permissions]
};
});
}
async revoke(input: {
tenantId: string; userId: string; orderId: string; shareId: string;
access: AccessProfile; traceId: string; ip: string; userAgent: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(connection, input.tenantId, input.orderId, true);
await this.assertOwnerOrManager(
connection, input.tenantId, input.userId, order, input.access
);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_order_shares
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(3)),
revoked_by = COALESCE(revoked_by, ?)
WHERE tenant_id = ? AND order_id = ? AND id = ?`,
[input.userId, input.tenantId, input.orderId, input.shareId]
);
if (result.affectedRows !== 1) throw new OrderShareError('ORDER_SHARE_NOT_FOUND');
await this.audit(connection, input, 'ORDER_SHARE_REVOKED', input.orderId, {
shareId: input.shareId
});
return { shareId: input.shareId, revoked: true };
});
}
async resolve(token: string, permission: SharePermission, context: {
traceId: string; ip: string; userAgent: string;
}) {
if (!/^[A-Za-z0-9_-]{40,64}$/.test(token)) {
throw new OrderShareError('ORDER_SHARE_INVALID');
}
return this.transaction(async (connection) => {
const [rows] = await connection.execute<ShareRow[]>(
`SELECT s.id, s.tenant_id AS tenantId, s.order_id AS orderId,
o.order_no AS orderNo, o.store_id AS storeId, o.room_id AS roomId,
o.status, o.start_at AS startAt, o.end_at AS endAt,
s.allow_view_room AS allowViewRoom,
s.allow_open_door AS allowOpenDoor, s.allow_renew AS allowRenew,
s.expires_at AS expiresAt, s.revoked_at AS revokedAt
FROM qipai_order_shares s
INNER JOIN qipai_orders o
ON o.tenant_id = s.tenant_id AND o.id = s.order_id AND o.deleted_at IS NULL
WHERE s.token_hash = ? LIMIT 1 FOR UPDATE`,
[hashToken(token)]
);
const share = rows[0];
if (!share || share.revokedAt || share.expiresAt <= new Date()) {
throw new OrderShareError('ORDER_SHARE_INVALID');
}
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(share.status)) {
throw new OrderShareError('ORDER_SHARE_INACTIVE');
}
const allowed = permission === 'VIEW_ROOM' ? Boolean(share.allowViewRoom)
: permission === 'OPEN_DOOR' ? Boolean(share.allowOpenDoor)
: Boolean(share.allowRenew);
if (!allowed) throw new OrderShareError('ORDER_SHARE_PERMISSION_DENIED');
await connection.execute(
`UPDATE qipai_order_shares
SET last_used_at = UTC_TIMESTAMP(3), use_count = use_count + 1 WHERE id = ?`,
[share.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 (?, 'SHARE_TOKEN', NULL, 'ORDER_SHARE_USED', 'ORDER', ?, ?, ?, ?,
JSON_OBJECT('shareId', ?, 'permission', ?))`,
[share.tenantId, share.orderId, context.traceId, context.ip,
context.userAgent.slice(0, 255), share.id, permission]
);
return {
shareId: String(share.id),
order: {
orderId: String(share.orderId),
orderNo: share.orderNo,
status: share.status,
startAt: share.startAt,
endAt: share.endAt,
storeId: permission === 'VIEW_ROOM' ? String(share.storeId) : undefined,
roomId: permission === 'VIEW_ROOM' ? String(share.roomId) : undefined
},
grantedPermission: permission
};
});
}
private async loadOrder(
connection: PoolConnection, tenantId: string, orderId: string, lock: boolean
) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT id, order_no AS orderNo, store_id AS storeId, room_id AS roomId,
status, start_at AS startAt, end_at AS endAt
FROM qipai_orders WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new OrderShareError('ORDER_NOT_FOUND');
return rows[0];
}
private async assertOwnerOrManager(
connection: PoolConnection, tenantId: string, userId: string,
order: OrderRow, access: AccessProfile
) {
if (canManageStore(access, order.storeId)) return;
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
[tenantId, order.id, userId]
);
if (!rows[0]) throw new OrderShareError('ORDER_ACCESS_FORBIDDEN');
}
private async audit(
connection: PoolConnection,
input: { tenantId: string; userId: string; traceId: string; ip: string; userAgent: string },
action: string, orderId: 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', ?, ?, 'ORDER', ?, ?, ?, ?, ?)`,
[input.tenantId, input.userId, action, orderId, input.traceId,
input.ip, input.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 hashToken(token: string) {
return createHash('sha256').update(token).digest('hex');
}
function canManageStore(access: AccessProfile, storeId: string) {
return access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId));
}
@@ -0,0 +1,282 @@
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',
'BEGIN_REFUND', 'COMPLETE_REFUND', 'CLOSE'
] as const;
export type OrderAction = typeof orderActions[number];
export type OrderStatus =
| 'DRAFT' | 'PENDING_PAYMENT' | 'PAID' | 'RESERVED' | 'IN_PROGRESS'
| 'FINISHED' | 'CANCELLED' | 'REFUNDING' | 'REFUNDED' | 'CLOSED';
export interface OrderActor {
tenantId: string;
userId: string;
actorType: 'USER' | 'SYSTEM';
source: 'APP' | 'ADMIN' | 'PAYMENT' | 'WORKER' | 'SYSTEM';
traceId: string;
ip: string;
userAgent: string;
access?: AccessProfile;
}
interface OrderRow extends RowDataPacket {
id: string;
storeId: string;
status: OrderStatus;
statusVersion: number;
}
interface HistoryRow extends RowDataPacket {
id: string;
fromStatus: OrderStatus | null;
toStatus: OrderStatus;
action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED';
actorType: string;
actorId: string | null;
source: string;
reason: string;
traceId: string;
createdAt: Date;
}
const targetByAction: Record<OrderAction, OrderStatus> = {
SUBMIT: 'PENDING_PAYMENT',
CONFIRM_PAYMENT: 'PAID',
RESERVE: 'RESERVED',
START: 'IN_PROGRESS',
FINISH: 'FINISHED',
CANCEL: 'CANCELLED',
BEGIN_REFUND: 'REFUNDING',
COMPLETE_REFUND: 'REFUNDED',
CLOSE: 'CLOSED'
};
const allowedActions: Record<OrderStatus, readonly 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: []
};
export class OrderStateError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class OrderStateRepository {
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) => {
const order = await this.loadOrder(connection, actor.tenantId, orderId, true);
await this.assertAuthorized(connection, actor, order, action);
const duplicate = await this.findByTrace(connection, actor.tenantId, orderId, actor.traceId);
if (duplicate) {
return {
orderId,
status: duplicate.toStatus,
statusVersion: null,
historyId: duplicate.id,
idempotent: true
};
}
if (!allowedActions[order.status].includes(action)) {
throw new OrderStateError('ORDER_TRANSITION_NOT_ALLOWED');
}
const targetStatus = targetByAction[action];
const nextVersion = Number(order.statusVersion) + 1;
await connection.execute(
`UPDATE qipai_orders
SET status = ?, status_version = ?, status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[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,
actor_id, source, reason, trace_id, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('statusVersion', ?))`,
[actor.tenantId, orderId, order.status, targetStatus, action,
actor.actorType, actor.userId, actor.source, reason.slice(0, 512),
actor.traceId, nextVersion]
);
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 (?, ?, ?, 'ORDER_STATUS_CHANGED', 'ORDER', ?, ?, ?, ?,
JSON_OBJECT('fromStatus', ?, 'toStatus', ?, 'orderAction', ?))`,
[actor.tenantId, actor.actorType, actor.userId, orderId, actor.traceId,
actor.ip, actor.userAgent.slice(0, 255), order.status, targetStatus, action]
);
return {
orderId,
status: targetStatus,
statusVersion: nextVersion,
historyId: String(result.insertId),
idempotent: false
};
});
}
async history(tenantId: string, userId: string, orderId: string, access: AccessProfile) {
const order = await this.loadOrder(this.pool, tenantId, orderId, false);
const manager = canManageStore(access, order.storeId);
if (!manager) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
[tenantId, orderId, userId]
);
if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN');
}
const [rows] = await this.pool.execute<HistoryRow[]>(
`SELECT id, from_status AS fromStatus, to_status AS toStatus, action,
actor_type AS actorType, actor_id AS actorId, source, reason,
trace_id AS traceId, created_at AS createdAt
FROM qipai_order_status_history
WHERE tenant_id = ? AND order_id = ? ORDER BY id`,
[tenantId, orderId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
actorId: row.actorId === null ? null : String(row.actorId)
}));
}
private async assertAuthorized(
connection: PoolConnection, actor: OrderActor, order: OrderRow, action: OrderAction
) {
if (actor.source !== 'APP') {
if (!actor.access || !canManageStore(actor.access, order.storeId)) {
throw new OrderStateError('ORDER_MANAGEMENT_FORBIDDEN');
}
return;
}
if (action !== 'CANCEL') throw new OrderStateError('ORDER_ACTION_FORBIDDEN');
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
[actor.tenantId, order.id, actor.userId]
);
if (!rows[0]) throw new OrderStateError('ORDER_ACCESS_FORBIDDEN');
}
private async loadOrder(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
orderId: string,
lock: boolean
) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT id, store_id AS storeId, status, status_version AS statusVersion
FROM qipai_orders
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new OrderStateError('ORDER_NOT_FOUND');
return rows[0];
}
private async findByTrace(
connection: PoolConnection, tenantId: string, orderId: string, traceId: string
) {
const [rows] = await connection.execute<HistoryRow[]>(
`SELECT id, to_status AS toStatus
FROM qipai_order_status_history
WHERE tenant_id = ? AND order_id = ? AND trace_id = ? LIMIT 1`,
[tenantId, orderId, traceId]
);
return rows[0] ?? null;
}
private async applyReservationState(
connection: PoolConnection, tenantId: string, orderId: string, status: OrderStatus
) {
if (['PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED'].includes(status)) {
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'`,
[tenantId, orderId]
);
} else if (['CANCELLED', 'REFUNDED', 'CLOSED'].includes(status)) {
await connection.execute(
`UPDATE qipai_room_reservations
SET status = 'RELEASED', released_at = COALESCE(released_at, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND order_id = ? AND status IN ('HELD', 'CONSUMED')`,
[tenantId, orderId]
);
await connection.execute(
`UPDATE qipai_order_user_access
SET revoked_at = COALESCE(revoked_at, UTC_TIMESTAMP(3))
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}`
});
}
}
}
}
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 canManageStore(access: AccessProfile, storeId: string) {
return access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId));
}
+393
View File
@@ -0,0 +1,393 @@
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';
export interface PricingAdjustment {
discountCents?: number;
packageCreditCents?: number;
}
export interface QuoteInput {
tenantId: string;
roomId: string;
startAt: Date;
endAt: Date;
pricingMode: PricingMode;
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;
timezone: string;
configurationStatus: string;
operationalStatus: string;
basePriceCents: number;
weekdayPriceCents: number;
holidayPriceCents: number;
overnightPriceCents: number;
fullDayPriceCents: number;
minimumSpendCents: number;
depositCents: number;
minimumMinutes: number;
maxAdvanceDays: number;
roomCategoryId: string | null;
}
interface CountRow extends RowDataPacket { total: number }
export class PricingError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PricingRepository {
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);
await this.releaseExpired(input.tenantId, input.roomId);
await this.assertAvailable(this.pool, input, false);
const isHoliday = await this.isHoliday(this.pool, input.tenantId, input.startAt, room.timezone);
return this.calculate(room, input, isHoliday);
}
async reserve(input: QuoteInput & {
userId: string;
holdMinutes?: number;
allowedStoreIds?: string[] | null;
benefits?: OrderBenefitInput | null;
}) {
return this.transaction(async (connection) => {
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');
}
await this.releaseExpired(input.tenantId, input.roomId, connection);
await this.assertAvailable(connection, input, true);
const isHoliday = await this.isHoliday(
connection, input.tenantId, input.startAt, room.timezone
);
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>(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
hold_expires_at, total_amount_cents)
VALUES (?, ?, ?, ?, 'PENDING_PAYMENT', ?, ?,
DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE), ?)`,
[input.tenantId, room.storeId, input.roomId, orderNo, input.startAt, input.endAt,
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,
subtotal_cents, minimum_spend_cents, deposit_cents, discount_cents,
package_credit_cents, total_cents, rules)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, orderId, input.pricingMode, quote.durationMinutes,
quote.unitPriceCents, quote.subtotalCents, quote.minimumSpendCents,
quote.depositCents, quote.discountCents, quote.packageCreditCents,
quote.totalCents, JSON.stringify(quote.rules)]
);
const [reservationResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_room_reservations
(tenant_id, order_id, room_id, starts_at, ends_at, expires_at)
VALUES (?, ?, ?, ?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE))`,
[input.tenantId, orderId, input.roomId, input.startAt, input.endAt, holdMinutes]
);
await connection.execute(
`INSERT INTO qipai_order_user_access (tenant_id, order_id, user_id)
VALUES (?, ?, ?)`,
[input.tenantId, orderId, input.userId]
);
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 (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?,
'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`,
[input.tenantId, orderId, input.userId, `order-created-${orderId}`]
);
return {
orderId,
orderNo,
storeId: String(room.storeId),
reservationId: String(reservationResult.insertId),
holdMinutes,
quote,
benefitReservation
};
});
}
async releaseExpired(
tenantId?: string,
roomId?: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool
) {
const filters = [`r.status = 'HELD'`, 'r.expires_at <= UTC_TIMESTAMP(3)'];
const params: string[] = [];
if (tenantId) {
filters.push('r.tenant_id = ?');
params.push(tenantId);
}
if (roomId) {
filters.push('r.room_id = ?');
params.push(roomId);
}
const [counts] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_reservations r
WHERE ${filters.join(' AND ')}`,
params
);
await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_order_status_history
(tenant_id, order_id, from_status, to_status, action, actor_type,
actor_id, source, reason, trace_id, metadata)
SELECT o.tenant_id, o.id, o.status, 'CLOSED', 'EXPIRED', 'SYSTEM',
NULL, 'WORKER', 'Payment hold expired',
CONCAT('hold-expired-', o.id), JSON_OBJECT('statusVersion', o.status_version + 1)
FROM qipai_room_reservations r
INNER JOIN qipai_orders o
ON o.id = r.order_id AND o.tenant_id = r.tenant_id
WHERE ${filters.join(' AND ')}`,
params
);
await connection.execute<ResultSetHeader>(
`UPDATE qipai_room_reservations r
INNER JOIN qipai_orders o
ON o.id = r.order_id AND o.tenant_id = r.tenant_id
SET r.status = 'RELEASED', r.released_at = UTC_TIMESTAMP(3),
o.status = 'CLOSED', o.status_version = o.status_version + 1,
o.status_updated_at = UTC_TIMESTAMP(3)
WHERE ${filters.join(' AND ')}`,
params
);
return { released: Number(counts[0]?.total ?? 0) };
}
private calculate(room: RoomPricingRow, input: QuoteInput, isHoliday: boolean) {
this.validateWindow(room, input);
const durationMinutes = Math.ceil(
(input.endAt.getTime() - input.startAt.getTime()) / 60000
);
const localDate = localDateKey(input.startAt, room.timezone);
const weekday = localWeekday(input.startAt, room.timezone);
const adjustment = input.adjustment ?? {};
let unitPriceCents = room.basePriceCents;
let priceSource = 'base';
if (input.pricingMode === 'OVERNIGHT') {
unitPriceCents = room.overnightPriceCents || room.basePriceCents;
priceSource = 'overnight';
} else if (input.pricingMode === 'FULL_DAY') {
unitPriceCents = room.fullDayPriceCents || room.basePriceCents * 24;
priceSource = 'fullDay';
} else if (isHoliday && room.holidayPriceCents > 0) {
unitPriceCents = room.holidayPriceCents;
priceSource = 'holiday';
} else if (weekday >= 1 && weekday <= 5 && room.weekdayPriceCents > 0) {
unitPriceCents = room.weekdayPriceCents;
priceSource = 'weekday';
}
const subtotalCents = input.pricingMode === 'HOURLY'
? Math.ceil(durationMinutes / 60) * unitPriceCents
: unitPriceCents;
const minimumSpendCents = room.minimumSpendCents;
const billableCents = Math.max(subtotalCents, minimumSpendCents);
const discountCents = clampAdjustment(adjustment.discountCents, billableCents);
const afterDiscount = billableCents - discountCents;
const packageCreditCents = clampAdjustment(
adjustment.packageCreditCents, afterDiscount
);
const totalCents = afterDiscount - packageCreditCents + room.depositCents;
return {
durationMinutes,
unitPriceCents,
subtotalCents,
minimumSpendCents,
depositCents: room.depositCents,
discountCents,
packageCreditCents,
totalCents,
rules: {
priceSource,
isHoliday,
localDate,
timezone: room.timezone,
pricingMode: input.pricingMode,
minimumMinutes: room.minimumMinutes
}
};
}
private validateWindow(room: RoomPricingRow, input: QuoteInput) {
if (input.endAt <= input.startAt) throw new PricingError('INVALID_TIME_WINDOW');
const durationMinutes = (input.endAt.getTime() - input.startAt.getTime()) / 60000;
if (durationMinutes < room.minimumMinutes) throw new PricingError('MINIMUM_DURATION_NOT_MET');
if (input.startAt.getTime() > Date.now() + room.maxAdvanceDays * 86400000) {
throw new PricingError('ADVANCE_WINDOW_EXCEEDED');
}
if (room.configurationStatus !== 'ENABLED' || room.operationalStatus !== 'AVAILABLE') {
throw new PricingError('ROOM_NOT_AVAILABLE');
}
}
private async loadRoom(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
roomId: string,
lock = false
) {
const [rows] = await connection.execute<RoomPricingRow[]>(
`SELECT r.id, r.store_id AS storeId, s.timezone,
r.configuration_status AS configurationStatus,
r.operational_status AS operationalStatus,
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_days AS maxAdvanceDays,
r.room_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
WHERE r.tenant_id = ? AND r.id = ? AND r.deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, roomId]
);
if (!rows[0]) throw new PricingError('ROOM_NOT_FOUND');
return rows[0];
}
private async assertAvailable(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
input: QuoteInput,
lock: boolean
) {
const [disabled] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_disabled_periods
WHERE tenant_id = ? AND room_id = ? AND starts_at < ? AND ends_at > ?`,
[input.tenantId, input.roomId, input.endAt, input.startAt]
);
if (Number(disabled[0]?.total ?? 0) > 0) throw new PricingError('ROOM_DISABLED_PERIOD');
const [reserved] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_room_reservations
WHERE tenant_id = ? AND room_id = ?
AND status IN ('HELD', 'CONSUMED')
AND (status = 'CONSUMED' OR expires_at > UTC_TIMESTAMP(3))
AND starts_at < ? AND ends_at > ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.roomId, input.endAt, input.startAt]
);
if (Number(reserved[0]?.total ?? 0) > 0) throw new PricingError('TIME_SLOT_CONFLICT');
}
private async isHoliday(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
date: Date,
timezone: string
) {
const [rows] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_holiday_calendar
WHERE tenant_id = ? AND holiday_date = ?`,
[tenantId, localDateKey(date, timezone)]
);
return Number(rows[0]?.total ?? 0) > 0;
}
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 clampAdjustment(value: number | undefined, maximum: number) {
if (!value || value < 0) return 0;
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'
}).format(date);
}
function localWeekday(date: Date, timezone: string) {
const day = new Intl.DateTimeFormat('en-US', {
timeZone: timezone, weekday: 'short'
}).format(date);
return ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(day);
}
+373
View File
@@ -0,0 +1,373 @@
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';
interface OrderRow extends RowDataPacket {
id: string;
storeId: string;
status: string;
totalAmountCents: number;
paidAmountCents: number;
}
interface PaymentRow extends RowDataPacket {
id: string;
orderId: string;
paymentNo: string;
provider: PaymentProvider;
status: string;
amountCents: number;
}
interface ConfigRow extends RowDataPacket {
id: string;
credentialRef: string;
settings: string | object;
scopeKey: string;
}
export class PaymentError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class PaymentRepository {
constructor(
private readonly pool: MySqlPool,
private readonly wallet?: Pick<WalletLedgerService, 'debitInTransaction'>,
private readonly benefits?: Pick<MarketingBenefitService, 'confirmReservedInTransaction'>
) {}
async createPayment(input: {
tenantId: string;
platformAppId: string;
userId: string;
orderId: string;
provider: PaymentProvider;
clientRequestId: string;
testAdapterEnabled: boolean;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOwnedOrder(
connection, input.tenantId, input.userId, input.orderId, true
);
if (!['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(order.status)) {
throw new PaymentError('PAYMENT_ORDER_STATUS_INVALID');
}
const amountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (amountCents <= 0) throw new PaymentError('PAYMENT_NOT_REQUIRED');
if (input.provider === 'TEST' && !input.testAdapterEnabled) {
throw new PaymentError('TEST_PAYMENT_DISABLED');
}
if (!['TEST', 'BALANCE'].includes(input.provider)) {
await this.resolveConfig(
connection, input.tenantId, input.platformAppId, order.storeId, input.provider
);
}
const [existing] = await connection.execute<PaymentRow[]>(
`SELECT id, order_id AS orderId, payment_no AS paymentNo, provider,
status, amount_cents AS amountCents
FROM qipai_payments
WHERE tenant_id = ? AND client_request_id = ? LIMIT 1`,
[input.tenantId, input.clientRequestId]
);
if (existing[0]) {
if (String(existing[0].orderId) !== input.orderId
|| existing[0].provider !== input.provider
|| Number(existing[0].amountCents) !== amountCents) {
throw new PaymentError('PAYMENT_IDEMPOTENCY_CONFLICT');
}
return this.paymentResponse(existing[0], true, input.testAdapterEnabled);
}
const paymentNo = `PAY${Date.now()}${randomBytes(5).toString('hex').toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_payments
(tenant_id, platform_app_id, order_id, store_id, payment_no,
channel, provider, client_request_id, status, amount_cents)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', ?)`,
[input.tenantId, input.platformAppId, input.orderId, order.storeId,
paymentNo, input.provider, input.provider, input.clientRequestId, amountCents]
);
const paymentId = String(result.insertId);
await connection.execute(
`INSERT INTO qipai_payment_attempts
(tenant_id, payment_id, attempt_no, status, request_payload, response_payload,
completed_at)
VALUES (?, ?, 1, 'CREATED',
JSON_OBJECT('provider', ?, 'amountCents', ?),
JSON_OBJECT('adapter', ?, 'credentialExposed', FALSE), UTC_TIMESTAMP(3))`,
[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
} as PaymentRow, false, input.testAdapterEnabled);
});
}
async processTestCallback(input: {
tenantId: string;
userId: string;
paymentId: string;
callbackId: string;
amountCents: number;
testAdapterEnabled: boolean;
traceId: string;
}) {
if (!input.testAdapterEnabled) throw new PaymentError('TEST_PAYMENT_DISABLED');
return this.transaction(async (connection) => {
const payment = await this.loadPayment(connection, input.tenantId, input.paymentId, true);
await this.assertOrderOwner(connection, input.tenantId, payment.orderId, input.userId);
if (payment.provider !== 'TEST') throw new PaymentError('PAYMENT_PROVIDER_INVALID');
const [callbackResult] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_payment_callbacks
(tenant_id, payment_id, provider, callback_id, callback_type,
verified, payload)
VALUES (?, ?, 'TEST', ?, 'PAYMENT_SUCCEEDED', 1,
JSON_OBJECT('amountCents', ?))`,
[input.tenantId, input.paymentId, input.callbackId, input.amountCents]
);
if (callbackResult.affectedRows === 0) {
return { paymentId: input.paymentId, status: payment.status, idempotent: true };
}
if (Number(payment.amountCents) !== input.amountCents) {
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'REJECTED', error_code = 'PAYMENT_AMOUNT_MISMATCH',
processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'TEST' AND callback_id = ?`,
[input.tenantId, input.callbackId]
);
return {
paymentId: input.paymentId,
status: 'REJECTED',
code: 'PAYMENT_AMOUNT_MISMATCH',
idempotent: false
};
}
if (payment.status === 'SUCCEEDED') {
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'DUPLICATE', processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'TEST' AND callback_id = ?`,
[input.tenantId, input.callbackId]
);
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: true };
}
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)
WHERE tenant_id = ? AND provider = 'TEST' AND callback_id = ?`,
[input.tenantId, input.callbackId]
);
return { paymentId: input.paymentId, status: 'SUCCEEDED', idempotent: false };
});
}
async resolveConfig(
connection: Pick<MySqlPool, 'execute'>,
tenantId: string,
platformAppId: string,
storeId: string,
provider: PaymentProvider
) {
const [rows] = await connection.execute<ConfigRow[]>(
`SELECT id, credential_ref AS credentialRef, settings, scope_key AS scopeKey
FROM qipai_payment_configs
WHERE provider = ? AND enabled = 1
AND (tenant_id IS NULL OR tenant_id = ?)
AND (platform_app_id IS NULL OR platform_app_id = ?)
AND (store_id IS NULL OR store_id = ?)
ORDER BY (store_id IS NOT NULL) DESC,
(tenant_id IS NOT NULL) DESC,
(platform_app_id IS NOT NULL) DESC, id DESC LIMIT 1`,
[provider, tenantId, platformAppId, storeId]
);
if (!rows[0]) throw new PaymentError('PAYMENT_CONFIG_NOT_FOUND');
return {
id: String(rows[0].id),
credentialRef: rows[0].credentialRef,
scopeKey: rows[0].scopeKey,
settings: typeof rows[0].settings === 'string'
? JSON.parse(rows[0].settings) : rows[0].settings
};
}
private paymentResponse(row: PaymentRow, idempotent: boolean, testEnabled: boolean) {
return {
paymentId: String(row.id),
orderId: String(row.orderId),
paymentNo: row.paymentNo,
provider: row.provider,
status: row.status,
amountCents: Number(row.amountCents),
idempotent,
testCompletionAvailable: row.provider === 'TEST' && testEnabled
};
}
private async loadOwnedOrder(
connection: PoolConnection, tenantId: string, userId: string,
orderId: string, lock: boolean
) {
await this.assertOrderOwner(connection, tenantId, orderId, userId);
return this.loadOrder(connection, tenantId, orderId, lock);
}
private async loadOrder(
connection: PoolConnection, tenantId: string, orderId: string, lock: boolean
) {
const [rows] = await connection.execute<Array<OrderRow & { statusVersion: number }>>(
`SELECT id, store_id AS storeId, status, status_version AS statusVersion,
total_amount_cents AS totalAmountCents, paid_amount_cents AS paidAmountCents
FROM qipai_orders WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new PaymentError('ORDER_NOT_FOUND');
return rows[0];
}
private async loadPayment(
connection: PoolConnection, tenantId: string, paymentId: string, lock: boolean
) {
const [rows] = await connection.execute<PaymentRow[]>(
`SELECT id, order_id AS orderId, payment_no AS paymentNo, provider,
status, amount_cents AS amountCents
FROM qipai_payments WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, paymentId]
);
if (!rows[0]) throw new PaymentError('PAYMENT_NOT_FOUND');
return rows[0];
}
private async assertOrderOwner(
connection: PoolConnection, tenantId: string, orderId: string, userId: string
) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ?`,
[tenantId, orderId, userId]
);
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 {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
@@ -0,0 +1,527 @@
import { createHash, randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import {
WechatPayClient, WechatPayError, type WechatPayCredential
} from './wechat-pay-client.js';
interface PaymentRow extends RowDataPacket {
id: string;
orderId: string;
storeId: string;
status: string;
provider: string;
providerPaymentId: string | null;
amountCents: number;
}
interface AccountRow extends RowDataPacket {
id: string;
storeId: string | null;
merchantId: string;
credentialRef: string;
authorizationStatus: string;
profitSharingEnabled: number;
}
interface PolicyRow extends RowDataPacket {
receiverId: string;
receiverType: string;
receiverMasked: string;
receiverCredentialRef: string;
receiverAuthorizationStatus: string;
percentageBps: number;
name: string;
}
interface ShareRow extends RowDataPacket {
id: string;
shareNo: string;
status: string;
amountCents: number;
}
export class ProfitSharingError extends Error {
constructor(public readonly code: string, message = code) {
super(message);
}
}
export class ProfitSharingService {
constructor(
private readonly pool: MySqlPool,
private readonly client: WechatPayClient,
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
private readonly mockEnabled: boolean
) {}
async saveCollectionAccount(input: {
tenantId: string;
platformAppId: string;
actorId: string;
access: AccessProfile;
storeId: string | null;
merchantId: string;
credentialRef: string;
authorizationStatus: 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
profitSharingEnabled: boolean;
enabled: boolean;
}) {
assertTenantManager(input.access);
if (!/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) {
throw new ProfitSharingError('COLLECTION_CREDENTIAL_REF_INVALID');
}
if (input.profitSharingEnabled && input.authorizationStatus !== 'AUTHORIZED') {
throw new ProfitSharingError('PROFIT_SHARING_NOT_AUTHORIZED');
}
await this.assertStore(input.tenantId, input.storeId);
const scopeKey = `app:${input.platformAppId}:store:${input.storeId ?? 'ALL'}`;
const [existing] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_collection_accounts
WHERE tenant_id = ? AND provider = 'WECHAT' AND scope_key = ? LIMIT 1`,
[input.tenantId, scopeKey]
);
if (existing[0]) {
await this.pool.execute(
`UPDATE qipai_collection_accounts
SET merchant_id = ?, credential_ref = ?, authorization_status = ?,
profit_sharing_enabled = ?, enabled = ?
WHERE tenant_id = ? AND id = ?`,
[input.merchantId, input.credentialRef, input.authorizationStatus,
input.profitSharingEnabled, input.enabled, input.tenantId, existing[0].id]
);
return { accountId: String(existing[0].id), created: false };
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_collection_accounts
(tenant_id, platform_app_id, store_id, provider, merchant_id,
scope_key, credential_ref, authorization_status,
profit_sharing_enabled, enabled)
VALUES (?, ?, ?, 'WECHAT', ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.platformAppId, input.storeId, input.merchantId,
scopeKey, input.credentialRef, input.authorizationStatus,
input.profitSharingEnabled, input.enabled]
);
return { accountId: String(result.insertId), created: true };
}
async saveReceiver(input: {
tenantId: string;
access: AccessProfile;
collectionAccountId: string;
receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
receiverAccount: string;
receiverCredentialRef: string;
relationType: string;
name: string;
authorizationStatus: 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
enabled: boolean;
}) {
assertTenantManager(input.access);
if (!/^receiver:[A-Z0-9_:-]+$/.test(input.receiverCredentialRef)) {
throw new ProfitSharingError('PROFIT_RECEIVER_REF_INVALID');
}
const account = await this.loadAccount(
input.tenantId, input.collectionAccountId, false
);
if (!account) throw new ProfitSharingError('COLLECTION_ACCOUNT_NOT_FOUND');
const hash = hashValue(input.receiverAccount);
const [existing] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_profit_share_receivers
WHERE tenant_id = ? AND collection_account_id = ? AND receiver_hash = ? LIMIT 1`,
[input.tenantId, input.collectionAccountId, hash]
);
if (existing[0]) {
await this.pool.execute(
`UPDATE qipai_profit_share_receivers
SET receiver_type = ?, receiver_masked = ?, receiver_credential_ref = ?,
relation_type = ?, name = ?, authorization_status = ?, enabled = ?
WHERE tenant_id = ? AND id = ?`,
[input.receiverType, maskValue(input.receiverAccount),
input.receiverCredentialRef, input.relationType, input.name,
input.authorizationStatus, input.enabled, input.tenantId, existing[0].id]
);
return { receiverId: String(existing[0].id), created: false };
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_profit_share_receivers
(tenant_id, collection_account_id, receiver_type, receiver_hash,
receiver_masked, receiver_credential_ref, relation_type, name,
enabled, authorization_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.collectionAccountId, input.receiverType, hash,
maskValue(input.receiverAccount), input.receiverCredentialRef,
input.relationType, input.name, input.enabled, input.authorizationStatus]
);
return { receiverId: String(result.insertId), created: true };
}
async savePolicy(input: {
tenantId: string;
access: AccessProfile;
collectionAccountId: string;
storeId: string | null;
receiverId: string;
percentageBps: number;
enabled: boolean;
}) {
assertTenantManager(input.access);
if (input.percentageBps <= 0 || input.percentageBps > 10000) {
throw new ProfitSharingError('PROFIT_SHARE_PERCENTAGE_INVALID');
}
await this.assertStore(input.tenantId, input.storeId);
const [receiverRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_profit_share_receivers
WHERE tenant_id = ? AND id = ? AND collection_account_id = ?`,
[input.tenantId, input.receiverId, input.collectionAccountId]
);
if (!receiverRows[0]) throw new ProfitSharingError('PROFIT_RECEIVER_NOT_FOUND');
const [sumRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT COALESCE(SUM(percentage_bps), 0) AS totalBps
FROM qipai_profit_share_policies
WHERE tenant_id = ? AND collection_account_id = ?
AND store_id <=> ? AND receiver_id <> ? AND enabled = 1`,
[input.tenantId, input.collectionAccountId, input.storeId, input.receiverId]
);
if (Number(sumRows[0].totalBps) + (input.enabled ? input.percentageBps : 0) > 10000) {
throw new ProfitSharingError('PROFIT_SHARE_TOTAL_EXCEEDED');
}
const scopeKey = input.storeId ? `store:${input.storeId}` : 'store:ALL';
await this.pool.execute(
`INSERT INTO qipai_profit_share_policies
(tenant_id, collection_account_id, store_id, receiver_id, scope_key,
percentage_bps, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE percentage_bps = VALUES(percentage_bps),
enabled = VALUES(enabled)`,
[input.tenantId, input.collectionAccountId, input.storeId,
input.receiverId, scopeKey, input.percentageBps, input.enabled]
);
return { saved: true };
}
async execute(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
paymentId: string;
clientRequestId: string;
mode: 'API' | 'MOCK';
}) {
assertTenantManager(input.access);
const [existing] = await this.pool.execute<ShareRow[]>(
`SELECT id, share_no AS shareNo, status, amount_cents AS amountCents
FROM qipai_profit_shares
WHERE tenant_id = ? AND batch_request_id = ? ORDER BY id`,
[input.tenantId, input.clientRequestId]
);
if (existing[0]) return { shares: existing.map(normalizeShare), idempotent: true };
const payment = await this.loadPayment(input.tenantId, input.paymentId);
if (payment.status !== 'SUCCEEDED' || payment.provider !== 'WECHAT'
|| !payment.providerPaymentId) {
throw new ProfitSharingError('PAYMENT_NOT_SHAREABLE');
}
const account = await this.resolveAccount(input.tenantId, payment.storeId);
if (!account.profitSharingEnabled || account.authorizationStatus !== 'AUTHORIZED') {
throw new ProfitSharingError('PROFIT_SHARING_NOT_AUTHORIZED');
}
const policies = await this.loadPolicies(
input.tenantId, account.id, payment.storeId
);
if (policies.length === 0) throw new ProfitSharingError('PROFIT_SHARE_POLICY_NOT_FOUND');
if (policies.some((policy) => policy.receiverAuthorizationStatus !== 'AUTHORIZED')) {
throw new ProfitSharingError('PROFIT_RECEIVER_NOT_AUTHORIZED');
}
if (input.mode === 'MOCK' && !this.mockEnabled) {
throw new ProfitSharingError('PROFIT_SHARE_MOCK_DISABLED');
}
const shares = policies.map((policy, index) => ({
policy,
shareNo: `SHR${Date.now()}${index}${randomBytes(3).toString('hex').toUpperCase()}`,
amountCents: Math.floor(
Number(payment.amountCents) * Number(policy.percentageBps) / 10000
)
})).filter((share) => share.amountCents > 0);
if (shares.length === 0) throw new ProfitSharingError('PROFIT_SHARE_AMOUNT_ZERO');
const credential = this.resolveCredential(account.credentialRef);
let response: Record<string, unknown>;
let status: string;
if (input.mode === 'MOCK') {
response = { adapter: 'mock', state: 'FINISHED' };
status = 'SUCCEEDED';
} else {
if (!credential) throw new ProfitSharingError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
if (credential.merchantId !== account.merchantId) {
throw new ProfitSharingError('COLLECTION_MERCHANT_MISMATCH');
}
const receivers = shares.map((share) => {
const receiver = credential.profitShareReceivers?.[
share.policy.receiverCredentialRef
];
if (!receiver) throw new ProfitSharingError('PROFIT_RECEIVER_CREDENTIAL_MISSING');
return {
type: share.policy.receiverType,
account: receiver,
amountCents: share.amountCents,
description: `订单分账-${share.policy.name}`
};
});
try {
response = await this.client.createProfitSharing(credential, {
transactionId: payment.providerPaymentId,
outOrderNo: input.clientRequestId,
receivers,
finish: true
});
status = mapShareState(response.state);
} catch (error) {
await this.recordShares(input, payment, account, shares, 'MANUAL_REVIEW', {
errorCode: error instanceof WechatPayError ? error.code : 'PROFIT_SHARE_API_FAILED'
});
throw error;
}
}
const recorded = await this.recordShares(
input, payment, account, shares, status, response
);
return { shares: recorded, idempotent: false };
}
async list(input: {
tenantId: string;
access: AccessProfile;
storeId?: string;
}) {
assertTenantManager(input.access);
const [accounts] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, platform_app_id AS platformAppId, store_id AS storeId,
provider, merchant_id AS merchantId,
authorization_status AS authorizationStatus,
profit_sharing_enabled AS profitSharingEnabled, enabled
FROM qipai_collection_accounts
WHERE tenant_id = ? ${input.storeId ? 'AND store_id = ?' : ''}
ORDER BY id`,
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
);
const [shares] = await this.pool.execute<RowDataPacket[]>(
`SELECT ps.id, ps.payment_id AS paymentId, ps.order_id AS orderId,
p.store_id AS storeId, ps.share_no AS shareNo,
ps.receiver_type AS receiverType, ps.receiver_ref AS receiverMasked,
ps.percentage_bps AS percentageBps, ps.amount_cents AS amountCents,
ps.status, ps.failure_code AS failureCode, ps.created_at AS createdAt
FROM qipai_profit_shares ps
INNER JOIN qipai_payments p
ON p.tenant_id = ps.tenant_id AND p.id = ps.payment_id
WHERE ps.tenant_id = ? ${input.storeId ? 'AND p.store_id = ?' : ''}
ORDER BY ps.id DESC LIMIT 100`,
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
);
const [receivers] = await this.pool.execute<RowDataPacket[]>(
`SELECT r.id, r.collection_account_id AS collectionAccountId,
r.receiver_type AS receiverType, r.receiver_masked AS receiverMasked,
r.relation_type AS relationType, r.name,
r.authorization_status AS authorizationStatus, r.enabled
FROM qipai_profit_share_receivers r
INNER JOIN qipai_collection_accounts a
ON a.tenant_id = r.tenant_id AND a.id = r.collection_account_id
WHERE r.tenant_id = ? ${input.storeId ? 'AND a.store_id = ?' : ''}
ORDER BY r.id`,
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
);
const [policies] = await this.pool.execute<RowDataPacket[]>(
`SELECT p.id, p.collection_account_id AS collectionAccountId,
p.store_id AS storeId, p.receiver_id AS receiverId,
p.percentage_bps AS percentageBps, p.enabled
FROM qipai_profit_share_policies p
INNER JOIN qipai_collection_accounts a
ON a.tenant_id = p.tenant_id AND a.id = p.collection_account_id
WHERE p.tenant_id = ? ${input.storeId ? 'AND (p.store_id = ? OR p.store_id IS NULL)' : ''}
ORDER BY p.id`,
input.storeId ? [input.tenantId, input.storeId] : [input.tenantId]
);
return { accounts, receivers, policies, shares };
}
private async recordShares(
input: {
tenantId: string;
clientRequestId: string;
},
payment: PaymentRow,
account: AccountRow,
shares: Array<{
policy: PolicyRow;
shareNo: string;
amountCents: number;
}>,
status: string,
response: Record<string, unknown>
) {
return this.transaction(async (connection) => {
const result = [];
for (const share of shares) {
const [insert] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_profit_shares
(tenant_id, payment_id, order_id, collection_account_id, receiver_id,
share_no, client_request_id, batch_request_id,
receiver_type, receiver_ref, percentage_bps,
amount_cents, status, failure_code,
provider_share_id, raw_response, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON),
IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL))`,
[input.tenantId, payment.id, payment.orderId, account.id,
share.policy.receiverId, share.shareNo,
`${input.clientRequestId}:${share.policy.receiverId}`,
input.clientRequestId,
share.policy.receiverType, share.policy.receiverMasked,
share.policy.percentageBps, share.amountCents, status,
status === 'MANUAL_REVIEW' ? stringValue(response.errorCode) : '',
stringValue(response.order_id), JSON.stringify(sanitizeResponse(response)), status]
);
result.push({
shareId: String(insert.insertId),
shareNo: share.shareNo,
amountCents: share.amountCents,
status
});
}
return result;
});
}
private async loadPayment(tenantId: string, paymentId: string) {
const [rows] = await this.pool.execute<PaymentRow[]>(
`SELECT id, order_id AS orderId, store_id AS storeId, status, provider,
provider_payment_id AS providerPaymentId, amount_cents AS amountCents
FROM qipai_payments
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
[tenantId, paymentId]
);
if (!rows[0]) throw new ProfitSharingError('PAYMENT_NOT_FOUND');
return rows[0];
}
private async resolveAccount(tenantId: string, storeId: string) {
const [rows] = await this.pool.execute<AccountRow[]>(
`SELECT id, store_id AS storeId, merchant_id AS merchantId,
credential_ref AS credentialRef,
authorization_status AS authorizationStatus,
profit_sharing_enabled AS profitSharingEnabled
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 ProfitSharingError('COLLECTION_ACCOUNT_NOT_FOUND');
return rows[0];
}
private async loadAccount(tenantId: string, accountId: string, enabledOnly: boolean) {
const [rows] = await this.pool.execute<AccountRow[]>(
`SELECT id, store_id AS storeId, merchant_id AS merchantId,
credential_ref AS credentialRef,
authorization_status AS authorizationStatus,
profit_sharing_enabled AS profitSharingEnabled
FROM qipai_collection_accounts
WHERE tenant_id = ? AND id = ? ${enabledOnly ? 'AND enabled = 1' : ''} LIMIT 1`,
[tenantId, accountId]
);
return rows[0] ?? null;
}
private async loadPolicies(tenantId: string, accountId: string, storeId: string) {
const [rows] = await this.pool.execute<PolicyRow[]>(
`SELECT p.receiver_id AS receiverId, r.receiver_type AS receiverType,
r.receiver_masked AS receiverMasked,
r.receiver_credential_ref AS receiverCredentialRef,
r.authorization_status AS receiverAuthorizationStatus,
p.percentage_bps AS percentageBps, r.name
FROM qipai_profit_share_policies p
INNER JOIN qipai_profit_share_receivers r
ON r.tenant_id = p.tenant_id AND r.id = p.receiver_id
WHERE p.tenant_id = ? AND p.collection_account_id = ?
AND p.enabled = 1 AND r.enabled = 1
AND (p.store_id IS NULL OR p.store_id = ?)
AND (p.store_id = ? OR NOT EXISTS (
SELECT 1 FROM qipai_profit_share_policies sp
WHERE sp.tenant_id = p.tenant_id
AND sp.collection_account_id = p.collection_account_id
AND sp.receiver_id = p.receiver_id
AND sp.store_id = ? AND sp.enabled = 1
))
ORDER BY (p.store_id IS NOT NULL) DESC, p.id`,
[tenantId, accountId, storeId, storeId, storeId]
);
return rows;
}
private resolveCredential(reference: string) {
return this.credentials.get(reference)
?? this.credentials.get(reference.replace(/^env:/, ''));
}
private async assertStore(tenantId: string, storeId: string | null) {
if (!storeId) return;
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[tenantId, storeId]
);
if (!rows[0]) throw new ProfitSharingError('STORE_NOT_FOUND');
}
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 assertTenantManager(access: AccessProfile) {
if (access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')) return;
throw new ProfitSharingError('PROFIT_SHARE_FORBIDDEN');
}
function hashValue(value: string) {
return createHash('sha256').update(value).digest('hex');
}
function maskValue(value: string) {
if (value.length <= 4) return '*'.repeat(value.length);
return `${value.slice(0, 2)}${'*'.repeat(Math.min(12, value.length - 4))}${value.slice(-2)}`;
}
function normalizeShare(row: ShareRow) {
return {
shareId: String(row.id),
shareNo: row.shareNo,
amountCents: Number(row.amountCents),
status: row.status
};
}
function mapShareState(value: unknown) {
if (value === 'FINISHED') return 'SUCCEEDED';
if (value === 'PROCESSING') return 'PROCESSING';
return 'MANUAL_REVIEW';
}
function stringValue(value: unknown) {
return typeof value === 'string' ? value : '';
}
function sanitizeResponse(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.receivers;
delete copy.account;
return copy;
}
+404
View File
@@ -0,0 +1,404 @@
import {
constants, createDecipheriv, createSign, createVerify, randomBytes
} from 'node:crypto';
export interface WechatPayCredential {
appId: string;
merchantId: string;
serialNo: string;
privateKeyPem: string;
apiV3Key: string;
platformCertificates: Record<string, string>;
profitShareReceivers?: Record<string, string>;
transferSceneId?: string;
transferSceneReportInfos?: Array<{ infoType: string; infoContent: string }>;
transferNotifyUrl?: string;
}
export interface WechatPayTransport {
request(input: {
method: 'GET' | 'POST';
url: string;
headers: Record<string, string>;
body?: string;
}): Promise<{ status: number; headers: Record<string, string>; body: string }>;
}
export interface WechatNotificationHeaders {
timestamp: string;
nonce: string;
serial: string;
signature: string;
}
export class WechatPayError extends Error {
constructor(public readonly code: string, message = code) {
super(message);
}
}
export class FetchWechatPayTransport implements WechatPayTransport {
async request(input: {
method: 'GET' | 'POST';
url: string;
headers: Record<string, string>;
body?: string;
}) {
const response = await fetch(input.url, {
method: input.method,
headers: input.headers,
body: input.body
});
return {
status: response.status,
headers: Object.fromEntries(response.headers.entries()),
body: await response.text()
};
}
}
export class WechatPayClient {
constructor(
private readonly transport: WechatPayTransport,
private readonly apiBase = 'https://api.mch.weixin.qq.com'
) {}
async createJsapiPrepay(
credential: WechatPayCredential,
input: {
description: string;
outTradeNo: string;
notifyUrl: string;
amountCents: number;
payerOpenId: string;
}
) {
const result = await this.apiRequest(credential, 'POST', '/v3/pay/transactions/jsapi', {
appid: credential.appId,
mchid: credential.merchantId,
description: input.description.slice(0, 127),
out_trade_no: input.outTradeNo,
notify_url: input.notifyUrl,
amount: { total: input.amountCents, currency: 'CNY' },
payer: { openid: input.payerOpenId }
});
const prepayId = stringField(result, 'prepay_id');
const timeStamp = String(Math.floor(Date.now() / 1000));
const nonceStr = randomBytes(16).toString('hex');
const packageValue = `prepay_id=${prepayId}`;
const paySign = signMessage(
credential.privateKeyPem,
`${credential.appId}\n${timeStamp}\n${nonceStr}\n${packageValue}\n`
);
return {
prepayId,
paymentParams: {
timeStamp,
nonceStr,
package: packageValue,
signType: 'RSA' as const,
paySign
}
};
}
async queryTransaction(credential: WechatPayCredential, outTradeNo: string) {
return this.apiRequest(
credential,
'GET',
`/v3/pay/transactions/out-trade-no/${encodeURIComponent(outTradeNo)}`
+ `?mchid=${encodeURIComponent(credential.merchantId)}`
);
}
async createRefund(
credential: WechatPayCredential,
input: {
outTradeNo: string;
outRefundNo: string;
reason: string;
notifyUrl: string;
refundCents: number;
totalCents: number;
}
) {
return this.apiRequest(credential, 'POST', '/v3/refund/domestic/refunds', {
out_trade_no: input.outTradeNo,
out_refund_no: input.outRefundNo,
reason: input.reason.slice(0, 80),
notify_url: input.notifyUrl,
amount: {
refund: input.refundCents,
total: input.totalCents,
currency: 'CNY'
}
});
}
async downloadTradeBill(
credential: WechatPayCredential,
billDate: string,
billType: 'ALL' | 'SUCCESS' | 'REFUND'
) {
return this.apiRequest(
credential,
'GET',
`/v3/bill/tradebill?bill_date=${encodeURIComponent(billDate)}`
+ `&bill_type=${encodeURIComponent(billType)}`
);
}
async createProfitSharing(
credential: WechatPayCredential,
input: {
transactionId: string;
outOrderNo: string;
receivers: Array<{
type: string;
account: string;
amountCents: number;
description: string;
}>;
finish: boolean;
}
) {
return this.apiRequest(credential, 'POST', '/v3/profitsharing/orders', {
appid: credential.appId,
transaction_id: input.transactionId,
out_order_no: input.outOrderNo,
receivers: input.receivers.map((receiver) => ({
type: receiver.type,
account: receiver.account,
amount: receiver.amountCents,
description: receiver.description.slice(0, 80)
})),
unfreeze_unsplit: input.finish
});
}
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,
rawBody: string
): Record<string, unknown> {
const certificate = credential.platformCertificates[headers.serial];
if (!certificate) throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
const verifier = createVerify('RSA-SHA256');
verifier.update(`${headers.timestamp}\n${headers.nonce}\n${rawBody}\n`);
verifier.end();
if (!verifier.verify(certificate, headers.signature, 'base64')) {
throw new WechatPayError('WECHAT_SIGNATURE_INVALID');
}
const envelope = parseJson(rawBody);
const resource = objectField(envelope, 'resource');
const ciphertext = Buffer.from(stringField(resource, 'ciphertext'), 'base64');
if (ciphertext.length <= 16) throw new WechatPayError('WECHAT_RESOURCE_INVALID');
const decipher = createDecipheriv(
'aes-256-gcm',
Buffer.from(credential.apiV3Key, 'utf8'),
Buffer.from(stringField(resource, 'nonce'), 'utf8')
);
const associatedData = optionalStringField(resource, 'associated_data');
if (associatedData) decipher.setAAD(Buffer.from(associatedData, 'utf8'));
decipher.setAuthTag(ciphertext.subarray(ciphertext.length - 16));
const plaintext = Buffer.concat([
decipher.update(ciphertext.subarray(0, ciphertext.length - 16)),
decipher.final()
]).toString('utf8');
return parseJson(plaintext);
}
private async apiRequest(
credential: WechatPayCredential,
method: 'GET' | 'POST',
path: string,
payload?: unknown
) {
const body = payload === undefined ? '' : JSON.stringify(payload);
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = randomBytes(16).toString('hex');
const signature = signMessage(
credential.privateKeyPem,
`${method}\n${path}\n${timestamp}\n${nonce}\n${body}\n`
);
const response = await this.transport.request({
method,
url: `${this.apiBase}${path}`,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `WECHATPAY2-SHA256-RSA2048 mchid="${credential.merchantId}",`
+ `nonce_str="${nonce}",timestamp="${timestamp}",`
+ `serial_no="${credential.serialNo}",signature="${signature}"`,
'User-Agent': 'qipai-backend/0.1'
},
body: body || undefined
});
if (response.status < 200 || response.status >= 300) {
throw new WechatPayError(
'WECHAT_API_FAILED',
`Wechat Pay API returned HTTP ${response.status}.`
);
}
return parseJson(response.body);
}
}
export function parseWechatPayCredentials(value: string) {
const parsed = parseJson(value);
const credentials = new Map<string, WechatPayCredential>();
for (const [key, raw] of Object.entries(parsed)) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new WechatPayError('WECHAT_CREDENTIAL_INVALID');
}
const item = raw as Record<string, unknown>;
const apiV3Key = stringField(item, 'apiV3Key');
if (Buffer.byteLength(apiV3Key, 'utf8') !== 32) {
throw new WechatPayError('WECHAT_API_V3_KEY_INVALID');
}
const certificates = objectField(item, 'platformCertificates');
credentials.set(key, {
appId: stringField(item, 'appId'),
merchantId: stringField(item, 'merchantId'),
serialNo: stringField(item, 'serialNo'),
privateKeyPem: stringField(item, 'privateKeyPem'),
apiV3Key,
platformCertificates: Object.fromEntries(
Object.entries(certificates).map(([serial, certificate]) => {
if (typeof certificate !== 'string') {
throw new WechatPayError('WECHAT_CERTIFICATE_INVALID');
}
return [serial, certificate];
})
),
profitShareReceivers: item.profitShareReceivers
&& typeof item.profitShareReceivers === 'object'
&& !Array.isArray(item.profitShareReceivers)
? Object.fromEntries(
Object.entries(item.profitShareReceivers as Record<string, unknown>)
.map(([reference, account]) => {
if (typeof account !== 'string' || account.length === 0) {
throw new WechatPayError('WECHAT_PROFIT_RECEIVER_INVALID');
}
return [reference, account];
})
) : {},
transferSceneId: optionalStringField(item, 'transferSceneId'),
transferSceneReportInfos: parseTransferReportInfos(item.transferSceneReportInfos),
transferNotifyUrl: optionalStringField(item, 'transferNotifyUrl')
});
}
return credentials;
}
function signMessage(privateKeyPem: string, message: string) {
const signer = createSign('RSA-SHA256');
signer.update(message);
signer.end();
return signer.sign({
key: privateKeyPem,
padding: constants.RSA_PKCS1_PADDING
}, 'base64');
}
function parseJson(value: string): Record<string, unknown> {
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('object required');
}
return parsed;
} catch {
throw new WechatPayError('WECHAT_JSON_INVALID');
}
}
function objectField(value: Record<string, unknown>, key: string) {
const field = value[key];
if (!field || typeof field !== 'object' || Array.isArray(field)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field as Record<string, unknown>;
}
function stringField(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'string' || field.length === 0) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
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')
};
});
}
@@ -0,0 +1,600 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { PaymentRepository } from './payment-repository.js';
import {
WechatPayClient, WechatPayError, type WechatNotificationHeaders,
type WechatPayCredential
} from './wechat-pay-client.js';
interface PaymentRow extends RowDataPacket {
id: string;
tenantId: string;
platformAppId: string;
orderId: string;
storeId: string;
paymentNo: string;
status: string;
amountCents: number;
paidAmountCents: number;
totalAmountCents: number;
orderStatus: string;
userId?: string;
}
interface RefundRow extends RowDataPacket {
id: string;
tenantId: string;
paymentId: string;
orderId: string;
refundNo: string;
status: string;
amountCents: number;
}
export class WechatPaymentService {
constructor(
private readonly pool: MySqlPool,
private readonly paymentRepository: PaymentRepository,
private readonly client: WechatPayClient,
private readonly credentials: ReadonlyMap<string, WechatPayCredential>
) {}
async createPrepay(input: {
tenantId: string;
platformAppId: string;
userId: string;
paymentId: string;
}) {
const payment = await this.loadOwnedPayment(input, false);
if (payment.status === 'SUCCEEDED') {
throw new WechatPayError('PAYMENT_ALREADY_SUCCEEDED');
}
if (payment.status !== 'PENDING') throw new WechatPayError('PAYMENT_STATUS_INVALID');
const config = await this.paymentRepository.resolveConfig(
this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT'
);
const credential = this.resolveCredential(config.credentialRef);
const settings = config.settings as Record<string, unknown>;
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT openid FROM qipai_user_identities
WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ?
AND provider = 'WECHAT' LIMIT 1`,
[input.tenantId, input.platformAppId, input.userId]
);
if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND');
const result = await this.client.createJsapiPrepay(credential, {
description: typeof settings.description === 'string'
? settings.description : `棋牌室订单 ${payment.paymentNo}`,
outTradeNo: payment.paymentNo,
notifyUrl: settingUrl(
settings, 'paymentNotifyUrl', 'https://api.txyundm.cn/app-api/pay/wechat/notify'
),
amountCents: Number(payment.amountCents),
payerOpenId: String(identityRows[0].openid)
});
await this.pool.execute(
`UPDATE qipai_payment_attempts
SET status = 'PREPAY_CREATED',
response_payload = JSON_OBJECT(
'prepayId', ?, 'credentialExposed', FALSE
),
completed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND payment_id = ? AND attempt_no = 1`,
[result.prepayId, input.tenantId, input.paymentId]
);
return {
paymentId: input.paymentId,
paymentNo: payment.paymentNo,
amountCents: Number(payment.amountCents),
...result.paymentParams
};
}
async queryPayment(input: {
tenantId: string;
platformAppId: string;
userId: string;
paymentId: string;
}) {
const payment = await this.loadOwnedPayment(input, false);
const config = await this.paymentRepository.resolveConfig(
this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT'
);
const result = await this.client.queryTransaction(
this.resolveCredential(config.credentialRef), payment.paymentNo
);
return {
paymentId: payment.id,
localStatus: payment.status,
providerStatus: stringValue(result.trade_state),
providerTransactionId: stringValue(result.transaction_id)
};
}
async processPaymentNotification(
headers: WechatNotificationHeaders,
rawBody: string,
traceId: string
) {
const data = this.verifyWithConfiguredCredential(headers, rawBody);
const paymentNo = requiredString(data, 'out_trade_no');
const transactionId = requiredString(data, 'transaction_id');
const tradeState = requiredString(data, 'trade_state');
const amount = objectValue(data.amount);
const total = requiredNumber(amount, 'total');
return this.transaction(async (connection) => {
const payment = await this.loadPaymentByNo(connection, paymentNo, true);
const callbackId = `${headers.serial}:${transactionId}:${tradeState}`;
const [callback] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_payment_callbacks
(tenant_id, payment_id, provider, callback_id, callback_type,
verified, payload)
VALUES (?, ?, 'WECHAT', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`,
[payment.tenantId, payment.id, callbackId, JSON.stringify(redactNotification(data))]
);
if (callback.affectedRows === 0) {
return { paymentId: payment.id, status: payment.status, idempotent: true };
}
if (tradeState !== 'SUCCESS' || total !== Number(payment.amountCents)) {
const code = tradeState !== 'SUCCESS'
? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH';
await this.rejectCallback(connection, payment.tenantId, callbackId, code);
return { paymentId: payment.id, status: 'REJECTED', code, idempotent: false };
}
await this.applyPaymentSuccess(
connection, payment, transactionId, callbackId, traceId
);
return { paymentId: payment.id, status: 'SUCCEEDED', idempotent: false };
});
}
async createRefund(input: {
tenantId: string;
platformAppId: string;
actorId: string;
paymentId: string;
amountCents: number;
reason: string;
clientRequestId: string;
}) {
const payment = await this.loadPayment(input.tenantId, input.paymentId, false);
if (payment.status !== 'SUCCEEDED' && payment.status !== 'PARTIALLY_REFUNDED') {
throw new WechatPayError('PAYMENT_NOT_REFUNDABLE');
}
const [sumRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT COALESCE(SUM(amount_cents), 0) AS refundedCents
FROM qipai_refunds
WHERE tenant_id = ? AND payment_id = ?
AND status IN ('PENDING', 'PROCESSING', 'SUCCEEDED')`,
[input.tenantId, input.paymentId]
);
const refundable = Number(payment.amountCents) - Number(sumRows[0].refundedCents);
if (input.amountCents > refundable) throw new WechatPayError('REFUND_AMOUNT_EXCEEDED');
const [existing] = await this.pool.execute<RefundRow[]>(
`SELECT id, tenant_id AS tenantId, payment_id AS paymentId,
order_id AS orderId, refund_no AS refundNo, status,
amount_cents AS amountCents
FROM qipai_refunds
WHERE tenant_id = ? AND client_request_id = ? LIMIT 1`,
[input.tenantId, input.clientRequestId]
);
if (existing[0]) {
if (String(existing[0].paymentId) !== input.paymentId
|| Number(existing[0].amountCents) !== input.amountCents) {
throw new WechatPayError('REFUND_IDEMPOTENCY_CONFLICT');
}
return { ...normalizeRefund(existing[0]), idempotent: true };
}
const refundNo = `REF${Date.now()}${randomBytes(5).toString('hex').toUpperCase()}`;
const [insert] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_refunds
(tenant_id, payment_id, order_id, client_request_id, provider,
refund_no, status, amount_cents, reason)
VALUES (?, ?, ?, ?, 'WECHAT', ?, 'PENDING', ?, ?)`,
[input.tenantId, input.paymentId, payment.orderId, input.clientRequestId,
refundNo, input.amountCents, input.reason.slice(0, 512)]
);
const config = await this.paymentRepository.resolveConfig(
this.pool, input.tenantId, input.platformAppId, payment.storeId, 'WECHAT'
);
const settings = config.settings as Record<string, unknown>;
try {
const response = await this.client.createRefund(
this.resolveCredential(config.credentialRef),
{
outTradeNo: payment.paymentNo,
outRefundNo: refundNo,
reason: input.reason,
notifyUrl: settingUrl(
settings, 'refundNotifyUrl', 'https://api.txyundm.cn/app-api/pay/wechat/refund-notify'
),
refundCents: input.amountCents,
totalCents: Number(payment.amountCents)
}
);
const providerRefundId = requiredString(response, 'refund_id');
const status = mapRefundStatus(requiredString(response, 'status'));
await this.pool.execute(
`UPDATE qipai_refunds
SET status = ?, provider_refund_id = ?, raw_response = CAST(? AS JSON),
completed_at = IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL)
WHERE tenant_id = ? AND id = ?`,
[status, providerRefundId, JSON.stringify(redactNotification(response)),
status, input.tenantId, insert.insertId]
);
if (status === 'SUCCEEDED') {
await this.finalizeRefund(input.tenantId, String(insert.insertId), 'refund-api');
}
return {
refundId: String(insert.insertId), refundNo, status,
amountCents: input.amountCents, refundableCents: refundable - input.amountCents,
idempotent: false
};
} catch (error) {
await this.pool.execute(
`UPDATE qipai_refunds
SET status = 'MANUAL_REVIEW', failure_code = ?
WHERE tenant_id = ? AND id = ?`,
[error instanceof WechatPayError ? error.code : 'WECHAT_REFUND_FAILED',
input.tenantId, insert.insertId]
);
throw error;
}
}
async processRefundNotification(
headers: WechatNotificationHeaders,
rawBody: string
) {
const data = this.verifyWithConfiguredCredential(headers, rawBody);
const refundNo = requiredString(data, 'out_refund_no');
const providerRefundId = requiredString(data, 'refund_id');
const status = mapRefundStatus(requiredString(data, 'refund_status'));
const [rows] = await this.pool.execute<RefundRow[]>(
`SELECT id, tenant_id AS tenantId, payment_id AS paymentId,
order_id AS orderId, refund_no AS refundNo, status,
amount_cents AS amountCents
FROM qipai_refunds WHERE refund_no = ? LIMIT 1`,
[refundNo]
);
const refund = rows[0];
if (!refund) throw new WechatPayError('REFUND_NOT_FOUND');
if (refund.status === 'SUCCEEDED') {
return { refundId: String(refund.id), status: refund.status, idempotent: true };
}
const callbackId = `${headers.serial}:${providerRefundId}:${status}`;
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_refunds
SET status = ?, provider_refund_id = ?, provider_callback_id = ?,
raw_response = CAST(? AS JSON),
completed_at = IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), completed_at),
failure_code = IF(? = 'FAILED', 'WECHAT_REFUND_FAILED', failure_code)
WHERE tenant_id = ? AND id = ?
AND (provider_callback_id IS NULL OR provider_callback_id <> ?)`,
[status, providerRefundId, callbackId,
JSON.stringify(redactNotification(data)), status, status,
refund.tenantId, refund.id, callbackId]
);
if (result.affectedRows === 0) {
return { refundId: String(refund.id), status: refund.status, idempotent: true };
}
if (status === 'SUCCEEDED') {
await this.finalizeRefund(refund.tenantId, String(refund.id), callbackId);
}
return { refundId: String(refund.id), status, idempotent: false };
}
async requestReconciliation(input: {
tenantId: string;
platformAppId: string;
storeId: string;
actorId: string;
billDate: string;
billType: 'ALL' | 'SUCCESS' | 'REFUND';
}) {
const config = await this.paymentRepository.resolveConfig(
this.pool, input.tenantId, input.platformAppId, input.storeId, 'WECHAT'
);
const [existing] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, status, download_url AS downloadUrl
FROM qipai_reconciliation_runs
WHERE tenant_id = ? AND payment_config_id = ? AND bill_date = ?
AND bill_type = ? LIMIT 1`,
[input.tenantId, config.id, input.billDate, input.billType]
);
if (existing[0]) return { ...existing[0], idempotent: true };
const response = await this.client.downloadTradeBill(
this.resolveCredential(config.credentialRef), input.billDate, input.billType
);
const downloadUrl = requiredString(response, 'download_url');
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_reconciliation_runs
(tenant_id, payment_config_id, bill_date, bill_type, status,
download_url, requested_by, completed_at)
VALUES (?, ?, ?, ?, 'READY', ?, ?, UTC_TIMESTAMP(3))`,
[input.tenantId, config.id, input.billDate, input.billType,
downloadUrl, input.actorId]
);
return { id: String(result.insertId), status: 'READY', downloadUrl, idempotent: false };
}
private resolveCredential(reference: string) {
const credential = this.credentials.get(reference)
?? this.credentials.get(reference.replace(/^env:/, ''));
if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
return credential;
}
private verifyWithConfiguredCredential(
headers: WechatNotificationHeaders,
rawBody: string
) {
let lastError: unknown;
for (const credential of this.credentials.values()) {
if (!credential.platformCertificates[headers.serial]) continue;
try {
return this.client.verifyAndDecrypt(credential, headers, rawBody);
} catch (error) {
lastError = error;
}
}
throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
}
private async loadOwnedPayment(input: {
tenantId: string;
platformAppId: string;
userId: string;
paymentId: string;
}, lock: boolean) {
const payment = await this.loadPayment(input.tenantId, input.paymentId, lock);
if (String(payment.platformAppId) !== input.platformAppId) {
throw new WechatPayError('PAYMENT_APP_MISMATCH');
}
const [access] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
[input.tenantId, payment.orderId, input.userId]
);
if (!access[0]) throw new WechatPayError('ORDER_ACCESS_FORBIDDEN');
return payment;
}
private async loadPayment(tenantId: string, paymentId: string, lock: boolean) {
const connection = lock ? await this.pool.getConnection() : this.pool;
try {
const [rows] = await connection.execute<PaymentRow[]>(
`SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId,
p.order_id AS orderId, p.store_id AS storeId,
p.payment_no AS paymentNo, p.status,
p.amount_cents AS amountCents,
o.paid_amount_cents AS paidAmountCents,
o.total_amount_cents AS totalAmountCents, o.status AS orderStatus
FROM qipai_payments p
INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id
WHERE p.tenant_id = ? AND p.id = ? AND p.provider = 'WECHAT'
AND p.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`,
[tenantId, paymentId]
);
if (!rows[0]) throw new WechatPayError('PAYMENT_NOT_FOUND');
return rows[0];
} finally {
if (lock && 'release' in connection) connection.release();
}
}
private async loadPaymentByNo(
connection: PoolConnection, paymentNo: string, lock: boolean
) {
const [rows] = await connection.execute<PaymentRow[]>(
`SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId,
p.order_id AS orderId, p.store_id AS storeId,
p.payment_no AS paymentNo, p.status,
p.amount_cents AS amountCents,
o.paid_amount_cents AS paidAmountCents,
o.total_amount_cents AS totalAmountCents, o.status AS orderStatus
FROM qipai_payments p
INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id
WHERE p.payment_no = ? AND p.provider = 'WECHAT'
AND p.deleted_at IS NULL ${lock ? 'FOR UPDATE' : ''}`,
[paymentNo]
);
if (!rows[0]) throw new WechatPayError('PAYMENT_NOT_FOUND');
return rows[0];
}
private async applyPaymentSuccess(
connection: PoolConnection,
payment: PaymentRow,
transactionId: string,
callbackId: string,
traceId: string
) {
if (payment.status === 'SUCCEEDED') {
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'DUPLICATE', processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`,
[payment.tenantId, callbackId]
);
return;
}
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)
WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`,
[payment.tenantId, callbackId]
);
}
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
) {
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'REJECTED', error_code = ?,
processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'WECHAT' AND callback_id = ?`,
[code, tenantId, callbackId]
);
}
private async finalizeRefund(tenantId: string, refundId: string, traceId: string) {
await this.transaction(async (connection) => {
const [rows] = await connection.execute<RefundRow[]>(
`SELECT id, tenant_id AS tenantId, payment_id AS paymentId,
order_id AS orderId, refund_no AS refundNo, status,
amount_cents AS amountCents
FROM qipai_refunds WHERE tenant_id = ? AND id = ? FOR UPDATE`,
[tenantId, refundId]
);
const refund = rows[0];
if (!refund || refund.status !== 'SUCCEEDED') return;
const [paymentRows] = await connection.execute<PaymentRow[]>(
`SELECT p.id, p.tenant_id AS tenantId, p.platform_app_id AS platformAppId,
p.order_id AS orderId, p.store_id AS storeId,
p.payment_no AS paymentNo, p.status,
p.amount_cents AS amountCents,
o.paid_amount_cents AS paidAmountCents,
o.total_amount_cents AS totalAmountCents, o.status AS orderStatus
FROM qipai_payments p
INNER JOIN qipai_orders o ON o.tenant_id = p.tenant_id AND o.id = p.order_id
WHERE p.tenant_id = ? AND p.id = ? FOR UPDATE`,
[tenantId, refund.paymentId]
);
const payment = paymentRows[0];
const [sumRows] = await connection.execute<RowDataPacket[]>(
`SELECT COALESCE(SUM(amount_cents), 0) AS refundedCents
FROM qipai_refunds
WHERE tenant_id = ? AND payment_id = ? AND status = 'SUCCEEDED'`,
[tenantId, refund.paymentId]
);
const refundedCents = Number(sumRows[0].refundedCents);
const paymentStatus = refundedCents >= Number(payment.amountCents)
? 'REFUNDED' : 'PARTIALLY_REFUNDED';
await connection.execute(
`UPDATE qipai_payments SET status = ? WHERE tenant_id = ? AND id = ?`,
[paymentStatus, tenantId, refund.paymentId]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = GREATEST(0, total_amount_cents - ?)
WHERE tenant_id = ? AND id = ?`,
[refundedCents, tenantId, refund.orderId]
);
if (paymentStatus === 'REFUNDED'
&& ['CANCELLED', 'REFUNDING'].includes(payment.orderStatus)) {
await connection.execute(
`UPDATE qipai_orders
SET status = 'REFUNDED', status_version = status_version + 1,
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[tenantId, refund.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 (?, ?, ?, 'REFUNDED', 'COMPLETE_REFUND', 'SYSTEM', NULL,
'PAYMENT', 'Verified Wechat refund', ?,
JSON_OBJECT('refundId', ?, 'amountCents', ?))`,
[tenantId, refund.orderId, payment.orderStatus, traceId,
refund.id, refund.amountCents]
);
}
});
}
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 requiredString(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'string' || field.length === 0) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
function requiredNumber(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'number' || !Number.isInteger(field)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
function objectValue(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return value as Record<string, unknown>;
}
function stringValue(value: unknown) {
return typeof value === 'string' ? value : '';
}
function settingUrl(
settings: Record<string, unknown>, key: string, fallback: string
) {
const value = settings[key];
return typeof value === 'string' && value.startsWith('https://') ? value : fallback;
}
function mapRefundStatus(value: string) {
if (value === 'SUCCESS') return 'SUCCEEDED';
if (value === 'CLOSED' || value === 'ABNORMAL') return 'FAILED';
return 'PROCESSING';
}
function normalizeRefund(row: RefundRow) {
return {
refundId: String(row.id),
refundNo: row.refundNo,
status: row.status,
amountCents: Number(row.amountCents)
};
}
function redactNotification(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.payer;
delete copy.user_received_account;
return copy;
}
+152
View File
@@ -0,0 +1,152 @@
import { randomUUID } from 'node:crypto';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import { signAccessToken } from '../auth/jwt.js';
import { WechatApiError, type WechatCodeExchange } from '../auth/wechat-client.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
const headersSchema = z.object({
'x-wechat-appid': z.string().trim().min(6).max(64),
'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const loginBodySchema = z.object({ code: z.string().trim().min(4).max(128) });
export interface AuthRouteOptions {
repository: Pick<AuthRepository, 'resolveLoginContext' | 'loginWithWechat' | 'validateSession' | 'revokeSession'>;
wechat: WechatCodeExchange;
jwtSecret: string;
accessTokenTtlSeconds: number;
sessionTtlSeconds: number;
accessControl?: {
getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile>;
};
}
export async function registerAuthRoutes(app: FastifyInstance, options: AuthRouteOptions): Promise<void> {
app.post('/app-api/auth/wechat-login', async (request, reply) => {
const headers = headersSchema.safeParse(request.headers);
const body = loginBodySchema.safeParse(request.body);
if (!headers.success || !body.success) {
return reply.status(400).send({
code: 'INVALID_LOGIN_REQUEST',
message: 'AppID, optional tenant-id and wx.login code are required.',
traceId: request.traceId
});
}
try {
const context = await options.repository.resolveLoginContext(
headers.data['x-wechat-appid'],
headers.data['tenant-id']
);
if (!context) {
return reply.status(404).send({
code: 'APP_TENANT_NOT_FOUND',
message: 'The application and tenant binding is not active.',
traceId: request.traceId
});
}
const identity = await options.wechat.exchange(context.appId, body.data.code);
const sessionId = randomUUID();
const expiresAt = new Date(Date.now() + options.sessionTtlSeconds * 1000);
const session = await options.repository.loginWithWechat({
context,
...identity,
sessionId,
expiresAt,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
});
const accessToken = signAccessToken({
sub: session.user.id,
sid: session.id,
tid: session.tenantId,
aid: session.platformAppId,
rv: session.user.roleVersion
}, options.jwtSecret, options.accessTokenTtlSeconds);
return {
code: 0,
data: {
accessToken,
expiresIn: options.accessTokenTtlSeconds,
user: publicUser(session.user)
},
traceId: request.traceId
};
} catch (error) {
if (error instanceof WechatApiError) {
return reply.status(401).send({
code: 'WECHAT_LOGIN_FAILED',
message: 'WeChat login code is invalid or expired.',
traceId: request.traceId
});
}
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
return reply.status(409).send({
code: 'TENANT_SELECTION_REQUIRED',
message: 'tenant-id is required for an application bound to multiple tenants.',
traceId: request.traceId
});
}
if (error instanceof Error && error.message === 'USER_DISABLED') {
return reply.status(403).send({
code: 'USER_DISABLED',
message: 'The user account is disabled.',
traceId: request.traceId
});
}
throw error;
}
});
app.get('/app-api/auth/me', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization, options.repository, options.jwtSecret
);
if (!auth) return unauthorized(reply, request.traceId);
const access = options.accessControl
? await options.accessControl.getAccessProfile(auth.session.user.tenantId, auth.session.user.id)
: { roles: [], capabilities: [], storeIds: [] };
return {
code: 0,
data: { user: publicUser(auth.session.user), access },
traceId: request.traceId
};
});
app.post('/app-api/auth/logout', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization, options.repository, options.jwtSecret
);
if (!auth) return unauthorized(reply, request.traceId);
await options.repository.revokeSession(auth.sessionId);
return { code: 0, data: { revoked: true }, traceId: request.traceId };
});
}
function unauthorized(reply: { status(code: number): { send(payload: unknown): unknown } }, traceId: string) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID',
message: 'The access token or server-side session is invalid.',
traceId
});
}
function publicUser(user: {
id: string;
tenantId: string;
userType: string;
nickname: string;
avatarUrl: string;
phone: string;
}) {
return {
id: user.id,
tenantId: user.tenantId,
userType: user.userType,
nickname: user.nickname,
avatarUrl: user.avatarUrl,
phone: user.phone
};
}
+707
View File
@@ -0,0 +1,707 @@
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 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 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 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)
});
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | '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('/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
}));
});
app.get('/admin-api/cleaning/tasks', 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
}));
});
app.get('/admin-api/cleaning/statistics', 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/: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.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 () => {
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
const image = await options.mediaStorage!.storeImage({
tenantId: actor.tenantId,
originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer
});
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
});
});
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
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/complete', 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
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/reject', 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
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/exempt', 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/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/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
}),
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 === 'CLEANING_TASK_FORBIDDEN'
|| code === 'CLEANING_SETTLEMENT_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);
};
+161
View File
@@ -0,0 +1,161 @@
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 { ContentError, type ContentRepository } from '../content/content-repository.js';
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 componentSchema = z.object({
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
props: z.record(z.unknown())
});
const decorationSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
templateCode: z.string().trim().min(1).max(64),
schemaVersion: z.number().int().min(1).max(100),
content: z.object({ components: z.array(componentSchema).max(50) })
});
const adSchema = z.object({
scopeType: z.enum(['PLATFORM', 'TENANT', 'STORE']),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().optional(),
title: z.string().trim().min(1).max(128),
imageAssetId: z.string().regex(/^[1-9]\d{0,19}$/),
targetType: z.enum(['NONE', 'PAGE', 'URL']).default('NONE'),
targetValue: z.string().trim().max(512).default(''),
startsAt: z.coerce.date().nullable().optional(),
endsAt: z.coerce.date().nullable().optional(),
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
}).refine((value) => !value.startsAt || !value.endsAt || value.endsAt > value.startsAt);
export interface ContentRouteOptions {
repository: Pick<ContentRepository,
'registerAsset' | 'saveDecoration' | 'publishDecoration'
| 'listAdvertisements' | 'saveAdvertisement'>;
mediaStorage: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerContentRoutes(app: FastifyInstance, options: ContentRouteOptions) {
if (!app.hasContentTypeParser('application/octet-stream')) {
app.addContentTypeParser(
'application/octet-stream',
{ parseAs: 'buffer', bodyLimit: 8 * 1024 * 1024 },
(_request, body, done) => done(null, body)
);
}
app.post('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
if (!actor) return;
const storeIdResult = z.string().regex(/^[1-9]\d{0,19}$/).optional()
.safeParse(singleHeader(request.headers['x-store-id']));
const originalName = singleHeader(request.headers['x-file-name']);
if (!storeIdResult.success || !originalName || !Buffer.isBuffer(request.body)) {
return invalid(reply, request.traceId);
}
const storeId = storeIdResult.data;
return handle(reply, request.traceId, async () => {
const image = await options.mediaStorage.storeImage({
tenantId: actor.tenantId, storeId, originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer
});
return reply.status(201).send({
code: 0, data: await options.repository.registerAsset(actor, storeId, image),
traceId: request.traceId
});
});
});
app.post('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const body = decorationSchema.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.saveDecoration(actor, body.data),
traceId: request.traceId
}));
});
app.post('/admin-api/decorations/:id/publish', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const params = idSchema.safeParse(request.params);
const query = storeQuerySchema.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.publishDecoration(actor, params.data.id, query.data.storeId),
traceId: request.traceId
}));
});
app.get('/admin-api/advertisements', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
if (!actor) return;
return { code: 0, data: await options.repository.listAdvertisements(actor),
traceId: request.traceId };
});
app.post('/admin-api/advertisements', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const body = adSchema.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.saveAdvertisement(actor, body.data),
traceId: request.traceId
}));
});
}
async function requireContentManager(
request: FastifyRequest, reply: FastifyReply, options: ContentRouteOptions
): 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.some((item) =>
item === 'store.operation.write' || item === 'tenant.manage'
) && !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({ code: 'CONTENT_MANAGEMENT_FORBIDDEN',
message: 'Content 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 ContentError) && !(error instanceof MediaValidationError)) throw error;
const forbidden = error.code.endsWith('_FORBIDDEN');
return reply.status(forbidden ? 403 : 400).send({
code: error.code, message: 'The content request is invalid or not allowed.', 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_CONTENT_REQUEST', message: 'The content request is invalid.', traceId
});
}
+253
View File
@@ -0,0 +1,253 @@
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 {
CustomerDeviceAccessError,
type CustomerDeviceAccessRepository
} from '../devices/customer-device-access-repository.js';
import {
DeviceControlError,
type CommandContext,
type DeviceControlService
} from '../devices/device-control-service.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const contextSchema = z.object({
storeId: id,
roomId: id,
orderId: id.nullable().optional()
});
const powerSchema = contextSchema.extend({
slot1: z.enum(['on', 'off']).optional(),
slot2: z.enum(['on', 'off']).optional(),
slot3: z.enum(['on', 'off']).optional(),
slotall: z.enum(['on', 'off']).optional()
}).refine((value) => value.slot1 || value.slot2 || value.slot3 || value.slotall);
const doorSchema = contextSchema.extend({
order: z.enum(['open', 'close']),
holdopen: z.union([z.literal(0), z.literal(1)]).default(0),
delayTime: z.number().int().min(1).max(14).default(4)
});
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)
});
const minuteSchema = contextSchema.extend({
minute: z.number().int().min(0).max(10080)
});
const taskSchema = contextSchema.extend({
minute: z.number().int().min(1).max(10080),
type: z.union([z.literal(1), z.literal(2), z.literal(3)]),
subID: z.string().min(1).max(64).optional(),
holdopen: z.union([z.literal(0), z.literal(1)]).default(0),
delayTime: z.number().int().min(1).max(14).default(4)
});
const extendSchema = contextSchema.extend({
addminute: z.number().int().min(1).max(10080)
});
const pairSchema = contextSchema.extend({
timeout: z.number().int().min(10).max(300).default(60)
});
const subLockSchema = contextSchema.extend({
subID: z.string().min(1).max(64),
order: z.enum([
'open', 'close', '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(),
dangerConfirmation: z.string().max(64).optional()
});
const socketReadSchema = contextSchema.extend({
target: z.enum(['basicInfo', 'workInfo']).default('workInfo')
});
const socketSwitchSchema = contextSchema.extend({
on: z.boolean(),
slotNum: z.number().int().min(1).max(20).default(1)
});
const socketTaskSchema = contextSchema.extend({
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()
});
const socketClearTaskSchema = contextSchema.extend({
taskNum: z.number().int().min(0).max(20)
});
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'>;
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerDeviceControlRoutes(
app: FastifyInstance, options: DeviceControlRouteOptions
) {
register('/admin-api/device-control/power', powerSchema,
(context, body) => options.service.controlPower(context, body));
register('/admin-api/device-control/door', doorSchema,
(context, body) => options.service.controlDoor(context, body));
register('/admin-api/device-control/tts', ttsSchema,
(context, body) => options.service.playTts(context, body));
register('/admin-api/device-control/tts/stop', contextSchema,
(context) => options.service.stopTts(context));
register('/admin-api/device-control/led', minuteSchema,
(context, body) => options.service.controlLed(context, body.minute));
register('/admin-api/device-control/task/start', taskSchema,
(context, body) => options.service.startTask(context, body));
register('/admin-api/device-control/task/extend', extendSchema,
(context, body) => options.service.extendTask(context, body.addminute));
register('/admin-api/device-control/task/cancel', contextSchema,
(context) => options.service.cancelTask(context));
register('/admin-api/device-control/sub-lock/pair', pairSchema,
(context, body) => options.service.pairSubLock(context, body.timeout));
register('/admin-api/device-control/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,
(context, body) => options.service.switchSmartSocket(context, body));
register('/admin-api/device-control/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));
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,
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,
schema: T,
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
) {
app.post(url, async (request, reply) => {
const parsed = schema.safeParse(request.body);
if (!parsed.success) return invalid(reply, request.traceId);
const context = await requireContext(request, reply, options, parsed.data);
if (!context) return;
try {
return {
code: 0, data: await handler(context, parsed.data), traceId: request.traceId
};
} catch (error) {
if (!(error instanceof DeviceControlError)) throw error;
const status = error.code.endsWith('_FORBIDDEN') ? 403
: error.code === 'DEVICE_OFFLINE' ? 409 : 400;
return reply.status(status).send({
code: error.code, message: 'Device control was rejected.', traceId: request.traceId
});
}
});
}
}
async function requireContext(
request: FastifyRequest, reply: FastifyReply, options: DeviceControlRouteOptions,
input: { storeId: string; roomId: string; orderId?: 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
);
return {
tenantId: auth.session.tenantId,
storeId: input.storeId,
roomId: input.roomId,
orderId: input.orderId,
traceId: request.traceId,
access
};
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_DEVICE_CONTROL_REQUEST',
message: 'The device control request is invalid.', traceId
});
}
+194
View File
@@ -0,0 +1,194 @@
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 { DeviceError, type DeviceRepository } from '../devices/device-repository.js';
const id = z.string().regex(/^[1-9]\d{0,19}$/);
const optionalId = id.nullable().optional();
const deviceIdentity = z.string().regex(/^[A-Za-z0-9_-]{1,64}$/);
const assetSchema = z.object({
storeId: id,
roomId: optionalId,
deviceId: deviceIdentity,
imei: z.string().trim().max(64).default(''),
iccid: z.string().trim().max(32).nullable().optional(),
deviceType: z.enum(['CONTROL_BOX', 'SUB_LOCK', 'SMART_SOCKET']),
model: z.string().trim().min(1).max(64),
firmwareVersion: z.string().trim().max(64).default(''),
signalStrength: z.number().int().min(-200).max(200).nullable().optional(),
capabilities: z.array(z.string().trim().regex(/^[A-Z0-9_]{1,64}$/)).max(64).default([])
});
const channelSchema = z.object({
assetId: id,
storeId: id,
roomId: id,
channelCode: z.enum(['SLOT1', 'SLOT2', 'SLOT3', 'MAIN', 'LOCK', 'LED', 'TTS']),
purpose: z.enum([
'ROOM_POWER', 'AIR_CONDITIONER', 'LIGHTING', 'DOOR_MAGNET',
'STORE_DOOR', 'ROOM_DOOR', 'TTS', 'LED'
])
});
const linkSchema = z.object({
parentAssetId: id,
childAssetId: id,
storeId: id,
roomId: id,
subId: z.string().trim().min(1).max(64),
subtype: z.string().trim().min(1).max(32)
});
const statusSchema = z.object({
storeId: id,
onlineStatus: z.enum(['ONLINE', 'OFFLINE', 'FAULT']),
signalStrength: z.number().int().min(-200).max(200).nullable().optional(),
firmwareVersion: z.string().trim().max(64).optional(),
snapshot: z.record(z.unknown()).default({})
});
const maintenanceSchema = z.object({
storeId: id,
roomId: optionalId,
recordType: z.enum(['INSPECTION', 'REPAIR', 'REPLACEMENT']),
status: z.enum(['OPEN', 'RESOLVED']),
description: z.string().trim().max(500).default('')
});
export interface DeviceRouteOptions {
repository: Pick<DeviceRepository,
'createAsset' | 'listAssets' | 'getTopology' | 'bindChannel' | 'bindSubDevice'
| 'recordStatus' | 'addMaintenance'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
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
}));
});
app.post('/admin-api/devices', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = assetSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.createAsset(actor, body.data),
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
}));
});
app.post('/admin-api/device-channels', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = channelSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.bindChannel(actor, body.data),
traceId: request.traceId
}));
});
app.post('/admin-api/device-links', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const body = linkSchema.safeParse(request.body);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.bindSubDevice(actor, body.data),
traceId: request.traceId
}));
});
app.post('/admin-api/devices/:id/status', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = statusSchema.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.recordStatus(actor, {
assetId: params.data.id, ...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/devices/:id/maintenance', async (request, reply) => {
const actor = await requireDeviceOperator(request, reply, options);
const params = z.object({ id }).safeParse(request.params);
const body = maintenanceSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.addMaintenance(actor, {
assetId: params.data.id, ...body.data
}),
traceId: request.traceId
}));
});
}
async function requireDeviceOperator(
request: FastifyRequest, reply: FastifyReply, options: DeviceRouteOptions
): 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.some((code) =>
code === 'device.read' || code === 'device.write' || code === 'tenant.manage'
) && !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'DEVICE_OPERATION_FORBIDDEN',
message: 'Device 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 mutate(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof DeviceError)) throw error;
const forbidden = error.code.endsWith('_FORBIDDEN');
const conflict = error.code.endsWith('_CONFLICT');
return reply.status(forbidden ? 403 : conflict ? 409 : 400).send({
code: error.code, message: 'The device operation is not allowed.', traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_DEVICE_REQUEST', message: 'The device request is invalid.', traceId
});
}
+60
View File
@@ -0,0 +1,60 @@
import type { FastifyInstance } from 'fastify';
import type { AppConfig } from '../config.js';
interface HealthPayload {
ok: true;
service: 'qipai-api';
version: string;
traceId: string;
}
interface ReadyPayload extends HealthPayload {
checks: {
mysqlConfigured: boolean;
mqttConfigured: boolean;
mqttConnected: boolean;
mqttSubscriptionsReady: boolean;
};
}
export interface MqttHealthProvider {
health(): {
configured: boolean;
connected: boolean;
subscriptionsReady: boolean;
};
}
export async function registerHealthRoutes(
app: FastifyInstance,
config: AppConfig,
mqtt?: MqttHealthProvider
): Promise<void> {
const health = async (request: { traceId: string }): Promise<HealthPayload> => ({
ok: true,
service: 'qipai-api',
version: config.version,
traceId: request.traceId
});
const ready = async (request: { traceId: string }): Promise<ReadyPayload> => {
const mqttHealth = mqtt?.health();
return {
...(await health(request)),
checks: {
mysqlConfigured: config.mysql.passwordConfigured,
mqttConfigured: mqttHealth?.configured
?? (config.mqtt.usernameConfigured && config.mqtt.passwordConfigured),
mqttConnected: mqttHealth?.connected ?? false,
mqttSubscriptionsReady: mqttHealth?.subscriptionsReady ?? false
}
};
};
app.get('/app-api/health', health);
app.get('/admin-api/health', health);
app.get('/app-api/ready', ready);
app.get('/admin-api/ready', ready);
app.get('/app-api/version', health);
app.get('/admin-api/version', health);
}
+163
View File
@@ -0,0 +1,163 @@
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
}));
});
app.get('/admin-api/members', 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
}));
});
app.get('/admin-api/members/:id', 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
});
}
+166
View File
@@ -0,0 +1,166 @@
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 {
OrderManagementError, type OrderManagementRepository
} from '../orders/order-management-repository.js';
import type { OrderActor } from '../orders/order-state-repository.js';
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
const renewSchema = z.object({
endAt: z.coerce.date(),
pricingPolicy: z.enum(['CURRENT', 'LOCKED']).default('CURRENT'),
reason: z.string().min(1).max(512)
}).strict();
const roomSchema = z.object({
roomId: z.string().regex(/^[1-9]\d{0,19}$/),
reason: z.string().min(1).max(512)
}).strict();
const timeSchema = z.object({
startAt: z.coerce.date().optional(),
endAt: z.coerce.date().optional(),
reason: z.string().min(1).max(512)
}).strict().refine((value) => value.startAt || value.endAt);
const noteSchema = z.object({ note: z.string().min(1).max(512) }).strict();
export interface OrderManagementRouteOptions {
repository: Pick<OrderManagementRepository,
'renew' | 'changeRoom' | 'adjustTime' | 'note' | 'cancellationQuote'
| 'customerRenew' | 'customerChangeRoom'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
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);
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.cancellationQuote(
auth.tenantId, auth.userId, params.data.orderId
),
traceId: request.traceId
}));
});
const adminActions = [
['renew', renewSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof renewSchema>) =>
options.repository.renew(actor, orderId, body)],
['change-room', roomSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof roomSchema>) =>
options.repository.changeRoom(actor, orderId, body)],
['adjust-time', timeSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof timeSchema>) =>
options.repository.adjustTime(actor, orderId, body)],
['note', noteSchema, (actor: OrderActor, orderId: string, body: z.infer<typeof noteSchema>) =>
options.repository.note(actor, orderId, body.note)]
] 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
}));
});
}
}
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
) {
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 OrderManagementError)) throw error;
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'ROOM_NOT_FOUND' ? 404
: error.code === 'TIME_SLOT_CONFLICT' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({
code: error.code,
message: 'The requested order adjustment 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_ADJUSTMENT', message: 'The order adjustment is invalid.', traceId
});
}
+103
View File
@@ -0,0 +1,103 @@
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()
});
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
) {
app.get('/app-api/orders', 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('/app-api/orders/: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
});
}
+120
View File
@@ -0,0 +1,120 @@
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 {
OrderShareError, type OrderShareRepository
} from '../orders/order-share-repository.js';
const orderParams = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
const revokeParams = orderParams.extend({ shareId: z.string().regex(/^[1-9]\d{0,19}$/) });
const tokenParams = z.object({ token: z.string().min(40).max(64) });
const createSchema = z.object({
permissions: z.array(z.enum(['VIEW_ROOM', 'OPEN_DOOR', 'RENEW'])).min(1).max(3)
.optional(),
ttlMinutes: z.number().int().min(5).max(1440).optional()
}).strict();
const resolveSchema = z.object({
permission: z.enum(['VIEW_ROOM', 'OPEN_DOOR', 'RENEW'])
}).strict();
export interface OrderShareRouteOptions {
repository: Pick<OrderShareRepository, 'create' | 'revoke' | 'resolve'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerOrderShareRoutes(
app: FastifyInstance, options: OrderShareRouteOptions
) {
app.post('/app-api/orders/:orderId/shares', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = orderParams.safeParse(request.params);
const body = createSchema.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 () => reply.status(201).send({
code: 0,
data: await options.repository.create({
tenantId: auth.tenantId, userId: auth.userId, orderId: params.data.orderId,
access: auth.access, permissions: body.data.permissions,
ttlMinutes: body.data.ttlMinutes, traceId: request.traceId,
ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
app.delete('/app-api/orders/:orderId/shares/:shareId', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = revokeParams.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.revoke({
tenantId: auth.tenantId, userId: auth.userId,
orderId: params.data.orderId, shareId: params.data.shareId,
access: auth.access, traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
app.post('/app-api/order-shares/:token/resolve', async (request, reply) => {
const params = tokenParams.safeParse(request.params);
const body = resolveSchema.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.resolve(params.data.token, body.data.permission, {
traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
}
async function authenticate(
authorization: string | undefined, options: OrderShareRouteOptions
) {
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 OrderShareError)) throw error;
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'ORDER_SHARE_NOT_FOUND'
? 404 : error.code === 'ORDER_SHARE_PERMISSION_DENIED' ? 403 : 400;
return reply.status(status).send({
code: error.code, message: 'The order share 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_SHARE_REQUEST', message: 'The share request is invalid.', traceId
});
}
+125
View File
@@ -0,0 +1,125 @@
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 {
OrderStateError, orderActions, type OrderStateRepository
} from '../orders/order-state-repository.js';
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
const transitionSchema = z.object({
action: z.enum(orderActions),
reason: z.string().max(512).default('')
}).strict();
const cancelSchema = z.object({ reason: z.string().max(512).default('') }).strict();
export interface OrderStateRouteOptions {
repository: Pick<OrderStateRepository, 'transition' | 'history'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
cancellationPolicy?: {
cancellationQuote(tenantId: string, userId: string, orderId: string): Promise<unknown>;
};
}
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
}));
});
app.post('/app-api/orders/:orderId/cancel', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paramsSchema.safeParse(request.params);
const body = cancelSchema.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 () => {
const cancellation = options.cancellationPolicy
? await options.cancellationPolicy.cancellationQuote(
auth.tenantId, auth.userId, params.data.orderId
)
: null;
const transition = await options.repository.transition({
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
source: 'APP', traceId: request.traceId, ip: request.ip,
userAgent: request.headers['user-agent'] ?? '', access: auth.access
}, params.data.orderId, 'CANCEL', body.data.reason);
return { code: 0, data: { transition, cancellation }, traceId: request.traceId };
});
});
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
}));
});
}
async function authenticate(
authorization: string | undefined, options: OrderStateRouteOptions
) {
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 OrderStateError)) throw error;
const status = error.code === 'ORDER_NOT_FOUND' ? 404
: error.code === 'ORDER_TRANSITION_NOT_ALLOWED' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({
code: error.code,
message: 'The requested order action 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_ACTION', message: 'The order action is invalid.', traceId
});
}
+395
View File
@@ -0,0 +1,395 @@
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 { RbacRepository } from '../auth/rbac-repository.js';
import {
PaymentError, type PaymentRepository
} from '../payments/payment-repository.js';
import type { WechatPaymentService } from '../payments/wechat-payment-service.js';
import { WechatPayError } from '../payments/wechat-pay-client.js';
import {
ProfitSharingError, type ProfitSharingService
} from '../payments/profit-sharing-service.js';
const createSchema = z.object({
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
provider: z.enum(['WECHAT', 'BALANCE', 'PACKAGE', 'GROUP_BUY', 'TEST']),
clientRequestId: z.string().min(8).max(128)
}).strict();
const paymentParams = z.object({ paymentId: z.string().regex(/^[1-9]\d{0,19}$/) });
const callbackSchema = z.object({
callbackId: z.string().min(8).max(128),
amountCents: z.number().int().positive()
}).strict();
const refundSchema = z.object({
paymentId: z.string().regex(/^[1-9]\d{0,19}$/),
amountCents: z.number().int().positive(),
reason: z.string().min(1).max(512),
clientRequestId: z.string().min(8).max(128)
}).strict();
const billSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
billDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
billType: z.enum(['ALL', 'SUCCESS', 'REFUND'])
}).strict();
const collectionAccountSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
merchantId: z.string().min(6).max(64),
credentialRef: z.string().min(5).max(255),
authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']),
profitSharingEnabled: z.boolean(),
enabled: z.boolean().default(true)
}).strict();
const receiverSchema = z.object({
collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/),
receiverType: z.enum(['MERCHANT_ID', 'PERSONAL_OPENID']),
receiverAccount: z.string().min(4).max(128),
receiverCredentialRef: z.string().min(10).max(255),
relationType: z.string().min(2).max(32),
name: z.string().min(1).max(128),
authorizationStatus: z.enum(['UNAUTHORIZED', 'PENDING', 'AUTHORIZED', 'REVOKED']),
enabled: z.boolean().default(true)
}).strict();
const policySchema = z.object({
collectionAccountId: z.string().regex(/^[1-9]\d{0,19}$/),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
receiverId: z.string().regex(/^[1-9]\d{0,19}$/),
percentageBps: z.number().int().min(1).max(10000),
enabled: z.boolean().default(true)
}).strict();
const executeShareSchema = z.object({
paymentId: z.string().regex(/^[1-9]\d{0,19}$/),
clientRequestId: z.string().min(8).max(96),
mode: z.enum(['API', 'MOCK'])
}).strict();
const shareListQuery = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
export interface PaymentRouteOptions {
repository: Pick<PaymentRepository, 'createPayment' | 'processTestCallback'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl?: Pick<RbacRepository, 'getAccessProfile'>;
wechat?: WechatPaymentService;
profitSharing?: ProfitSharingService;
jwtSecret: string;
testAdapterEnabled: boolean;
}
export async function registerPaymentRoutes(
app: FastifyInstance, options: PaymentRouteOptions
) {
app.post('/app-api/payments', 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.repository.createPayment({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
userId: auth.userId,
orderId: body.data.orderId,
provider: body.data.provider,
clientRequestId: body.data.clientRequestId,
testAdapterEnabled: options.testAdapterEnabled
}),
traceId: request.traceId
}));
});
app.post('/app-api/payments/:paymentId/test-complete', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paymentParams.safeParse(request.params);
const body = callbackSchema.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 () => {
const data = await options.repository.processTestCallback({
tenantId: auth.tenantId,
userId: auth.userId,
paymentId: params.data.paymentId,
callbackId: body.data.callbackId,
amountCents: body.data.amountCents,
testAdapterEnabled: options.testAdapterEnabled,
traceId: request.traceId
});
if ('code' in data && data.code === 'PAYMENT_AMOUNT_MISMATCH') {
return reply.status(400).send({
code: data.code,
message: 'The callback amount does not match the payment.',
data,
traceId: request.traceId
});
}
return { code: 0, data, traceId: request.traceId };
});
});
if (options.wechat) {
app.post('/app-api/pay/wechat/prepay', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = z.object({
paymentId: z.string().regex(/^[1-9]\d{0,19}$/)
}).strict().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.wechat!.createPrepay({
...auth, paymentId: body.data.paymentId
}),
traceId: request.traceId
}));
});
app.get('/app-api/pay/wechat/payments/:paymentId', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const params = paymentParams.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.wechat!.queryPayment({
...auth, paymentId: params.data.paymentId
}),
traceId: request.traceId
}));
});
app.post('/app-api/pay/wechat/notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.wechat!.processPaymentNotification(
notificationHeaders(request.headers),
request.rawBody,
request.traceId
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
app.post('/app-api/pay/wechat/refund-notify', {
preParsing: captureRawBody
}, async (request, reply) => {
return handle(reply, request.traceId, async () => {
const data = await options.wechat!.processRefundNotification(
notificationHeaders(request.headers),
request.rawBody
);
return reply.send({ code: 'SUCCESS', message: '成功', data });
});
});
app.post('/admin-api/pay/refund', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = refundSchema.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.wechat!.createRefund({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
actorId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/pay/reconciliation', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = billSchema.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.wechat!.requestReconciliation({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
actorId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
}
if (options.profitSharing) {
app.put('/admin-api/pay/collection-account', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = collectionAccountSchema.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.profitSharing!.saveCollectionAccount({
tenantId: auth.tenantId,
platformAppId: auth.platformAppId,
actorId: auth.userId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/pay/profit-share-receiver', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = receiverSchema.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.profitSharing!.saveReceiver({
tenantId: auth.tenantId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/pay/profit-share-policy', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = policySchema.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.profitSharing!.savePolicy({
tenantId: auth.tenantId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/pay/profit-shares', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const body = executeShareSchema.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.profitSharing!.execute({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access,
...body.data
}),
traceId: request.traceId
}));
});
app.get('/admin-api/pay/profit-shares', async (request, reply) => {
const auth = await authenticateAdmin(request.headers.authorization, options);
const query = shareListQuery.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.profitSharing!.list({
tenantId: auth.tenantId,
access: auth.access,
storeId: query.data.storeId
}),
traceId: request.traceId
}));
});
}
}
async function authenticate(
authorization: string | undefined, options: PaymentRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
return {
tenantId: result.session.tenantId,
platformAppId: result.session.platformAppId,
userId: result.session.user.id
};
}
async function authenticateAdmin(
authorization: string | undefined, options: PaymentRouteOptions
) {
const auth = await authenticate(authorization, options);
if (!auth || !options.accessControl) return null;
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
if (!access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) return null;
return { ...auth, access };
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof PaymentError)
&& !(error instanceof WechatPayError)
&& !(error instanceof ProfitSharingError)) throw error;
const status = error.code === 'ORDER_NOT_FOUND' || error.code === 'PAYMENT_NOT_FOUND'
? 404 : error.code === 'PAYMENT_IDEMPOTENCY_CONFLICT' ? 409
: error.code.includes('FORBIDDEN') ? 403 : 400;
return reply.status(status).send({
code: error.code, message: 'The payment 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_PAYMENT_REQUEST', message: 'The payment request is invalid.', traceId
});
}
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import {
AmbiguousAppTenantError,
type PlatformBootstrap
} from '../tenancy/platform-config-repository.js';
const headerSchema = z.object({
'x-wechat-appid': z.string().trim().min(6).max(64),
'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
export interface PlatformConfigResolver {
resolveBootstrap(appId: string, tenantId?: string): Promise<PlatformBootstrap | null>;
}
export async function registerPlatformBootstrapRoutes(
app: FastifyInstance,
repository: PlatformConfigResolver
): Promise<void> {
app.get('/app-api/bootstrap', async (request, reply) => {
const parsed = headerSchema.safeParse(request.headers);
if (!parsed.success) {
return reply.status(400).send({
code: 'INVALID_APP_CONTEXT',
message: 'x-wechat-appid is required and tenant-id must be a positive integer.',
traceId: request.traceId
});
}
try {
const bootstrap = await repository.resolveBootstrap(
parsed.data['x-wechat-appid'],
parsed.data['tenant-id']
);
if (!bootstrap) {
return reply.status(404).send({
code: 'APP_TENANT_NOT_FOUND',
message: 'The application and tenant binding is not active.',
traceId: request.traceId
});
}
return { code: 0, data: bootstrap, traceId: request.traceId };
} catch (error) {
if (error instanceof AmbiguousAppTenantError) {
return reply.status(409).send({
code: 'TENANT_SELECTION_REQUIRED',
message: error.message,
traceId: request.traceId
});
}
throw error;
}
});
}
+157
View File
@@ -0,0 +1,157 @@
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 { PricingError, type PricingRepository } from '../orders/pricing-repository.js';
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'),
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}$/)
}));
export interface PricingRouteOptions {
repository: Pick<PricingRepository, 'quote' | 'reserve' | 'releaseExpired'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerPricingRoutes(app: FastifyInstance, options: PricingRouteOptions) {
app.post('/app-api/pricing/quote', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = requestSchema.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.repository.quote({
tenantId: auth.tenantId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/app-api/orders/reserve', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
const body = requestSchema.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.repository.reserve({
tenantId: auth.tenantId,
userId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
app.post('/admin-api/reservations/release-expired', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options);
if (!auth) return unauthorized(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
if (!access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
return reply.status(403).send({
code: 'RESERVATION_RELEASE_FORBIDDEN',
message: 'Tenant management permission is required.',
traceId: request.traceId
});
}
return {
code: 0,
data: await options.repository.releaseExpired(auth.tenantId),
traceId: request.traceId
};
});
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.',
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(
authorization: string | undefined,
options: PricingRouteOptions
) {
const result = await authenticateAccessToken(
authorization, options.authRepository, options.jwtSecret
);
if (!result) return null;
return {
tenantId: result.session.tenantId,
userId: result.session.user.id
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} 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({
code: error.code,
message: 'The requested price or time slot 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_PRICING_REQUEST',
message: 'The pricing 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
});
}
+170
View File
@@ -0,0 +1,170 @@
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 {
StoreAccessError,
type StoreAccessRepository
} from '../stores/access-repository.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const storeIdSchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
const sceneSchema = z.object({
targetType: z.enum(['STORE', 'ROOM']),
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
roomId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).refine((value) => value.targetType === 'STORE' || value.roomId !== undefined);
const resolveSchema = z.object({
code: z.string().trim().min(12).max(32),
sourceType: z.enum(['QRCODE', 'NFC']).default('QRCODE')
});
export interface StoreAccessRouteOptions {
repository: Pick<StoreAccessRepository,
'regenerateScene' | 'revokeScene' | 'resolveScene' | 'sceneStats' | 'getWifi'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerStoreAccessRoutes(
app: FastifyInstance, options: StoreAccessRouteOptions
) {
app.post('/admin-api/scene-codes/regenerate', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const body = sceneSchema.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.regenerateScene(actor, body.data),
traceId: request.traceId
}));
});
app.post('/admin-api/scene-codes/:id/revoke', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = idSchema.safeParse(request.params);
const query = storeIdSchema.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.revokeScene(actor, params.data.id, query.data.storeId),
traceId: request.traceId
}));
});
app.get('/admin-api/stores/:storeId/scene-code-stats', async (request, reply) => {
const actor = await requireManager(request, reply, options);
const params = storeIdSchema.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.sceneStats(actor, params.data.storeId),
traceId: request.traceId
}));
});
app.post('/app-api/scenes/resolve', async (request, reply) => {
const body = resolveSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.resolveScene({
...body.data,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
app.get('/app-api/stores/:storeId/wifi', async (request, reply) => {
const auth = await authenticateAccessToken(
request.headers.authorization, options.authRepository, options.jwtSecret
);
const params = storeIdSchema.safeParse(request.params);
if (!auth) {
return reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
traceId: request.traceId
});
}
if (!params.success) return invalid(reply, request.traceId);
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId, auth.session.user.id
);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getWifi({
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
storeId: params.data.storeId,
traceId: request.traceId,
ip: request.ip,
userAgent: request.headers['user-agent'] ?? ''
}),
traceId: request.traceId
}));
});
}
async function requireManager(
request: FastifyRequest, reply: FastifyReply, options: StoreAccessRouteOptions
): 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.some((item) =>
item === 'store.operation.write' || item === 'tenant.manage'
) && !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'SCENE_MANAGEMENT_FORBIDDEN',
message: 'Store 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 StoreAccessError)) throw error;
const forbidden = error.code.endsWith('_FORBIDDEN');
const notFound = error.code.endsWith('_NOT_FOUND') || error.code === 'SCENE_CODE_INVALID';
return reply.status(forbidden ? 403 : notFound ? 404 : 400).send({
code: error.code,
message: 'The scene or Wi-Fi request is invalid or not allowed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_STORE_ACCESS_REQUEST',
message: 'The scene or Wi-Fi request is invalid.',
traceId
});
}
+152
View File
@@ -0,0 +1,152 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { AuthRepository, LoginContext } from '../auth/auth-repository.js';
import type { StoreDiscoveryRepository } from '../stores/store-discovery-repository.js';
const headersSchema = z.object({
'x-wechat-appid': z.string().trim().min(6).max(64),
'tenant-id': z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const querySchema = z.object({
city: z.string().trim().min(1).max(64).optional(),
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).optional(),
openNow: z.enum(['true', 'false']).transform((value) => value === 'true').optional(),
latitude: z.coerce.number().min(-90).max(90).optional(),
longitude: z.coerce.number().min(-180).max(180).optional(),
maxDistanceMeters: z.coerce.number().int().min(1).max(500000).optional()
}).refine((value) => (value.latitude === undefined) === (value.longitude === undefined), {
message: 'latitude and longitude must be supplied together'
}).refine((value) => value.maxDistanceMeters === undefined || value.latitude !== undefined, {
message: 'distance filter requires coordinates'
});
export interface StoreDiscoveryRouteOptions {
repository: Pick<StoreDiscoveryRepository, 'findStores' | 'getStore' | 'listRooms'>;
tenancy: Pick<AuthRepository, 'resolveLoginContext'>;
}
export async function registerStoreDiscoveryRoutes(
app: FastifyInstance, options: StoreDiscoveryRouteOptions
) {
app.get('/app-api/stores', async (request, reply) => {
const headers = headersSchema.safeParse(request.headers);
const query = querySchema.safeParse(request.query);
if (!headers.success || !query.success) {
return reply.status(400).send({
code: 'INVALID_STORE_DISCOVERY_REQUEST',
message: 'AppID and valid city or coordinate filters are required.',
traceId: request.traceId
});
}
try {
const context = await options.tenancy.resolveLoginContext(
headers.data['x-wechat-appid'], headers.data['tenant-id']
);
if (!context) {
return reply.status(404).send({
code: 'APP_TENANT_NOT_FOUND', message: 'Application tenant binding was not found.',
traceId: request.traceId
});
}
return {
code: 0,
data: await options.repository.findStores({ tenantId: context.tenantId, ...query.data }),
traceId: request.traceId
};
} catch (error) {
if (error instanceof Error && error.message === 'TENANT_SELECTION_REQUIRED') {
return reply.status(409).send({
code: 'TENANT_SELECTION_REQUIRED',
message: 'tenant-id is required for an application bound to multiple tenants.',
traceId: request.traceId
});
}
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
});
}
+214
View File
@@ -0,0 +1,214 @@
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 {
StoreRoomError,
type StoreRoomRepository
} from '../stores/store-room-repository.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const storeIdSchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
const coordinate = z.number().finite();
const hoursSchema = z.array(z.object({
weekday: z.number().int().min(1).max(7),
openMinute: z.number().int().min(0).max(1439),
closeMinute: z.number().int().min(0).max(1439),
isClosed: z.boolean().default(false)
})).max(7).refine((items) => new Set(items.map((item) => item.weekday)).size === items.length);
const storeSchema = z.object({
name: z.string().trim().min(1).max(128),
address: z.string().trim().max(255).default(''),
city: z.string().trim().max(64).default(''),
district: z.string().trim().max(64).default(''),
longitude: coordinate.min(-180).max(180).nullable().optional(),
latitude: coordinate.min(-90).max(90).nullable().optional(),
contactPhone: z.string().trim().max(32).default(''),
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(''),
notificationUrl: z.union([z.string().url(), z.literal('')]).default(''),
sortOrder: z.number().int().min(-100000).max(100000).default(0),
businessHours: hoursSchema.default([])
});
const roomSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
categoryName: z.string().trim().min(1).max(128),
name: z.string().trim().min(1).max(128),
roomNo: z.string().trim().min(1).max(64),
capacity: z.number().int().min(1).max(100),
basePriceCents: z.number().int().min(0).max(10000000),
weekdayPriceCents: z.number().int().min(0).max(10000000),
holidayPriceCents: z.number().int().min(0).max(10000000),
overnightPriceCents: z.number().int().min(0).max(10000000),
fullDayPriceCents: z.number().int().min(0).max(10000000).default(0),
minimumSpendCents: z.number().int().min(0).max(10000000).default(0),
depositCents: z.number().int().min(0).max(10000000),
minimumMinutes: z.number().int().min(15).max(1440),
maxAdvanceStartMinutes: z.number().int().min(0).max(1440),
maxAdvanceDays: z.number().int().min(0).max(365),
configurationStatus: z.enum(['ENABLED', 'DISABLED']),
operationalStatus: z.enum([
'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED'
]),
tags: z.array(z.string().trim().min(1).max(32)).max(20).default([]),
images: z.array(z.string().url()).max(20).default([]),
sortOrder: z.number().int().min(-100000).max(100000).default(0)
});
const disabledPeriodSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
startsAt: z.coerce.date(),
endsAt: z.coerce.date(),
reason: z.string().trim().max(255).default('')
}).refine((value) => value.endsAt > value.startsAt);
export interface StoreRoomRouteOptions {
repository: Pick<StoreRoomRepository,
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerStoreRoomRoutes(app: FastifyInstance, options: StoreRoomRouteOptions) {
app.get('/admin-api/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);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.createStore(actor, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/stores/:id', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = storeSchema.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.updateStore(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
app.delete('/admin-api/stores/:id', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.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.archiveStore(actor, params.data.id),
traceId: request.traceId
}));
});
app.get('/admin-api/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);
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0, data: await options.repository.createRoom(actor, body.data),
traceId: request.traceId
}));
});
app.put('/admin-api/rooms/:id', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = roomSchema.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.updateRoom(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);
const query = storeIdSchema.safeParse(request.query);
if (!actor || !params.success || !query.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return mutate(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.archiveRoom(actor, params.data.id, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/rooms/:id/disabled-periods', async (request, reply) => {
const actor = await requireOperator(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = disabledPeriodSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return mutate(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.addDisabledPeriod(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
}
async function requireOperator(
request: FastifyRequest, reply: FastifyReply, options: StoreRoomRouteOptions
): 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.some((code) =>
code === 'store.operation.read' || code === 'store.operation.write' || code === 'tenant.manage'
) && !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({ code: 'STORE_OPERATION_FORBIDDEN',
message: 'Store operation 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 mutate(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof StoreRoomError)) throw error;
const forbidden = error.code.endsWith('_FORBIDDEN');
return reply.status(forbidden ? 403 : 404).send({
code: error.code, message: 'The store or room operation is not allowed.', traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_STORE_ROOM_REQUEST', message: 'The store or room request is invalid.', traceId
});
}
+244
View File
@@ -0,0 +1,244 @@
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 {
ThirdPartyError, type ThirdPartyProvider
} from '../third-party/third-party-client.js';
import type { ThirdPartyService } from '../third-party/third-party-service.js';
const providerSchema = z.enum(['MEITUAN', 'DIANPING', 'DOUYIN', 'KUAISHOU']);
const redeemSchema = z.object({
provider: providerSchema,
voucherCode: z.string().min(4).max(128),
orderId: z.string().regex(/^[1-9]\d{0,19}$/),
clientRequestId: z.string().min(8).max(128)
}).strict();
const manualSchema = redeemSchema.extend({
amountCents: z.number().int().positive(),
note: z.string().min(1).max(512)
}).strict();
const notifyParams = z.object({
tenantId: z.string().regex(/^[1-9]\d{0,19}$/),
provider: providerSchema
});
const bookingSchema = z.object({
eventId: z.string().min(4).max(128),
externalBookingNo: z.string().min(4).max(128),
externalStoreRef: z.string().min(1).max(128),
externalRoomRef: z.string().min(1).max(128),
customerRef: z.string().max(255).default(''),
startsAt: z.coerce.date(),
endsAt: z.coerce.date(),
amountCents: z.number().int().nonnegative()
}).strict().refine((value) => value.endsAt > value.startsAt);
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()
});
const configSchema = z.object({
provider: providerSchema,
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
mode: z.enum(['MANUAL', 'MOCK', 'API']),
enabled: z.boolean().default(true),
credentialRef: z.string().max(255).default(''),
settings: z.record(z.unknown()).default({})
}).strict();
const mappingSchema = z.object({
provider: providerSchema,
resourceType: z.enum(['STORE', 'ROOM']),
externalRef: z.string().min(1).max(128),
localResourceId: z.string().regex(/^[1-9]\d{0,19}$/)
}).strict();
export interface ThirdPartyRouteOptions {
service: ThirdPartyService;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: {
getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile>;
};
jwtSecret: string;
}
export async function registerThirdPartyRoutes(
app: FastifyInstance,
options: ThirdPartyRouteOptions
) {
app.post('/app-api/group-vouchers/redeem', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, false);
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.redeemVoucher({
tenantId: auth.tenantId,
userId: auth.userId,
...body.data
}),
traceId: request.traceId
}));
});
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
}));
});
app.post(
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
async (request, reply) => {
const params = notifyParams.safeParse(request.params);
const body = bookingSchema.safeParse(request.body);
const signature = request.headers['x-third-party-signature'];
if (!params.success || !body.success || typeof signature !== 'string') {
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.service.receiveDirectBooking({
tenantId: params.data.tenantId,
provider: params.data.provider,
signature,
rawBody: JSON.stringify(request.body),
payload: request.body as Record<string, unknown>,
...body.data
}),
traceId: request.traceId
}));
}
);
app.post('/app-api/third-party/bookings/:bookingId/claim', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, false);
const params = bookingParams.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.claimDirectBooking({
tenantId: auth.tenantId,
userId: auth.userId,
bookingId: params.data.bookingId
}),
traceId: request.traceId
}));
});
app.get('/admin-api/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
}),
traceId: request.traceId
}));
});
app.put('/admin-api/third-party/config', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = configSchema.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.saveConfig({
tenantId: auth.tenantId,
actorId: auth.userId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
app.put('/admin-api/third-party/mappings', async (request, reply) => {
const auth = await authenticate(request.headers.authorization, options, true);
const body = mappingSchema.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.saveMapping({
tenantId: auth.tenantId,
access: auth.access!,
...body.data
}),
traceId: request.traceId
}));
});
}
async function authenticate(
authorization: string | undefined,
options: ThirdPartyRouteOptions,
withAccess: boolean
) {
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: withAccess
? await options.accessControl.getAccessProfile(tenantId, userId)
: undefined
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof ThirdPartyError)) throw error;
const status = error.code.includes('NOT_FOUND') ? 404
: error.code.includes('CONFLICT') || error.code.includes('ALREADY') ? 409
: error.code.includes('FORBIDDEN') ? 403
: error.code.includes('SIGNATURE') ? 401 : 400;
return reply.status(status).send({
code: error.code,
message: 'The third-party request 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_THIRD_PARTY_REQUEST',
message: 'The third-party request is invalid.',
traceId
});
}
+160
View File
@@ -0,0 +1,160 @@
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 {
UserManagementError,
type AssignableRole,
type ManagementActor,
type UserManagementRepository
} from '../auth/user-management-repository.js';
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const rolesSchema = z.array(z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN'])).max(4);
const storesSchema = z.array(z.string().regex(/^[1-9]\d{0,19}$/)).max(100);
const createSchema = z.object({
nickname: z.string().trim().min(1).max(128),
phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/),
note: z.string().trim().max(1000).default(''),
roles: rolesSchema.default(['STAFF']),
storeIds: storesSchema.default([])
});
const updateSchema = z.object({
nickname: z.string().trim().min(1).max(128).optional(),
phone: z.string().trim().regex(/^\+?[0-9]{6,20}$/).optional(),
note: z.string().trim().max(1000).optional(),
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
roles: rolesSchema.optional(),
storeIds: storesSchema.optional()
}).refine((value) => Object.keys(value).length > 0);
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(),
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
search: z.string().trim().max(128).optional()
});
export interface UserManagementRouteOptions {
repository: Pick<UserManagementRepository, 'listUsers' | 'createStaff' | 'updateUser' | 'resetSessions'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
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 };
});
app.post('/admin-api/staff', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const body = createSchema.safeParse(request.body);
if (!body.success) return invalid(reply, request.traceId);
return handleMutation(reply, request.traceId, async () => {
const data = await options.repository.createStaff(actor, {
...body.data,
roles: body.data.roles as AssignableRole[]
});
return reply.status(201).send({ code: 0, data, traceId: request.traceId });
});
});
app.patch('/admin-api/users/:id', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
const body = updateSchema.safeParse(request.body);
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handleMutation(reply, request.traceId, async () => {
const data = await options.repository.updateUser(actor, params.data.id, {
...body.data,
roles: body.data.roles as AssignableRole[] | undefined
});
return { code: 0, data, traceId: request.traceId };
});
});
app.post('/admin-api/users/:id/reset-sessions', async (request, reply) => {
const actor = await requireManager(request, reply, options);
if (!actor) return;
const params = idSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handleMutation(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.resetSessions(actor, params.data.id),
traceId: request.traceId
}));
});
}
async function requireManager(
request: FastifyRequest,
reply: FastifyReply,
options: UserManagementRouteOptions
): 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: 'The access token or session is invalid.',
traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(
auth.session.tenantId,
auth.session.user.id
);
if (!access.capabilities.some((code) => code === 'staff.manage' || code === 'tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'STAFF_MANAGEMENT_FORBIDDEN', message: 'Staff 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 handleMutation(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof UserManagementError)) throw error;
const forbidden = error.code.endsWith('_FORBIDDEN') || error.code === 'USER_NOT_MANAGEABLE';
return reply.status(forbidden ? 403 : 404).send({
code: error.code,
message: 'The requested user, role or store assignment is not allowed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_USER_MANAGEMENT_REQUEST',
message: 'The user management request is invalid.',
traceId
});
}
+218
View File
@@ -0,0 +1,218 @@
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 { AuthRepository } from './auth/auth-repository.js';
import { RbacRepository } from './auth/rbac-repository.js';
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
import { UserManagementRepository } from './auth/user-management-repository.js';
import { StoreRoomRepository } from './stores/store-room-repository.js';
import { ContentRepository } from './content/content-repository.js';
import { MediaStorage } from './content/media-storage.js';
import { resolve } from 'node:path';
import { StoreDiscoveryRepository } from './stores/store-discovery-repository.js';
import { StoreAccessRepository } from './stores/access-repository.js';
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
} from './payments/wechat-pay-client.js';
import { WechatPaymentService } from './payments/wechat-payment-service.js';
import { ProfitSharingService } from './payments/profit-sharing-service.js';
import {
FetchThirdPartyTransport, parseThirdPartyCredentials, ThirdPartyClient
} from './third-party/third-party-client.js';
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';
const config = loadConfig();
const pool = createMySqlPool(config);
const authRepository = new AuthRepository(pool);
const accessControl = new RbacRepository(pool);
const orderManagementRepository = new OrderManagementRepository(pool);
const walletLedgerService = new WalletLedgerService(pool);
const marketingBenefits = new MarketingBenefitService(pool);
const cleaningTaskRepository = new CleaningTaskRepository(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) =>
iotMessages.handle(topic, payload)
);
const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
const app = await buildApp({
config,
mqtt,
platformConfigRepository: new PlatformConfigRepository(pool),
auth: {
repository: authRepository,
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
jwtSecret: config.auth.jwtSecret,
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
sessionTtlSeconds: config.auth.sessionTtlSeconds,
accessControl
},
userManagement: {
repository: new UserManagementRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
storeRoom: {
repository: new StoreRoomRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
content: {
repository: new ContentRepository(pool),
mediaStorage: new MediaStorage(resolve(process.cwd(), 'shared', 'uploads')),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
storeDiscovery: {
repository: new StoreDiscoveryRepository(pool),
tenancy: authRepository
},
storeAccess: {
repository: new StoreAccessRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
pricing: {
repository: new PricingRepository(pool, marketingBenefits),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
orderState: {
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,
accessControl,
jwtSecret: config.auth.jwtSecret
},
orderShare: {
repository: new OrderShareRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
payment: {
repository: paymentRepository,
wechat: new WechatPaymentService(
pool,
paymentRepository,
wechatPayClient,
wechatCredentials
),
profitSharing: new ProfitSharingService(
pool,
wechatPayClient,
wechatCredentials,
config.payment.profitShareMockEnabled
),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret,
testAdapterEnabled: config.payment.testAdapterEnabled
},
thirdParty: {
service: new ThirdPartyService(
pool,
new PricingRepository(pool),
new ThirdPartyClient(new FetchThirdPartyTransport()),
thirdPartyCredentials
),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
devices: {
repository: new DeviceRepository(pool),
authRepository,
accessControl,
jwtSecret: config.auth.jwtSecret
},
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
}
});
app.addHook('onClose', async () => {
await mqtt.stop();
await closeMySqlPool(pool);
});
try {
mqtt.start();
await app.listen({ host: config.host, port: config.port });
} catch (error) {
app.log.error(error);
process.exit(1);
}
+220
View File
@@ -0,0 +1,220 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { AccessProfile } from '../auth/rbac-repository.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
interface SceneRow extends RowDataPacket {
id: string;
tenantId: string;
targetType: 'STORE' | 'ROOM';
storeId: string;
roomId: string | null;
generation: number;
scanCount: number;
}
interface WifiRow extends RowDataPacket { ssid: string; password: string }
interface CountRow extends RowDataPacket { total: number }
export class StoreAccessError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class StoreAccessRepository {
constructor(private readonly pool: MySqlPool) {}
async regenerateScene(actor: ManagementActor, input: {
targetType: 'STORE' | 'ROOM'; storeId: string; roomId?: string;
}) {
this.assertStoreManager(actor.access, input.storeId);
return this.transaction(async (connection) => {
await this.assertTarget(connection, actor.tenantId, input);
const [generations] = await connection.execute<CountRow[]>(
`SELECT COALESCE(MAX(generation), 0) + 1 AS total
FROM qipai_scene_codes
WHERE tenant_id = ? AND target_type = ? AND store_id = ?
AND room_id <=> ? FOR UPDATE`,
[actor.tenantId, input.targetType, input.storeId, input.roomId ?? null]
);
await connection.execute(
`UPDATE qipai_scene_codes SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND target_type = ? AND store_id = ?
AND room_id <=> ? AND status = 'ACTIVE'`,
[actor.tenantId, input.targetType, input.storeId, input.roomId ?? null]
);
const code = randomBytes(12).toString('base64url');
const generation = Number(generations[0]?.total ?? 1);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_scene_codes
(tenant_id, code, target_type, store_id, room_id, generation, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[actor.tenantId, code, input.targetType, input.storeId,
input.roomId ?? null, generation, actor.userId]
);
await this.audit(connection, actor, 'SCENE_CODE_REGENERATED', 'SCENE_CODE', String(result.insertId));
return { sceneCodeId: String(result.insertId), code, generation };
});
}
async revokeScene(actor: ManagementActor, sceneCodeId: string, storeId: string) {
this.assertStoreManager(actor.access, storeId);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_scene_codes SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND store_id = ? AND status = 'ACTIVE'`,
[actor.tenantId, sceneCodeId, storeId]
);
if (result.affectedRows !== 1) throw new StoreAccessError('SCENE_CODE_NOT_FOUND');
return { sceneCodeId, revoked: true };
}
async resolveScene(input: {
code: string; sourceType: 'QRCODE' | 'NFC'; traceId: string;
ip: string; userAgent: string; userId?: string;
}) {
return this.transaction(async (connection) => {
const [rows] = await connection.execute<SceneRow[]>(
`SELECT id, tenant_id AS tenantId, target_type AS targetType,
store_id AS storeId, room_id AS roomId, generation,
scan_count AS scanCount
FROM qipai_scene_codes
WHERE code = ? AND status = 'ACTIVE' LIMIT 1 FOR UPDATE`,
[input.code]
);
const scene = rows[0];
if (!scene) throw new StoreAccessError('SCENE_CODE_INVALID');
await connection.execute(
`UPDATE qipai_scene_codes SET scan_count = scan_count + 1,
last_scanned_at = UTC_TIMESTAMP(3) WHERE id = ?`,
[scene.id]
);
await connection.execute(
`INSERT INTO qipai_scene_scan_events
(tenant_id, scene_code_id, source_type, user_id, trace_id, ip, user_agent)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[scene.tenantId, scene.id, input.sourceType, input.userId ?? null,
input.traceId, input.ip, input.userAgent.slice(0, 255)]
);
return {
targetType: scene.targetType,
storeId: String(scene.storeId),
roomId: scene.roomId === null ? null : String(scene.roomId),
page: scene.targetType === 'ROOM' ? '/pages/room/detail' : '/pages/store/detail',
permissions: []
};
});
}
async sceneStats(actor: ManagementActor, storeId: string) {
this.assertStoreManager(actor.access, storeId);
const [rows] = await this.pool.execute<SceneRow[]>(
`SELECT id, tenant_id AS tenantId, target_type AS targetType,
store_id AS storeId, room_id AS roomId, generation,
scan_count AS scanCount
FROM qipai_scene_codes
WHERE tenant_id = ? AND store_id = ? AND status = 'ACTIVE'
ORDER BY target_type, room_id`,
[actor.tenantId, storeId]
);
return rows.map((row) => ({
sceneCodeId: String(row.id),
targetType: row.targetType,
storeId: String(row.storeId),
roomId: row.roomId === null ? null : String(row.roomId),
generation: row.generation,
scanCount: Number(row.scanCount)
}));
}
async getWifi(input: {
tenantId: string; userId: string; access: AccessProfile; storeId: string;
traceId: string; ip: string; userAgent: string;
}) {
const manager = this.canManageStore(input.access, input.storeId);
if (!manager) {
const [rows] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total
FROM qipai_order_user_access a
INNER JOIN qipai_orders o
ON o.id = a.order_id AND o.tenant_id = a.tenant_id AND o.deleted_at IS NULL
WHERE a.tenant_id = ? AND a.user_id = ? AND a.revoked_at IS NULL
AND o.store_id = ?
AND o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'CONFIRMED', 'IN_USE')
AND UTC_TIMESTAMP(3) BETWEEN DATE_SUB(o.start_at, INTERVAL 30 MINUTE) AND o.end_at`,
[input.tenantId, input.userId, input.storeId]
);
if (Number(rows[0]?.total ?? 0) === 0) throw new StoreAccessError('WIFI_ACCESS_FORBIDDEN');
}
const [rows] = await this.pool.execute<WifiRow[]>(
`SELECT wifi_ssid AS ssid, wifi_password AS password
FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
[input.tenantId, input.storeId]
);
if (!rows[0] || !rows[0].ssid) throw new StoreAccessError('WIFI_NOT_CONFIGURED');
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 (?, 'USER', ?, 'WIFI_CREDENTIAL_ACCESSED', 'STORE', ?, ?, ?, ?,
JSON_OBJECT('ssid', ?, 'passwordReturned', TRUE))`,
[input.tenantId, input.userId, input.storeId, input.traceId,
input.ip, input.userAgent.slice(0, 255), rows[0].ssid]
);
return rows[0];
}
private assertStoreManager(access: AccessProfile, storeId: string) {
if (!this.canManageStore(access, storeId)) throw new StoreAccessError('STORE_SCOPE_FORBIDDEN');
}
private canManageStore(access: AccessProfile, storeId: string) {
return access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write') && access.storeIds.includes(storeId));
}
private async assertTarget(
connection: PoolConnection, tenantId: string,
input: { targetType: 'STORE' | 'ROOM'; storeId: string; roomId?: string }
) {
if (input.targetType === 'ROOM' && !input.roomId) throw new StoreAccessError('ROOM_REQUIRED');
const sql = input.targetType === 'ROOM'
? `SELECT COUNT(*) AS total FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL`
: `SELECT COUNT(*) AS total FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`;
const params: string[] = input.targetType === 'ROOM'
? [tenantId, input.storeId, input.roomId as string] : [tenantId, input.storeId];
const [rows] = await connection.execute<CountRow[]>(sql, params);
if (Number(rows[0]?.total ?? 0) !== 1) throw new StoreAccessError('SCENE_TARGET_NOT_FOUND');
}
private async audit(
connection: PoolConnection, actor: ManagementActor,
action: string, resourceType: string, resourceId: string
) {
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())`,
[actor.tenantId, actor.userId, action, resourceType, resourceId,
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
);
}
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();
}
}
}
@@ -0,0 +1,264 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export interface StoreDiscoveryQuery {
tenantId: string;
city?: string;
businessStatus?: 'OPEN' | 'CLOSED' | 'SUSPENDED';
openNow?: boolean;
latitude?: number;
longitude?: number;
maxDistanceMeters?: number;
now?: Date;
}
interface DiscoveryRow extends RowDataPacket {
id: string;
name: string;
address: string;
city: string;
district: string;
longitude: string | null;
latitude: string | null;
contactPhone: string;
timezone: string;
businessStatus: string;
weekday: number | null;
openMinute: number | null;
closeMinute: number | null;
isClosed: number | null;
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;
address: string;
city: string;
district: string;
longitude: number | null;
latitude: number | null;
contactPhone: string;
timezone: string;
businessStatus: string;
openNow: boolean;
distanceMeters: number | null;
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) {}
async findStores(query: StoreDiscoveryQuery): Promise<DiscoveredStore[]> {
const filters = ['s.tenant_id = ?', 's.deleted_at IS NULL'];
const params: Array<string> = [query.tenantId];
if (query.city) {
filters.push('s.city = ?');
params.push(query.city);
}
if (query.businessStatus) {
filters.push('s.business_status = ?');
params.push(query.businessStatus);
}
const [rows] = await this.pool.execute<DiscoveryRow[]>(
`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.sort_order AS sortOrder,
h.weekday, h.open_minute AS openMinute, h.close_minute AS closeMinute,
h.is_closed AS isClosed
FROM qipai_stores s
LEFT JOIN qipai_store_business_hours h
ON h.tenant_id = s.tenant_id AND h.store_id = s.id
WHERE ${filters.join(' AND ')}
ORDER BY s.sort_order, s.id`,
params
);
const grouped = new Map<string, { store: Omit<DiscoveredStore, 'openNow' | 'distanceMeters'>;
hours: DiscoveryRow[] }>();
for (const row of rows) {
const id = String(row.id);
const existing = grouped.get(id);
if (existing) {
existing.hours.push(row);
continue;
}
grouped.set(id, {
store: {
id,
name: row.name,
address: row.address,
city: row.city,
district: row.district,
longitude: row.longitude === null ? null : Number(row.longitude),
latitude: row.latitude === null ? null : Number(row.latitude),
contactPhone: row.contactPhone,
timezone: row.timezone,
businessStatus: row.businessStatus,
sortOrder: row.sortOrder
},
hours: [row]
});
}
const now = query.now ?? new Date();
const stores = [...grouped.values()].map(({ store, hours }) => {
const openNow = store.businessStatus === 'OPEN' && isOpenAt(hours, store.timezone, now);
const distanceMeters = query.latitude !== undefined && query.longitude !== undefined
&& store.latitude !== null && store.longitude !== null
? Math.round(haversineMeters(
query.latitude, query.longitude, store.latitude, store.longitude
))
: null;
return { ...store, openNow, distanceMeters };
}).filter((store) => {
if (query.openNow === true && !store.openNow) return false;
if (query.openNow === false && store.openNow) return false;
return query.maxDistanceMeters === undefined || store.distanceMeters === null
|| store.distanceMeters <= query.maxDistanceMeters;
});
return stores.sort((left, right) => {
if (left.distanceMeters !== null && right.distanceMeters !== null
&& left.distanceMeters !== right.distanceMeters) {
return left.distanceMeters - right.distanceMeters;
}
if (left.distanceMeters !== null) return -1;
if (right.distanceMeters !== null) return 1;
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(
latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number
): number {
const radians = (degrees: number) => degrees * Math.PI / 180;
const latitudeDelta = radians(latitudeB - latitudeA);
const longitudeDelta = radians(longitudeB - longitudeA);
const a = Math.sin(latitudeDelta / 2) ** 2
+ Math.cos(radians(latitudeA)) * Math.cos(radians(latitudeB))
* Math.sin(longitudeDelta / 2) ** 2;
return 6371008.8 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function isOpenAt(hours: DiscoveryRow[], timezone: string, now: Date): boolean {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23'
}).formatToParts(now);
const weekdayName = parts.find((part) => part.type === 'weekday')?.value ?? '';
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(weekdayName) + 1;
const hour = Number(parts.find((part) => part.type === 'hour')?.value ?? 0);
const minute = Number(parts.find((part) => part.type === 'minute')?.value ?? 0);
const currentMinute = hour * 60 + minute;
const today = hours.find((item) => item.weekday === weekday);
if (!today || today.isClosed === 1 || today.openMinute === null || today.closeMinute === null) {
return false;
}
if (today.openMinute === today.closeMinute) return true;
if (today.closeMinute > today.openMinute) {
return currentMinute >= today.openMinute && currentMinute < today.closeMinute;
}
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 [];
}
}
+410
View File
@@ -0,0 +1,410 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { ManagementActor } from '../auth/user-management-repository.js';
export interface StoreInput {
name: string;
address: string;
city: string;
district: string;
longitude?: number | null;
latitude?: number | null;
contactPhone: string;
timezone: string;
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
wifiSsid: string;
wifiPassword: string;
notificationUrl: string;
sortOrder: number;
businessHours: Array<{
weekday: number;
openMinute: number;
closeMinute: number;
isClosed: boolean;
}>;
}
export interface RoomInput {
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: 'ENABLED' | 'DISABLED';
operationalStatus: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED';
tags: string[];
images: string[];
sortOrder: number;
}
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;
}
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 class StoreRoomError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class StoreRoomRepository {
constructor(private readonly pool: MySqlPool) {}
async listStores(actor: ManagementActor) {
const scope = this.scope(actor, 's.id');
const [rows] = await this.pool.execute<StoreRow[]>(
`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,
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]
);
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)
}));
}
async createStore(actor: ManagementActor, input: StoreInput) {
this.requireTenantManager(actor);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_stores
(tenant_id, name, address, city, district, longitude, latitude, contact_phone, timezone,
business_status, wifi_ssid, wifi_password, notification_url, sort_order)
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]
);
const storeId = String(result.insertId);
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
await this.audit(connection, actor, 'STORE_CREATED', 'STORE', storeId);
return { storeId };
});
}
async updateStore(actor: ManagementActor, storeId: string, input: StoreInput) {
return this.transaction(async (connection) => {
await this.lockStore(connection, actor, storeId);
await connection.execute(
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
longitude = ?, latitude = ?,
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
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]
);
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
return { storeId };
});
}
async archiveStore(actor: ManagementActor, storeId: string) {
this.requireTenantManager(actor);
return this.transaction(async (connection) => {
await this.lockStore(connection, actor, storeId);
const [counts] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`,
[actor.tenantId, storeId]
);
if (Number(counts[0]?.total ?? 0) > 0) throw new StoreRoomError('STORE_HAS_ACTIVE_ROOMS');
await connection.execute(
`UPDATE qipai_stores SET deleted_at = UTC_TIMESTAMP(3), business_status = 'CLOSED'
WHERE tenant_id = ? AND id = ?`,
[actor.tenantId, storeId]
);
await this.audit(connection, actor, 'STORE_ARCHIVED', 'STORE', storeId);
return { storeId, archived: true };
});
}
async listRooms(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
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
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
ORDER BY r.sort_order, r.id`,
[actor.tenantId, storeId]
);
return rows.map((row) => ({
...row, id: String(row.id), storeId: String(row.storeId),
tags: parseJsonArray(row.tags), images: parseJsonArray(row.images)
}));
}
async createRoom(actor: ManagementActor, input: RoomInput) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
await this.lockStore(connection, actor, input.storeId);
const categoryId = await this.ensureCategory(
connection, actor.tenantId, input.storeId, input.categoryName
);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_rooms
(tenant_id, store_id, category_id, name, room_no, capacity, base_price_cents,
weekday_price_cents, holiday_price_cents, overnight_price_cents,
full_day_price_cents, minimum_spend_cents, deposit_cents, minimum_minutes,
max_advance_start_minutes, max_advance_days,
configuration_status, operational_status, tags, images, sort_order, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
roomParams(actor.tenantId, categoryId, input)
);
const roomId = String(result.insertId);
await this.audit(connection, actor, 'ROOM_CREATED', 'ROOM', roomId);
return { roomId };
});
}
async updateRoom(actor: ManagementActor, roomId: string, input: RoomInput) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
await this.lockRoom(connection, actor, roomId, input.storeId);
const categoryId = await this.ensureCategory(
connection, actor.tenantId, input.storeId, input.categoryName
);
await connection.execute(
`UPDATE qipai_rooms SET category_id = ?, name = ?, room_no = ?, capacity = ?,
base_price_cents = ?, weekday_price_cents = ?, holiday_price_cents = ?,
overnight_price_cents = ?, full_day_price_cents = ?, minimum_spend_cents = ?,
deposit_cents = ?, minimum_minutes = ?,
max_advance_start_minutes = ?, max_advance_days = ?, configuration_status = ?,
operational_status = ?, tags = ?, images = ?, sort_order = ?, status = ?
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
[...roomUpdateParams(categoryId, input), actor.tenantId, input.storeId, roomId]
);
await this.audit(connection, actor, 'ROOM_UPDATED', 'ROOM', roomId);
return { roomId };
});
}
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
await this.lockRoom(connection, actor, roomId, storeId);
await connection.execute(
`UPDATE qipai_rooms SET deleted_at = UTC_TIMESTAMP(3),
configuration_status = 'DISABLED', status = 'DISABLED'
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
[actor.tenantId, storeId, roomId]
);
await this.audit(connection, actor, 'ROOM_ARCHIVED', 'ROOM', roomId);
return { roomId, archived: true };
});
}
async addDisabledPeriod(actor: ManagementActor, roomId: string, input: {
storeId: string; startsAt: Date; endsAt: Date; reason: string;
}) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
await this.lockRoom(connection, actor, roomId, input.storeId);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_room_disabled_periods
(tenant_id, room_id, starts_at, ends_at, reason, created_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[actor.tenantId, roomId, input.startsAt, input.endsAt, input.reason, actor.userId]
);
await this.audit(connection, actor, 'ROOM_DISABLED_PERIOD_CREATED', 'ROOM', roomId);
return { disabledPeriodId: String(result.insertId) };
});
}
private requireTenantManager(actor: ManagementActor) {
if (!actor.access.capabilities.includes('tenant.manage')
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
throw new StoreRoomError('STORE_CREATE_FORBIDDEN');
}
}
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.write')
|| !actor.access.storeIds.includes(storeId)) {
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
}
}
private scope(actor: ManagementActor, expression: string) {
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: '1 = 0', params: [] as string[] };
return {
sql: `${expression} IN (${actor.access.storeIds.map(() => '?').join(',')})`,
params: actor.access.storeIds
};
}
private async lockStore(connection: PoolConnection, actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, storeId]
);
if (!rows[0]) throw new StoreRoomError('STORE_NOT_FOUND');
}
private async lockRoom(
connection: PoolConnection, actor: ManagementActor, roomId: string, storeId: string
) {
await this.lockStore(connection, actor, storeId);
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, storeId, roomId]
);
if (!rows[0]) throw new StoreRoomError('ROOM_NOT_FOUND');
}
private async ensureCategory(
connection: PoolConnection, tenantId: string, storeId: string, name: string
) {
await connection.execute(
`INSERT INTO qipai_room_categories (tenant_id, store_id, name)
VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[tenantId, storeId, name]
);
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_room_categories
WHERE tenant_id = ? AND store_id = ? AND name = ? AND deleted_at IS NULL`,
[tenantId, storeId, name]
);
if (!rows[0]) throw new StoreRoomError('ROOM_CATEGORY_NOT_FOUND');
return String(rows[0].id);
}
private async replaceHours(
connection: PoolConnection, tenantId: string, storeId: string,
hours: StoreInput['businessHours']
) {
await connection.execute(
'DELETE FROM qipai_store_business_hours WHERE tenant_id = ? AND store_id = ?',
[tenantId, storeId]
);
for (const item of hours) {
await connection.execute(
`INSERT INTO qipai_store_business_hours
(tenant_id, store_id, weekday, open_minute, close_minute, is_closed)
VALUES (?, ?, ?, ?, ?, ?)`,
[tenantId, storeId, item.weekday, item.openMinute, item.closeMinute, item.isClosed]
);
}
}
private async audit(
connection: PoolConnection, actor: ManagementActor,
action: string, resourceType: string, resourceId: string
) {
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())`,
[actor.tenantId, actor.userId, action, resourceType, resourceId,
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
);
}
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 roomParams(tenantId: string, categoryId: string, input: RoomInput) {
const legacyStatus = input.configurationStatus === 'DISABLED'
? 'DISABLED' : input.operationalStatus;
return [
tenantId, input.storeId, categoryId, input.name, input.roomNo, input.capacity,
input.basePriceCents, input.weekdayPriceCents, input.holidayPriceCents,
input.overnightPriceCents, input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0,
input.depositCents, input.minimumMinutes,
input.maxAdvanceStartMinutes, input.maxAdvanceDays, input.configurationStatus,
input.operationalStatus, JSON.stringify(input.tags), JSON.stringify(input.images),
input.sortOrder, legacyStatus
];
}
function roomUpdateParams(categoryId: string, input: RoomInput) {
const legacyStatus = input.configurationStatus === 'DISABLED'
? 'DISABLED' : input.operationalStatus;
return [
categoryId, input.name, input.roomNo, input.capacity, input.basePriceCents,
input.weekdayPriceCents, input.holidayPriceCents, input.overnightPriceCents,
input.fullDayPriceCents ?? 0, input.minimumSpendCents ?? 0, input.depositCents,
input.minimumMinutes, input.maxAdvanceStartMinutes,
input.maxAdvanceDays, input.configurationStatus, input.operationalStatus,
JSON.stringify(input.tags), JSON.stringify(input.images), input.sortOrder, legacyStatus
];
}
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 [];
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export interface AppendOutboxEventInput {
tenantId: string;
aggregateType: string;
aggregateId: string;
eventType: string;
idempotencyKey: string;
payload: unknown;
availableAt?: Date;
}
type SqlExecutor = Pick<MySqlPool, 'execute'>;
export class OutboxRepository {
constructor(private readonly executor: SqlExecutor) {}
async append(input: AppendOutboxEventInput): Promise<{ id: string; created: boolean }> {
const [result] = await this.executor.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_outbox_events
(tenant_id, aggregate_type, aggregate_id, event_type,
idempotency_key, payload, available_at)
VALUES (?, ?, ?, ?, ?, CAST(? AS JSON), ?)`,
[
input.tenantId,
input.aggregateType,
input.aggregateId,
input.eventType,
input.idempotencyKey,
JSON.stringify(input.payload),
input.availableAt ?? new Date()
]
);
if (result.insertId > 0) {
return { id: String(result.insertId), created: true };
}
const [rows] = await this.executor.execute<Array<RowDataPacket & { id: string }>>(
`SELECT id FROM qipai_outbox_events
WHERE tenant_id = ? AND idempotency_key = ?`,
[input.tenantId, input.idempotencyKey]
);
if (!rows[0]) throw new Error('Idempotent outbox lookup failed after duplicate insert.');
return { id: String(rows[0].id), created: false };
}
async markPublished(eventId: string): Promise<boolean> {
const [result] = await this.executor.execute<ResultSetHeader>(
`UPDATE qipai_outbox_events
SET status = 'PUBLISHED', published_at = UTC_TIMESTAMP(3),
attempts = attempts + 1, last_error = NULL
WHERE id = ? AND status = 'PENDING'`,
[eventId]
);
return result.affectedRows === 1;
}
async markFailed(eventId: string, error: unknown, delayMs: number): Promise<boolean> {
const message = error instanceof Error ? error.message : String(error);
const [result] = await this.executor.execute<ResultSetHeader>(
`UPDATE qipai_outbox_events
SET available_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MICROSECOND),
attempts = attempts + 1, last_error = ?
WHERE id = ? AND status = 'PENDING'`,
[delayMs * 1000, message.slice(0, 1000), eventId]
);
return result.affectedRows === 1;
}
}
+177
View File
@@ -0,0 +1,177 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export const taskTypes = [
'notification.dispatch',
'order.advance',
'device.command',
'refund.query',
'statistics.aggregate',
'outbox.publish'
] as const;
export type TaskType = (typeof taskTypes)[number];
export type TaskStatus =
| 'PENDING'
| 'RUNNING'
| 'RETRY'
| 'SUCCEEDED'
| 'FAILED'
| 'COMPENSATION_REQUIRED'
| 'CANCELLED';
export interface AsyncTask {
id: string;
tenantId: string;
taskType: TaskType;
idempotencyKey: string;
payload: unknown;
status: TaskStatus;
attempts: number;
maxAttempts: number;
}
interface TaskRow extends RowDataPacket {
id: string;
tenantId: string;
taskType: TaskType;
idempotencyKey: string;
payload: string | object;
status: TaskStatus;
attempts: number;
maxAttempts: number;
}
export interface EnqueueTaskInput {
tenantId: string;
taskType: TaskType;
idempotencyKey: string;
payload: unknown;
priority?: number;
availableAt?: Date;
maxAttempts?: number;
}
export class TaskRepository {
constructor(private readonly pool: MySqlPool) {}
async enqueue(input: EnqueueTaskInput): Promise<{ id: string; created: boolean }> {
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_async_tasks
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
VALUES (?, ?, ?, CAST(? AS JSON), ?, ?, ?)`,
[
input.tenantId,
input.taskType,
input.idempotencyKey,
JSON.stringify(input.payload),
input.priority ?? 0,
input.availableAt ?? new Date(),
input.maxAttempts ?? 8
]
);
if (result.insertId > 0) {
return { id: String(result.insertId), created: true };
}
const [rows] = await this.pool.execute<Array<RowDataPacket & { id: string }>>(
`SELECT id FROM qipai_async_tasks
WHERE tenant_id = ? AND task_type = ? AND idempotency_key = ?`,
[input.tenantId, input.taskType, input.idempotencyKey]
);
if (!rows[0]) throw new Error('Idempotent task lookup failed after duplicate insert.');
return { id: String(rows[0].id), created: false };
}
async claimNext(workerId: string, leaseMs: number): Promise<AsyncTask | null> {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
await this.recoverExpiredLease(connection);
const [rows] = await connection.execute<TaskRow[]>(
`SELECT id, tenant_id AS tenantId, task_type AS taskType,
idempotency_key AS idempotencyKey, payload, status,
attempts, max_attempts AS maxAttempts
FROM qipai_async_tasks
WHERE status IN ('PENDING', 'RETRY')
AND available_at <= UTC_TIMESTAMP(3)
ORDER BY priority DESC, available_at ASC, id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`
);
const row = rows[0];
if (!row) {
await connection.commit();
return null;
}
await connection.execute(
`UPDATE qipai_async_tasks
SET status = 'RUNNING',
lease_owner = ?,
lease_expires_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MICROSECOND),
attempts = attempts + 1,
last_error = NULL
WHERE id = ?`,
[workerId, leaseMs * 1000, row.id]
);
await connection.commit();
return {
...row,
id: String(row.id),
tenantId: String(row.tenantId),
status: 'RUNNING',
attempts: row.attempts + 1,
payload: typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload
};
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async complete(taskId: string, workerId: string): Promise<boolean> {
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_async_tasks
SET status = 'SUCCEEDED', completed_at = UTC_TIMESTAMP(3),
lease_owner = NULL, lease_expires_at = NULL
WHERE id = ? AND status = 'RUNNING' AND lease_owner = ?`,
[taskId, workerId]
);
return result.affectedRows === 1;
}
async fail(task: AsyncTask, workerId: string, error: unknown): Promise<TaskStatus> {
const terminal = task.attempts >= task.maxAttempts;
const nextStatus: TaskStatus = terminal ? 'COMPENSATION_REQUIRED' : 'RETRY';
const delayMs = retryDelayMs(task.attempts);
const message = error instanceof Error ? error.message : String(error);
const [result] = await this.pool.execute<ResultSetHeader>(
`UPDATE qipai_async_tasks
SET status = ?, available_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MICROSECOND),
lease_owner = NULL, lease_expires_at = NULL, last_error = ?
WHERE id = ? AND status = 'RUNNING' AND lease_owner = ?`,
[nextStatus, delayMs * 1000, message.slice(0, 1000), task.id, workerId]
);
if (result.affectedRows !== 1) {
throw new Error(`Task ${task.id} lease was lost before failure could be recorded.`);
}
return nextStatus;
}
private async recoverExpiredLease(connection: PoolConnection): Promise<void> {
await connection.execute(
`UPDATE qipai_async_tasks
SET status = 'RETRY', lease_owner = NULL, lease_expires_at = NULL,
available_at = UTC_TIMESTAMP(3),
last_error = COALESCE(last_error, 'worker lease expired')
WHERE status = 'RUNNING' AND lease_expires_at < UTC_TIMESTAMP(3)`
);
}
}
export function retryDelayMs(attempts: number): number {
const exponent = Math.max(0, Math.min(attempts - 1, 20));
return Math.min(60 * 60 * 1000, 1000 * 2 ** exponent);
}
+94
View File
@@ -0,0 +1,94 @@
import { hostname } from 'node:os';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { AsyncTask, TaskType } from './task-repository.js';
import { TaskRepository } from './task-repository.js';
import { closeMySqlPool, createMySqlPool } from '../db/mysql.js';
import { loadConfig } from '../config.js';
import { MqttService } from '../mqtt/mqtt-service.js';
import { DeviceCommandService } from '../devices/device-command-service.js';
import { DeviceControlService } from '../devices/device-control-service.js';
import { IotMessageService } from '../devices/iot-message-service.js';
import { OrderDeviceAutomationService } from '../devices/order-device-automation-service.js';
type TaskHandler = (task: AsyncTask) => Promise<void>;
export interface WorkerOptions {
repository: TaskRepository;
handlers: ReadonlyMap<TaskType, TaskHandler>;
workerId: string;
leaseMs?: number;
}
export class TaskWorker {
private stopping = false;
constructor(private readonly options: WorkerOptions) {}
stop(): void {
this.stopping = true;
}
async runOnce(): Promise<boolean> {
const task = await this.options.repository.claimNext(
this.options.workerId,
this.options.leaseMs ?? 60_000
);
if (!task) return false;
const handler = this.options.handlers.get(task.taskType);
try {
if (!handler) throw new Error(`No handler registered for task type ${task.taskType}.`);
await handler(task);
if (!(await this.options.repository.complete(task.id, this.options.workerId))) {
throw new Error(`Task ${task.id} lease was lost before completion.`);
}
} catch (error) {
await this.options.repository.fail(task, this.options.workerId, error);
}
return true;
}
async run(pollIntervalMs = 1000): Promise<void> {
while (!this.stopping) {
const processed = await this.runOnce();
if (!processed) await sleep(pollIntervalMs);
}
}
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
const config = loadConfig();
const pool = createMySqlPool(config);
const iotMessages = new IotMessageService(pool);
const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) =>
iotMessages.handle(topic, payload)
);
const deviceControl = new DeviceControlService(
pool,
new DeviceCommandService(iotMessages, mqtt)
);
const orderDevices = new OrderDeviceAutomationService(pool, deviceControl);
const worker = new TaskWorker({
repository: new TaskRepository(pool),
handlers: new Map([
['device.command', async (task) => { await orderDevices.handleTask(task); }]
]),
workerId: `${hostname()}:${process.pid}:${randomUUID()}`
});
const shutdown = () => worker.stop();
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
try {
mqtt.start();
await worker.run();
} finally {
await mqtt.stop();
await closeMySqlPool(pool);
}
}
@@ -0,0 +1,107 @@
import type { RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export interface PlatformBootstrap {
appId: string;
tenantId: string;
tenantCode: string;
tenantName: string;
brand: {
name: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
};
defaultStoreId: string | null;
}
interface PlatformBootstrapRow extends RowDataPacket {
appId: string;
tenantId: string;
tenantCode: string;
tenantName: string;
brandName: string;
logoUrl: string;
themeColor: string;
servicePhone: string;
franchisePhone: string;
shareTitle: string;
shareImageUrl: string;
defaultStoreId: string | null;
}
export class AmbiguousAppTenantError extends Error {
constructor(appId: string) {
super(`Application ${appId} is bound to multiple tenants; tenant-id is required.`);
this.name = 'AmbiguousAppTenantError';
}
}
export class PlatformConfigRepository {
constructor(private readonly pool: Pick<MySqlPool, 'execute'>) {}
async resolveBootstrap(appId: string, tenantId?: string): Promise<PlatformBootstrap | null> {
const tenantFilter = tenantId ? 'AND ta.tenant_id = ?' : '';
const params = tenantId ? [appId, tenantId] : [appId];
const [rows] = await this.pool.execute<PlatformBootstrapRow[]>(
`SELECT pa.appid AS appId,
ta.tenant_id AS tenantId,
t.code AS tenantCode,
t.name AS tenantName,
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
FROM qipai_platform_apps pa
INNER JOIN qipai_tenant_apps ta
ON ta.platform_app_id = pa.id
AND ta.status = 'ACTIVE'
AND ta.deleted_at IS NULL
INNER JOIN qipai_tenants t
ON t.id = ta.tenant_id
AND t.status = 'ACTIVE'
AND t.deleted_at IS NULL
INNER JOIN qipai_tenant_configs tc
ON tc.platform_app_id = pa.id
AND tc.tenant_id = ta.tenant_id
AND tc.deleted_at IS NULL
WHERE pa.appid = ?
AND pa.status = 'ACTIVE'
AND pa.deleted_at IS NULL
${tenantFilter}
ORDER BY ta.is_default DESC, ta.tenant_id ASC
LIMIT 2`,
params
);
if (!tenantId && rows.length > 1) {
throw new AmbiguousAppTenantError(appId);
}
const row = rows[0];
if (!row) return null;
return {
appId: row.appId,
tenantId: String(row.tenantId),
tenantCode: row.tenantCode,
tenantName: row.tenantName,
brand: {
name: 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)
};
}
}
+174
View File
@@ -0,0 +1,174 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
export interface ThirdPartyCredential {
webhookSecret?: string;
apiToken?: string;
}
export interface ThirdPartyTransport {
request(input: {
url: string;
method: 'POST';
headers: Record<string, string>;
body: string;
}): Promise<{ status: number; body: string }>;
}
export interface VoucherRedeemResult {
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
amountCents: number;
externalProductId?: string;
failureCode?: string;
response?: Record<string, unknown>;
}
export class ThirdPartyError extends Error {
constructor(public readonly code: string, message = code) {
super(message);
}
}
export class FetchThirdPartyTransport implements ThirdPartyTransport {
async request(input: {
url: string;
method: 'POST';
headers: Record<string, string>;
body: string;
}) {
const response = await fetch(input.url, {
method: input.method,
headers: input.headers,
body: input.body
});
return { status: response.status, body: await response.text() };
}
}
export class ThirdPartyClient {
constructor(private readonly transport: ThirdPartyTransport) {}
verifyWebhook(secret: string, rawBody: string, signature: string) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const left = Buffer.from(expected, 'utf8');
const right = Buffer.from(signature.toLowerCase(), 'utf8');
if (left.length !== right.length || !timingSafeEqual(left, right)) {
throw new ThirdPartyError('THIRD_PARTY_SIGNATURE_INVALID');
}
}
async redeemVoucher(input: {
mode: ThirdPartyMode;
provider: ThirdPartyProvider;
voucherCode: string;
orderNo: string;
expectedAmountCents: number;
settings: Record<string, unknown>;
credential?: ThirdPartyCredential;
}): Promise<VoucherRedeemResult> {
if (input.mode === 'MANUAL') {
throw new ThirdPartyError('THIRD_PARTY_MANUAL_ONLY');
}
if (input.mode === 'MOCK') {
if (input.voucherCode.startsWith('FAIL-')) {
return {
status: 'FAILED',
amountCents: 0,
failureCode: 'MOCK_VOUCHER_REJECTED',
response: { adapter: 'mock', accepted: false }
};
}
return {
status: 'SUCCEEDED',
amountCents: input.expectedAmountCents,
externalProductId: 'mock-product',
response: { adapter: 'mock', accepted: true }
};
}
const endpoint = input.settings.redeemEndpoint;
if (typeof endpoint !== 'string' || !endpoint.startsWith('https://')) {
throw new ThirdPartyError('THIRD_PARTY_ENDPOINT_INVALID');
}
if (!input.credential?.apiToken) {
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_NOT_CONFIGURED');
}
const response = await this.transport.request({
url: endpoint,
method: 'POST',
headers: {
Authorization: `Bearer ${input.credential.apiToken}`,
'Content-Type': 'application/json',
'User-Agent': 'qipai-backend/0.1'
},
body: JSON.stringify({
voucherCode: input.voucherCode,
orderNo: input.orderNo
})
});
if (response.status < 200 || response.status >= 300) {
return {
status: 'PENDING',
amountCents: 0,
failureCode: `THIRD_PARTY_HTTP_${response.status}`,
response: { httpStatus: response.status }
};
}
const payload = parseObject(response.body);
const accepted = payload.accepted === true;
const amountCents = Number(payload.amountCents);
return {
status: accepted ? 'SUCCEEDED' : 'FAILED',
amountCents: Number.isSafeInteger(amountCents) && amountCents >= 0
? amountCents : 0,
externalProductId: typeof payload.productId === 'string' ? payload.productId : '',
failureCode: accepted ? '' : stringValue(payload.failureCode, 'VOUCHER_REJECTED'),
response: sanitizeResponse(payload)
};
}
}
export function parseThirdPartyCredentials(value: string) {
const raw = parseObject(value);
const result = new Map<string, ThirdPartyCredential>();
for (const [key, item] of Object.entries(raw)) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_INVALID');
}
const credential = item as Record<string, unknown>;
result.set(key, {
webhookSecret: optionalString(credential.webhookSecret),
apiToken: optionalString(credential.apiToken)
});
}
return result;
}
function parseObject(value: string) {
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('object required');
}
return parsed as Record<string, unknown>;
} catch {
throw new ThirdPartyError('THIRD_PARTY_JSON_INVALID');
}
}
function optionalString(value: unknown) {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function stringValue(value: unknown, fallback: string) {
return typeof value === 'string' && value.length > 0 ? value : fallback;
}
function sanitizeResponse(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.voucherCode;
delete copy.token;
delete copy.secret;
return copy;
}
+676
View File
@@ -0,0 +1,676 @@
import { createHash, randomBytes } from 'node:crypto';
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 { PricingRepository } from '../orders/pricing-repository.js';
import {
ThirdPartyClient, ThirdPartyError, type ThirdPartyCredential,
type ThirdPartyMode, type ThirdPartyProvider
} from './third-party-client.js';
interface OrderRow extends RowDataPacket {
id: string;
orderNo: string;
storeId: string;
status: string;
totalAmountCents: number;
paidAmountCents: number;
}
interface ConfigRow extends RowDataPacket {
id: string;
mode: ThirdPartyMode;
credentialRef: string;
settings: string | Record<string, unknown>;
}
interface RedemptionRow extends RowDataPacket {
id: string;
orderId: string;
status: string;
voucherMasked: string;
}
interface BookingRow extends RowDataPacket {
id: string;
tenantId: string;
provider: ThirdPartyProvider;
storeId: string | null;
roomId: string | null;
startsAt: Date;
endsAt: Date;
amountCents: number;
status: string;
orderId: string | null;
}
export class ThirdPartyService {
constructor(
private readonly pool: MySqlPool,
private readonly pricing: PricingRepository,
private readonly client: ThirdPartyClient,
private readonly credentials: ReadonlyMap<string, ThirdPartyCredential>
) {}
async redeemVoucher(input: {
tenantId: string;
userId: string;
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.loadOwnedOrder(input.tenantId, input.userId, input.orderId);
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,
actorId: input.userId,
mode: config.mode,
amountCents: result.amountCents,
expectedAmountCents,
externalProductId: result.externalProductId ?? '',
status: result.status,
failureCode: result.failureCode ?? '',
response: result.response ?? {}
});
}
async redeemVoucherManually(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
amountCents: number;
clientRequestId: string;
note: 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 expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
if (input.amountCents !== expectedAmountCents) {
throw new ThirdPartyError('VOUCHER_AMOUNT_INVALID');
}
return this.recordRedemption({
...input,
mode: 'MANUAL',
expectedAmountCents,
externalProductId: '',
status: 'SUCCEEDED',
failureCode: '',
response: { manual: true, note: input.note.slice(0, 200) }
});
}
async receiveDirectBooking(input: {
tenantId: string;
provider: ThirdPartyProvider;
signature: string;
rawBody: string;
eventId: string;
externalBookingNo: string;
externalStoreRef: string;
externalRoomRef: string;
customerRef: string;
startsAt: Date;
endsAt: Date;
amountCents: number;
payload: Record<string, unknown>;
}) {
const config = await this.resolveConfig(input.tenantId, null, input.provider);
const credential = this.resolveCredential(config.credentialRef);
if (!credential?.webhookSecret) {
throw new ThirdPartyError('THIRD_PARTY_WEBHOOK_NOT_CONFIGURED');
}
this.client.verifyWebhook(credential.webhookSecret, input.rawBody, input.signature);
const [existing] = await this.pool.execute<BookingRow[]>(
`SELECT id, tenant_id AS tenantId, provider, store_id AS storeId,
room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt,
amount_cents AS amountCents, status, order_id AS orderId
FROM qipai_direct_bookings
WHERE tenant_id = ? AND provider = ? AND event_id = ? LIMIT 1`,
[input.tenantId, input.provider, input.eventId]
);
if (existing[0]) return { ...normalizeBooking(existing[0]), idempotent: true };
const mapping = await this.resolveMappings(
input.tenantId, input.provider, input.externalStoreRef, input.externalRoomRef
);
const status = mapping.storeId && mapping.roomId ? 'READY_TO_CLAIM' : 'PENDING_MAPPING';
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_direct_bookings
(tenant_id, provider, event_id, external_booking_no,
external_store_ref, external_room_ref, store_id, room_id,
customer_ref_hash, starts_at, ends_at, amount_cents, status, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
[input.tenantId, input.provider, input.eventId, input.externalBookingNo,
input.externalStoreRef, input.externalRoomRef, mapping.storeId, mapping.roomId,
hashValue(input.customerRef), input.startsAt, input.endsAt, input.amountCents,
status, JSON.stringify(sanitizePayload(input.payload))]
);
return {
bookingId: String(result.insertId),
status,
mapped: status === 'READY_TO_CLAIM',
idempotent: false
};
}
async claimDirectBooking(input: {
tenantId: string;
userId: string;
bookingId: string;
}) {
const [rows] = await this.pool.execute<BookingRow[]>(
`SELECT id, tenant_id AS tenantId, provider, store_id AS storeId,
room_id AS roomId, starts_at AS startsAt, ends_at AS endsAt,
amount_cents AS amountCents, status, order_id AS orderId
FROM qipai_direct_bookings
WHERE tenant_id = ? AND id = ? LIMIT 1`,
[input.tenantId, input.bookingId]
);
const booking = rows[0];
if (!booking) throw new ThirdPartyError('DIRECT_BOOKING_NOT_FOUND');
if (booking.status === 'CLAIMED') {
return { bookingId: input.bookingId, orderId: String(booking.orderId), idempotent: true };
}
if (booking.status !== 'READY_TO_CLAIM' || !booking.roomId) {
throw new ThirdPartyError('DIRECT_BOOKING_NOT_READY');
}
const quote = await this.pricing.quote({
tenantId: input.tenantId,
roomId: String(booking.roomId),
startAt: booking.startsAt,
endAt: booking.endsAt,
pricingMode: 'HOURLY'
});
if (Number(booking.amountCents) > quote.totalCents) {
throw new ThirdPartyError('DIRECT_BOOKING_AMOUNT_MISMATCH');
}
const order = await this.pricing.reserve({
tenantId: input.tenantId,
userId: input.userId,
roomId: String(booking.roomId),
startAt: booking.startsAt,
endAt: booking.endsAt,
pricingMode: 'HOURLY',
adjustment: { discountCents: quote.totalCents - Number(booking.amountCents) }
});
try {
await this.transaction(async (connection) => {
const [claim] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_direct_bookings
SET status = 'CLAIMED', order_id = ?, claimed_user_id = ?,
claimed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ? AND status = 'READY_TO_CLAIM'`,
[order.orderId, input.userId, input.tenantId, input.bookingId]
);
if (claim.affectedRows !== 1) throw new ThirdPartyError('DIRECT_BOOKING_CLAIM_CONFLICT');
await this.insertSuccessfulPayment(connection, {
tenantId: input.tenantId,
orderId: order.orderId,
storeId: order.storeId,
amountCents: order.quote.totalCents,
providerReference: `BOOKING-${input.bookingId}`
});
});
} catch (error) {
await this.releaseClaimOrder(input.tenantId, order.orderId);
throw error;
}
return { bookingId: input.bookingId, orderId: order.orderId, idempotent: false };
}
async listRecords(input: {
tenantId: string;
access: AccessProfile;
provider?: ThirdPartyProvider;
status?: string;
}) {
const storeFilter = input.access.capabilities.includes('tenant.manage')
|| input.access.roles.includes('PLATFORM_ADMIN')
? null : input.access.storeIds;
if (storeFilter && storeFilter.length === 0) {
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
const params: string[] = [input.tenantId];
const filters = ['tenant_id = ?'];
if (input.provider) {
filters.push('provider = ?');
params.push(input.provider);
}
if (input.status) {
filters.push('status = ?');
params.push(input.status);
}
if (storeFilter) {
filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
params.push(...storeFilter);
}
const [bookings] = await this.pool.execute<RowDataPacket[]>(
`SELECT id, provider, external_booking_no AS externalBookingNo,
store_id AS storeId, room_id AS roomId, starts_at AS startsAt,
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
);
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
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(',')})` : ''}
ORDER BY r.id DESC LIMIT 100`,
storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId]
);
return { bookings, redemptions };
}
async saveConfig(input: {
tenantId: string;
actorId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
storeId: string | null;
mode: ThirdPartyMode;
enabled: boolean;
credentialRef: string;
settings: Record<string, unknown>;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
if (input.credentialRef && !/^env:[A-Z0-9_:-]+$/.test(input.credentialRef)) {
throw new ThirdPartyError('THIRD_PARTY_CREDENTIAL_REF_INVALID');
}
if (input.storeId) {
const [stores] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, input.storeId]
);
if (!stores[0]) throw new ThirdPartyError('STORE_NOT_FOUND');
}
const [existing] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_third_party_configs
WHERE tenant_id = ? AND provider = ? AND store_id <=> ? LIMIT 1`,
[input.tenantId, input.provider, input.storeId]
);
if (existing[0]) {
await this.pool.execute(
`UPDATE qipai_third_party_configs
SET mode = ?, enabled = ?, credential_ref = ?, settings = CAST(? AS JSON)
WHERE tenant_id = ? AND id = ?`,
[input.mode, input.enabled, input.credentialRef, JSON.stringify(input.settings),
input.tenantId, existing[0].id]
);
return { configId: String(existing[0].id), created: false };
}
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT INTO qipai_third_party_configs
(tenant_id, store_id, provider, mode, enabled, credential_ref, settings)
VALUES (?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
[input.tenantId, input.storeId, input.provider, input.mode, input.enabled,
input.credentialRef, JSON.stringify(input.settings)]
);
return { configId: String(result.insertId), created: true };
}
async saveMapping(input: {
tenantId: string;
access: AccessProfile;
provider: ThirdPartyProvider;
resourceType: 'STORE' | 'ROOM';
externalRef: string;
localResourceId: string;
}) {
if (!input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
}
const table = input.resourceType === 'STORE' ? 'qipai_stores' : 'qipai_rooms';
const [resources] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM ${table}
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.tenantId, input.localResourceId]
);
if (!resources[0]) throw new ThirdPartyError(`${input.resourceType}_NOT_FOUND`);
await this.pool.execute(
`INSERT INTO qipai_third_party_mappings
(tenant_id, provider, resource_type, external_ref, local_resource_id)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE local_resource_id = VALUES(local_resource_id)`,
[input.tenantId, input.provider, input.resourceType,
input.externalRef, input.localResourceId]
);
return { mapped: true };
}
private async recordRedemption(input: {
tenantId: string;
actorId: string;
provider: ThirdPartyProvider;
voucherCode: string;
orderId: string;
clientRequestId: string;
mode: ThirdPartyMode;
amountCents: number;
expectedAmountCents: number;
externalProductId: string;
status: 'SUCCEEDED' | 'FAILED' | 'PENDING';
failureCode: string;
response: Record<string, unknown>;
}) {
return this.transaction(async (connection) => {
const order = await this.loadOrder(input.tenantId, input.orderId, connection, true);
const [existing] = await connection.execute<RedemptionRow[]>(
`SELECT r.id, r.order_id AS orderId, r.status,
v.voucher_masked AS voucherMasked
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 = ? AND r.client_request_id = ? LIMIT 1`,
[input.tenantId, input.clientRequestId]
);
if (existing[0]) {
if (String(existing[0].orderId) !== input.orderId) {
throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT');
}
return { ...existing[0], idempotent: true };
}
const voucherHash = hashValue(input.voucherCode);
const [voucherResult] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_group_vouchers
(tenant_id, provider, voucher_hash, voucher_masked,
external_product_id, status, amount_cents, redeemed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, IF(? = 'SUCCEEDED', UTC_TIMESTAMP(3), NULL))
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
[input.tenantId, input.provider, voucherHash, maskVoucher(input.voucherCode),
input.externalProductId, input.status === 'SUCCEEDED' ? 'REDEEMED' : input.status,
input.amountCents, input.status]
);
const voucherId = String(voucherResult.insertId);
const [used] = await connection.execute<RowDataPacket[]>(
`SELECT order_id AS orderId FROM qipai_group_redemptions
WHERE tenant_id = ? AND voucher_id = ? LIMIT 1`,
[input.tenantId, voucherId]
);
if (used[0]) throw new ThirdPartyError('VOUCHER_ALREADY_REDEEMED');
const [redemption] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_group_redemptions
(tenant_id, voucher_id, order_id, store_id, actor_id,
redemption_mode, client_request_id, status, failure_code,
provider_response, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON),
IF(? IN ('SUCCEEDED', 'FAILED'), UTC_TIMESTAMP(3), NULL))`,
[input.tenantId, voucherId, input.orderId, order.storeId, input.actorId,
input.mode, input.clientRequestId, input.status, input.failureCode,
JSON.stringify(input.response), input.status]
);
if (input.status === 'SUCCEEDED') {
if (input.amountCents !== input.expectedAmountCents) {
throw new ThirdPartyError('VOUCHER_AMOUNT_MISMATCH');
}
await this.insertSuccessfulPayment(connection, {
tenantId: input.tenantId,
orderId: input.orderId,
storeId: order.storeId,
amountCents: input.amountCents,
providerReference: `VOUCHER-${voucherId}`
});
}
return {
redemptionId: String(redemption.insertId),
voucherMasked: maskVoucher(input.voucherCode),
status: input.status,
failureCode: input.failureCode,
idempotent: false
};
});
}
private async findRedemptionByRequest(
tenantId: string,
clientRequestId: string,
orderId: string
) {
const [rows] = await this.pool.execute<RedemptionRow[]>(
`SELECT r.id, r.order_id AS orderId, r.status,
v.voucher_masked AS voucherMasked
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 = ? AND r.client_request_id = ? LIMIT 1`,
[tenantId, clientRequestId]
);
if (!rows[0]) return null;
if (String(rows[0].orderId) !== orderId) {
throw new ThirdPartyError('VOUCHER_IDEMPOTENCY_CONFLICT');
}
return rows[0];
}
private async insertSuccessfulPayment(
connection: PoolConnection,
input: {
tenantId: string;
orderId: string;
storeId: string;
amountCents: number;
providerReference: string;
}
) {
const paymentNo = `GRP${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [payment] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_payments
(tenant_id, order_id, store_id, payment_no, channel, provider,
client_request_id, status, amount_cents, provider_payment_id, paid_at)
VALUES (?, ?, ?, ?, 'GROUP_BUY', 'GROUP_BUY', ?, 'SUCCEEDED', ?, ?,
UTC_TIMESTAMP(3))`,
[input.tenantId, input.orderId, input.storeId, paymentNo,
input.providerReference, input.amountCents, input.providerReference]
);
await connection.execute(
`UPDATE qipai_orders
SET paid_amount_cents = paid_amount_cents + ?,
status = IF(paid_amount_cents + ? >= total_amount_cents, 'PAID', status),
status_version = IF(paid_amount_cents + ? >= total_amount_cents,
status_version + 1, status_version),
status_updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[input.amountCents, input.amountCents, input.amountCents,
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', 'Verified group-buy payment', ?,
JSON_OBJECT('paymentId', ?, 'providerReference', ?))`,
[input.tenantId, input.orderId, `group-payment-${payment.insertId}`,
payment.insertId, input.providerReference]
);
}
private async resolveConfig(
tenantId: string, storeId: string | null, provider: ThirdPartyProvider
) {
const [rows] = await this.pool.execute<ConfigRow[]>(
`SELECT id, mode, credential_ref AS credentialRef, settings
FROM qipai_third_party_configs
WHERE tenant_id = ? AND provider = ? AND enabled = 1
AND (store_id IS NULL OR store_id = ?)
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
[tenantId, provider, storeId]
);
if (!rows[0]) throw new ThirdPartyError('THIRD_PARTY_CONFIG_NOT_FOUND');
return {
id: String(rows[0].id),
mode: rows[0].mode,
credentialRef: rows[0].credentialRef,
settings: typeof rows[0].settings === 'string'
? JSON.parse(rows[0].settings) : rows[0].settings
};
}
private resolveCredential(reference: string) {
if (!reference) return undefined;
return this.credentials.get(reference)
?? this.credentials.get(reference.replace(/^env:/, ''));
}
private async resolveMappings(
tenantId: string,
provider: ThirdPartyProvider,
externalStoreRef: string,
externalRoomRef: string
) {
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT resource_type AS resourceType, local_resource_id AS localResourceId
FROM qipai_third_party_mappings
WHERE tenant_id = ? AND provider = ?
AND ((resource_type = 'STORE' AND external_ref = ?)
OR (resource_type = 'ROOM' AND external_ref = ?))`,
[tenantId, provider, externalStoreRef, externalRoomRef]
);
const storeId = rows.find((row) => row.resourceType === 'STORE')?.localResourceId;
const roomId = rows.find((row) => row.resourceType === 'ROOM')?.localResourceId;
if (storeId && roomId) {
const [valid] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_rooms
WHERE tenant_id = ? AND id = ? AND store_id = ? AND deleted_at IS NULL`,
[tenantId, roomId, storeId]
);
if (!valid[0]) return { storeId: null, roomId: null };
}
return {
storeId: storeId ? String(storeId) : null,
roomId: roomId ? String(roomId) : null
};
}
private async loadOwnedOrder(tenantId: string, userId: string, orderId: string) {
const [access] = await this.pool.execute<RowDataPacket[]>(
`SELECT 1 FROM qipai_order_user_access
WHERE tenant_id = ? AND order_id = ? AND user_id = ? AND revoked_at IS NULL`,
[tenantId, orderId, userId]
);
if (!access[0]) throw new ThirdPartyError('ORDER_ACCESS_FORBIDDEN');
return this.loadOrder(tenantId, orderId);
}
private async loadOrder(
tenantId: string,
orderId: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool,
lock = false
) {
const [rows] = await connection.execute<OrderRow[]>(
`SELECT id, order_no AS orderNo, store_id AS storeId, status,
total_amount_cents AS totalAmountCents,
paid_amount_cents AS paidAmountCents
FROM qipai_orders
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
${lock ? 'FOR UPDATE' : ''}`,
[tenantId, orderId]
);
if (!rows[0]) throw new ThirdPartyError('ORDER_NOT_FOUND');
if (rows[0].status !== 'PENDING_PAYMENT') {
throw new ThirdPartyError('ORDER_NOT_PAYABLE');
}
return rows[0];
}
private async releaseClaimOrder(tenantId: string, orderId: string) {
await this.pool.execute(
`UPDATE qipai_room_reservations
SET status = 'RELEASED', released_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND order_id = ?`,
[tenantId, orderId]
);
await this.pool.execute(
`UPDATE qipai_orders SET status = 'CLOSED'
WHERE tenant_id = ? AND id = ? AND status = 'PENDING_PAYMENT'`,
[tenantId, orderId]
);
}
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 assertStoreAccess(access: AccessProfile, storeId: string) {
if (access.capabilities.includes('tenant.manage')
|| access.roles.includes('PLATFORM_ADMIN')
|| (access.capabilities.includes('store.operation.write')
&& access.storeIds.includes(String(storeId)))) return;
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
}
function hashValue(value: string) {
return createHash('sha256').update(value).digest('hex');
}
function maskVoucher(value: string) {
if (value.length <= 4) return '*'.repeat(value.length);
return `${value.slice(0, 2)}${'*'.repeat(Math.min(8, value.length - 4))}${value.slice(-2)}`;
}
function sanitizePayload(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.voucherCode;
delete copy.phone;
delete copy.openid;
delete copy.token;
return copy;
}
function normalizeBooking(row: BookingRow) {
return {
bookingId: String(row.id),
status: row.status,
orderId: row.orderId ? String(row.orderId) : null,
mapped: Boolean(row.storeId && row.roomId)
};
}
@@ -0,0 +1,482 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
interface CouponRow extends RowDataPacket {
id: string;
status: string;
validFrom: Date;
validTo: Date;
remainingUses: number;
couponType: 'TIME' | 'FULL_REDUCTION';
discountAmountCents: number;
timeMinutes: number;
minOrderAmountCents: number;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
weekdaysJson: string | number[] | null;
holidayOnly: number;
}
interface PackageRow extends RowDataPacket {
id: string;
status: string;
validFrom: Date;
validTo: Date;
remainingMinutes: number;
remainingAmountCents: number;
storeId: string | null;
roomCategoryId: string | null;
roomId: string | null;
weekdaysJson: string | number[] | null;
holidayOnly: number;
}
interface UsageRow extends RowDataPacket {
id: string;
benefitType: 'COUPON' | 'PACKAGE';
benefitId: string;
status: string;
discountCents: number;
packageCreditCents: number;
minutes: number;
}
export class MarketingBenefitError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class MarketingBenefitService {
constructor(private readonly pool: MySqlPool) {}
async reserveForOrder(input: {
tenantId: string;
userId: string;
orderId: string;
storeId: string;
roomId?: string | null;
roomCategoryId?: string | null;
orderAmountCents: number;
orderStartAt: Date;
isHoliday?: boolean;
couponGrantId?: string | null;
packageHoldingId?: string | null;
packageMinutes?: number;
packageCreditCents?: number;
clientRequestId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
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;
orderId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
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;
orderId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
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 },
couponGrantId: string
) {
const [rows] = await connection.execute<CouponRow[]>(
`SELECT g.id, g.status, g.valid_from AS validFrom, g.valid_to AS validTo,
g.remaining_uses AS remainingUses,
t.coupon_type AS couponType, t.discount_amount_cents AS discountAmountCents,
t.time_minutes AS timeMinutes, t.min_order_amount_cents AS minOrderAmountCents,
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
t.room_id AS roomId, t.weekdays_json AS weekdaysJson, t.holiday_only AS holidayOnly
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 g.id = ?
AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.userId, couponGrantId]
);
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_COUPON_NOT_FOUND');
return normalizeScopeRow(rows[0]);
}
private async loadPackageHolding(
connection: PoolConnection,
input: { tenantId: string; userId: string },
packageHoldingId: string
) {
const [rows] = await connection.execute<PackageRow[]>(
`SELECT h.id, h.status, h.valid_from AS validFrom, h.valid_to AS validTo,
h.remaining_minutes AS remainingMinutes,
h.remaining_amount_cents AS remainingAmountCents,
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
p.room_id AS roomId, p.weekdays_json AS weekdaysJson, p.holiday_only AS holidayOnly
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 h.id = ?
AND p.status = 'ACTIVE' AND p.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.userId, packageHoldingId]
);
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_PACKAGE_NOT_FOUND');
return normalizeScopeRow(rows[0]);
}
private async findUsageByRequest(
connection: PoolConnection,
input: { tenantId: string; userId: string; clientRequestId: string }
) {
const [rows] = await connection.execute<UsageRow[]>(
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
discount_cents AS discountCents,
package_credit_cents AS packageCreditCents, minutes
FROM qipai_benefit_usages
WHERE tenant_id = ? AND user_id = ? AND client_request_id = ?`,
[input.tenantId, input.userId, input.clientRequestId]
);
return rows.map(normalizeUsageRow);
}
private async loadOrderUsages(
connection: PoolConnection,
input: { tenantId: string; userId: string; orderId: string },
lock: boolean
) {
const [rows] = await connection.execute<UsageRow[]>(
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
discount_cents AS discountCents,
package_credit_cents AS packageCreditCents, minutes
FROM qipai_benefit_usages
WHERE tenant_id = ? AND user_id = ? AND order_id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.userId, input.orderId]
);
return rows.map(normalizeUsageRow);
}
private async insertUsage(
connection: PoolConnection,
input: {
tenantId: string;
userId: string;
orderId: string;
clientRequestId: string;
traceId: string;
benefitType: 'COUPON' | 'PACKAGE';
benefitId: string;
discountCents: number;
packageCreditCents: number;
minutes: number;
}
) {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_benefit_usages
(tenant_id, user_id, order_id, benefit_type, benefit_id,
client_request_id, discount_cents, package_credit_cents, minutes, trace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.tenantId, input.userId, input.orderId, input.benefitType, input.benefitId,
input.clientRequestId, input.discountCents, input.packageCreditCents,
input.minutes, input.traceId
]
);
return {
id: String(result.insertId),
benefitType: input.benefitType,
benefitId: input.benefitId,
status: 'FROZEN',
discountCents: input.discountCents,
packageCreditCents: input.packageCreditCents,
minutes: input.minutes
} as UsageRow;
}
private async updateUsageStatus(
connection: PoolConnection,
tenantId: string,
usageId: string,
status: 'CONFIRMED' | 'RELEASED'
) {
await connection.execute(
`UPDATE qipai_benefit_usages
SET status = ?
WHERE tenant_id = ? AND id = ?`,
[status, tenantId, usageId]
);
}
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 assertUsableBenefit(
row: CouponRow | PackageRow,
input: {
storeId: string;
roomId?: string | null;
roomCategoryId?: string | null;
orderAmountCents: number;
orderStartAt: Date;
isHoliday?: boolean;
},
type: 'COUPON' | 'PACKAGE'
) {
if (row.status !== 'AVAILABLE' && row.status !== 'ACTIVE') {
throw new MarketingBenefitError(`BENEFIT_${type}_STATUS_INVALID`);
}
const orderTime = input.orderStartAt.getTime();
if (row.validFrom.getTime() > orderTime || row.validTo.getTime() <= orderTime) {
throw new MarketingBenefitError(`BENEFIT_${type}_EXPIRED`);
}
if ('remainingUses' in row && Number(row.remainingUses) <= 0) {
throw new MarketingBenefitError('BENEFIT_COUPON_USED_UP');
}
if ('minOrderAmountCents' in row && input.orderAmountCents < Number(row.minOrderAmountCents)) {
throw new MarketingBenefitError('BENEFIT_COUPON_MIN_AMOUNT_NOT_MET');
}
if (row.storeId && row.storeId !== input.storeId) {
throw new MarketingBenefitError(`BENEFIT_${type}_STORE_MISMATCH`);
}
if (row.roomCategoryId && row.roomCategoryId !== (input.roomCategoryId ?? null)) {
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_CATEGORY_MISMATCH`);
}
if (row.roomId && row.roomId !== (input.roomId ?? null)) {
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_MISMATCH`);
}
if (Number(row.holidayOnly) === 1 && input.isHoliday !== true) {
throw new MarketingBenefitError(`BENEFIT_${type}_HOLIDAY_ONLY`);
}
const weekdays = parseWeekdays(row.weekdaysJson);
if (weekdays.length > 0 && !weekdays.includes(input.orderStartAt.getUTCDay())) {
throw new MarketingBenefitError(`BENEFIT_${type}_WEEKDAY_MISMATCH`);
}
}
function normalizeScopeRow<T extends CouponRow | PackageRow>(row: T): T {
return {
...row,
id: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
roomId: row.roomId === null ? null : String(row.roomId)
};
}
function normalizeUsageRow(row: UsageRow): UsageRow {
return {
...row,
id: String(row.id),
benefitId: String(row.benefitId),
discountCents: Number(row.discountCents),
packageCreditCents: Number(row.packageCreditCents),
minutes: Number(row.minutes)
};
}
function parseWeekdays(value: string | number[] | null): number[] {
if (!value) return [];
if (Array.isArray(value)) return value.map(Number);
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map(Number) : [];
} catch {
return [];
}
}
function reserveResponse(usages: UsageRow[], idempotent: boolean) {
return {
idempotent,
status: usages.every((usage) => usage.status === usages[0]?.status)
? usages[0]?.status ?? 'EMPTY'
: 'MIXED',
discountCents: usages.reduce((sum, usage) => sum + Number(usage.discountCents), 0),
packageCreditCents: usages.reduce((sum, usage) => sum + Number(usage.packageCreditCents), 0),
minutes: usages.reduce((sum, usage) => sum + Number(usage.minutes), 0),
usages: usages.map((usage) => ({
usageId: String(usage.id),
benefitType: usage.benefitType,
benefitId: String(usage.benefitId),
status: usage.status
}))
};
}
@@ -0,0 +1,477 @@
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 [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 ? OFFSET ?`,
[...params, input.pageSize, (input.page - 1) * input.pageSize]
);
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', 'IN_USE', 'COMPLETED')
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
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})
)`,
params: 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;
}
+569
View File
@@ -0,0 +1,569 @@
import { randomBytes } from 'node:crypto';
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
import type { PaymentRepository } from '../payments/payment-repository.js';
import {
WechatPayClient,
WechatPayError,
type WechatNotificationHeaders,
type WechatPayCredential
} from '../payments/wechat-pay-client.js';
import type { WalletLedgerService } from './wallet-ledger-service.js';
interface PlanRow extends RowDataPacket {
id: string;
tenantId: string;
storeId: string | null;
name: string;
payAmountCents: number;
giftAmountCents: number;
scopeType: 'TENANT' | 'STORE';
startsAt: Date | null;
endsAt: Date | null;
purchaseLimitPerUser: number | null;
status: string;
}
interface RechargeOrderRow extends RowDataPacket {
id: string;
userId: string;
storeId: string | null;
planId: string;
rechargeNo: string;
payAmountCents: number;
giftAmountCents: number;
status: string;
providerPaymentId?: string | null;
}
interface CountRow extends RowDataPacket { total: number }
export class RechargeError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class RechargeService {
constructor(
private readonly pool: MySqlPool,
private readonly wallet: Pick<WalletLedgerService, 'credit'>,
private readonly wechat?: {
paymentRepository: Pick<PaymentRepository, 'resolveConfig'>;
client: WechatPayClient;
credentials: ReadonlyMap<string, WechatPayCredential>;
}
) {}
async listAvailablePlans(input: {
tenantId: string;
storeId?: string | null;
}) {
const params: Array<string | null> = [input.tenantId];
const storeFilter = input.storeId
? 'AND (store_id IS NULL OR store_id = ?)'
: '';
if (input.storeId) params.push(input.storeId);
const [rows] = await this.pool.execute<PlanRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents,
scope_type AS scopeType, starts_at AS startsAt, ends_at AS endsAt,
purchase_limit_per_user AS purchaseLimitPerUser, status
FROM qipai_recharge_plans
WHERE tenant_id = ? AND deleted_at IS NULL AND status = 'ACTIVE'
AND (starts_at IS NULL OR starts_at <= UTC_TIMESTAMP(3))
AND (ends_at IS NULL OR ends_at > UTC_TIMESTAMP(3))
${storeFilter}
ORDER BY pay_amount_cents ASC, id ASC`,
params
);
return rows.map((row) => ({
planId: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId),
name: row.name,
payAmountCents: Number(row.payAmountCents),
giftAmountCents: Number(row.giftAmountCents),
scopeType: row.scopeType,
purchaseLimitPerUser: row.purchaseLimitPerUser === null
? null : Number(row.purchaseLimitPerUser),
startsAt: row.startsAt,
endsAt: row.endsAt
}));
}
async createRechargeOrder(input: {
tenantId: string;
userId: string;
planId: string;
storeId?: string | null;
clientRequestId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const duplicate = await this.findByRequest(connection, input);
if (duplicate) return rechargeResponse(duplicate, true);
const plan = await this.loadPlan(connection, input.tenantId, input.planId);
assertPlanAvailable(plan, input.storeId ?? null);
await this.assertPurchaseLimit(connection, input.tenantId, input.userId, plan);
const rechargeNo = `RCH${Date.now()}${randomBytes(4).toString('hex').toUpperCase()}`;
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_recharge_orders
(tenant_id, user_id, store_id, plan_id, recharge_no, client_request_id,
pay_amount_cents, gift_amount_cents, trace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[input.tenantId, input.userId, plan.storeId ?? input.storeId ?? null, plan.id,
rechargeNo, input.clientRequestId, plan.payAmountCents, plan.giftAmountCents,
input.traceId]
);
return rechargeResponse({
id: String(result.insertId),
userId: input.userId,
storeId: plan.storeId ?? input.storeId ?? null,
planId: plan.id,
rechargeNo,
payAmountCents: plan.payAmountCents,
giftAmountCents: plan.giftAmountCents,
status: 'PENDING_PAYMENT'
} as RechargeOrderRow, false);
});
}
async markPaidAndCredit(input: {
tenantId: string;
rechargeOrderId: string;
paymentId: string;
traceId: string;
}) {
return this.transaction(async (connection) => {
const order = await this.loadRechargeOrder(connection, input, true);
if (order.status === 'CREDITED') {
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true };
}
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
throw new RechargeError('RECHARGE_STATUS_INVALID');
}
await connection.execute(
`UPDATE qipai_recharge_orders
SET status = 'PAID', payment_id = ?, paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[input.paymentId, input.tenantId, order.id]
);
const plan = await this.loadPlan(connection, input.tenantId, order.planId);
await this.wallet.credit({
tenantId: input.tenantId,
userId: String(order.userId),
scopeType: plan.scopeType,
storeId: plan.scopeType === 'STORE' ? order.storeId : null,
businessType: 'RECHARGE',
businessId: order.id,
entryType: 'RECHARGE',
cashDeltaCents: Number(order.payAmountCents),
giftDeltaCents: Number(order.giftAmountCents),
traceId: input.traceId,
metadata: { paymentId: input.paymentId, rechargeNo: order.rechargeNo }
});
await connection.execute(
`UPDATE qipai_recharge_orders
SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, order.id]
);
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false };
});
}
async createWechatPrepay(input: {
tenantId: string;
platformAppId: string;
userId: string;
rechargeOrderId: string;
}) {
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
const order = await this.loadOwnedRechargeOrder(input, false);
if (order.status === 'CREDITED') throw new RechargeError('RECHARGE_ALREADY_CREDITED');
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
throw new RechargeError('RECHARGE_STATUS_INVALID');
}
const config = await this.wechat.paymentRepository.resolveConfig(
this.pool,
input.tenantId,
input.platformAppId,
order.storeId ?? '0',
'WECHAT'
);
const credential = this.resolveWechatCredential(config.credentialRef);
const settings = config.settings as Record<string, unknown>;
const [identityRows] = await this.pool.execute<RowDataPacket[]>(
`SELECT openid FROM qipai_user_identities
WHERE tenant_id = ? AND platform_app_id = ? AND user_id = ?
AND provider = 'WECHAT' LIMIT 1`,
[input.tenantId, input.platformAppId, input.userId]
);
if (!identityRows[0]?.openid) throw new WechatPayError('WECHAT_OPENID_NOT_FOUND');
const result = await this.wechat.client.createJsapiPrepay(credential, {
description: typeof settings.rechargeDescription === 'string'
? settings.rechargeDescription : `棋牌室余额充值 ${order.rechargeNo}`,
outTradeNo: order.rechargeNo,
notifyUrl: settingUrl(
settings, 'rechargeNotifyUrl', 'https://api.txyundm.cn/app-api/recharge/wechat/notify'
),
amountCents: Number(order.payAmountCents),
payerOpenId: String(identityRows[0].openid)
});
await this.pool.execute(
`UPDATE qipai_recharge_orders
SET payment_provider = 'WECHAT', provider_prepay_id = ?
WHERE tenant_id = ? AND id = ? AND user_id = ?`,
[result.prepayId, input.tenantId, input.rechargeOrderId, input.userId]
);
return {
rechargeOrderId: input.rechargeOrderId,
rechargeNo: order.rechargeNo,
amountCents: Number(order.payAmountCents),
...result.paymentParams
};
}
async processWechatNotification(
headers: WechatNotificationHeaders,
rawBody: string,
traceId: string
) {
if (!this.wechat) throw new WechatPayError('WECHAT_PAYMENT_NOT_CONFIGURED');
const data = this.verifyWithConfiguredCredential(headers, rawBody);
const rechargeNo = requiredString(data, 'out_trade_no');
const transactionId = requiredString(data, 'transaction_id');
const tradeState = requiredString(data, 'trade_state');
const amount = objectValue(data.amount);
const total = requiredNumber(amount, 'total');
return this.transaction(async (connection) => {
const order = await this.loadRechargeOrderByNo(connection, rechargeNo, true);
const callbackId = `${headers.serial}:${transactionId}:${tradeState}`;
const [callback] = await connection.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_payment_callbacks
(tenant_id, payment_id, provider, callback_id, callback_type,
verified, payload)
VALUES (?, NULL, 'WECHAT_RECHARGE', ?, 'PAYMENT_NOTIFICATION', 1, CAST(? AS JSON))`,
[order.tenantId, callbackId, JSON.stringify(redactNotification(data))]
);
if (callback.affectedRows === 0) {
return { rechargeOrderId: order.id, status: order.status, idempotent: true };
}
if (tradeState !== 'SUCCESS' || total !== Number(order.payAmountCents)) {
const code = tradeState !== 'SUCCESS'
? `WECHAT_TRADE_${tradeState}` : 'PAYMENT_AMOUNT_MISMATCH';
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'REJECTED', error_code = ?,
processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
[code, order.tenantId, callbackId]
);
return { rechargeOrderId: order.id, status: 'REJECTED', code, idempotent: false };
}
const credited = await this.creditRechargeOrder(connection, {
tenantId: String(order.tenantId),
rechargeOrderId: String(order.id),
providerTransactionId: transactionId,
providerCallbackId: callbackId,
rawNotify: redactNotification(data),
traceId
});
await connection.execute(
`UPDATE qipai_payment_callbacks
SET processing_status = 'PROCESSED', processed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND provider = 'WECHAT_RECHARGE' AND callback_id = ?`,
[order.tenantId, callbackId]
);
return credited;
});
}
private async loadPlan(connection: PoolConnection, tenantId: string, planId: string) {
const [rows] = await connection.execute<PlanRow[]>(
`SELECT id, tenant_id AS tenantId, store_id AS storeId, name,
pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents,
scope_type AS scopeType, starts_at AS startsAt, ends_at AS endsAt,
purchase_limit_per_user AS purchaseLimitPerUser, status
FROM qipai_recharge_plans
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[tenantId, planId]
);
if (!rows[0]) throw new RechargeError('RECHARGE_PLAN_NOT_FOUND');
return {
...rows[0],
id: String(rows[0].id),
tenantId: String(rows[0].tenantId),
storeId: rows[0].storeId === null ? null : String(rows[0].storeId)
};
}
private async findByRequest(
connection: PoolConnection,
input: { tenantId: string; userId: string; clientRequestId: string }
) {
const [rows] = await connection.execute<RechargeOrderRow[]>(
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents, status
FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ? AND client_request_id = ? LIMIT 1`,
[input.tenantId, input.userId, input.clientRequestId]
);
return rows[0] ?? null;
}
private async loadRechargeOrder(
connection: PoolConnection,
input: { tenantId: string; rechargeOrderId: string },
lock: boolean
) {
const [rows] = await connection.execute<RechargeOrderRow[]>(
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents, status
FROM qipai_recharge_orders
WHERE tenant_id = ? AND id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.rechargeOrderId]
);
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
return {
...rows[0],
id: String(rows[0].id),
userId: String(rows[0].userId),
storeId: rows[0].storeId === null ? null : String(rows[0].storeId),
planId: String(rows[0].planId)
};
}
private async loadOwnedRechargeOrder(
input: { tenantId: string; userId: string; rechargeOrderId: string },
lock: boolean
) {
const [rows] = await this.pool.execute<RechargeOrderRow[]>(
`SELECT id, user_id AS userId, store_id AS storeId, plan_id AS planId,
recharge_no AS rechargeNo, pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents, status,
provider_payment_id AS providerPaymentId
FROM qipai_recharge_orders
WHERE tenant_id = ? AND id = ? AND user_id = ?
${lock ? 'FOR UPDATE' : ''}`,
[input.tenantId, input.rechargeOrderId, input.userId]
);
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
return normalizeRechargeOrder(rows[0]);
}
private async loadRechargeOrderByNo(
connection: PoolConnection,
rechargeNo: string,
lock: boolean
) {
const [rows] = await connection.execute<Array<RechargeOrderRow & { tenantId: string }>>(
`SELECT id, tenant_id AS tenantId, user_id AS userId,
store_id AS storeId, plan_id AS planId, recharge_no AS rechargeNo,
pay_amount_cents AS payAmountCents,
gift_amount_cents AS giftAmountCents, status,
provider_payment_id AS providerPaymentId
FROM qipai_recharge_orders
WHERE recharge_no = ?
${lock ? 'FOR UPDATE' : ''}`,
[rechargeNo]
);
if (!rows[0]) throw new RechargeError('RECHARGE_ORDER_NOT_FOUND');
return { ...normalizeRechargeOrder(rows[0]), tenantId: String(rows[0].tenantId) };
}
private async creditRechargeOrder(
connection: PoolConnection,
input: {
tenantId: string;
rechargeOrderId: string;
providerTransactionId: string;
providerCallbackId: string;
rawNotify: Record<string, unknown>;
traceId: string;
}
) {
const order = await this.loadRechargeOrder(connection, input, true);
if (order.status === 'CREDITED') {
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: true };
}
if (order.status !== 'PENDING_PAYMENT' && order.status !== 'PAID') {
throw new RechargeError('RECHARGE_STATUS_INVALID');
}
await connection.execute(
`UPDATE qipai_recharge_orders
SET status = 'PAID',
provider_payment_id = ?,
provider_callback_id = ?,
raw_notify = CAST(? AS JSON),
paid_at = COALESCE(paid_at, UTC_TIMESTAMP(3))
WHERE tenant_id = ? AND id = ?`,
[
input.providerTransactionId,
input.providerCallbackId,
JSON.stringify(input.rawNotify),
input.tenantId,
order.id
]
);
const plan = await this.loadPlan(connection, input.tenantId, order.planId);
await this.wallet.credit({
tenantId: input.tenantId,
userId: String(order.userId),
scopeType: plan.scopeType,
storeId: plan.scopeType === 'STORE' ? order.storeId : null,
businessType: 'RECHARGE',
businessId: order.id,
entryType: 'RECHARGE',
cashDeltaCents: Number(order.payAmountCents),
giftDeltaCents: Number(order.giftAmountCents),
traceId: input.traceId,
metadata: {
provider: 'WECHAT',
providerTransactionId: input.providerTransactionId,
rechargeNo: order.rechargeNo
}
});
await connection.execute(
`UPDATE qipai_recharge_orders
SET status = 'CREDITED', credited_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[input.tenantId, order.id]
);
return { rechargeOrderId: order.id, status: 'CREDITED', idempotent: false };
}
private resolveWechatCredential(reference: string) {
const credential = this.wechat?.credentials.get(reference)
?? this.wechat?.credentials.get(reference.replace(/^env:/, ''));
if (!credential) throw new WechatPayError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
return credential;
}
private verifyWithConfiguredCredential(
headers: WechatNotificationHeaders,
rawBody: string
) {
let lastError: unknown;
for (const credential of this.wechat?.credentials.values() ?? []) {
if (!credential.platformCertificates[headers.serial]) continue;
try {
return this.wechat!.client.verifyAndDecrypt(credential, headers, rawBody);
} catch (error) {
lastError = error;
}
}
throw lastError ?? new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
}
private async assertPurchaseLimit(
connection: PoolConnection,
tenantId: string,
userId: string,
plan: PlanRow
) {
if (!plan.purchaseLimitPerUser) return;
const [rows] = await connection.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_recharge_orders
WHERE tenant_id = ? AND user_id = ? AND plan_id = ?
AND status IN ('PENDING_PAYMENT', 'PAID', 'CREDITED')`,
[tenantId, userId, plan.id]
);
if (Number(rows[0]?.total ?? 0) >= Number(plan.purchaseLimitPerUser)) {
throw new RechargeError('RECHARGE_LIMIT_REACHED');
}
}
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 assertPlanAvailable(plan: PlanRow, requestedStoreId: string | null) {
if (plan.status !== 'ACTIVE') throw new RechargeError('RECHARGE_PLAN_DISABLED');
const now = Date.now();
if (plan.startsAt && plan.startsAt.getTime() > now) {
throw new RechargeError('RECHARGE_PLAN_NOT_STARTED');
}
if (plan.endsAt && plan.endsAt.getTime() <= now) {
throw new RechargeError('RECHARGE_PLAN_EXPIRED');
}
if (plan.storeId && requestedStoreId && plan.storeId !== requestedStoreId) {
throw new RechargeError('RECHARGE_PLAN_STORE_MISMATCH');
}
}
function rechargeResponse(row: RechargeOrderRow, idempotent: boolean) {
return {
rechargeOrderId: String(row.id),
planId: String(row.planId),
rechargeNo: row.rechargeNo,
status: row.status,
payAmountCents: Number(row.payAmountCents),
giftAmountCents: Number(row.giftAmountCents),
idempotent
};
}
function normalizeRechargeOrder(row: RechargeOrderRow): RechargeOrderRow {
return {
...row,
id: String(row.id),
userId: String(row.userId),
storeId: row.storeId === null ? null : String(row.storeId),
planId: String(row.planId),
payAmountCents: Number(row.payAmountCents),
giftAmountCents: Number(row.giftAmountCents)
};
}
function settingUrl(
settings: Record<string, unknown>, key: string, fallback: string
) {
const value = settings[key];
return typeof value === 'string' && value.startsWith('https://') ? value : fallback;
}
function requiredString(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'string' || field.length === 0) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
function requiredNumber(value: Record<string, unknown>, key: string) {
const field = value[key];
if (typeof field !== 'number' || !Number.isInteger(field)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return field;
}
function objectValue(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
}
return value as Record<string, unknown>;
}
function redactNotification(value: Record<string, unknown>) {
const copy = { ...value };
delete copy.payer;
delete copy.user_received_account;
return copy;
}
@@ -0,0 +1,236 @@
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { MySqlPool } from '../db/mysql.js';
export type WalletScopeType = 'TENANT' | 'STORE';
export type WalletEntryType = 'RECHARGE' | 'GIFT' | 'CONSUME' | 'REFUND' | 'ADJUST';
interface WalletAccountRow extends RowDataPacket {
id: string;
tenantId: string;
userId: string;
scopeType: WalletScopeType;
storeId: string | null;
cashBalanceCents: number;
giftBalanceCents: number;
status: string;
}
interface WalletLedgerRow extends RowDataPacket {
id: string;
cashBalanceAfterCents: number;
giftBalanceAfterCents: number;
cashDeltaCents: number;
giftDeltaCents: number;
}
export class WalletLedgerError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class WalletLedgerService {
constructor(private readonly pool: MySqlPool) {}
async credit(input: WalletMutationInput & {
cashDeltaCents?: number;
giftDeltaCents?: number;
}) {
return this.transaction(async (connection) => {
const account = await this.ensureAccount(connection, input, true);
const duplicate = await this.findDuplicate(connection, account, input);
if (duplicate) return ledgerResponse(account, duplicate, true);
const cashDelta = nonNegative(input.cashDeltaCents ?? 0, 'WALLET_CASH_DELTA_INVALID');
const giftDelta = nonNegative(input.giftDeltaCents ?? 0, 'WALLET_GIFT_DELTA_INVALID');
if (cashDelta + giftDelta <= 0) throw new WalletLedgerError('WALLET_CREDIT_ZERO');
return this.applyDelta(connection, account, input, cashDelta, giftDelta, false);
});
}
async debit(input: WalletMutationInput & { amountCents: number }) {
return this.transaction(async (connection) => {
return this.debitInTransaction(connection, input);
});
}
async debitInTransaction(
connection: PoolConnection,
input: WalletMutationInput & { amountCents: number }
) {
const account = await this.ensureAccount(connection, input, true);
const duplicate = await this.findDuplicate(connection, account, input);
if (duplicate) return ledgerResponse(account, duplicate, true);
const amount = nonNegative(input.amountCents, 'WALLET_DEBIT_AMOUNT_INVALID');
if (amount <= 0) throw new WalletLedgerError('WALLET_DEBIT_AMOUNT_INVALID');
const giftUsed = Math.min(Number(account.giftBalanceCents), amount);
const cashUsed = amount - giftUsed;
if (cashUsed > Number(account.cashBalanceCents)) {
throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT');
}
return this.applyDelta(connection, account, input, -cashUsed, -giftUsed, false);
}
async adjust(input: WalletMutationInput & {
cashDeltaCents?: number;
giftDeltaCents?: number;
}) {
return this.transaction(async (connection) => {
const account = await this.ensureAccount(connection, input, true);
const duplicate = await this.findDuplicate(connection, account, input);
if (duplicate) return ledgerResponse(account, duplicate, true);
const cashDelta = integer(input.cashDeltaCents ?? 0, 'WALLET_CASH_DELTA_INVALID');
const giftDelta = integer(input.giftDeltaCents ?? 0, 'WALLET_GIFT_DELTA_INVALID');
if (cashDelta === 0 && giftDelta === 0) throw new WalletLedgerError('WALLET_ADJUST_ZERO');
return this.applyDelta(connection, account, input, cashDelta, giftDelta, true);
});
}
private async applyDelta(
connection: PoolConnection,
account: WalletAccountRow,
input: WalletMutationInput,
cashDelta: number,
giftDelta: number,
allowNegativeDelta: boolean
) {
if (!allowNegativeDelta && (cashDelta < 0 || giftDelta < 0) && input.entryType !== 'CONSUME') {
throw new WalletLedgerError('WALLET_DELTA_DIRECTION_INVALID');
}
const nextCash = Number(account.cashBalanceCents) + cashDelta;
const nextGift = Number(account.giftBalanceCents) + giftDelta;
if (nextCash < 0 || nextGift < 0) throw new WalletLedgerError('WALLET_BALANCE_INSUFFICIENT');
await connection.execute(
`UPDATE qipai_wallet_accounts
SET cash_balance_cents = ?, gift_balance_cents = ?
WHERE tenant_id = ? AND id = ?`,
[nextCash, nextGift, account.tenantId, account.id]
);
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_wallet_ledger_entries
(tenant_id, account_id, user_id, store_id, business_type, business_id,
entry_type, cash_delta_cents, gift_delta_cents, cash_balance_after_cents,
gift_balance_after_cents, operator_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[account.tenantId, account.id, account.userId, account.storeId,
input.businessType, input.businessId, input.entryType, cashDelta, giftDelta,
nextCash, nextGift, input.operatorId ?? null, input.traceId,
(input.note ?? '').slice(0, 512), JSON.stringify(input.metadata ?? {})]
);
return {
accountId: account.id,
ledgerId: String(result.insertId),
cashBalanceCents: nextCash,
giftBalanceCents: nextGift,
cashDeltaCents: cashDelta,
giftDeltaCents: giftDelta,
idempotent: false
};
}
private async ensureAccount(
connection: PoolConnection,
input: WalletMutationInput,
lock: boolean
) {
await connection.execute(
`INSERT IGNORE INTO qipai_wallet_accounts
(tenant_id, user_id, scope_type, store_id)
VALUES (?, ?, ?, ?)`,
[input.tenantId, input.userId, input.scopeType, input.storeId ?? null]
);
const [rows] = await connection.execute<WalletAccountRow[]>(
`SELECT id, tenant_id AS tenantId, user_id AS userId, scope_type AS scopeType,
store_id AS storeId, cash_balance_cents AS cashBalanceCents,
gift_balance_cents AS giftBalanceCents, status
FROM qipai_wallet_accounts
WHERE tenant_id = ? AND user_id = ? AND scope_type = ?
AND ${input.storeId ? 'store_id = ?' : 'store_id IS NULL'}
${lock ? 'FOR UPDATE' : ''}`,
input.storeId
? [input.tenantId, input.userId, input.scopeType, input.storeId]
: [input.tenantId, input.userId, input.scopeType]
);
const account = rows[0];
if (!account) throw new WalletLedgerError('WALLET_ACCOUNT_NOT_FOUND');
if (account.status !== 'ACTIVE') throw new WalletLedgerError('WALLET_ACCOUNT_DISABLED');
return {
...account,
id: String(account.id),
tenantId: String(account.tenantId),
userId: String(account.userId),
storeId: account.storeId === null ? null : String(account.storeId)
};
}
private async findDuplicate(
connection: PoolConnection,
account: WalletAccountRow,
input: WalletMutationInput
) {
const [rows] = await connection.execute<WalletLedgerRow[]>(
`SELECT id, cash_delta_cents AS cashDeltaCents,
gift_delta_cents AS giftDeltaCents,
cash_balance_after_cents AS cashBalanceAfterCents,
gift_balance_after_cents AS giftBalanceAfterCents
FROM qipai_wallet_ledger_entries
WHERE tenant_id = ? AND account_id = ?
AND business_type = ? AND business_id = ? LIMIT 1`,
[account.tenantId, account.id, input.businessType, input.businessId]
);
return rows[0] ?? null;
}
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 interface WalletMutationInput {
tenantId: string;
userId: string;
scopeType: WalletScopeType;
storeId?: string | null;
businessType: string;
businessId: string;
entryType: WalletEntryType;
operatorId?: string | null;
traceId: string;
note?: string;
metadata?: Record<string, unknown>;
}
function ledgerResponse(
account: WalletAccountRow,
row: WalletLedgerRow,
idempotent: boolean
) {
return {
accountId: String(account.id),
ledgerId: String(row.id),
cashBalanceCents: Number(row.cashBalanceAfterCents),
giftBalanceCents: Number(row.giftBalanceAfterCents),
cashDeltaCents: Number(row.cashDeltaCents),
giftDeltaCents: Number(row.giftDeltaCents),
idempotent
};
}
function integer(value: number, code: string) {
if (!Number.isSafeInteger(value)) throw new WalletLedgerError(code);
return value;
}
function nonNegative(value: number, code: string) {
const parsed = integer(value, code);
if (parsed < 0) throw new WalletLedgerError(code);
return parsed;
}
+165
View File
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken, verifyAccessToken } from '../dist/auth/jwt.js';
import { WechatApiError, parseWechatAppSecrets } from '../dist/auth/wechat-client.js';
const secret = 'test-only-jwt-secret-with-at-least-32-characters';
const token = signAccessToken({
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 3
}, secret, 900, 1000);
assert.deepEqual(verifyAccessToken(token, secret, 1001), {
iss: 'qipai-api',
aud: 'qipai-miniapp',
sub: '21',
sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7',
aid: '9',
rv: 3,
iat: 1000,
exp: 1900
});
assert.throws(() => verifyAccessToken(token, `${secret}-wrong`, 1001), /signature/);
assert.throws(() => verifyAccessToken(token, secret, 1900), /expired/);
assert.deepEqual(parseWechatAppSecrets('{"wx-app":"secret-value"}'), {
'wx-app': 'secret-value'
});
let sessionValid = true;
let revokedSessionId = null;
const auth = {
repository: {
async resolveLoginContext(appId, tenantId) {
assert.equal(appId, 'wx-test-app');
assert.equal(tenantId, '7');
return { appId, tenantId, platformAppId: '9' };
},
async loginWithWechat(input) {
assert.equal(input.openid, 'openid-test');
return {
id: input.sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: input.expiresAt,
user: {
id: '21',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
},
async validateSession(sessionId, tenantId, userId) {
if (!sessionValid) return null;
return {
id: sessionId,
tenantId,
platformAppId: '9',
expiresAt: new Date(Date.now() + 60_000),
user: {
id: userId,
tenantId,
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
},
async revokeSession(sessionId) {
revokedSessionId = sessionId;
sessionValid = false;
return true;
}
},
wechat: {
async exchange(appId, code) {
assert.equal(appId, 'wx-test-app');
assert.equal(code, 'valid-code');
return { openid: 'openid-test', unionid: 'unionid-test' };
}
},
jwtSecret: secret,
accessTokenTtlSeconds: 900,
sessionTtlSeconds: 604800,
accessControl: {
async getAccessProfile() {
return {
roles: ['CUSTOMER'],
capabilities: ['order.self.read', 'profile.read'],
storeIds: []
};
}
}
};
const app = await buildApp({ auth });
const login = await app.inject({
method: 'POST',
url: '/app-api/auth/wechat-login',
headers: {
'x-wechat-appid': 'wx-test-app',
'tenant-id': '7'
},
payload: { code: 'valid-code' }
});
assert.equal(login.statusCode, 200);
const accessToken = login.json().data.accessToken;
assert.equal(login.json().data.user.tenantId, '7');
const me = await app.inject({
method: 'GET',
url: '/app-api/auth/me',
headers: { authorization: `Bearer ${accessToken}` }
});
assert.equal(me.statusCode, 200);
assert.equal(me.json().data.user.id, '21');
assert.deepEqual(me.json().data.access.roles, ['CUSTOMER']);
const logout = await app.inject({
method: 'POST',
url: '/app-api/auth/logout',
headers: { authorization: `Bearer ${accessToken}` }
});
assert.equal(logout.statusCode, 200);
assert.ok(revokedSessionId);
const afterLogout = await app.inject({
method: 'GET',
url: '/app-api/auth/me',
headers: { authorization: `Bearer ${accessToken}` }
});
assert.equal(afterLogout.statusCode, 401);
assert.equal(afterLogout.json().code, 'AUTH_SESSION_INVALID');
await app.close();
const failedApp = await buildApp({
auth: {
...auth,
wechat: {
async exchange() {
throw new WechatApiError('invalid code');
}
}
}
});
const failedLogin = await failedApp.inject({
method: 'POST',
url: '/app-api/auth/wechat-login',
headers: { 'x-wechat-appid': 'wx-test-app', 'tenant-id': '7' },
payload: { code: 'bad-code' }
});
assert.equal(failedLogin.statusCode, 401);
assert.equal(failedLogin.json().code, 'WECHAT_LOGIN_FAILED');
await failedApp.close();
console.log('PASS: M02-B JWT, WeChat login and revocable session flow is present.');
+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const read = (path) => readFileSync(join(root, path), 'utf8');
const packageJson = JSON.parse(read('package.json'));
assert.equal(packageJson.engines.node, '>=20.0.0');
assert.equal(packageJson.dependencies.fastify.startsWith('^5.'), true);
assert.equal(packageJson.dependencies['@fastify/cors'].startsWith('^11.'), true);
assert.equal(packageJson.dependencies['@fastify/rate-limit'].startsWith('^11.'), true);
assert.equal(packageJson.dependencies.zod.startsWith('^3.'), true);
assert.equal(packageJson.dependencies.mysql2.startsWith('^3.'), true);
assert.equal(packageJson.dependencies.kysely, undefined);
const tsconfig = JSON.parse(read('tsconfig.json'));
assert.equal(tsconfig.compilerOptions.strict, true);
assert.equal(tsconfig.compilerOptions.moduleResolution, 'NodeNext');
const appSource = read('src/app.ts');
assert.match(appSource, /genReqId/);
assert.match(appSource, /x-trace-id/);
assert.match(appSource, /setErrorHandler/);
assert.match(appSource, /rateLimit/);
assert.match(appSource, /cors/);
const configSource = read('src/config.ts');
assert.match(configSource, /z\.object/);
assert.match(configSource, /QIPAI_MQTT_URL/);
assert.match(configSource, /QIPAI_MYSQL_PASSWORD/);
assert.match(configSource, /QIPAI_JWT_SECRET/);
assert.match(configSource, /explicitly configured in production/);
const healthSource = read('src/routes/health.ts');
for (const route of [
'/app-api/health',
'/admin-api/health',
'/app-api/ready',
'/admin-api/ready',
'/app-api/version',
'/admin-api/version'
]) {
assert.match(healthSource, new RegExp(route));
}
console.log('PASS: backend M01-A contract is present.');
@@ -0,0 +1,280 @@
import assert from 'node:assert/strict';
import { loadConfig } from '../dist/config.js';
import {
CleaningPayoutError,
CleaningPayoutService
} from '../dist/cleaning/cleaning-payout-service.js';
assert.equal(loadConfig({
NODE_ENV: 'production',
QIPAI_MQTT_USERNAME: 'backend-test',
QIPAI_MQTT_PASSWORD: 'not-a-real-secret',
QIPAI_JWT_SECRET: 'production-cleaning-payout-secret-long-enough',
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
}).payment.cleaningPayoutMockEnabled, false);
assert.equal(loadConfig({
NODE_ENV: 'test',
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
}).payment.cleaningPayoutMockEnabled, true);
const actor = {
tenantId: '7',
userId: '21',
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId: 'cleaning-payout-test'
};
{
const harness = createHarness({});
const result = await harness.service.preflightWechatTransfer({
...actor,
settlementId: '501'
});
assert.equal(result.ready, true);
assert.equal(result.account.merchantIdMasked, '19***0109');
assert.equal(result.credential.reportInfoCount, 1);
assert.equal(result.cleaner.openidConfigured, true);
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'PASS');
}
{
const harness = createHarness({
credentialPatch: { transferSceneId: '', transferSceneReportInfos: [] },
openid: ''
});
const result = await harness.service.preflightWechatTransfer({
...actor,
settlementId: '501'
});
assert.equal(result.ready, false);
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'FAIL');
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_report_infos').status, 'WARN');
assert.equal(result.checks.find((item) => item.key === 'cleaner_openid').status, 'FAIL');
}
{
const harness = createHarness({ transferState: 'SUCCESS' });
const result = await harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'API'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
assert.equal(harness.state.transferInput.amountCents, 1200);
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
}
{
const harness = createHarness({ transferState: 'WAIT_USER_CONFIRM', packageInfo: 'package-info' });
const result = await harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'API',
note: 'need user confirm'
});
assert.equal(result.transferState, 'WAIT_USER_CONFIRM');
assert.equal(harness.repository.pending[0].payoutState, 'WAIT_USER_CONFIRM');
assert.equal(harness.repository.pending[0].payoutPackageInfo, 'package-info');
assert.equal(harness.repository.paid.length, 0);
}
{
const harness = createHarness({ queryState: 'SUCCESS' });
const result = await harness.service.syncWechatTransfer({
...actor,
settlementId: '501',
note: 'poll success'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.state.queryOutBillNo, 'CLS-20260627-501');
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
}
{
const harness = createHarness({ queryState: 'FAIL', queryFailReason: 'ACCOUNT_ABNORMAL' });
const result = await harness.service.syncWechatTransfer({
...actor,
settlementId: '501'
});
assert.equal(result.transferState, 'FAIL');
assert.equal(harness.repository.failures[0].error, 'ACCOUNT_ABNORMAL');
}
{
const harness = createHarness({ queryState: 'PROCESSING' });
const result = await harness.service.syncWechatTransfer({
...actor,
settlementId: '501'
});
assert.equal(result.transferState, 'PROCESSING');
assert.equal(harness.repository.pending[0].payoutState, 'PROCESSING');
}
{
const harness = createHarness({
notificationPayload: {
mch_id: '1900000109',
out_bill_no: 'CLS-20260627-501',
transfer_bill_no: 'wx-notify-501',
state: 'SUCCESS'
}
});
const result = await harness.service.processWechatTransferNotification({
timestamp: '1782700000',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signature'
}, '{"resource":"encrypted"}', 'notify-trace');
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.state.verifiedNotification.rawBody, '{"resource":"encrypted"}');
assert.equal(harness.repository.paid[0].tenantId, '7');
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
}
{
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
const result = await harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'API'
});
assert.equal(result.transferState, 'FAIL');
assert.equal(harness.repository.failures[0].error, 'REAL_NAME_CHECK_FAILED');
}
{
const harness = createHarness({ mockEnabled: true });
const result = await harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'MOCK'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER_MOCK');
}
{
const harness = createHarness({ mockEnabled: false });
await assert.rejects(
() => harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'MOCK'
}),
(error) => error instanceof CleaningPayoutError
&& error.code === 'CLEANING_PAYOUT_MOCK_DISABLED'
);
}
function createHarness(options = {}) {
const state = {
transferInput: null,
queryOutBillNo: null,
settlement: {
id: '501',
settlementNo: 'CLS-20260627-501',
cleanerUserId: '31',
storeId: '11',
status: options.status ?? 'CONFIRMED',
totalRewardCents: 1200,
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
payoutReference: options.payoutReference ?? 'CLS-20260627-501',
payoutState: options.payoutState ?? 'WAIT_USER_CONFIRM',
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
},
account: {
id: '41',
platformAppId: '9',
storeId: '11',
merchantId: '1900000109',
credentialRef: 'wechat-cleaning',
authorizationStatus: options.authorizationStatus ?? 'AUTHORIZED'
}
};
const pool = {
async execute(sql) {
if (sql.includes("payout_channel = 'WECHAT_TRANSFER'")) {
return [[{ ...state.settlement, tenantId: '7' }], []];
}
if (sql.includes('FROM qipai_cleaning_settlements')) {
return [[state.settlement], []];
}
if (sql.includes('FROM qipai_collection_accounts')) {
return [[state.account], []];
}
if (sql.includes('FROM qipai_user_identities')) {
return [options.openid === '' ? [] : [{ openid: options.openid ?? 'openid-cleaner' }], []];
}
throw new Error(`Unexpected SQL: ${sql}`);
}
};
const repository = {
paid: [],
failures: [],
pending: [],
async markSettlementPaid(input) {
this.paid.push(input);
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
},
async recordSettlementPayoutFailure(input) {
this.failures.push(input);
return { ...state.settlement, payoutError: input.error };
},
async recordSettlementPayoutPending(input) {
this.pending.push(input);
return { ...state.settlement, payoutState: input.payoutState };
}
};
const client = {
async createMerchantTransfer(_credential, input) {
state.transferInput = input;
return {
outBillNo: input.outBillNo,
transferBillNo: 'wx-transfer-501',
state: options.transferState ?? 'SUCCESS',
failReason: options.failReason ?? '',
packageInfo: options.packageInfo ?? ''
};
},
async queryMerchantTransferByOutBillNo(_credential, outBillNo) {
state.queryOutBillNo = outBillNo;
return {
merchantId: options.queryMerchantId ?? '1900000109',
outBillNo,
transferBillNo: 'wx-transfer-query-501',
state: options.queryState ?? 'SUCCESS',
failReason: options.queryFailReason ?? '',
amountCents: options.queryAmountCents ?? 1200
};
},
verifyAndDecrypt(_credential, headers, rawBody) {
state.verifiedNotification = { headers, rawBody };
return options.notificationPayload ?? {};
}
};
const credential = {
appId: 'wx-test',
merchantId: '1900000109',
serialNo: 'serial',
privateKeyPem: 'private-key',
apiV3Key: '0123456789abcdef0123456789abcdef',
platformCertificates: { 'PLATFORM-SERIAL': 'certificate' },
transferSceneId: '1000',
transferSceneReportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
};
Object.assign(credential, options.credentialPatch ?? {});
return {
state,
repository,
service: new CleaningPayoutService(
pool,
repository,
client,
new Map([['wechat-cleaning', credential]]),
options.mockEnabled ?? false
)
};
}
console.log('PASS: M08-B cleaning payout service handles WeChat transfer states and mock gate.');
+712
View File
@@ -0,0 +1,712 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
const secret = 'test-only-cleaning-route-secret';
const token = signAccessToken({
sub: '31', sid: '9c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const forbiddenToken = signAccessToken({
sub: '32', sid: '8c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
tid: '7', aid: '9', rv: 1
}, secret, 900);
const calls = [];
const app = await buildApp({
cleaning: {
jwtSecret: secret,
authRepository: {
async validateSession(sessionId) {
return {
id: sessionId,
tenantId: '7',
platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: {
id: sessionId.startsWith('8') ? '32' : '31',
tenantId: '7',
userType: 'CUSTOMER',
status: 'ACTIVE',
roleVersion: 1,
nickname: '',
avatarUrl: '',
phone: ''
}
};
}
},
accessControl: {
async getAccessProfile(tenantId, userId) {
return userId === '31'
? {
roles: ['CLEANER'],
capabilities: [
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read',
'cleaning.settlement.read', 'cleaning.settlement.write'
],
storeIds: ['11']
}
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
}
},
repository: {
async listHall(input) {
calls.push(['listHall', input]);
return { items: [task('WAITING')], total: 1, page: input.page, pageSize: input.pageSize };
},
async listMine(input) {
calls.push(['listMine', input]);
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
},
async listManage(input) {
calls.push(['listManage', input]);
return { items: [task(input.status || 'SUBMITTED')], total: 1, page: input.page, pageSize: input.pageSize };
},
async claim(input) {
calls.push(['claim', input]);
return task('CLAIMED');
},
async start(input) {
calls.push(['start', input]);
return task('STARTED');
},
async rework(input) {
calls.push(['rework', input]);
return task('STARTED');
},
async assertCanUploadPhoto(input) {
calls.push(['assertCanUploadPhoto', input]);
},
async submit(input) {
calls.push(['submit', input]);
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
},
async assign(input) {
calls.push(['assign', input]);
return { ...task('CLAIMED'), cleanerUserId: input.cleanerUserId };
},
async complete(input) {
calls.push(['complete', input]);
return task('COMPLETED');
},
async reject(input) {
calls.push(['reject', input]);
return { ...task('REJECTED'), rejectReason: input.reason };
},
async exempt(input) {
calls.push(['exempt', input]);
return { ...task('EXEMPT'), rewardCents: 0 };
},
async listMembers(input) {
calls.push(['listMembers', input]);
return [member('LEAD', '31', 500), member('ASSIST', '33', 100)];
},
async listEvents(input) {
calls.push(['listEvents', input]);
return [{
id: '9001',
taskId: input.taskId,
fromStatus: 'STARTED',
toStatus: 'SUBMITTED',
action: 'SUBMIT',
actorId: input.userId,
traceId: input.traceId,
note: 'done',
createdAt: new Date()
}];
},
async addMember(input) {
calls.push(['addMember', input]);
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
},
async removeMember(input) {
calls.push(['removeMember', input]);
return [member('LEAD', '31', 600)];
},
async settlementCandidates(input) {
calls.push(['settlementCandidates', input]);
return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize };
},
async listSettlements(input) {
calls.push(['listSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
},
async getSettlementDetail(input) {
calls.push(['getSettlementDetail', input]);
return {
settlement: settlementResponse('DRAFT'),
items: [settlementItemResponse()]
};
},
async generateSettlement(input) {
calls.push(['generateSettlement', input]);
return settlementResponse('DRAFT');
},
async confirmSettlement(input) {
calls.push(['confirmSettlement', input]);
return settlementResponse('CONFIRMED');
},
async markSettlementPaid(input) {
calls.push(['markSettlementPaid', input]);
return {
...settlementResponse('PAID'),
paidBy: input.userId,
paidAt: new Date(),
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
payoutError: ''
};
},
async recordSettlementPayoutFailure(input) {
calls.push(['recordSettlementPayoutFailure', input]);
return {
...settlementResponse('CONFIRMED'),
payoutChannel: input.payoutChannel || '',
payoutReference: input.payoutReference || '',
payoutError: input.error
};
},
async reclaimTimeouts(input) {
calls.push(['reclaimTimeouts', input]);
return { reclaimed: 2, taskIds: ['101', '102'] };
},
async stats(input) {
calls.push(['stats', input]);
return {
byStatus: { SUBMITTED: 2, COMPLETED: 1, REJECTED: 1 },
taskTotal: 4,
completed: 1,
rejected: 1,
completionRate: 25,
pendingSettlementCents: 1200,
settledRewardCents: 600
};
},
async managerStatistics(input) {
calls.push(['managerStatistics', input]);
return {
summary: {
taskTotal: 5,
pendingReview: 2,
active: 1,
rejected: 1,
exempted: 1,
completed: 1,
rewardCents: 3000,
pendingSettlementCents: 1200,
confirmedSettlementCents: 800,
paidSettlementCents: 600
},
byStatus: [{ status: 'SUBMITTED', total: 2, rewardCents: 1200 }],
byStore: [{
storeId: '11',
storeName: 'Test Store',
taskTotal: 5,
pendingReview: 2,
completed: 1,
rejected: 1,
exempted: 1,
rewardCents: 3000
}],
settlements: [{ status: 'CONFIRMED', total: 1, rewardCents: 800 }],
members: [{
cleanerUserId: '31',
cleanerName: 'Cleaner',
taskCount: 3,
completedTaskCount: 2,
rejectedTaskCount: 1,
pendingSettlementCents: 1200,
settledRewardCents: 600
}],
trend: [{
date: '2026-06-30',
taskTotal: 5,
pendingReview: 2,
completed: 1,
rejected: 1,
exempted: 1,
rewardCents: 3000,
paidSettlementCents: 600
}]
};
}
},
payoutService: {
async preflightWechatTransfer(input) {
calls.push(['preflightWechatTransfer', input]);
return {
ready: true,
settlement: { id: input.settlementId, settlementNo: 'CLS-501', status: 'CONFIRMED' },
account: { configured: true, merchantIdMasked: '19***0109' },
credential: { configured: true, transferSceneId: '1000', reportInfoCount: 1 },
cleaner: { openidConfigured: true },
checks: [{ key: 'transfer_scene_id', status: 'PASS', message: 'ok' }]
};
},
async executeWechatTransfer(input) {
calls.push(['executeWechatTransfer', input]);
return {
settlement: settlementResponse(input.mode === 'MOCK' ? 'PAID' : 'CONFIRMED'),
transferState: input.mode === 'MOCK' ? 'SUCCESS' : 'WAIT_USER_CONFIRM',
idempotent: false
};
},
async syncWechatTransfer(input) {
calls.push(['syncWechatTransfer', input]);
return {
settlement: {
...settlementResponse('PAID'),
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: 'wx-transfer-sync-501'
},
transferState: 'SUCCESS',
idempotent: false
};
},
async processWechatTransferNotification(headers, rawBody, traceId) {
calls.push(['processWechatTransferNotification', { headers, rawBody, traceId }]);
return {
settlement: {
...settlementResponse('PAID'),
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: 'wx-transfer-notify-501'
},
transferState: 'SUCCESS',
idempotent: false
};
}
},
mediaStorage: {
async storeImage(input) {
calls.push(['storeImage', input]);
return {
storagePath: 'tenants/7/shared/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
mimeType: 'image/webp',
byteSize: input.body.length,
width: 640,
height: 480,
checksumSha256: 'abc'
};
}
}
}
});
const hall = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(hall.statusCode, 200);
assert.equal(hall.json().data.items[0].status, 'WAITING');
assert.equal(calls.at(-1)[1].tenantId, '7');
assert.equal(calls.at(-1)[1].userId, '31');
const mine = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/mine?status=CLAIMED',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(mine.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listMine');
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
const manageList = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks?status=SUBMITTED&storeId=11&cleanerUserId=31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(manageList.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listManage');
assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
assert.equal(calls.at(-1)[1].storeId, '11');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const managerStats = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(managerStats.statusCode, 200);
assert.equal(calls.at(-1)[0], 'managerStatistics');
assert.equal(calls.at(-1)[1].storeId, '11');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
assert.equal(managerStats.json().data.summary.pendingSettlementCents, 1200);
assert.equal(managerStats.json().data.summary.exempted, 1);
assert.equal(managerStats.json().data.byStore[0].exempted, 1);
assert.equal(managerStats.json().data.members[0].completedTaskCount, 2);
assert.equal(managerStats.json().data.members[0].rejectedTaskCount, 1);
assert.equal(managerStats.json().data.trend[0].exempted, 1);
assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600);
const claim = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/claim',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(claim.statusCode, 200);
assert.equal(calls.at(-1)[0], 'claim');
assert.equal(calls.at(-1)[1].taskId, '101');
const start = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/start',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(start.statusCode, 200);
assert.equal(calls.at(-1)[0], 'start');
const rework = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/rework',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(rework.statusCode, 200);
assert.equal(calls.at(-1)[0], 'rework');
const photo = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/photos',
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/octet-stream',
'x-file-name': 'cleaning.jpg',
'x-image-content-type': 'image/jpeg'
},
payload: Buffer.from('fake-image')
});
assert.equal(photo.statusCode, 201);
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-1)[0], 'storeImage');
const submit = await app.inject({
method: 'POST',
url: '/app-api/cleaning/tasks/101/submit',
headers: { authorization: `Bearer ${token}` },
payload: { photoUrls: ['https://api.txyundm.cn/uploads/cleaning/101.jpg'], note: 'ok' }
});
assert.equal(submit.statusCode, 200);
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
const assign = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/assign',
headers: { authorization: `Bearer ${token}` },
payload: { cleanerUserId: '33', note: 'manual dispatch' }
});
assert.equal(assign.statusCode, 200);
assert.equal(calls.at(-1)[0], 'assign');
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
const complete = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/complete',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'ok' }
});
assert.equal(complete.statusCode, 200);
assert.equal(calls.at(-1)[0], 'complete');
const reject = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/reject',
headers: { authorization: `Bearer ${token}` },
payload: { reason: 'photo is unclear' }
});
assert.equal(reject.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reject');
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
const exempt = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/exempt',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'no cleaning required' }
});
assert.equal(exempt.statusCode, 200);
assert.equal(exempt.json().data.status, 'EXEMPT');
assert.equal(calls.at(-1)[0], 'exempt');
assert.equal(calls.at(-1)[1].note, 'no cleaning required');
const members = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/members',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(members.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listMembers');
assert.equal(members.json().data.length, 2);
const events = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/events',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(events.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listEvents');
assert.equal(calls.at(-1)[1].taskId, '101');
assert.equal(events.json().data[0].action, 'SUBMIT');
const addedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members',
headers: { authorization: `Bearer ${token}` },
payload: { cleanerUserId: '33', rewardCents: 100, note: 'support' }
});
assert.equal(addedMember.statusCode, 200);
assert.equal(calls.at(-1)[0], 'addMember');
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
assert.equal(calls.at(-1)[1].rewardCents, 100);
const removedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members/33/remove',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'done' }
});
assert.equal(removedMember.statusCode, 200);
assert.equal(calls.at(-1)[0], 'removeMember');
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
const settlement = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10&storeId=11&cleanerUserId=31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlement.statusCode, 200);
assert.equal(calls.at(-1)[0], 'settlementCandidates');
assert.equal(calls.at(-1)[1].storeId, '11');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const settlementList = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlementList.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listSettlements');
assert.equal(calls.at(-1)[1].status, 'DRAFT');
assert.equal(calls.at(-1)[1].payoutState, 'FAIL');
assert.equal(calls.at(-1)[1].storeId, '11');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const settlementDetail = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements/501',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlementDetail.statusCode, 200);
assert.equal(calls.at(-1)[0], 'getSettlementDetail');
assert.equal(settlementDetail.json().data.items[0].taskNo, 'CLN-20260625-0001');
const generatedSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements',
headers: { authorization: `Bearer ${token}` },
payload: { cleanerUserId: '31', storeId: '11', note: 'weekly settlement' }
});
assert.equal(generatedSettlement.statusCode, 201);
assert.equal(calls.at(-1)[0], 'generateSettlement');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const confirmedSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/confirm',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'confirmed' }
});
assert.equal(confirmedSettlement.statusCode, 200);
assert.equal(calls.at(-1)[0], 'confirmSettlement');
assert.equal(calls.at(-1)[1].settlementId, '501');
const failedPayout = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/payout-failure',
headers: { authorization: `Bearer ${token}` },
payload: {
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: 'wx-failed-001',
error: 'insufficient merchant balance',
note: 'retry later'
}
});
assert.equal(failedPayout.statusCode, 200);
assert.equal(calls.at(-1)[0], 'recordSettlementPayoutFailure');
assert.equal(calls.at(-1)[1].settlementId, '501');
assert.equal(calls.at(-1)[1].error, 'insufficient merchant balance');
const paidSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/paid',
headers: { authorization: `Bearer ${token}` },
payload: {
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: 'wx-paid-001',
note: 'paid'
}
});
assert.equal(paidSettlement.statusCode, 200);
assert.equal(paidSettlement.json().data.status, 'PAID');
assert.equal(paidSettlement.json().data.payoutReference, 'wx-paid-001');
assert.equal(calls.at(-1)[0], 'markSettlementPaid');
const wechatTransfer = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/wechat-transfer',
headers: { authorization: `Bearer ${token}` },
payload: { mode: 'API', note: 'wechat transfer' }
});
assert.equal(wechatTransfer.statusCode, 200);
assert.equal(wechatTransfer.json().data.transferState, 'WAIT_USER_CONFIRM');
assert.equal(calls.at(-1)[0], 'executeWechatTransfer');
assert.equal(calls.at(-1)[1].settlementId, '501');
assert.equal(calls.at(-1)[1].mode, 'API');
const wechatTransferPreflight = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements/501/wechat-transfer/preflight',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(wechatTransferPreflight.statusCode, 200);
assert.equal(wechatTransferPreflight.json().data.ready, true);
assert.equal(wechatTransferPreflight.json().data.account.merchantIdMasked, '19***0109');
assert.equal(calls.at(-1)[0], 'preflightWechatTransfer');
assert.equal(calls.at(-1)[1].settlementId, '501');
const syncedWechatTransfer = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/wechat-transfer/sync',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'poll wechat transfer' }
});
assert.equal(syncedWechatTransfer.statusCode, 200);
assert.equal(syncedWechatTransfer.json().data.transferState, 'SUCCESS');
assert.equal(syncedWechatTransfer.json().data.settlement.payoutReference, 'wx-transfer-sync-501');
assert.equal(calls.at(-1)[0], 'syncWechatTransfer');
assert.equal(calls.at(-1)[1].settlementId, '501');
const wechatTransferNotify = await app.inject({
method: 'POST',
url: '/app-api/cleaning/wechat-transfer/notify',
headers: {
'content-type': 'application/json',
'wechatpay-timestamp': '1782700001',
'wechatpay-nonce': 'notify-nonce',
'wechatpay-serial': 'PLATFORM-SERIAL',
'wechatpay-signature': 'signature'
},
payload: '{"resource":"encrypted"}'
});
assert.equal(wechatTransferNotify.statusCode, 200);
assert.equal(wechatTransferNotify.json().code, 'SUCCESS');
assert.equal(calls.at(-1)[0], 'processWechatTransferNotification');
assert.equal(calls.at(-1)[1].headers.serial, 'PLATFORM-SERIAL');
assert.equal(calls.at(-1)[1].rawBody, '{"resource":"encrypted"}');
const reclaimed = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/reclaim-timeouts',
headers: { authorization: `Bearer ${token}` },
payload: { olderThanMinutes: 30, limit: 10 }
});
assert.equal(reclaimed.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
const stats = await app.inject({
method: 'GET',
url: '/app-api/cleaning/stats',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(stats.statusCode, 200);
assert.equal(stats.json().data.pendingSettlementCents, 1200);
assert.equal(stats.json().data.settledRewardCents, 600);
assert.equal(stats.json().data.completionRate, 25);
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' });
assert.equal(unauthorized.statusCode, 401);
const forbidden = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall',
headers: { authorization: `Bearer ${forbiddenToken}` }
});
assert.equal(forbidden.statusCode, 403);
await app.close();
console.log('PASS: M08-B cleaning routes authenticate and forward cleaner task workflows.');
function task(status) {
return {
id: '101',
taskNo: 'CLN-20260625-0001',
storeId: '11',
storeName: 'Test Store',
roomId: '21',
roomName: 'A Room',
roomNo: 'A01',
orderId: '301',
orderNo: 'O301',
status,
rewardCents: 600,
photoUrls: []
};
}
function settlementResponse(status) {
return {
id: '501',
settlementNo: 'CLS-20260626-0001',
cleanerUserId: '31',
cleanerName: 'Cleaner',
storeId: '11',
storeName: 'Test Store',
status,
taskCount: 2,
totalRewardCents: 1200,
periodStart: new Date(),
periodEnd: new Date(),
paidBy: status === 'PAID' ? '31' : null,
confirmedAt: status === 'CONFIRMED' ? new Date() : null,
paidAt: status === 'PAID' ? new Date() : null,
payoutChannel: '',
payoutReference: '',
payoutError: '',
note: ''
};
}
function settlementItemResponse() {
return {
id: '601',
settlementId: '501',
taskId: '101',
taskNo: 'CLN-20260625-0001',
orderNo: 'O301',
storeName: 'Test Store',
roomName: 'A Room',
roomNo: 'A01',
cleanerUserId: '31',
cleanerName: 'Cleaner',
rewardCents: 600,
completedAt: new Date(),
createdAt: new Date()
};
}
function member(memberRole, userId, rewardCents) {
return {
id: `${userId}01`,
taskId: '101',
userId,
nickname: `Cleaner ${userId}`,
memberRole,
rewardCents,
joinedAt: new Date(),
removedAt: null,
settledAt: null
};
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js';
import { ContentError, ContentRepository } from '../dist/content/content-repository.js';
const root = await mkdtemp(join(tmpdir(), 'qipai-media-'));
try {
const png = await sharp({
create: { width: 2400, height: 1200, channels: 3, background: '#336699' }
}).png().toBuffer();
const storage = new MediaStorage(root);
const image = await storage.storeImage({
tenantId: '7', storeId: '11', originalName: 'banner.png',
contentType: 'image/png', body: png
});
assert.equal(image.mimeType, 'image/webp');
assert.ok(image.width <= 1920);
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
body: Buffer.from('not an image')
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
);
} finally {
await rm(root, { recursive: true, force: true });
}
const storeActor = {
tenantId: '7', userId: '21',
access: {
roles: ['STORE_ADMIN'], capabilities: ['store.operation.write'], storeIds: ['11']
},
traceId: 'trace', ip: '127.0.0.1', userAgent: 'test'
};
const repository = new ContentRepository({ async execute() { return [[], []]; } });
await assert.rejects(
() => repository.saveAdvertisement(storeActor, {
scopeType: 'PLATFORM', title: 'x', imageAssetId: '1',
targetType: 'NONE', targetValue: '', status: 'DRAFT', sortOrder: 0
}),
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
);
console.log('PASS: M03-B image compression, tenant paths and advertisement scope validation are present.');

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