Compare commits
220 Commits
d6f0e10990
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01e86e6856 | |||
| 3bee3c47b6 | |||
| 96c5fa4c90 | |||
| 1a1892cfb4 | |||
| 06ea496eb7 | |||
| de0013e329 | |||
| dabb71d8ab | |||
| 0f85422e82 | |||
| 053e27fd40 | |||
| a4c2d14c41 | |||
| c40cbcac11 | |||
| eb838f5a7e | |||
| 0bdcb220e5 | |||
| 642659ce3e | |||
| 19d6734b68 | |||
| 8ffb940bdb | |||
| 2a4db54b94 | |||
| 6979614aeb | |||
| eb0f58912e | |||
| 38410b1185 | |||
| ee39e98418 | |||
| 3713ddd1aa | |||
| 31052625ba | |||
| b66b8d4d78 | |||
| 650b9b917a | |||
| 85d65068e7 | |||
| e490637179 | |||
| bcc89addba | |||
| 7e8fa275d5 | |||
| 8a17659bdf | |||
| 98783714a3 | |||
| 9d8fb3db6b | |||
| c026ef7ebe | |||
| dc27897ea4 | |||
| 654f41fa40 | |||
| 05cf27f112 | |||
| b643fda99c | |||
| 1c1039ae0e | |||
| 0d0bdf20d5 | |||
| 6961193857 | |||
| 8c60c35444 | |||
| a720c05c9d | |||
| d0cf00309f | |||
| 2d07c92a88 | |||
| 38ef98b06b | |||
| a067c1d020 | |||
| 6c977632c0 | |||
| a09683b82d | |||
| 7a4797021b | |||
| a1adf4d6ab | |||
| 300e23c93c | |||
| 2e200842c9 | |||
| 31aed4835e | |||
| 464566a5e0 | |||
| 99d9d862b7 | |||
| 9f718ab154 | |||
| 1e930597b1 | |||
| a9bb06f732 | |||
| a8c3fde95a | |||
| 14fcfc2578 | |||
| 89b5263352 | |||
| 0eb6349021 | |||
| a95fdcbb93 | |||
| 1543a7df99 | |||
| 86106459c4 | |||
| 06f905e792 | |||
| 1f758cc29f | |||
| b5e7121c35 | |||
| fda768d4ce | |||
| a44864309c | |||
| 8e4264589d | |||
| 273e429d9e | |||
| 435c9f3f97 | |||
| 9ab3f5ff46 | |||
| 9346e7a18a | |||
| 0b3246ae8a | |||
| a50c6afdbe | |||
| d6bb9ccb1e | |||
| 44af0ee155 | |||
| 52fb508e03 | |||
| 8fae70ef8e | |||
| aa4f74360d | |||
| a264b37bd0 | |||
| c1165ccfd8 | |||
| b091c7caf1 | |||
| 8f7a914e7d | |||
| 08c152aecc | |||
| 983966561f | |||
| 2fa1f079df | |||
| 9f6e267d43 | |||
| 2ce59eee3b | |||
| 5eff0a4a18 | |||
| 986a90806b | |||
| 957a29b11e | |||
| 67e7796652 | |||
| 78fb6ed2d0 | |||
| e6bb5e6dc6 | |||
| e46d8c0f0a | |||
| dac353d184 | |||
| 335adcdc22 | |||
| d35c39e33f | |||
| 326c534ba7 | |||
| e48d1f0301 | |||
| f59beb0e75 | |||
| 9506021b29 | |||
| 1fada09249 | |||
| a1bace2dac | |||
| b07c451700 | |||
| 05907a6bea | |||
| 5d224693ae | |||
| 03496d74ab | |||
| 1dc36d716a | |||
| 2ec4376b1c | |||
| 704dca7bcf | |||
| 59ce3eca53 | |||
| c8ddc8dd00 | |||
| fe1a08880e | |||
| f7e0c8d063 | |||
| 2a4dcd0fd8 | |||
| 9f7c2e4be7 | |||
| 47016744d4 | |||
| 59c4ef330e | |||
| dda2b3cbc6 | |||
| 3d885852cd | |||
| 53f5d6776f | |||
| 2bbb16e6f0 | |||
| e24920c9cc | |||
| fb1b593b24 | |||
| dbdfe3dfca | |||
| cb1109f387 | |||
| 7446852cca | |||
| c7c869c18b | |||
| 874b9b8e29 | |||
| a4e13d911e | |||
| eaa86547c2 | |||
| d2c73a2c28 | |||
| eea8eea12d | |||
| 47ec7fc30e | |||
| 4ac2c960e4 | |||
| c90f6e34a1 | |||
| ed0d455083 | |||
| 239ef4dcf1 | |||
| 4ae9296f83 | |||
| ba99beb221 | |||
| d9743d36d8 | |||
| db1d08ecf8 | |||
| 63711adafc | |||
| 8de3d05da6 | |||
| d01f77151e | |||
| c00526f3e3 | |||
| 8936ad1c6f | |||
| e4941c2ffa | |||
| 9144fa8102 | |||
| 48638606fe | |||
| d15fd3f0ff | |||
| f71ac09a0b | |||
| e795cdb693 | |||
| c96045f6ef | |||
| 74a67ac92d | |||
| 07790a7924 | |||
| c140613718 | |||
| a8c3e7d2fe | |||
| 1680d734bf | |||
| 52edf2482e | |||
| cda640baa0 | |||
| 3ec74bb751 | |||
| 4c66e192b9 | |||
| dea2ee5ee1 | |||
| 6c506ff862 | |||
| f9ec0af2ee | |||
| 91801fe6a7 | |||
| 3c5cf071b9 | |||
| 089d34877b | |||
| 4122db45ec | |||
| 40c891f1b7 | |||
| 725028cb2d | |||
| af7a45d878 | |||
| 6be7fa79c5 | |||
| d563078a9f | |||
| 88fa22ee92 | |||
| df373faa82 | |||
| 9417064e61 | |||
| c658b0912b | |||
| db014635ac | |||
| 0251226d9e | |||
| d4c026b247 | |||
| f74624296d | |||
| 7e9b53487b | |||
| 15139b3962 | |||
| 47094d5309 | |||
| 5a300a82f6 | |||
| 46acb8422a | |||
| caacc78545 | |||
| a8c2a3b3e1 | |||
| 647ef7c83c | |||
| 0db9505553 | |||
| 8b8fb28c36 | |||
| 9d13f75dc0 | |||
| 2b90d1f101 | |||
| a2d578f73b | |||
| 1192dcb6b2 | |||
| 5117ae27d6 | |||
| 8749a44adf | |||
| 2571f6fe5b | |||
| b8b384ed67 | |||
| 2715405c7d | |||
| e069c50331 | |||
| 3cabb71de5 | |||
| 3add64bef9 | |||
| 8801881f9c | |||
| de63164972 | |||
| 6114124ecb | |||
| ea884e166f | |||
| 98b66d22f2 | |||
| eb259ce2a4 | |||
| b44f447630 | |||
| 3106fa80ca | |||
| ef0edda3ed | |||
| d2fe866e81 | |||
| 59d92c00b0 |
@@ -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`
|
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
|
||||||
- 默认分支:`main`
|
- 默认分支:`main`
|
||||||
- 生产域名:`https://api.txyundm.cn`
|
- 生产 API:`https://api.txyundm.cn`
|
||||||
- 生产系统:Ubuntu Server 24.04 x86-64/amd64
|
- 小程序 API:`https://api.txyundm.cn/app-api`
|
||||||
- 部署方式:无 Docker,使用根目录 `setup.sh` 菜单式部署
|
- 后台 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
|
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
||||||
- `admin/`:Vue3 后台管理端
|
- `admin/`:Vue3 后台管理端,已接入 M08-B 保洁运营工作台、运营待处理入口、运营待处理导出、待验收任务批量验收/驳回、免清洁标记、批量免清洁与统计展示、协作者管理、任务成员导出、任务详情照片预览、任务操作流水、任务流水导出、任务详情协作分账、任务列表导出、任务详情导出、结算详情、结算详情导出、待结算候选预览与导出、结算列表导出、结算批量同步微信状态、结算转账状态筛选、微信确认参数展示与导出、微信转账预检资料展示与导出、真实转账前自动预检拦截、统计明细、每日趋势、保洁员绩效排行、统计导出、保洁员资料管理、保洁员列表导出、保洁员待补资料导出、微信绑定状态、保洁员选择器、任务/结算/统计筛选、联调检查项搜索、联调筛选结果导出与筛选清除、联调负责人筛选、联调负责人看板、联调执行阶段看板、联调期限记录、联调逾期清单、联调风险清单导出和联调归档包导出,必须适配桌面、平板和手机
|
||||||
- `miniapp/`:微信原生小程序
|
- `miniapp/`:微信原生小程序,已接入顾客端主链路与保洁端任务大厅、我的任务、照片上传、驳回补做和保洁员统计看板
|
||||||
- `database/`:迁移、种子和兼容 SQL
|
- `database/`:迁移、种子和兼容 SQL
|
||||||
- `deploy/`:部署说明、版本和生产配置模板
|
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
||||||
- `scripts/`:Windows、WSL 和 Ubuntu 辅助脚本
|
- `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,按当前进度继续开发。
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_ADMIN_API_BASE_URL=https://api.txyundm.cn/admin-api
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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>
|
||||||
Generated
+1582
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 : '-';
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
|
||||||
Generated
+2202
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]}.*.*` : '***';
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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: []
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
@@ -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.');
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user