Compare commits
260 Commits
3cabb71de5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 93af128002 | |||
| 2de796143f | |||
| fd6be4f9b9 | |||
| 1f1071501f | |||
| 5e0f5e39ee | |||
| bc7cb8fab9 | |||
| b507e87999 | |||
| 3faf86872d | |||
| bb89061671 | |||
| da5751f191 | |||
| 95cc5ea444 | |||
| 36b3b2fd25 | |||
| aed36f0f82 | |||
| 3ff68d103b | |||
| dabc656f63 | |||
| b71b48b3d0 | |||
| 9b2f055ecd | |||
| b35af9037f | |||
| 05110087d1 | |||
| 93d83e81b9 | |||
| 7e170e8743 | |||
| bd00aa6275 | |||
| aa4a6bd014 | |||
| e0e17159a7 | |||
| c1c02421a1 | |||
| ec4219ae31 | |||
| c7f27bc724 | |||
| b3fba4b815 | |||
| 5c9fd697f3 | |||
| 7c5f55cccf | |||
| 93f06d2ed6 | |||
| c71e130c5c | |||
| 7b978089d5 | |||
| e8bd176914 | |||
| 4eac99dd87 | |||
| 041146460d | |||
| 20a292e4ce | |||
| 3502f5b5f7 | |||
| 1ca783e91b | |||
| 04afce4417 | |||
| 65f2e7c916 | |||
| b08a2cca43 | |||
| ce52f07588 | |||
| 3bfccb8513 | |||
| 65932a8000 | |||
| a95e8bf8c8 | |||
| 1b0839e881 | |||
| 8b46863cd8 | |||
| 566e61b5d9 | |||
| 8b2156cdd5 | |||
| b70ada06a8 | |||
| 1160f16e8d | |||
| 90e230c153 | |||
| 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 |
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
HOOK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
REPO_ROOT=$(git -C "$HOOK_DIR" rev-parse --show-toplevel 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -z "$REPO_ROOT" ]; then
|
||||||
|
echo "FAIL: 无法确定 Git 仓库根目录,中文提交门禁未执行。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "FAIL: 未找到 Node.js,无法执行中文提交门禁。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec node "$REPO_ROOT/scripts/check-commit-message.mjs" --file "$1"
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# 自助棋牌室系统
|
# 自助棋牌室系统
|
||||||
|
|
||||||
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.2.md`](./V5.2.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
|
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.7.md`](./V5.7.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
|
||||||
|
|
||||||
## 固定约束
|
## 固定约束
|
||||||
|
|
||||||
- 当前权威总纲:`V5.2.md`
|
- 当前权威总纲:`V5.7.md`
|
||||||
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
|
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
|
||||||
- 默认分支:`main`
|
- 默认分支:`main`
|
||||||
- 生产 API:`https://api.txyundm.cn`
|
- 生产 API:`https://api.txyundm.cn`
|
||||||
@@ -13,15 +13,14 @@
|
|||||||
- 后台 Web:`https://api.txyundm.cn/admin/`
|
- 后台 Web:`https://api.txyundm.cn/admin/`
|
||||||
- 正式 MQTT Broker:`101.42.38.246:1883`
|
- 正式 MQTT Broker:`101.42.38.246:1883`
|
||||||
- 生产系统:Ubuntu Server 24.04 x86-64/amd64,无桌面、无 Docker、无微信云开发
|
- 生产系统:Ubuntu Server 24.04 x86-64/amd64,无桌面、无 Docker、无微信云开发
|
||||||
- 后端运行时:Node.js 20+、npm 10+
|
|
||||||
- 部署入口:Ubuntu 执行 `sudo bash /opt/apps/setup.sh`
|
- 部署入口:Ubuntu 执行 `sudo bash /opt/apps/setup.sh`
|
||||||
- 开发流程:Windows 本地开发与测试 → 完成一个模块 → 更新文档 → commit → SSH push `origin/main`
|
- 开发流程:Windows 本地开发与测试 → 按 `M00→M10` 固定队列持续编码 → 每个子阶段测试、commit、SSH push、远端校验 → 自动进入下一子阶段
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
||||||
- `admin/`:Vue3 后台管理端,必须适配桌面、平板和手机
|
- `admin/`:Vue3 后台管理端,已完成租户账号登录、短期访问令牌、轮换刷新令牌、当前会话、注销和 401 自动清理,并接入 M08-B 保洁运营工作台及 M08-D 计划内运营工作区;必须适配桌面、平板和手机
|
||||||
- `miniapp/`:微信原生小程序
|
- `miniapp/`:微信原生小程序,已接入顾客端主链路与保洁端任务大厅、我的任务、照片上传、驳回补做和保洁员统计看板
|
||||||
- `database/`:迁移、种子和兼容 SQL
|
- `database/`:迁移、种子和兼容 SQL
|
||||||
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
||||||
- `scripts/`:Windows、WSL 和 Ubuntu 辅助脚本
|
- `scripts/`:Windows、WSL 和 Ubuntu 辅助脚本
|
||||||
@@ -36,17 +35,17 @@
|
|||||||
| 用途 | 位置 |
|
| 用途 | 位置 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 项目入口 | `D:\qipai\README.md` |
|
| 项目入口 | `D:\qipai\README.md` |
|
||||||
| 当前总纲 | `D:\qipai\V5.2.md` |
|
| 当前总纲 | `D:\qipai\V5.7.md` |
|
||||||
| 完整配置索引 | `D:\qipai\docs\configuration.md` |
|
| 完整配置索引 | `D:\qipai\docs\configuration.md` |
|
||||||
| 仓库目录映射 | `D:\qipai\docs\repository-map.md` |
|
| 仓库目录映射 | `D:\qipai\docs\repository-map.md` |
|
||||||
| 后端开发配置 | `D:\qipai\backend\.env.development` |
|
| 后端开发配置 | `D:\qipai\backend\.env.development` |
|
||||||
| 后端测试配置 | `D:\qipai\backend\.env.test` |
|
| 后端测试配置 | `D:\qipai\backend\.env.test` |
|
||||||
| 后端配置模板 | `D:\qipai\backend\.env.example` |
|
| 后端配置模板 | `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.development` |
|
||||||
| 后台生产配置 | `D:\qipai\admin\.env.production` |
|
| 后台生产配置 | `D:\qipai\admin\.env.production` |
|
||||||
| 小程序环境配置 | `D:\qipai\miniapp\config\env.js` |
|
| 小程序环境配置 | `D:\qipai\miniapp\config\env.js` |
|
||||||
| WSL MQTT 本地配置 | `D:\qipai\config\dev\mqtt.local.env` |
|
|
||||||
| WSL MQTT 模板 | `D:\qipai\config\dev\mqtt.env.example` |
|
|
||||||
| PM2 模板 | `D:\qipai\deploy\pm2\ecosystem.config.cjs` |
|
| PM2 模板 | `D:\qipai\deploy\pm2\ecosystem.config.cjs` |
|
||||||
| Nginx 模板 | `D:\qipai\deploy\nginx\api.txyundm.cn.conf` |
|
| Nginx 模板 | `D:\qipai\deploy\nginx\api.txyundm.cn.conf` |
|
||||||
| EMQX 模板 | `D:\qipai\deploy\emqx\` |
|
| EMQX 模板 | `D:\qipai\deploy\emqx\` |
|
||||||
@@ -66,6 +65,9 @@
|
|||||||
| MQTTX CLI | `/usr/local/bin/mqttx` |
|
| MQTTX CLI | `/usr/local/bin/mqttx` |
|
||||||
| EMQX Dashboard | `http://127.0.0.1:18083` |
|
| EMQX Dashboard | `http://127.0.0.1:18083` |
|
||||||
| MQTT TCP | `127.0.0.1:1883` 或 WSL 当前 IP `:1883` |
|
| 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`。
|
WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running)` 且已开机启动;监听 `1883/8883/8083/8084/18083`。
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
|||||||
| 小程序源码镜像 | `/opt/apps/qipai-miniapp/source/` |
|
| 小程序源码镜像 | `/opt/apps/qipai-miniapp/source/` |
|
||||||
| 非敏感配置 | `/etc/qipai/qipai.conf` |
|
| 非敏感配置 | `/etc/qipai/qipai.conf` |
|
||||||
| 敏感配置 | `/etc/qipai/qipai.secrets` |
|
| 敏感配置 | `/etc/qipai/qipai.secrets` |
|
||||||
|
| MySQL 客户端凭据 | `/etc/qipai/mysql-client.cnf`(600) |
|
||||||
| 部署 SSH 私钥 | `/etc/qipai/ssh/id_ed25519` |
|
| 部署 SSH 私钥 | `/etc/qipai/ssh/id_ed25519` |
|
||||||
| Nginx 站点 | `/etc/nginx/sites-available/api.txyundm.cn.conf` |
|
| Nginx 站点 | `/etc/nginx/sites-available/api.txyundm.cn.conf` |
|
||||||
| PM2 配置 | `/opt/apps/qipai-backend/deploy/pm2/ecosystem.config.cjs` |
|
| PM2 配置 | `/opt/apps/qipai-backend/deploy/pm2/ecosystem.config.cjs` |
|
||||||
@@ -96,35 +99,66 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
|||||||
| 环境 | 服务 | 地址 | 账号 | 密码 | 说明 |
|
| 环境 | 服务 | 地址 | 账号 | 密码 | 说明 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| WSL 本地 | EMQX Dashboard | `http://127.0.0.1:18083` | `admin` | `admin123` | 已知 Dashboard 登录凭据;是否可用于 MQTT 客户端认证仍待验证 |
|
| 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 已配置 |
|
| Windows | Gitea SSH | `ssh://git@git.txyundm.cn:2222/panda/qipai.git` | SSH Key | 免密 | Windows 已配置 |
|
||||||
| Ubuntu 生产 | MQTT | `101.42.38.246:1883` | 待配置 | 待配置 | 禁止复用 WSL 凭据 |
|
| Ubuntu 生产 | MQTT | `101.42.38.246:1883` | 待配置 | 待配置 | 禁止复用 WSL 凭据 |
|
||||||
| Ubuntu 生产 | MySQL | `127.0.0.1:3306` | 待配置 | 待配置 | 配置后写入 `/etc/qipai/qipai.secrets` 并同步更新本表 |
|
| Ubuntu 生产 | MySQL | `127.0.0.1:3306` | `root` | `Da@Shuai!6y8c..XT` | 已确认;写入 `/etc/qipai/qipai.secrets` 与 `/etc/qipai/mysql-client.cnf` |
|
||||||
|
|
||||||
## Codex 工程优先纪律
|
数据库密码包含特殊字符时,脚本不得直接拼接到 shell 命令或未编码 URI。生产环境检查、迁移和备份统一通过权限为 `600` 的 `/etc/qipai/mysql-client.cnf` 或等效 `--defaults-extra-file` 连接,日志不得打印密码。
|
||||||
|
|
||||||
- 默认流程:读取必要文档 → 选择一个未完成子阶段 → 先修改工程文件 → 测试 → 增量更新既有文档 → commit → push。
|
## 持续开发规则
|
||||||
- 除用户明确要求只更新文档外,普通开发提交必须包含后端、后台、小程序、迁移、测试、脚本或部署配置中的实质工程变化。
|
|
||||||
- 只修改 Markdown 不得把模块标记为 `DONE`,不得增加功能完成数。
|
### 一次性启用 Git 中文提交门禁
|
||||||
- 禁止连续两个纯文档提交;同一模块子阶段只保留一份开发日志,补丁追加到原日志。
|
|
||||||
- `docs/current-baseline.md` 使用 `audited_commit` 做增量核验,禁止每次重新生成全量审计。
|
每次新克隆仓库后必须执行一次安装脚本;Git hook 不会仅因克隆仓库自动启用。Windows 在仓库内执行:
|
||||||
- 详细门禁见 `V5.2.md` 的 `0.0 工程编码优先与反文档循环硬门禁`。
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\setup-git-hooks.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
WSL/Ubuntu 在仓库内执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sh scripts/setup-git-hooks.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会把当前克隆的 `core.hooksPath` 配置为 `.githooks`。之后每次提交都会校验首行符合 `<type>(<scope>): 中文摘要`;可单独运行 `node scripts/tests/check-commit-message.test.mjs` 验证门禁。
|
||||||
|
|
||||||
|
- 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`。
|
||||||
|
- `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。
|
||||||
|
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
|
||||||
|
- 每个子阶段必须完成工程编码、真实测试、增量文档、commit、push,并验证 `HEAD == origin/main`。
|
||||||
|
- 完成一个子阶段后,在会话资源允许时自动继续下一子阶段,不询问“是否继续”。
|
||||||
|
- 纯 Markdown 变更不计业务进度;状态文档只随对应工程阶段伴随更新。
|
||||||
|
- 当前游标、最近工程提交和下一工程目标以 `docs/module-status.md`、`docs/current-baseline.md` 为准,不在 README 中猜测。
|
||||||
|
|
||||||
## 当前进度
|
## 当前进度
|
||||||
|
|
||||||
项目已开发部分模块;具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||||
|
|
||||||
- 最近工程提交:`3add64b fix(M01-B): 避免连接池凭据误触秘密扫描`。
|
- 当前执行游标:`M09-D2`(TODO);`M09-D1` 已在当前工作树完成商品目录与库存底座收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||||
- 下一工程目标:以 `docs/current-baseline.md` 的 `next_engineering_target` 为准,当前为 M01-B 迁移执行器、MySQL dry-run 与旧表兼容 Repository。
|
- 最近工程结果:M09-D1 已建立 9 张商品/库存表、分类/商品/SKU/门店售卖与跨日营业时段、可用/锁定/损耗库存、幂等请求和不可变流水;增量/崩溃重试迁移、数据库级迁移互斥、生产触发器前置、API/RBAC 与 MySQL 8.4.10 往返门禁通过,随本次中文工程提交固化。
|
||||||
- 开发纪律:普通“继续开发”必须产生工程文件变化、测试、commit 和 push;只改 Markdown 不计入模块进度。
|
- 下一工程目标:执行 M09-D2,建立商品订单、订单项快照、支付状态衔接,以及库存锁定、扣减、释放和取消回补闭环。
|
||||||
|
|
||||||
## 版本递进
|
## 版本递进
|
||||||
|
|
||||||
- **V5.0**:固化 WSL EMQX 5.8.9、MQTTX CLI 1.13.0 和已有代码续接规则。
|
|
||||||
- **V5.1**:同步 README,明确全部配置位置、明文凭据登记和 README 随总纲递进规则。
|
- **V5.1**:同步 README,明确全部配置位置、明文凭据登记和 README 随总纲递进规则。
|
||||||
- **V5.2**:增加工程编码优先、禁止文档循环、基线增量核验和工程证据门禁。
|
- **V5.2**:增加工程编码优先、反文档循环、增量审计和工程完成证据。
|
||||||
|
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
|
||||||
|
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
|
||||||
|
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
|
||||||
|
- **V5.7**:增加中文 Git 提交门禁,恢复并收口 M09-D1 商品目录/库存工作树,游标推进至 M09-D2。
|
||||||
|
|
||||||
## Codex 入口
|
## Codex 入口
|
||||||
|
|
||||||
```text
|
```text
|
||||||
请阅读 V5.2.md,按当前进度继续开发。
|
请阅读 V5.7.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
+2030
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@qipai/admin",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"test": "node tests/navigation.test.mjs && node ../scripts/check-admin-m08-d-r1.mjs --source-only",
|
||||||
|
"verify:r1": "npm run build && npm test && node ../scripts/check-admin-m08-d-r1.mjs --dist-only",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@lucide/vue": "^1.22.0",
|
||||||
|
"element-plus": "^2.10.4",
|
||||||
|
"vue": "^3.5.17",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"unplugin-vue-components": "^28.8.0",
|
||||||
|
"vite": "^6.4.3",
|
||||||
|
"vue-tsc": "^2.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider>
|
||||||
|
<AdminLoginPanel
|
||||||
|
v-if="!adminSessionState.token"
|
||||||
|
@authenticated="handleAuthenticated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main v-else-if="adminSessionState.ready && !restoreError" class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">棋</div>
|
||||||
|
<div>
|
||||||
|
<h1>自助棋牌室</h1>
|
||||||
|
<p>运营后台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-list" aria-label="后台模块">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in visibleNavigation"
|
||||||
|
:key="item.menuKey"
|
||||||
|
:to="item.path"
|
||||||
|
class="nav-item"
|
||||||
|
active-class="active"
|
||||||
|
>
|
||||||
|
<component :is="navigationIcons[item.icon]" :size="18" />
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">{{ route.meta.stage || 'M08-D' }}</p>
|
||||||
|
<h2>{{ route.meta.title || '运营后台' }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="session-box">
|
||||||
|
<div class="session-user">
|
||||||
|
<span>{{ adminSessionState.user?.nickname?.slice(0, 1) || '管' }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ adminSessionState.user?.nickname || '管理员' }}</strong>
|
||||||
|
<small>{{ adminSessionState.access.roles.join(' · ') || '已登录' }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-tooltip content="安全退出" placement="bottom">
|
||||||
|
<el-button :icon="LogOut" circle @click="handleLogout" />
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="routeLoadError"
|
||||||
|
class="route-alert"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
title="页面资源加载失败"
|
||||||
|
:description="routeLoadError"
|
||||||
|
>
|
||||||
|
<template #default>
|
||||||
|
<el-button type="primary" plain @click="retryLazyRoute">重新加载</el-button>
|
||||||
|
</template>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<RouterView v-else v-slot="{ Component }">
|
||||||
|
<Suspense>
|
||||||
|
<component
|
||||||
|
:is="Component"
|
||||||
|
:session="session"
|
||||||
|
@open-cleaning="openCleaning"
|
||||||
|
/>
|
||||||
|
<template #fallback>
|
||||||
|
<section class="route-state" aria-live="polite">
|
||||||
|
<el-skeleton :rows="8" animated />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</RouterView>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<main v-else class="session-recovery" aria-live="polite">
|
||||||
|
<section v-if="restoreError" class="session-recovery-card">
|
||||||
|
<h1>暂时无法恢复后台会话</h1>
|
||||||
|
<p>{{ restoreError }}</p>
|
||||||
|
<div>
|
||||||
|
<el-button type="primary" :loading="restoring" @click="restoreSession">重试</el-button>
|
||||||
|
<el-button @click="clearSession">返回登录</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section v-else class="session-recovery-card">
|
||||||
|
<el-skeleton :rows="4" animated />
|
||||||
|
<p>正在安全恢复会话与权限菜单…</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
Handshake,
|
||||||
|
Images,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
PanelsTopLeft,
|
||||||
|
RadioTower,
|
||||||
|
ScrollText,
|
||||||
|
Sparkles,
|
||||||
|
Store,
|
||||||
|
TicketCheck,
|
||||||
|
UsersRound,
|
||||||
|
WalletCards
|
||||||
|
} from '@lucide/vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import AdminLoginPanel from './components/AdminLoginPanel.vue';
|
||||||
|
import {
|
||||||
|
clearStoredSession,
|
||||||
|
getAdminMe,
|
||||||
|
logoutAdmin,
|
||||||
|
storeAdminSession,
|
||||||
|
type ApiSession
|
||||||
|
} from './api';
|
||||||
|
import {
|
||||||
|
ADMIN_NAVIGATION,
|
||||||
|
firstAuthorizedPath,
|
||||||
|
isNavigationAllowed,
|
||||||
|
resolveAuthorizedPath,
|
||||||
|
type AdminMenuKey
|
||||||
|
} from './navigation.mjs';
|
||||||
|
import { router } from './router';
|
||||||
|
import {
|
||||||
|
adminSessionState,
|
||||||
|
applyAdminIdentity,
|
||||||
|
applyAdminSession,
|
||||||
|
clearAdminSessionState
|
||||||
|
} from './session';
|
||||||
|
import type { AdminSessionPayload } from './types';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const restoring = ref(false);
|
||||||
|
const restoreError = ref('');
|
||||||
|
const routeLoadError = ref('');
|
||||||
|
const session = computed<ApiSession>(() => ({ token: adminSessionState.token }));
|
||||||
|
const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
|
||||||
|
(item) => isNavigationAllowed(adminSessionState.access, item)
|
||||||
|
));
|
||||||
|
const navigationIcons: Record<string, Component> = {
|
||||||
|
ClipboardList,
|
||||||
|
Handshake,
|
||||||
|
Images,
|
||||||
|
LayoutDashboard,
|
||||||
|
PanelsTopLeft,
|
||||||
|
RadioTower,
|
||||||
|
ScrollText,
|
||||||
|
Sparkles,
|
||||||
|
Store,
|
||||||
|
TicketCheck,
|
||||||
|
UsersRound,
|
||||||
|
WalletCards
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleAuthenticated(payload: AdminSessionPayload) {
|
||||||
|
storeAdminSession(payload);
|
||||||
|
applyAdminSession(payload);
|
||||||
|
restoreError.value = '';
|
||||||
|
void ensureAllowedRoute();
|
||||||
|
ElMessage.success('登录成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
try {
|
||||||
|
await logoutAdmin(session.value);
|
||||||
|
} catch {
|
||||||
|
// 本地令牌清理是退出动作的最终安全边界。
|
||||||
|
}
|
||||||
|
clearSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
clearStoredSession();
|
||||||
|
clearAdminSessionState();
|
||||||
|
restoreError.value = '';
|
||||||
|
routeLoadError.value = '';
|
||||||
|
void router.replace('/overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefreshed(event: Event) {
|
||||||
|
applyAdminSession((event as CustomEvent<AdminSessionPayload>).detail);
|
||||||
|
void ensureAllowedRoute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSession() {
|
||||||
|
if (!adminSessionState.token || restoring.value) return;
|
||||||
|
restoring.value = true;
|
||||||
|
restoreError.value = '';
|
||||||
|
adminSessionState.ready = false;
|
||||||
|
try {
|
||||||
|
const result = await getAdminMe(session.value);
|
||||||
|
applyAdminIdentity(result.user, result.access);
|
||||||
|
await ensureAllowedRoute();
|
||||||
|
} catch (error) {
|
||||||
|
if (!localStorage.getItem('qipai.admin.token')) {
|
||||||
|
clearAdminSessionState();
|
||||||
|
} else {
|
||||||
|
restoreError.value = error instanceof Error
|
||||||
|
? `${error.message}。请检查网络后重试,或返回登录。`
|
||||||
|
: '会话恢复失败,请检查网络后重试。';
|
||||||
|
adminSessionState.ready = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
restoring.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureAllowedRoute() {
|
||||||
|
const menuKey = route.meta.menuKey as AdminMenuKey | undefined;
|
||||||
|
const target = menuKey
|
||||||
|
? resolveAuthorizedPath(adminSessionState.access, menuKey)
|
||||||
|
: firstAuthorizedPath(adminSessionState.access);
|
||||||
|
if (target && target !== route.path) await router.replace(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCleaning() {
|
||||||
|
void router.push('/cleaning');
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLazyRoute() {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRouterErrorHandler = router.onError((error) => {
|
||||||
|
routeLoadError.value = error instanceof Error
|
||||||
|
? `${error.message}。可重新加载页面恢复。`
|
||||||
|
: '路由资源加载失败,可重新加载页面恢复。';
|
||||||
|
});
|
||||||
|
const removeAfterEach = router.afterEach((_to, _from, failure) => {
|
||||||
|
if (!failure) routeLoadError.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('qipai:admin-refreshed', handleRefreshed);
|
||||||
|
window.addEventListener('qipai:admin-unauthorized', clearSession);
|
||||||
|
if (adminSessionState.token) void restoreSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('qipai:admin-refreshed', handleRefreshed);
|
||||||
|
window.removeEventListener('qipai:admin-unauthorized', clearSession);
|
||||||
|
removeRouterErrorHandler();
|
||||||
|
removeAfterEach();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,964 @@
|
|||||||
|
import type {
|
||||||
|
Advertisement,
|
||||||
|
AdvertisementInput,
|
||||||
|
AdminIdentity,
|
||||||
|
AdminSessionPayload,
|
||||||
|
AdminAccess,
|
||||||
|
AuditLog,
|
||||||
|
CleaningSettlement,
|
||||||
|
CleaningSettlementDetail,
|
||||||
|
CleaningStatistics,
|
||||||
|
CleaningTask,
|
||||||
|
CleaningTemplate,
|
||||||
|
CleaningTemplateScope,
|
||||||
|
CleaningExemptPolicy,
|
||||||
|
CleaningTaskEvent,
|
||||||
|
CleaningTaskMember,
|
||||||
|
CleaningTaskSubmission,
|
||||||
|
DecorationComponent,
|
||||||
|
DecorationVersion,
|
||||||
|
FranchiseApplication,
|
||||||
|
FranchiseApplicationDetail,
|
||||||
|
FranchiseFollowUpType,
|
||||||
|
FranchiseStatus,
|
||||||
|
DeviceTopology,
|
||||||
|
DeviceType,
|
||||||
|
BusinessStatistics,
|
||||||
|
ManagedRoom,
|
||||||
|
MemberCard,
|
||||||
|
MemberDetail,
|
||||||
|
ManagedOrder,
|
||||||
|
ManagedStore,
|
||||||
|
ManagedUser,
|
||||||
|
MediaAsset,
|
||||||
|
PageResult,
|
||||||
|
OrderAction,
|
||||||
|
OrderHistoryItem,
|
||||||
|
OrderStatus,
|
||||||
|
PaymentAuthorizationStatus,
|
||||||
|
PlatformApplication,
|
||||||
|
ProfitSharingSnapshot,
|
||||||
|
PayoutStateFilter,
|
||||||
|
StaffRole,
|
||||||
|
SettlementStatus,
|
||||||
|
SystemOverview,
|
||||||
|
TaskStatus,
|
||||||
|
TransferMode,
|
||||||
|
RoomConfigurationStatus,
|
||||||
|
RoomInput,
|
||||||
|
RoomOperationalStatus,
|
||||||
|
StoreInput,
|
||||||
|
TenantApplicationConfig,
|
||||||
|
ThirdPartyMode,
|
||||||
|
ThirdPartyProvider,
|
||||||
|
ThirdPartyRecords,
|
||||||
|
ThirdPartySetup,
|
||||||
|
UserStatus,
|
||||||
|
WechatTransferPreflight
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const API_BASE = (import.meta.env.VITE_ADMIN_API_BASE_URL || '/admin-api').replace(/\/$/, '');
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public readonly code: string,
|
||||||
|
message = code,
|
||||||
|
public readonly traceId = ''
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiSession {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = 'qipai.admin.token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'qipai.admin.refreshToken';
|
||||||
|
let refreshInFlight: Promise<AdminSessionPayload> | null = null;
|
||||||
|
|
||||||
|
export async function adminPasswordLogin(input: {
|
||||||
|
tenantCode: string; loginName: string; password: string;
|
||||||
|
}) {
|
||||||
|
return publicRequest<AdminSessionPayload>('/auth/login', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminMe(session: ApiSession) {
|
||||||
|
return request<{ user: AdminIdentity; access: AdminAccess }>(session, '/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logoutAdmin(session: ApiSession) {
|
||||||
|
return request<{ revoked: boolean }>(session, '/auth/logout', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminCredential(session: ApiSession, userId: string, input: {
|
||||||
|
loginName: string; password: string;
|
||||||
|
}) {
|
||||||
|
return request<{ userId: string; configured: boolean }>(session,
|
||||||
|
`/auth/credentials/${encodeURIComponent(userId)}`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listManagedStores(session: ApiSession) {
|
||||||
|
return request<ManagedStore[]>(session, '/stores');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManagedStore(session: ApiSession, input: StoreInput) {
|
||||||
|
return request<{ storeId: string }>(session, '/stores', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManagedStore(session: ApiSession, storeId: string, input: StoreInput) {
|
||||||
|
return request<{ storeId: string }>(session, `/stores/${storeId}`, {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archiveManagedStore(session: ApiSession, storeId: string) {
|
||||||
|
return request<{ storeId: string; archived: boolean }>(session, `/stores/${storeId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listManagedRooms(session: ApiSession, storeId: string) {
|
||||||
|
return request<ManagedRoom[]>(session, `/stores/${storeId}/rooms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createManagedRoom(session: ApiSession, input: RoomInput) {
|
||||||
|
return request<{ roomId: string }>(session, '/rooms', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManagedRoom(session: ApiSession, roomId: string, input: RoomInput) {
|
||||||
|
return request<{ roomId: string }>(session, `/rooms/${roomId}`, {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateManagedRoomStatus(
|
||||||
|
session: ApiSession,
|
||||||
|
roomId: string,
|
||||||
|
input: {
|
||||||
|
storeId: string;
|
||||||
|
configurationStatus?: RoomConfigurationStatus;
|
||||||
|
operationalStatus?: RoomOperationalStatus;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<{ roomId: string }>(session, `/rooms/${roomId}/status`, {
|
||||||
|
method: 'PATCH', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function archiveManagedRoom(session: ApiSession, roomId: string, storeId: string) {
|
||||||
|
return request<{ roomId: string; archived: boolean }>(
|
||||||
|
session, `/rooms/${roomId}?${new URLSearchParams({ storeId })}`, { method: 'DELETE' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addManagedRoomDisabledPeriod(
|
||||||
|
session: ApiSession,
|
||||||
|
roomId: string,
|
||||||
|
input: { storeId: string; startsAt: string; endsAt: string; reason: string }
|
||||||
|
) {
|
||||||
|
return request<{ disabledPeriodId: string }>(session, `/rooms/${roomId}/disabled-periods`, {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listManagedOrders(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { page: number; pageSize: number; status?: OrderStatus; storeId?: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
return request<PageResult<ManagedOrder>>(session, `/orders?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManagedOrder(session: ApiSession, orderId: string) {
|
||||||
|
return request<ManagedOrder>(session, `/orders/${orderId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listManagedOrderHistory(session: ApiSession, orderId: string) {
|
||||||
|
return request<OrderHistoryItem[]>(session, `/orders/${orderId}/history`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeManagedOrderAction(
|
||||||
|
session: ApiSession, orderId: string, action: OrderAction, reason: string
|
||||||
|
) {
|
||||||
|
return request<{ orderId: string; status: OrderStatus }>(session, `/orders/${orderId}/actions`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ action, reason })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addManagedOrderNote(session: ApiSession, orderId: string, note: string) {
|
||||||
|
return request<{ orderId: string }>(session, `/orders/${orderId}/note`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ note })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustManagedOrderTime(
|
||||||
|
session: ApiSession, orderId: string,
|
||||||
|
input: { startAt?: string; endAt?: string; reason: string }
|
||||||
|
) {
|
||||||
|
return request<{ orderId: string }>(session, `/orders/${orderId}/adjust-time`, {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renewManagedOrder(
|
||||||
|
session: ApiSession, orderId: string,
|
||||||
|
input: { endAt: string; pricingPolicy: 'CURRENT' | 'LOCKED'; reason: string }
|
||||||
|
) {
|
||||||
|
return request<{ orderId: string }>(session, `/orders/${orderId}/renew`, {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function changeManagedOrderRoom(
|
||||||
|
session: ApiSession, orderId: string, input: { roomId: string; reason: string }
|
||||||
|
) {
|
||||||
|
return request<{ orderId: string }>(session, `/orders/${orderId}/change-room`, {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfitSharingSnapshot(session: ApiSession, storeId?: string) {
|
||||||
|
const query = storeId ? `?${new URLSearchParams({ storeId })}` : '';
|
||||||
|
return request<ProfitSharingSnapshot>(session, `/pay/profit-shares${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCollectionAccount(session: ApiSession, input: {
|
||||||
|
storeId: string | null; merchantId: string; credentialRef: string;
|
||||||
|
authorizationStatus: PaymentAuthorizationStatus; profitSharingEnabled: boolean; enabled: boolean;
|
||||||
|
}) {
|
||||||
|
return request<{ collectionAccountId: string }>(session, '/pay/collection-account', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfitShareReceiver(session: ApiSession, input: {
|
||||||
|
collectionAccountId: string; receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
|
||||||
|
receiverAccount: string; receiverCredentialRef: string; relationType: string; name: string;
|
||||||
|
authorizationStatus: PaymentAuthorizationStatus; enabled: boolean;
|
||||||
|
}) {
|
||||||
|
return request<{ receiverId: string }>(session, '/pay/profit-share-receiver', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfitSharePolicy(session: ApiSession, input: {
|
||||||
|
collectionAccountId: string; storeId: string | null; receiverId: string;
|
||||||
|
percentageBps: number; enabled: boolean;
|
||||||
|
}) {
|
||||||
|
return request<{ policyId: string }>(session, '/pay/profit-share-policy', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeProfitSharing(
|
||||||
|
session: ApiSession, input: { paymentId: string; clientRequestId: string; mode: 'API' | 'MOCK' }
|
||||||
|
) {
|
||||||
|
return request<{ shares: unknown[]; idempotent: boolean }>(session, '/pay/profit-shares', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWechatRefund(session: ApiSession, input: {
|
||||||
|
paymentId: string; amountCents: number; reason: string; clientRequestId: string;
|
||||||
|
}) {
|
||||||
|
return request<{ refundId: string; refundNo: string; status: string; refundableCents: number }>(
|
||||||
|
session, '/pay/refund', { method: 'POST', body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestWechatReconciliation(session: ApiSession, input: {
|
||||||
|
storeId: string; billDate: string; billType: 'ALL' | 'SUCCESS' | 'REFUND';
|
||||||
|
}) {
|
||||||
|
return request<{ id: string; status: string; downloadUrl: string; idempotent: boolean }>(
|
||||||
|
session, '/pay/reconciliation', { method: 'POST', body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listThirdPartyRecords(session: ApiSession, input: {
|
||||||
|
provider?: ThirdPartyProvider; status?: string; storeId?: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (input.provider) params.set('provider', input.provider);
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
const query = params.toString();
|
||||||
|
return request<ThirdPartyRecords>(session, `/third-party/records${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getThirdPartySetup(session: ApiSession, provider?: ThirdPartyProvider) {
|
||||||
|
const query = provider ? `?${new URLSearchParams({ provider })}` : '';
|
||||||
|
return request<ThirdPartySetup>(session, `/third-party/setup${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveThirdPartyConfig(session: ApiSession, input: {
|
||||||
|
provider: ThirdPartyProvider; storeId: string | null; mode: ThirdPartyMode;
|
||||||
|
enabled: boolean; credentialRef: string; settings: Record<string, unknown>;
|
||||||
|
}) {
|
||||||
|
return request<{ configId: string; created: boolean }>(session, '/third-party/config', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveThirdPartyMapping(session: ApiSession, input: {
|
||||||
|
provider: ThirdPartyProvider; resourceType: 'STORE' | 'ROOM';
|
||||||
|
externalRef: string; localResourceId: string;
|
||||||
|
}) {
|
||||||
|
return request<{ mapped: boolean }>(session, '/third-party/mappings', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redeemGroupVoucher(session: ApiSession, input: {
|
||||||
|
provider: ThirdPartyProvider; voucherCode: string; orderId: string; clientRequestId: string;
|
||||||
|
}) {
|
||||||
|
return request<{ redemptionId: string; status: string; voucherMasked: string }>(
|
||||||
|
session, '/group-vouchers/redeem', { method: 'POST', body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function redeemGroupVoucherManually(session: ApiSession, input: {
|
||||||
|
provider: ThirdPartyProvider; voucherCode: string; orderId: string;
|
||||||
|
amountCents: number; note: string; clientRequestId: string;
|
||||||
|
}) {
|
||||||
|
return request<{ redemptionId: string; status: string; voucherMasked: string }>(
|
||||||
|
session, '/group-vouchers/redeem-manual', { method: 'POST', body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBusinessStatistics(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { storeId: string; from: string; to: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams(input);
|
||||||
|
return request<BusinessStatistics>(session, `/statistics?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
session: ApiSession,
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
if (!session.token.trim()) {
|
||||||
|
throw new ApiError('AUTH_TOKEN_REQUIRED', '需要先填入后台访问令牌');
|
||||||
|
}
|
||||||
|
let response = await authorizedFetch(path, options, session.token.trim());
|
||||||
|
if (response.status === 401 && localStorage.getItem(REFRESH_TOKEN_KEY)) {
|
||||||
|
try {
|
||||||
|
const refreshed = await refreshAccessToken();
|
||||||
|
response = await authorizedFetch(path, options, refreshed.accessToken);
|
||||||
|
} catch {
|
||||||
|
clearStoredSession();
|
||||||
|
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload.code !== 0) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearStoredSession();
|
||||||
|
window.dispatchEvent(new Event('qipai:admin-unauthorized'));
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
String(payload.code || `HTTP_${response.status}`),
|
||||||
|
String(payload.message || '请求失败'),
|
||||||
|
String(payload.traceId || '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publicRequest<T>(path: string, options: RequestInit): Promise<T> {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set('content-type', 'application/json');
|
||||||
|
headers.set('x-request-id', requestId());
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || payload.code !== 0) throw new ApiError(
|
||||||
|
String(payload.code || `HTTP_${response.status}`),
|
||||||
|
String(payload.message || '请求失败'), String(payload.traceId || '')
|
||||||
|
);
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizedFetch(path: string, options: RequestInit, token: string) {
|
||||||
|
const headers = new Headers(options.headers);
|
||||||
|
headers.set('authorization', `Bearer ${token}`);
|
||||||
|
headers.set('x-request-id', requestId());
|
||||||
|
if (options.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
||||||
|
return fetch(`${API_BASE}${path}`, { ...options, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken() {
|
||||||
|
if (!refreshInFlight) {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || '';
|
||||||
|
refreshInFlight = publicRequest<AdminSessionPayload>('/auth/refresh', {
|
||||||
|
method: 'POST', body: JSON.stringify({ refreshToken })
|
||||||
|
}).then((payload) => {
|
||||||
|
storeAdminSession(payload);
|
||||||
|
window.dispatchEvent(new CustomEvent('qipai:admin-refreshed', { detail: payload }));
|
||||||
|
return payload;
|
||||||
|
}).finally(() => { refreshInFlight = null; });
|
||||||
|
}
|
||||||
|
return refreshInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeAdminSession(payload: AdminSessionPayload) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, payload.accessToken);
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredSession() {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestId() {
|
||||||
|
return globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningTasks(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { page: number; pageSize: number; status?: TaskStatus; storeId?: string; cleanerUserId?: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(input.page),
|
||||||
|
pageSize: String(input.pageSize)
|
||||||
|
});
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||||
|
return request<PageResult<CleaningTask>>(session, `/cleaning/tasks?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCleaningStatistics(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { from?: string; to?: string; storeId?: string; cleanerUserId?: string } = {}
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (input.from) params.set('from', input.from);
|
||||||
|
if (input.to) params.set('to', input.to);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||||
|
const query = params.toString();
|
||||||
|
return request<CleaningStatistics>(session, `/cleaning/statistics${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningTemplates(session: ApiSession) {
|
||||||
|
return request<CleaningTemplate[]>(session, '/cleaning/templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertCleaningTemplate(
|
||||||
|
session: ApiSession,
|
||||||
|
input: {
|
||||||
|
scopeType: CleaningTemplateScope;
|
||||||
|
scopeId?: string;
|
||||||
|
name: string;
|
||||||
|
requirement: string;
|
||||||
|
photoRequired: boolean;
|
||||||
|
minPhotoCount: number;
|
||||||
|
maxPhotoCount: number;
|
||||||
|
exemptPolicy: CleaningExemptPolicy;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return request<CleaningTemplate>(session, '/cleaning/templates', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listStaffUsers(
|
||||||
|
session: ApiSession,
|
||||||
|
input: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
role?: StaffRole;
|
||||||
|
status?: UserStatus;
|
||||||
|
search?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(input.page),
|
||||||
|
pageSize: String(input.pageSize),
|
||||||
|
userType: 'STAFF'
|
||||||
|
});
|
||||||
|
if (input.role) params.set('role', input.role);
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.search) params.set('search', input.search);
|
||||||
|
return request<{ items: ManagedUser[]; total: number }>(session, `/users?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMembers(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { page: number; pageSize: number; status?: UserStatus; search?: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.search) params.set('search', input.search);
|
||||||
|
return request<{ items: MemberCard[]; total: number }>(session, `/members?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMember(session: ApiSession, memberId: string) {
|
||||||
|
return request<MemberDetail>(session, `/members/${encodeURIComponent(memberId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeviceTopology(session: ApiSession, storeId: string) {
|
||||||
|
return request<DeviceTopology>(session, `/device-topology?${new URLSearchParams({ storeId })}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDeviceAsset(session: ApiSession, input: {
|
||||||
|
storeId: string; roomId: string | null; deviceId: string; imei: string; iccid: string | null;
|
||||||
|
deviceType: DeviceType; model: string; firmwareVersion: string;
|
||||||
|
signalStrength: number | null; capabilities: string[];
|
||||||
|
}) {
|
||||||
|
return request<{ assetId: string }>(session, '/devices', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindDeviceChannel(session: ApiSession, input: {
|
||||||
|
assetId: string; storeId: string; roomId: string; channelCode: string; purpose: string;
|
||||||
|
}) {
|
||||||
|
return request<{ assetId: string; targetKey: string }>(session, '/device-channels', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindSubDevice(session: ApiSession, input: {
|
||||||
|
parentAssetId: string; childAssetId: string; storeId: string; roomId: string;
|
||||||
|
subId: string; subtype: string;
|
||||||
|
}) {
|
||||||
|
return request<{ childAssetId: string; parentAssetId: string }>(session, '/device-links', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDeviceMaintenance(session: ApiSession, assetId: string, input: {
|
||||||
|
storeId: string; roomId: string | null; recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
|
||||||
|
status: 'OPEN' | 'RESOLVED'; description: string;
|
||||||
|
}) {
|
||||||
|
return request<{ maintenanceId: string }>(session, `/devices/${encodeURIComponent(assetId)}/maintenance`, { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function controlDevice(session: ApiSession, action: 'door' | 'power' | 'socket/switch', input: Record<string, unknown>) {
|
||||||
|
return request<{ commandId?: string; status?: string }>(session, `/device-control/${action}`, { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPlatformApplications(session: ApiSession) {
|
||||||
|
return request<PlatformApplication[]>(session, '/platform-apps');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlatformApplicationConfig(session: ApiSession, platformAppId: string, input: TenantApplicationConfig) {
|
||||||
|
return request<{ platformAppId: string; updated: boolean }>(session, `/platform-apps/${encodeURIComponent(platformAppId)}/config`, { method: 'PUT', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindPlatformApplication(session: ApiSession, input: {
|
||||||
|
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantApplicationConfig;
|
||||||
|
}) {
|
||||||
|
return request<{ platformAppId: string; bound: boolean }>(session, '/platform-apps/bind', { method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listMediaAssets(session: ApiSession, storeId?: string) {
|
||||||
|
const query = storeId ? `?${new URLSearchParams({ storeId })}` : '';
|
||||||
|
return request<MediaAsset[]>(session, `/media/images${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadMediaImage(session: ApiSession, file: File, storeId?: string) {
|
||||||
|
const headers = new Headers({
|
||||||
|
'content-type': 'application/octet-stream',
|
||||||
|
'x-image-content-type': file.type,
|
||||||
|
'x-file-name': file.name.replace(/[^\x20-\x7e]/g, '_') || 'image'
|
||||||
|
});
|
||||||
|
if (storeId) headers.set('x-store-id', storeId);
|
||||||
|
return request<{ assetId: string; url: string }>(session, '/media/images', {
|
||||||
|
method: 'POST', headers, body: await file.arrayBuffer()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listDecorations(session: ApiSession, storeId: string) {
|
||||||
|
return request<DecorationVersion[]>(
|
||||||
|
session, `/decorations?${new URLSearchParams({ storeId })}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveDecoration(session: ApiSession, input: {
|
||||||
|
storeId: string; templateCode: string; schemaVersion: number;
|
||||||
|
content: { components: DecorationComponent[] };
|
||||||
|
}) {
|
||||||
|
return request<{ decorationId: string; version: number }>(session, '/decorations', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publishDecoration(session: ApiSession, decorationId: string, storeId: string) {
|
||||||
|
return request<{ decorationId: string; published: boolean }>(
|
||||||
|
session,
|
||||||
|
`/decorations/${encodeURIComponent(decorationId)}/publish?${new URLSearchParams({ storeId })}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAdvertisements(session: ApiSession) {
|
||||||
|
return request<Advertisement[]>(session, '/advertisements');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAdvertisement(session: ApiSession, input: AdvertisementInput) {
|
||||||
|
return request<{ advertisementId: string }>(session, '/advertisements', {
|
||||||
|
method: 'POST', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAdvertisement(
|
||||||
|
session: ApiSession, advertisementId: string, input: AdvertisementInput
|
||||||
|
) {
|
||||||
|
return request<{ advertisementId: string }>(
|
||||||
|
session, `/advertisements/${encodeURIComponent(advertisementId)}`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify(input) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listFranchiseApplications(session: ApiSession, input: {
|
||||||
|
page: number; pageSize: number; status?: FranchiseStatus; assigneeUserId?: string; search?: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.assigneeUserId) params.set('assigneeUserId', input.assigneeUserId);
|
||||||
|
if (input.search) params.set('search', input.search);
|
||||||
|
return request<PageResult<FranchiseApplication>>(session, `/franchise-applications?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFranchiseApplication(session: ApiSession, applicationId: string) {
|
||||||
|
return request<FranchiseApplicationDetail>(session, `/franchise-applications/${encodeURIComponent(applicationId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignFranchiseApplication(session: ApiSession, applicationId: string, assigneeUserId: string | null) {
|
||||||
|
return request<{ applicationId: string; assigneeUserId: string | null }>(session,
|
||||||
|
`/franchise-applications/${encodeURIComponent(applicationId)}/assignee`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ assigneeUserId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addFranchiseFollowUp(session: ApiSession, applicationId: string, input: {
|
||||||
|
followUpType: Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>;
|
||||||
|
note: string; nextFollowUpAt: string | null; status?: FranchiseStatus;
|
||||||
|
}) {
|
||||||
|
return request<{ applicationId: string; followUpId: string; status: FranchiseStatus }>(session,
|
||||||
|
`/franchise-applications/${encodeURIComponent(applicationId)}/follow-ups`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAuditLogs(session: ApiSession, input: {
|
||||||
|
page: number; pageSize: number; action?: string; resourceType?: string; search?: string;
|
||||||
|
from?: string; to?: string;
|
||||||
|
}) {
|
||||||
|
const params = new URLSearchParams({ page: String(input.page), pageSize: String(input.pageSize) });
|
||||||
|
if (input.action) params.set('action', input.action);
|
||||||
|
if (input.resourceType) params.set('resourceType', input.resourceType);
|
||||||
|
if (input.search) params.set('search', input.search);
|
||||||
|
if (input.from) params.set('from', input.from);
|
||||||
|
if (input.to) params.set('to', input.to);
|
||||||
|
return request<PageResult<AuditLog>>(session, `/audit-logs?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemOverview(session: ApiSession) {
|
||||||
|
return request<SystemOverview>(session, '/system/overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTenantSystemConfig(session: ApiSession, input: {
|
||||||
|
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
|
||||||
|
}) {
|
||||||
|
return request<{ tenantId: string; updated: boolean }>(session, '/system/tenant', {
|
||||||
|
method: 'PUT', body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStaffUser(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { nickname: string; phone: string; note?: string; roles: StaffRole[]; storeIds: string[] }
|
||||||
|
) {
|
||||||
|
return request<{ userId: string }>(session, '/staff', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateStaffUser(
|
||||||
|
session: ApiSession,
|
||||||
|
userId: string,
|
||||||
|
input: Partial<{
|
||||||
|
nickname: string;
|
||||||
|
phone: string;
|
||||||
|
note: string;
|
||||||
|
status: UserStatus;
|
||||||
|
roles: StaffRole[];
|
||||||
|
storeIds: string[];
|
||||||
|
}>
|
||||||
|
) {
|
||||||
|
return request<{ userId: string }>(session, `/users/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetStaffSessions(session: ApiSession, userId: string) {
|
||||||
|
return request<{ userId: string; revokedSessions: number }>(
|
||||||
|
session,
|
||||||
|
`/users/${encodeURIComponent(userId)}/reset-sessions`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignCleaningTask(
|
||||||
|
session: ApiSession,
|
||||||
|
taskId: string,
|
||||||
|
input: { cleanerUserId: string; note?: string }
|
||||||
|
) {
|
||||||
|
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function completeCleaningTask(session: ApiSession, taskId: string, note?: string) {
|
||||||
|
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rejectCleaningTask(session: ApiSession, taskId: string, reason: string) {
|
||||||
|
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exemptCleaningTask(session: ApiSession, taskId: string, note?: string) {
|
||||||
|
return request<CleaningTask>(session, `/cleaning/tasks/${encodeURIComponent(taskId)}/exempt`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningTaskMembers(session: ApiSession, taskId: string) {
|
||||||
|
return request<CleaningTaskMember[]>(
|
||||||
|
session,
|
||||||
|
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningTaskEvents(session: ApiSession, taskId: string) {
|
||||||
|
return request<CleaningTaskEvent[]>(
|
||||||
|
session,
|
||||||
|
`/cleaning/tasks/${encodeURIComponent(taskId)}/events`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningTaskSubmissions(session: ApiSession, taskId: string) {
|
||||||
|
return request<CleaningTaskSubmission[]>(
|
||||||
|
session,
|
||||||
|
`/cleaning/tasks/${encodeURIComponent(taskId)}/submissions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCleaningTaskMember(
|
||||||
|
session: ApiSession,
|
||||||
|
taskId: string,
|
||||||
|
input: { cleanerUserId: string; rewardCents: number; note?: string }
|
||||||
|
) {
|
||||||
|
return request<CleaningTaskMember[]>(
|
||||||
|
session,
|
||||||
|
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCleaningTaskMember(
|
||||||
|
session: ApiSession,
|
||||||
|
taskId: string,
|
||||||
|
cleanerUserId: string,
|
||||||
|
note?: string
|
||||||
|
) {
|
||||||
|
return request<CleaningTaskMember[]>(
|
||||||
|
session,
|
||||||
|
`/cleaning/tasks/${encodeURIComponent(taskId)}/members/${encodeURIComponent(cleanerUserId)}/remove`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reclaimCleaningTimeouts(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { olderThanMinutes: number; limit: number }
|
||||||
|
) {
|
||||||
|
return request<{ reclaimed: number; taskIds: string[] }>(session, '/cleaning/reclaim-timeouts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningSettlements(
|
||||||
|
session: ApiSession,
|
||||||
|
input: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status?: SettlementStatus;
|
||||||
|
payoutState?: PayoutStateFilter;
|
||||||
|
storeId?: string;
|
||||||
|
cleanerUserId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(input.page),
|
||||||
|
pageSize: String(input.pageSize)
|
||||||
|
});
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.payoutState) params.set('payoutState', input.payoutState);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||||
|
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportCleaningSettlements(
|
||||||
|
session: ApiSession,
|
||||||
|
input: {
|
||||||
|
status?: SettlementStatus;
|
||||||
|
payoutState?: PayoutStateFilter;
|
||||||
|
storeId?: string;
|
||||||
|
cleanerUserId?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (input.status) params.set('status', input.status);
|
||||||
|
if (input.payoutState) params.set('payoutState', input.payoutState);
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||||
|
return request<{
|
||||||
|
items: CleaningSettlement[];
|
||||||
|
total: number;
|
||||||
|
exported: number;
|
||||||
|
truncated: boolean;
|
||||||
|
}>(session, `/cleaning/settlements/export?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCleaningSettlementCandidates(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
page: String(input.page),
|
||||||
|
pageSize: String(input.pageSize)
|
||||||
|
});
|
||||||
|
if (input.storeId) params.set('storeId', input.storeId);
|
||||||
|
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
|
||||||
|
return request<PageResult<CleaningTask>>(session, `/cleaning/settlement-candidates?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCleaningSettlement(
|
||||||
|
session: ApiSession,
|
||||||
|
input: { cleanerUserId: string; storeId?: string; note?: string }
|
||||||
|
) {
|
||||||
|
return request<CleaningSettlement>(session, '/cleaning/settlements', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCleaningSettlementDetail(session: ApiSession, settlementId: string) {
|
||||||
|
return request<CleaningSettlementDetail>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
|
||||||
|
return request<CleaningSettlement>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/confirm`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cancelCleaningSettlement(session: ApiSession, settlementId: string, reason: string) {
|
||||||
|
return request<CleaningSettlement>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/cancel`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ reason })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markCleaningSettlementPaid(
|
||||||
|
session: ApiSession,
|
||||||
|
settlementId: string,
|
||||||
|
input: { payoutChannel: string; payoutReference: string; note?: string }
|
||||||
|
) {
|
||||||
|
return request<CleaningSettlement>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/paid`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeWechatTransfer(
|
||||||
|
session: ApiSession,
|
||||||
|
settlementId: string,
|
||||||
|
input: { mode: TransferMode; note?: string }
|
||||||
|
) {
|
||||||
|
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preflightWechatTransfer(session: ApiSession, settlementId: string) {
|
||||||
|
return request<WechatTransferPreflight>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/preflight`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncWechatTransfer(session: ApiSession, settlementId: string, note?: string) {
|
||||||
|
return request<{ settlement: CleaningSettlement; transferState: string; idempotent: boolean }>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/wechat-transfer/sync`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ note })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordPayoutFailure(
|
||||||
|
session: ApiSession,
|
||||||
|
settlementId: string,
|
||||||
|
input: { error: string; payoutChannel?: string; payoutReference?: string; note?: string }
|
||||||
|
) {
|
||||||
|
return request<CleaningSettlement>(
|
||||||
|
session,
|
||||||
|
`/cleaning/settlements/${encodeURIComponent(settlementId)}/payout-failure`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<main class="login-shell">
|
||||||
|
<section class="login-story" aria-label="平台介绍">
|
||||||
|
<div class="login-brand"><span>棋</span><strong>自助棋牌室运营平台</strong></div>
|
||||||
|
<div><p class="section-kicker">QIPAI OPERATIONS</p><h1>一处掌握门店、订单、设备与资金。</h1><p>统一租户权限、可撤销会话与全链路审计,为日常运营提供可信工作台。</p></div>
|
||||||
|
<ul><li><ShieldCheck :size="18" />短期访问令牌与轮换刷新令牌</li><li><Fingerprint :size="18" />scrypt 密码哈希与失败锁定</li><li><ScrollText :size="18" />关键操作按租户完整留痕</li></ul>
|
||||||
|
</section>
|
||||||
|
<section class="login-form-wrap">
|
||||||
|
<div class="login-card">
|
||||||
|
<header><p class="section-kicker">管理后台</p><h2>欢迎回来</h2><p>使用租户代码和后台账号登录。</p></header>
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon :closable="false" />
|
||||||
|
<el-form label-position="top" @submit.prevent="login">
|
||||||
|
<el-form-item label="租户代码"><el-input v-model="form.tenantCode" size="large" autocomplete="organization" placeholder="例如 demo" @keyup.enter="login" /></el-form-item>
|
||||||
|
<el-form-item label="登录账号"><el-input v-model="form.loginName" size="large" autocomplete="username" placeholder="请输入后台账号" @keyup.enter="login" /></el-form-item>
|
||||||
|
<el-form-item label="密码"><el-input v-model="form.password" size="large" type="password" show-password autocomplete="current-password" placeholder="请输入密码" @keyup.enter="login" /></el-form-item>
|
||||||
|
<el-button type="primary" size="large" :loading="loading" :disabled="!canSubmit" @click="login">安全登录</el-button>
|
||||||
|
</el-form>
|
||||||
|
<footer><LockKeyhole :size="15" /><span>连续失败 5 次将锁定账号 15 分钟</span></footer>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { Fingerprint, LockKeyhole, ScrollText, ShieldCheck } from '@lucide/vue';
|
||||||
|
import { adminPasswordLogin, ApiError } from '../api';
|
||||||
|
import type { AdminSessionPayload } from '../types';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ authenticated: [payload: AdminSessionPayload] }>();
|
||||||
|
const loading = ref(false); const lastError = ref('');
|
||||||
|
const form = reactive({ tenantCode: localStorage.getItem('qipai.admin.tenantCode') || '', loginName: '', password: '' });
|
||||||
|
const canSubmit = computed(() => form.tenantCode.trim().length >= 2 && form.loginName.trim().length >= 3 && form.password.length > 0);
|
||||||
|
async function login() { if (!canSubmit.value || loading.value) return; loading.value = true; lastError.value = ''; try { const payload = await adminPasswordLogin({ tenantCode: form.tenantCode.trim(), loginName: form.loginName.trim(), ['password']: form.password }); localStorage.setItem('qipai.admin.tenantCode', form.tenantCode.trim()); form.password = ''; emit('authenticated', payload); } catch (error) { lastError.value = error instanceof ApiError ? loginMessage(error) : error instanceof Error ? error.message : '登录失败'; } finally { loading.value = false; } }
|
||||||
|
function loginMessage(error: ApiError) { if (error.code === 'ADMIN_LOGIN_LOCKED') return '登录失败次数过多,账号已暂时锁定,请 15 分钟后重试。'; if (error.code === 'ADMIN_LOGIN_INVALID') return '租户代码、登录账号或密码不正确。'; return `${error.code}${error.traceId ? ` · 追踪号 ${error.traceId}` : ''}`; }
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,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,246 @@
|
|||||||
|
<template>
|
||||||
|
<section class="rules-panel">
|
||||||
|
<header class="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">M09-B · 清洁规则</p>
|
||||||
|
<h3>门店与房间清洁模板</h3>
|
||||||
|
<p>模板按房间、门店、租户依次匹配;保存后只影响新任务,既有任务继续使用创建时快照。</p>
|
||||||
|
</div>
|
||||||
|
<el-button :loading="loading" @click="loadTemplates">刷新</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
title="配置变更不会静默改写在途任务;每次保存都会记录审计。"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form class="rule-form" label-position="top" @submit.prevent>
|
||||||
|
<el-form-item label="作用范围">
|
||||||
|
<el-select v-model="draft.scopeType" @change="handleScopeChange">
|
||||||
|
<el-option label="租户默认" value="TENANT" />
|
||||||
|
<el-option label="指定门店" value="STORE" />
|
||||||
|
<el-option label="指定房间" value="ROOM" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="draft.scopeType === 'STORE'" label="门店">
|
||||||
|
<el-select v-model="draft.scopeId" filterable placeholder="请选择门店">
|
||||||
|
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="draft.scopeType === 'ROOM'" label="所属门店">
|
||||||
|
<el-select v-model="draft.roomStoreId" filterable placeholder="先选择门店" @change="handleRoomStoreChange">
|
||||||
|
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="draft.scopeType === 'ROOM'" label="房间">
|
||||||
|
<el-select v-model="draft.scopeId" filterable placeholder="请选择房间" :disabled="!draft.roomStoreId">
|
||||||
|
<el-option
|
||||||
|
v-for="room in rooms"
|
||||||
|
:key="room.id"
|
||||||
|
:label="`${room.roomNo} · ${room.name}`"
|
||||||
|
:value="room.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="模板名称">
|
||||||
|
<el-input v-model.trim="draft.name" maxlength="128" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="draft.status">
|
||||||
|
<el-option label="启用" value="ACTIVE" />
|
||||||
|
<el-option label="停用" value="DISABLED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item class="wide-field" label="清洁要求">
|
||||||
|
<el-input v-model.trim="draft.requirement" type="textarea" :rows="3" maxlength="512" show-word-limit />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="必须上传照片">
|
||||||
|
<el-switch v-model="draft.photoRequired" @change="normalizePhotoRule" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最少照片">
|
||||||
|
<el-input-number v-model="draft.minPhotoCount" :min="draft.photoRequired ? 1 : 0" :max="draft.maxPhotoCount" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="最多照片">
|
||||||
|
<el-input-number v-model="draft.maxPhotoCount" :min="Math.max(1, draft.minPhotoCount)" :max="9" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="免清洁策略">
|
||||||
|
<el-select v-model="draft.exemptPolicy">
|
||||||
|
<el-option label="不允许免清洁" value="DISABLED" />
|
||||||
|
<el-option label="开始前允许" value="BEFORE_START" />
|
||||||
|
<el-option label="活动阶段均允许" value="ANY_ACTIVE" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<div class="wide-field form-actions">
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveTemplate">保存模板</el-button>
|
||||||
|
<el-button @click="resetDraft">新建配置</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="templates" class="data-table" empty-text="暂无清洁模板">
|
||||||
|
<el-table-column prop="scopeKey" label="范围" min-width="130" />
|
||||||
|
<el-table-column prop="name" label="模板" min-width="150" />
|
||||||
|
<el-table-column label="照片" min-width="130">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.photoRequired ? `${row.minPhotoCount}–${row.maxPhotoCount} 张` : `可选,最多 ${row.maxPhotoCount} 张` }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="免清洁" min-width="130">
|
||||||
|
<template #default="{ row }">{{ exemptPolicyLabel(row.exemptPolicy) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="版本" width="88">
|
||||||
|
<template #default="{ row }">v{{ row.version }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="88">
|
||||||
|
<template #default="{ row }">{{ row.status === 'ACTIVE' ? '启用' : '停用' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="88" fixed="right">
|
||||||
|
<template #default="{ row }"><el-button link type="primary" @click="editTemplate(row)">编辑</el-button></template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import {
|
||||||
|
listCleaningTemplates,
|
||||||
|
listManagedRooms,
|
||||||
|
listManagedStores,
|
||||||
|
upsertCleaningTemplate,
|
||||||
|
type ApiSession
|
||||||
|
} from '../api';
|
||||||
|
import type {
|
||||||
|
CleaningExemptPolicy,
|
||||||
|
CleaningTemplate,
|
||||||
|
CleaningTemplateScope,
|
||||||
|
ManagedRoom,
|
||||||
|
ManagedStore
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const templates = ref<CleaningTemplate[]>([]);
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const rooms = ref<ManagedRoom[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const draft = reactive({
|
||||||
|
scopeType: 'TENANT' as CleaningTemplateScope,
|
||||||
|
scopeId: '',
|
||||||
|
roomStoreId: '',
|
||||||
|
name: '默认清洁模板',
|
||||||
|
requirement: '完成桌面、地面与设备周边清洁',
|
||||||
|
photoRequired: true,
|
||||||
|
minPhotoCount: 1,
|
||||||
|
maxPhotoCount: 9,
|
||||||
|
exemptPolicy: 'ANY_ACTIVE' as CleaningExemptPolicy,
|
||||||
|
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED'
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
if (!props.session.token || loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
templates.value = await listCleaningTemplates(props.session);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '清洁模板加载失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
try {
|
||||||
|
stores.value = await listManagedStores(props.session);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '门店列表加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRooms(storeId = draft.roomStoreId) {
|
||||||
|
if (!storeId) {
|
||||||
|
rooms.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rooms.value = await listManagedRooms(props.session, storeId);
|
||||||
|
} catch (error) {
|
||||||
|
rooms.value = [];
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '房间列表加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTemplate() {
|
||||||
|
if (!draft.name || (draft.scopeType !== 'TENANT' && !/^[1-9]\d{0,19}$/.test(draft.scopeId))) {
|
||||||
|
return ElMessage.warning('请填写模板名称和有效作用范围');
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await upsertCleaningTemplate(props.session, {
|
||||||
|
scopeType: draft.scopeType,
|
||||||
|
scopeId: draft.scopeType === 'TENANT' ? undefined : draft.scopeId,
|
||||||
|
name: draft.name,
|
||||||
|
requirement: draft.requirement,
|
||||||
|
photoRequired: draft.photoRequired,
|
||||||
|
minPhotoCount: draft.minPhotoCount,
|
||||||
|
maxPhotoCount: draft.maxPhotoCount,
|
||||||
|
exemptPolicy: draft.exemptPolicy,
|
||||||
|
status: draft.status
|
||||||
|
});
|
||||||
|
ElMessage.success('清洁模板已保存,仅影响后续新任务');
|
||||||
|
await loadTemplates();
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '清洁模板保存失败');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editTemplate(template: CleaningTemplate) {
|
||||||
|
draft.scopeType = template.scopeType;
|
||||||
|
draft.scopeId = template.storeId || template.roomId || '';
|
||||||
|
draft.roomStoreId = template.scopeType === 'ROOM' ? template.storeId || '' : '';
|
||||||
|
if (draft.roomStoreId) await loadRooms();
|
||||||
|
draft.name = template.name;
|
||||||
|
draft.requirement = template.requirement;
|
||||||
|
draft.photoRequired = template.photoRequired;
|
||||||
|
draft.minPhotoCount = template.minPhotoCount;
|
||||||
|
draft.maxPhotoCount = template.maxPhotoCount;
|
||||||
|
draft.exemptPolicy = template.exemptPolicy;
|
||||||
|
draft.status = template.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDraft() {
|
||||||
|
Object.assign(draft, {
|
||||||
|
scopeType: 'TENANT', scopeId: '', roomStoreId: '', name: '默认清洁模板',
|
||||||
|
requirement: '完成桌面、地面与设备周边清洁', photoRequired: true,
|
||||||
|
minPhotoCount: 1, maxPhotoCount: 9, exemptPolicy: 'ANY_ACTIVE', status: 'ACTIVE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScopeChange() { draft.scopeId = ''; draft.roomStoreId = ''; rooms.value = []; }
|
||||||
|
async function handleRoomStoreChange() { draft.scopeId = ''; await loadRooms(); }
|
||||||
|
function normalizePhotoRule() { draft.minPhotoCount = draft.photoRequired ? Math.max(1, draft.minPhotoCount) : 0; }
|
||||||
|
function exemptPolicyLabel(policy: CleaningExemptPolicy) {
|
||||||
|
return { DISABLED: '不允许', BEFORE_START: '开始前', ANY_ACTIVE: '活动阶段' }[policy];
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.session.token, (token) => {
|
||||||
|
if (token) void Promise.all([loadTemplates(), loadStores()]);
|
||||||
|
}, { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rules-panel { display: grid; gap: 18px; }
|
||||||
|
.panel-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||||
|
.panel-heading h3 { margin: 4px 0 6px; color: var(--ink); }
|
||||||
|
.panel-heading p:last-child { margin: 0; color: var(--muted); }
|
||||||
|
.rule-form { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0 14px; padding: 18px; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); }
|
||||||
|
.wide-field { grid-column: 1 / -1; }
|
||||||
|
.form-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||||
|
@media (max-width: 980px) { .rule-form { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 560px) { .panel-heading { align-items: stretch; flex-direction: column; } .rule-form { grid-template-columns: 1fr; padding: 14px; } .wide-field { grid-column: auto; } .form-actions { justify-content: stretch; } .form-actions :deep(.el-button) { flex: 1; } }
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
|||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<el-form class="stats-filter" label-position="top">
|
||||||
|
<el-form-item label="周期">
|
||||||
|
<el-segmented :options="quickOptions" @update:model-value="applyQuickRange" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始">
|
||||||
|
<el-input v-model="filterDraft.from" placeholder="2026-06-01" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束">
|
||||||
|
<el-input v-model="filterDraft.to" placeholder="2026-07-01" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="门店 ID">
|
||||||
|
<el-input v-model="filterDraft.storeId" inputmode="numeric" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="保洁员">
|
||||||
|
<el-select
|
||||||
|
v-model="filterDraft.cleanerUserId"
|
||||||
|
class="filter-select"
|
||||||
|
clearable
|
||||||
|
filterable
|
||||||
|
placeholder="保洁员"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cleaner in cleaners"
|
||||||
|
:key="cleaner.id"
|
||||||
|
:label="cleanerLabel(cleaner)"
|
||||||
|
:value="cleaner.id"
|
||||||
|
>
|
||||||
|
<div class="option-row">
|
||||||
|
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||||
|
<span>{{ cleaner.maskedPhone || '未留手机' }}</span>
|
||||||
|
</div>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :icon="Search" @click="emitFilter">查询</el-button>
|
||||||
|
</el-form>
|
||||||
|
<el-button :icon="Download" @click="exportStatistics">导出</el-button>
|
||||||
|
<el-tooltip content="刷新统计">
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-body" v-loading="loading">
|
||||||
|
<section class="metric-grid compact" aria-label="统计概览">
|
||||||
|
<div class="metric">
|
||||||
|
<span>任务总数</span>
|
||||||
|
<strong>{{ statistics.summary.taskTotal }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>待验收</span>
|
||||||
|
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>待结算</span>
|
||||||
|
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>{{ '\u514d\u6e05\u6d01' }}</span>
|
||||||
|
<strong>{{ statistics.summary.exempted }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>已发放</span>
|
||||||
|
<strong>{{ money(statistics.summary.paidSettlementCents) }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-block cleaner-performance-board">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h3>保洁员绩效排行</h3>
|
||||||
|
<span>任务 / 完成率 / 驳回 / 结算</span>
|
||||||
|
</div>
|
||||||
|
<el-button :icon="Download" :disabled="cleanerPerformanceRows.length === 0" @click="exportCleanerPerformance">
|
||||||
|
导出排行
|
||||||
|
</el-button>
|
||||||
|
</header>
|
||||||
|
<div class="cleaner-performance-grid">
|
||||||
|
<button
|
||||||
|
v-for="row in cleanerPerformanceRows"
|
||||||
|
:key="row.cleanerUserId"
|
||||||
|
type="button"
|
||||||
|
class="cleaner-performance-card"
|
||||||
|
@click="filterByCleaner(row.cleanerUserId)"
|
||||||
|
>
|
||||||
|
<span>第 {{ row.rank }} 名</span>
|
||||||
|
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
|
||||||
|
<em>任务 {{ row.taskCount }} · 完成 {{ row.completedTaskCount }} · 驳回 {{ row.rejectedTaskCount }}</em>
|
||||||
|
<small>完成率 {{ row.completionRate }}% · 待结算 {{ money(row.pendingSettlementCents) }} · 已结算 {{ money(row.settledRewardCents) }}</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="cleanerPerformanceRows.length === 0" description="暂无保洁员绩效数据" :image-size="72" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>任务状态</h3>
|
||||||
|
<span>{{ money(statistics.summary.rewardCents) }}</span>
|
||||||
|
</header>
|
||||||
|
<el-table :data="statistics.byStatus" size="small">
|
||||||
|
<el-table-column prop="status" label="状态" />
|
||||||
|
<el-table-column prop="total" label="数量" width="90" />
|
||||||
|
<el-table-column prop="rewardCents" label="奖励" width="120">
|
||||||
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>结算口径</h3>
|
||||||
|
<span>待发 {{ money(statistics.summary.confirmedSettlementCents) }}</span>
|
||||||
|
</header>
|
||||||
|
<el-table :data="statistics.settlements" size="small">
|
||||||
|
<el-table-column prop="status" label="状态" />
|
||||||
|
<el-table-column prop="total" label="单数" width="90" />
|
||||||
|
<el-table-column prop="rewardCents" label="金额" width="120">
|
||||||
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>每日趋势</h3>
|
||||||
|
<span>任务 / 待验 / 发放</span>
|
||||||
|
</header>
|
||||||
|
<div class="trend-list">
|
||||||
|
<div v-for="row in statistics.trend" :key="row.date" class="trend-row">
|
||||||
|
<div class="trend-date">{{ row.date }}</div>
|
||||||
|
<div class="trend-track" aria-label="每日任务趋势">
|
||||||
|
<span class="trend-bar" :style="{ width: trendWidth(row.taskTotal) }" />
|
||||||
|
</div>
|
||||||
|
<div class="trend-meta">
|
||||||
|
<strong>{{ row.taskTotal }}</strong>
|
||||||
|
<span>待验 {{ row.pendingReview }}</span>
|
||||||
|
<span>完成 {{ row.completed }}</span>
|
||||||
|
<span>驳回 {{ row.rejected }}</span>
|
||||||
|
<span>{{ '\u514d\u6e05\u6d01' }} {{ row.exempted }}</span>
|
||||||
|
<span>奖励 {{ money(row.rewardCents) }}</span>
|
||||||
|
<span>发放 {{ money(row.paidSettlementCents) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-if="statistics.trend.length === 0" description="暂无趋势数据" :image-size="72" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>门店明细</h3>
|
||||||
|
<span>按待验收优先</span>
|
||||||
|
</header>
|
||||||
|
<el-table :data="statistics.byStore" size="small">
|
||||||
|
<el-table-column prop="storeName" label="门店" min-width="150" />
|
||||||
|
<el-table-column prop="taskTotal" label="任务" width="90" />
|
||||||
|
<el-table-column prop="pendingReview" label="待验" width="90" />
|
||||||
|
<el-table-column prop="completed" label="完成" width="90" />
|
||||||
|
<el-table-column prop="rejected" label="驳回" width="90" />
|
||||||
|
<el-table-column prop="exempted" :label="'\u514d\u6e05\u6d01'" width="90" />
|
||||||
|
<el-table-column prop="rewardCents" label="奖励" width="120">
|
||||||
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>成员分账</h3>
|
||||||
|
<span>按待结算金额排序</span>
|
||||||
|
</header>
|
||||||
|
<el-table :data="statistics.members" size="small">
|
||||||
|
<el-table-column prop="cleanerName" label="保洁员" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack">
|
||||||
|
<strong>{{ row.cleanerName || compactText(row.cleanerUserId) }}</strong>
|
||||||
|
<span>ID {{ row.cleanerUserId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="taskCount" label="任务" width="90" />
|
||||||
|
<el-table-column prop="completedTaskCount" label="完成" width="90" />
|
||||||
|
<el-table-column prop="rejectedTaskCount" label="驳回" width="90" />
|
||||||
|
<el-table-column prop="pendingSettlementCents" label="待结算" width="130">
|
||||||
|
<template #default="{ row }">{{ money(row.pendingSettlementCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="settledRewardCents" label="已结算" width="130">
|
||||||
|
<template #default="{ row }">{{ money(row.settledRewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue';
|
||||||
|
import { Download, RefreshCw, Search } from '@lucide/vue';
|
||||||
|
import { compactText, money } from '../format';
|
||||||
|
import type { CleaningStatistics, ManagedUser } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
loading: boolean;
|
||||||
|
statistics: CleaningStatistics;
|
||||||
|
filters: { from: string; to: string; storeId: string; cleanerUserId: string };
|
||||||
|
cleaners: ManagedUser[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
filter: [{ from: string; to: string; storeId: string; cleanerUserId: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const filterDraft = reactive({ ...props.filters });
|
||||||
|
const quickOptions = [
|
||||||
|
{ label: '今日', value: 'today' },
|
||||||
|
{ label: '近7天', value: '7d' },
|
||||||
|
{ label: '本月', value: 'month' }
|
||||||
|
];
|
||||||
|
const maxTrendTotal = computed(() => Math.max(1, ...props.statistics.trend.map((row) => row.taskTotal)));
|
||||||
|
const cleanerPerformanceRows = computed(() => props.statistics.members
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
completionRate: row.taskCount ? Math.round((row.completedTaskCount / row.taskCount) * 100) : 0,
|
||||||
|
settlementTotalCents: row.pendingSettlementCents + row.settledRewardCents
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (
|
||||||
|
b.taskCount - a.taskCount
|
||||||
|
|| b.completedTaskCount - a.completedTaskCount
|
||||||
|
|| a.rejectedTaskCount - b.rejectedTaskCount
|
||||||
|
|| b.settlementTotalCents - a.settlementTotalCents
|
||||||
|
))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((row, index) => ({ ...row, rank: index + 1 })));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.filters,
|
||||||
|
(value) => Object.assign(filterDraft, value),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function emitFilter() {
|
||||||
|
emit('filter', {
|
||||||
|
from: filterDraft.from.trim(),
|
||||||
|
to: filterDraft.to.trim(),
|
||||||
|
storeId: filterDraft.storeId.trim(),
|
||||||
|
cleanerUserId: filterDraft.cleanerUserId.trim()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanerLabel(cleaner: ManagedUser) {
|
||||||
|
return `${cleaner.nickname || compactText(cleaner.id)} / ${cleaner.maskedPhone || '未留手机'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyQuickRange(value: string | number | boolean) {
|
||||||
|
const now = new Date();
|
||||||
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||||
|
let start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
if (value === '7d') {
|
||||||
|
start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6);
|
||||||
|
}
|
||||||
|
if (value === 'month') {
|
||||||
|
start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
}
|
||||||
|
filterDraft.from = formatDate(start);
|
||||||
|
filterDraft.to = formatDate(end);
|
||||||
|
emitFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendWidth(total: number) {
|
||||||
|
return `${Math.max(6, Math.round((total / maxTrendTotal.value) * 100))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByCleaner(cleanerUserId: string) {
|
||||||
|
filterDraft.cleanerUserId = cleanerUserId;
|
||||||
|
emitFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCleanerPerformance() {
|
||||||
|
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
|
||||||
|
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算(分)', '已结算(分)'],
|
||||||
|
...cleanerPerformanceRows.value.map((row) => [
|
||||||
|
String(row.rank),
|
||||||
|
row.cleanerName || '',
|
||||||
|
row.cleanerUserId,
|
||||||
|
String(row.taskCount),
|
||||||
|
String(row.completedTaskCount),
|
||||||
|
String(row.rejectedTaskCount),
|
||||||
|
`${row.completionRate}%`,
|
||||||
|
String(row.pendingSettlementCents),
|
||||||
|
String(row.settledRewardCents)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportStatistics() {
|
||||||
|
downloadCsv(`cleaning-statistics-${Date.now()}.csv`, [
|
||||||
|
['section', 'key', 'value', 'extra1', 'extra2', 'extra3', 'extra4'],
|
||||||
|
['filter', 'from', filterDraft.from || '\u672a\u8bbe\u7f6e', '', '', '', ''],
|
||||||
|
['filter', 'to', filterDraft.to || '\u672a\u8bbe\u7f6e', '', '', '', ''],
|
||||||
|
['filter', 'storeId', filterDraft.storeId || '\u5168\u90e8\u95e8\u5e97', '', '', '', ''],
|
||||||
|
['filter', 'cleanerUserId', filterDraft.cleanerUserId || '\u5168\u90e8\u4fdd\u6d01\u5458', '', '', '', ''],
|
||||||
|
['summary', '\u4efb\u52a1\u603b\u6570', String(props.statistics.summary.taskTotal), '', '', '', ''],
|
||||||
|
['summary', '\u5f85\u9a8c\u6536', String(props.statistics.summary.pendingReview), '', '', '', ''],
|
||||||
|
['summary', '\u8fdb\u884c\u4e2d', String(props.statistics.summary.active), '', '', '', ''],
|
||||||
|
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
|
||||||
|
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
|
||||||
|
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
|
||||||
|
['summary', '\u5956\u52b1\u5408\u8ba1(\u5206)', String(props.statistics.summary.rewardCents), '', '', '', ''],
|
||||||
|
['summary', '\u5f85\u7ed3\u7b97(\u5206)', String(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
|
||||||
|
['summary', '\u5f85\u53d1\u653e(\u5206)', String(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
|
||||||
|
['summary', '\u5df2\u53d1\u653e(\u5206)', String(props.statistics.summary.paidSettlementCents), '', '', '', ''],
|
||||||
|
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1(\u5206)', '', '', ''],
|
||||||
|
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), String(row.rewardCents), '', '', '']),
|
||||||
|
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d(\u5206)', '', '', ''],
|
||||||
|
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), String(row.rewardCents), '', '', '']),
|
||||||
|
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
||||||
|
...props.statistics.trend.map((row) => [
|
||||||
|
'\u6bcf\u65e5\u8d8b\u52bf',
|
||||||
|
row.date,
|
||||||
|
String(row.taskTotal),
|
||||||
|
String(row.pendingReview),
|
||||||
|
String(row.completed),
|
||||||
|
String(row.rejected),
|
||||||
|
String(row.exempted)
|
||||||
|
]),
|
||||||
|
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e(\u5206)', '', '', '', ''],
|
||||||
|
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, String(row.paidSettlementCents), '', '', '', '']),
|
||||||
|
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
|
||||||
|
...props.statistics.byStore.map((row) => [
|
||||||
|
'\u95e8\u5e97\u660e\u7ec6',
|
||||||
|
`${row.storeName || ''}(${row.storeId})`,
|
||||||
|
String(row.taskTotal),
|
||||||
|
String(row.pendingReview),
|
||||||
|
String(row.completed),
|
||||||
|
String(row.rejected),
|
||||||
|
String(row.exempted)
|
||||||
|
]),
|
||||||
|
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1(\u5206)', '', '', '', ''],
|
||||||
|
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, String(row.rewardCents), '', '', '', '']),
|
||||||
|
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97(\u5206)', '\u5df2\u7ed3\u7b97(\u5206)'],
|
||||||
|
...props.statistics.members.map((row) => [
|
||||||
|
'\u6210\u5458\u5206\u8d26',
|
||||||
|
`${row.cleanerName || ''}(${row.cleanerUserId})`,
|
||||||
|
String(row.taskCount),
|
||||||
|
String(row.completedTaskCount),
|
||||||
|
String(row.rejectedTaskCount),
|
||||||
|
String(row.pendingSettlementCents),
|
||||||
|
String(row.settledRewardCents)
|
||||||
|
]),
|
||||||
|
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1(\u5206)'],
|
||||||
|
...cleanerPerformanceRows.value.map((row) => [
|
||||||
|
'\u7ee9\u6548\u6392\u884c',
|
||||||
|
String(row.rank),
|
||||||
|
`${row.cleanerName || ''}(${row.cleanerUserId})`,
|
||||||
|
String(row.taskCount),
|
||||||
|
`${row.completionRate}%`,
|
||||||
|
String(row.rejectedTaskCount),
|
||||||
|
String(row.settlementTotalCents)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
function downloadCsv(filename: string, rows: string[][]) {
|
||||||
|
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||||
|
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string) {
|
||||||
|
return `"${String(value ?? '').replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,903 @@
|
|||||||
|
<template>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-toolbar">
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-segmented
|
||||||
|
:model-value="status"
|
||||||
|
:options="statusOptions"
|
||||||
|
@update:model-value="$emit('status-change', $event as TaskStatus | '')"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="filterDraft.storeId"
|
||||||
|
class="narrow-input"
|
||||||
|
placeholder="门店 ID"
|
||||||
|
clearable
|
||||||
|
@keyup.enter="submitFilters"
|
||||||
|
@clear="submitFilters"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="filterDraft.cleanerUserId"
|
||||||
|
class="filter-select"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
placeholder="保洁员"
|
||||||
|
@change="submitFilters"
|
||||||
|
@clear="submitFilters"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cleaner in cleaners"
|
||||||
|
:key="cleaner.id"
|
||||||
|
:label="cleanerLabel(cleaner)"
|
||||||
|
:value="cleaner.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button :icon="Download" :disabled="items.length === 0" @click="exportTasks">
|
||||||
|
导出
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="success"
|
||||||
|
:icon="BadgeCheck"
|
||||||
|
:disabled="selectedSubmittedCount === 0"
|
||||||
|
@click="submitBatchComplete"
|
||||||
|
>
|
||||||
|
批量验收
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
:icon="Undo2"
|
||||||
|
:disabled="selectedSubmittedCount === 0"
|
||||||
|
@click="openBatchReject"
|
||||||
|
>
|
||||||
|
批量驳回
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
:icon="Ban"
|
||||||
|
:disabled="selectedExemptableCount === 0"
|
||||||
|
@click="submitBatchExempt"
|
||||||
|
>
|
||||||
|
{{ '\u6279\u91cf\u514d\u6e05\u6d01' }}
|
||||||
|
</el-button>
|
||||||
|
<el-popover trigger="click" width="280">
|
||||||
|
<template #reference>
|
||||||
|
<el-button :icon="RotateCcw">回收</el-button>
|
||||||
|
</template>
|
||||||
|
<el-form label-position="top" class="compact-form">
|
||||||
|
<el-form-item label="分钟">
|
||||||
|
<el-input-number v-model="reclaimForm.olderThanMinutes" :min="5" :max="1440" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="数量">
|
||||||
|
<el-input-number v-model="reclaimForm.limit" :min="1" :max="100" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :icon="Check" @click="$emit('reclaim', reclaimForm)">
|
||||||
|
执行
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-popover>
|
||||||
|
<el-tooltip content="刷新任务">
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
:data="items"
|
||||||
|
:loading="loading"
|
||||||
|
class="data-table"
|
||||||
|
row-key="id"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="44" :selectable="canSelectTask" />
|
||||||
|
<el-table-column prop="taskNo" label="任务" min-width="180" fixed>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack">
|
||||||
|
<strong>{{ row.taskNo }}</strong>
|
||||||
|
<span>{{ row.storeName }} · {{ row.roomName || row.roomNo }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="status" label="状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="taskTag(row.status)" effect="light">{{ row.status }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
|
||||||
|
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="memberCount" label="成员" width="90" />
|
||||||
|
<el-table-column prop="rewardCents" label="奖励" width="110">
|
||||||
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="submittedAt" label="提交" width="130">
|
||||||
|
<template #default="{ row }">{{ shortDate(row.submittedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="照片" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-badge :value="row.photoUrls?.length || 0" :hidden="!row.photoUrls?.length">
|
||||||
|
<Image :size="18" />
|
||||||
|
</el-badge>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="340" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
|
||||||
|
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
|
||||||
|
<el-button size="small" :icon="Users" @click="openMembers(row)">成员</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
:icon="BadgeCheck"
|
||||||
|
:disabled="row.status !== 'SUBMITTED'"
|
||||||
|
@click="$emit('complete', { taskId: row.id, note: '后台验收通过' })"
|
||||||
|
>
|
||||||
|
验收
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
:icon="Undo2"
|
||||||
|
:disabled="row.status !== 'SUBMITTED'"
|
||||||
|
@click="openReject(row)"
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="warning"
|
||||||
|
:icon="Ban"
|
||||||
|
:disabled="!canExempt(row)"
|
||||||
|
@click="$emit('exempt', { taskId: row.id, note: '\u540e\u53f0\u6807\u8bb0\u514d\u6e05\u6d01' })"
|
||||||
|
>
|
||||||
|
{{ '\u514d\u6e05\u6d01' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
layout="prev, pager, next, total"
|
||||||
|
:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
@current-change="$emit('page-change', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="assignDialog.open" title="指派保洁员" width="420px">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="保洁员">
|
||||||
|
<el-select
|
||||||
|
v-model="assignDialog.cleanerUserId"
|
||||||
|
class="cleaner-select"
|
||||||
|
filterable
|
||||||
|
placeholder="选择保洁员"
|
||||||
|
:disabled="!cleaners.length"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cleaner in cleaners"
|
||||||
|
:key="cleaner.id"
|
||||||
|
:label="cleanerLabel(cleaner)"
|
||||||
|
:value="cleaner.id"
|
||||||
|
>
|
||||||
|
<div class="option-row">
|
||||||
|
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||||
|
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
||||||
|
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="assignDialog.note" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="assignDialog.open = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="submitAssign">确认</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="rejectDialog.open" title="驳回任务" width="420px">
|
||||||
|
<el-input v-model="rejectDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit />
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="rejectDialog.open = false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="submitReject">驳回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchRejectDialog.open" title="批量驳回待验收任务" width="460px">
|
||||||
|
<div class="dialog-stack">
|
||||||
|
<el-alert
|
||||||
|
:title="`将驳回 ${selectedSubmittedCount} 个已选择的待验收任务`"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="batchRejectDialog.reason"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
maxlength="512"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="请输入统一驳回原因"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchRejectDialog.open = false">取消</el-button>
|
||||||
|
<el-button type="danger" @click="submitBatchReject">批量驳回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="memberDialog.open" title="协作者管理" width="640px">
|
||||||
|
<div class="dialog-stack">
|
||||||
|
<div class="detail-header">
|
||||||
|
<strong>{{ memberDialog.taskNo }}</strong>
|
||||||
|
<span>任务奖励 {{ money(memberDialog.taskRewardCents) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button size="small" :icon="Download" :disabled="!memberDialog.members.length" @click="exportTaskMembers">
|
||||||
|
导出成员
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="memberDialog.members" size="small" v-loading="memberDialog.loading">
|
||||||
|
<el-table-column prop="nickname" label="成员" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack">
|
||||||
|
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
|
||||||
|
<span>ID {{ row.userId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="memberRole" label="角色" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
|
||||||
|
{{ row.memberRole }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="rewardCents" label="分账" width="110">
|
||||||
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="settledAt" label="结算" width="130">
|
||||||
|
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
:icon="UserMinus"
|
||||||
|
:disabled="row.memberRole === 'LEAD' || !!row.settledAt"
|
||||||
|
@click="removeMember(row.userId)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-form label-position="top" class="member-form">
|
||||||
|
<el-form-item label="协作者">
|
||||||
|
<el-select
|
||||||
|
v-model="memberDialog.cleanerUserId"
|
||||||
|
class="cleaner-select"
|
||||||
|
filterable
|
||||||
|
placeholder="选择协作者"
|
||||||
|
:disabled="!cleaners.length"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="cleaner in cleaners"
|
||||||
|
:key="cleaner.id"
|
||||||
|
:label="cleanerLabel(cleaner)"
|
||||||
|
:value="cleaner.id"
|
||||||
|
>
|
||||||
|
<div class="option-row">
|
||||||
|
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
|
||||||
|
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
|
||||||
|
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
|
||||||
|
</el-tag>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分账金额">
|
||||||
|
<el-input-number v-model="memberDialog.rewardCents" :min="0" :max="1000000" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注">
|
||||||
|
<el-input v-model="memberDialog.note" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :icon="UserPlus" @click="submitMember">添加/更新</el-button>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="memberDialog.open = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="detailDialog.open" title="保洁任务详情" width="880px">
|
||||||
|
<div v-if="detailDialog.task" class="dialog-stack">
|
||||||
|
<div class="detail-grid">
|
||||||
|
<span>任务号<strong>{{ detailDialog.task.taskNo }}</strong></span>
|
||||||
|
<span>状态<strong>{{ detailDialog.task.status }}</strong></span>
|
||||||
|
<span>奖励<strong>{{ money(detailDialog.task.rewardCents) }}</strong></span>
|
||||||
|
<span>保洁员<strong>{{ compactText(detailDialog.task.cleanerUserId) }}</strong></span>
|
||||||
|
</div>
|
||||||
|
<el-descriptions :column="2" size="small" border>
|
||||||
|
<el-descriptions-item label="门店房间">
|
||||||
|
{{ detailDialog.task.storeName }} · {{ detailDialog.task.roomName || detailDialog.task.roomNo }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="订单">
|
||||||
|
{{ compactText(detailDialog.task.orderNo) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="要求" :span="2">
|
||||||
|
{{ compactText(detailDialog.task.requirement) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="照片规则">
|
||||||
|
{{ detailDialog.task.photoRequired ? `${detailDialog.task.minPhotoCount}–${detailDialog.task.maxPhotoCount} 张` : `可选,最多 ${detailDialog.task.maxPhotoCount} 张` }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="任务快照">
|
||||||
|
模板 v{{ detailDialog.task.cleaningTemplateVersion }} · 照片修订 {{ detailDialog.task.photoRevision }} · 补做 {{ detailDialog.task.reworkCount }} 次
|
||||||
|
</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="驳回原因" :span="2">
|
||||||
|
{{ compactText(detailDialog.task.rejectReason) }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="timeline-grid">
|
||||||
|
<span>领取<strong>{{ shortDate(detailDialog.task.claimedAt) }}</strong></span>
|
||||||
|
<span>开始<strong>{{ shortDate(detailDialog.task.startedAt) }}</strong></span>
|
||||||
|
<span>提交<strong>{{ shortDate(detailDialog.task.submittedAt) }}</strong></span>
|
||||||
|
<span>完成<strong>{{ shortDate(detailDialog.task.completedAt) }}</strong></span>
|
||||||
|
</div>
|
||||||
|
<section class="stat-block" v-loading="detailDialog.loadingSubmissions">
|
||||||
|
<header>
|
||||||
|
<h3>验收照片版本</h3>
|
||||||
|
<span>{{ detailDialog.submissions.length }} 次提交</span>
|
||||||
|
</header>
|
||||||
|
<div v-if="detailDialog.submissions.length" class="submission-list">
|
||||||
|
<article
|
||||||
|
v-for="submission in detailDialog.submissions"
|
||||||
|
:key="submission.id"
|
||||||
|
class="submission-card"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<strong>第 {{ submission.revision }} 版</strong>
|
||||||
|
<el-tag
|
||||||
|
:type="submission.status === 'ACCEPTED' ? 'success' : submission.status === 'REJECTED' ? 'danger' : 'warning'"
|
||||||
|
effect="light"
|
||||||
|
>
|
||||||
|
{{ submission.status }}
|
||||||
|
</el-tag>
|
||||||
|
<span>{{ shortDate(submission.submittedAt) }}</span>
|
||||||
|
</header>
|
||||||
|
<p v-if="submission.rejectReason" class="submission-reason">
|
||||||
|
驳回原因:{{ submission.rejectReason }}
|
||||||
|
</p>
|
||||||
|
<p v-if="submission.note" class="submission-note">提交备注:{{ submission.note }}</p>
|
||||||
|
<div v-if="submission.photoUrls.length" class="photo-grid">
|
||||||
|
<el-image
|
||||||
|
v-for="url in submission.photoUrls"
|
||||||
|
:key="`${submission.id}-${url}`"
|
||||||
|
:src="url"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="submission.photoUrls"
|
||||||
|
preview-teleported
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="本版无照片" :image-size="48" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无提交版本" :image-size="64" />
|
||||||
|
</section>
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>操作流水</h3>
|
||||||
|
<span>最近 {{ detailDialog.events.length }} 条</span>
|
||||||
|
</header>
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button size="small" :icon="Download" :disabled="!detailDialog.events.length" @click="exportTaskEvents">
|
||||||
|
导出流水
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="detailDialog.events" size="small" v-loading="detailDialog.loadingEvents">
|
||||||
|
<el-table-column prop="action" label="动作" width="130" />
|
||||||
|
<el-table-column label="状态" width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ compactText(row.fromStatus) }} → {{ row.toStatus }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="actorId" label="操作人" width="100">
|
||||||
|
<template #default="{ row }">{{ compactText(row.actorId) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="note" label="备注" min-width="160">
|
||||||
|
<template #default="{ row }">{{ compactText(row.note) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="时间" width="130">
|
||||||
|
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
<section class="stat-block">
|
||||||
|
<header>
|
||||||
|
<h3>协作分账</h3>
|
||||||
|
<span>{{ detailDialog.members.length }} 人</span>
|
||||||
|
</header>
|
||||||
|
<el-table :data="detailDialog.members" size="small" v-loading="detailDialog.loadingMembers">
|
||||||
|
<el-table-column prop="nickname" label="成员" min-width="140">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack">
|
||||||
|
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
|
||||||
|
<span>ID {{ row.userId }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="memberRole" label="角色" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
|
||||||
|
{{ row.memberRole }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="rewardCents" label="分账" width="110">
|
||||||
|
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="settledAt" label="结算" width="130">
|
||||||
|
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
<div class="photo-grid" v-if="detailDialog.task.photoUrls.length">
|
||||||
|
<el-image
|
||||||
|
v-for="url in detailDialog.task.photoUrls"
|
||||||
|
:key="url"
|
||||||
|
:src="url"
|
||||||
|
fit="cover"
|
||||||
|
:preview-src-list="detailDialog.task.photoUrls"
|
||||||
|
preview-teleported
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无保洁照片" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button :icon="Download" :disabled="!detailDialog.task" @click="exportTaskDetail">
|
||||||
|
导出详情
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="detailDialog.open = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
BadgeCheck,
|
||||||
|
Ban,
|
||||||
|
Check,
|
||||||
|
Download,
|
||||||
|
Image,
|
||||||
|
ListChecks,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Search,
|
||||||
|
Undo2,
|
||||||
|
UserMinus,
|
||||||
|
UserPlus,
|
||||||
|
Users
|
||||||
|
} from '@lucide/vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import {
|
||||||
|
addCleaningTaskMember,
|
||||||
|
listCleaningTaskEvents,
|
||||||
|
listCleaningTaskMembers,
|
||||||
|
listCleaningTaskSubmissions,
|
||||||
|
removeCleaningTaskMember
|
||||||
|
} from '../api';
|
||||||
|
import { compactText, money, shortDate } from '../format';
|
||||||
|
import type {
|
||||||
|
CleaningTask,
|
||||||
|
CleaningTaskEvent,
|
||||||
|
CleaningTaskMember,
|
||||||
|
CleaningTaskSubmission,
|
||||||
|
ManagedUser,
|
||||||
|
PageResult,
|
||||||
|
TaskStatus
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
session: { token: string };
|
||||||
|
loading: boolean;
|
||||||
|
items: PageResult<CleaningTask>['items'];
|
||||||
|
cleaners: ManagedUser[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status: TaskStatus | '';
|
||||||
|
filters: { storeId: string; cleanerUserId: string };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: [];
|
||||||
|
'status-change': [TaskStatus | ''];
|
||||||
|
filter: [{ storeId: string; cleanerUserId: string }];
|
||||||
|
'page-change': [number];
|
||||||
|
assign: [{ taskId: string; cleanerUserId: string; note?: string }];
|
||||||
|
complete: [{ taskId: string; note?: string }];
|
||||||
|
'complete-many': [{ taskIds: string[]; note?: string }];
|
||||||
|
reject: [{ taskId: string; reason: string }];
|
||||||
|
'reject-many': [{ taskIds: string[]; reason: string }];
|
||||||
|
exempt: [{ taskId: string; note?: string }];
|
||||||
|
'exempt-many': [{ taskIds: string[]; note?: string }];
|
||||||
|
reclaim: [{ olderThanMinutes: number; limit: number }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '待抢', value: 'WAITING' },
|
||||||
|
{ label: '已抢', value: 'CLAIMED' },
|
||||||
|
{ label: '进行', value: 'STARTED' },
|
||||||
|
{ label: '待验', value: 'SUBMITTED' },
|
||||||
|
{ label: '完成', value: 'COMPLETED' },
|
||||||
|
{ label: '\u9a73\u56de', value: 'REJECTED' },
|
||||||
|
{ label: '\u514d\u6e05\u6d01', value: 'EXEMPT' }
|
||||||
|
];
|
||||||
|
const filterDraft = reactive({ storeId: props.filters.storeId, cleanerUserId: props.filters.cleanerUserId });
|
||||||
|
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
|
||||||
|
const selectedTasks = ref<CleaningTask[]>([]);
|
||||||
|
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
|
||||||
|
const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
|
||||||
|
const batchRejectDialog = reactive({ open: false, reason: '' });
|
||||||
|
const detailDialog = reactive<{
|
||||||
|
open: boolean;
|
||||||
|
loadingEvents: boolean;
|
||||||
|
loadingMembers: boolean;
|
||||||
|
loadingSubmissions: boolean;
|
||||||
|
task: CleaningTask | null;
|
||||||
|
events: CleaningTaskEvent[];
|
||||||
|
members: CleaningTaskMember[];
|
||||||
|
submissions: CleaningTaskSubmission[];
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
loadingEvents: false,
|
||||||
|
loadingMembers: false,
|
||||||
|
loadingSubmissions: false,
|
||||||
|
task: null,
|
||||||
|
events: [],
|
||||||
|
members: [],
|
||||||
|
submissions: []
|
||||||
|
});
|
||||||
|
const memberDialog = reactive({
|
||||||
|
open: false,
|
||||||
|
loading: false,
|
||||||
|
taskId: '',
|
||||||
|
taskNo: '',
|
||||||
|
taskRewardCents: 0,
|
||||||
|
cleanerUserId: '',
|
||||||
|
rewardCents: 0,
|
||||||
|
note: '',
|
||||||
|
members: [] as CleaningTaskMember[]
|
||||||
|
});
|
||||||
|
const selectedSubmittedTasks = computed(() => selectedTasks.value
|
||||||
|
.filter((task) => task.status === 'SUBMITTED'));
|
||||||
|
const selectedSubmittedCount = computed(() => selectedSubmittedTasks.value.length);
|
||||||
|
const selectedExemptableTasks = computed(() => selectedTasks.value
|
||||||
|
.filter((task) => canExempt(task)));
|
||||||
|
const selectedExemptableCount = computed(() => selectedExemptableTasks.value.length);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.filters,
|
||||||
|
(value) => Object.assign(filterDraft, value),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
function taskTag(status: TaskStatus) {
|
||||||
|
if (status === 'SUBMITTED') return 'warning';
|
||||||
|
if (status === 'COMPLETED' || status === 'SETTLED') return 'success';
|
||||||
|
if (status === 'REJECTED') return 'danger';
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSelectTask(row: CleaningTask) {
|
||||||
|
return row.status === 'SUBMITTED' || canExempt(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canExempt(task: CleaningTask) {
|
||||||
|
if (task.exemptPolicy === 'DISABLED') return false;
|
||||||
|
if (task.exemptPolicy === 'BEFORE_START') return ['WAITING', 'CLAIMED'].includes(task.status);
|
||||||
|
return ['WAITING', 'CLAIMED', 'STARTED', 'SUBMITTED', 'REJECTED'].includes(task.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectionChange(rows: CleaningTask[]) {
|
||||||
|
selectedTasks.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanerLabel(cleaner: ManagedUser) {
|
||||||
|
const name = cleaner.nickname || `ID ${cleaner.id}`;
|
||||||
|
const phone = cleaner.maskedPhone ? ` · ${cleaner.maskedPhone}` : '';
|
||||||
|
const stores = cleaner.storeIds.length ? ` · 门店 ${cleaner.storeIds.join('/')}` : '';
|
||||||
|
return `${name}${phone}${stores}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitFilters() {
|
||||||
|
emit('filter', {
|
||||||
|
storeId: filterDraft.storeId.trim(),
|
||||||
|
cleanerUserId: filterDraft.cleanerUserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: CleaningTask) {
|
||||||
|
detailDialog.open = true;
|
||||||
|
detailDialog.task = row;
|
||||||
|
detailDialog.events = [];
|
||||||
|
detailDialog.members = [];
|
||||||
|
detailDialog.submissions = [];
|
||||||
|
detailDialog.loadingEvents = true;
|
||||||
|
detailDialog.loadingMembers = true;
|
||||||
|
detailDialog.loadingSubmissions = true;
|
||||||
|
try {
|
||||||
|
const [events, members, submissions] = await Promise.all([
|
||||||
|
listCleaningTaskEvents(props.session, row.id),
|
||||||
|
listCleaningTaskMembers(props.session, row.id),
|
||||||
|
listCleaningTaskSubmissions(props.session, row.id)
|
||||||
|
]);
|
||||||
|
detailDialog.events = events;
|
||||||
|
detailDialog.members = members;
|
||||||
|
detailDialog.submissions = submissions;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '任务详情加载失败');
|
||||||
|
} finally {
|
||||||
|
detailDialog.loadingEvents = false;
|
||||||
|
detailDialog.loadingMembers = false;
|
||||||
|
detailDialog.loadingSubmissions = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssign(row: CleaningTask) {
|
||||||
|
assignDialog.open = true;
|
||||||
|
assignDialog.taskId = row.id;
|
||||||
|
assignDialog.cleanerUserId = row.cleanerUserId || '';
|
||||||
|
assignDialog.note = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitAssign() {
|
||||||
|
if (!assignDialog.cleanerUserId) {
|
||||||
|
ElMessage.warning('请选择保洁员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('assign', {
|
||||||
|
taskId: assignDialog.taskId,
|
||||||
|
cleanerUserId: assignDialog.cleanerUserId,
|
||||||
|
note: assignDialog.note
|
||||||
|
});
|
||||||
|
assignDialog.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReject(row: CleaningTask) {
|
||||||
|
rejectDialog.open = true;
|
||||||
|
rejectDialog.taskId = row.id;
|
||||||
|
rejectDialog.reason = row.rejectReason || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitReject() {
|
||||||
|
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
|
||||||
|
rejectDialog.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBatchComplete() {
|
||||||
|
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
|
||||||
|
if (!taskIds.length) {
|
||||||
|
ElMessage.warning('请选择待验收任务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('complete-many', { taskIds, note: '后台批量验收通过' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchReject() {
|
||||||
|
if (!selectedSubmittedCount.value) {
|
||||||
|
ElMessage.warning('请选择待验收任务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
batchRejectDialog.open = true;
|
||||||
|
batchRejectDialog.reason = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBatchReject() {
|
||||||
|
const reason = batchRejectDialog.reason.trim();
|
||||||
|
if (!reason) {
|
||||||
|
ElMessage.warning('请输入驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const taskIds = selectedSubmittedTasks.value.map((task) => task.id);
|
||||||
|
emit('reject-many', { taskIds, reason });
|
||||||
|
batchRejectDialog.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBatchExempt() {
|
||||||
|
const taskIds = selectedExemptableTasks.value.map((task) => task.id);
|
||||||
|
if (!taskIds.length) {
|
||||||
|
ElMessage.warning('\u8bf7\u9009\u62e9\u53ef\u514d\u6e05\u6d01\u4efb\u52a1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('exempt-many', {
|
||||||
|
taskIds,
|
||||||
|
note: '\u540e\u53f0\u6279\u91cf\u6807\u8bb0\u514d\u6e05\u6d01'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
memberDialog.loading = true;
|
||||||
|
try {
|
||||||
|
memberDialog.members = await listCleaningTaskMembers(props.session, memberDialog.taskId);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '成员加载失败');
|
||||||
|
} finally {
|
||||||
|
memberDialog.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMembers(row: CleaningTask) {
|
||||||
|
memberDialog.open = true;
|
||||||
|
memberDialog.taskId = row.id;
|
||||||
|
memberDialog.taskNo = row.taskNo;
|
||||||
|
memberDialog.taskRewardCents = row.rewardCents;
|
||||||
|
memberDialog.cleanerUserId = '';
|
||||||
|
memberDialog.rewardCents = 0;
|
||||||
|
memberDialog.note = '';
|
||||||
|
await loadMembers();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMember() {
|
||||||
|
if (!memberDialog.cleanerUserId) {
|
||||||
|
ElMessage.warning('请选择协作者');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
memberDialog.members = await addCleaningTaskMember(props.session, memberDialog.taskId, {
|
||||||
|
cleanerUserId: memberDialog.cleanerUserId,
|
||||||
|
rewardCents: memberDialog.rewardCents,
|
||||||
|
note: memberDialog.note
|
||||||
|
});
|
||||||
|
memberDialog.cleanerUserId = '';
|
||||||
|
memberDialog.rewardCents = 0;
|
||||||
|
memberDialog.note = '';
|
||||||
|
ElMessage.success('协作者已更新');
|
||||||
|
emit('refresh');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '协作者更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMember(cleanerUserId: string) {
|
||||||
|
try {
|
||||||
|
memberDialog.members = await removeCleaningTaskMember(
|
||||||
|
props.session,
|
||||||
|
memberDialog.taskId,
|
||||||
|
cleanerUserId,
|
||||||
|
'后台移除协作者'
|
||||||
|
);
|
||||||
|
ElMessage.success('协作者已移除');
|
||||||
|
emit('refresh');
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '协作者移除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTasks() {
|
||||||
|
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
|
||||||
|
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额(分)', '照片数', '提交时间', '完成时间', '驳回原因'],
|
||||||
|
...props.items.map((item) => [
|
||||||
|
item.taskNo,
|
||||||
|
item.status,
|
||||||
|
item.storeName,
|
||||||
|
item.roomName || item.roomNo,
|
||||||
|
item.orderNo || '',
|
||||||
|
item.cleanerUserId || '',
|
||||||
|
String(item.memberCount || 0),
|
||||||
|
String(item.rewardCents),
|
||||||
|
String(item.photoUrls?.length || 0),
|
||||||
|
shortDate(item.submittedAt),
|
||||||
|
shortDate(item.completedAt),
|
||||||
|
item.rejectReason || ''
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTaskDetail() {
|
||||||
|
const task = detailDialog.task;
|
||||||
|
if (!task) return;
|
||||||
|
downloadCsv(`cleaning-task-detail-${task.taskNo}-${Date.now()}.csv`, [
|
||||||
|
['类别', '字段', '值'],
|
||||||
|
['task', '任务号', task.taskNo],
|
||||||
|
['task', '状态', task.status],
|
||||||
|
['task', '门店', task.storeName],
|
||||||
|
['task', '房间', task.roomName || task.roomNo],
|
||||||
|
['task', '订单号', task.orderNo || ''],
|
||||||
|
['task', '保洁员ID', task.cleanerUserId || ''],
|
||||||
|
['task', '奖励金额(分)', String(task.rewardCents)],
|
||||||
|
['task', '照片数', String(task.photoUrls?.length || 0)],
|
||||||
|
['task', '领取时间', shortDate(task.claimedAt)],
|
||||||
|
['task', '开始时间', shortDate(task.startedAt)],
|
||||||
|
['task', '提交时间', shortDate(task.submittedAt)],
|
||||||
|
['task', '完成时间', shortDate(task.completedAt)],
|
||||||
|
['task', '保洁要求', task.requirement || ''],
|
||||||
|
['task', '驳回原因', task.rejectReason || ''],
|
||||||
|
...detailDialog.members.map((member) => [
|
||||||
|
'member',
|
||||||
|
`${member.memberRole}:${member.userId}`,
|
||||||
|
`${member.nickname || ''} ${member.rewardCents}分 ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
|
||||||
|
]),
|
||||||
|
...detailDialog.events.map((event) => [
|
||||||
|
'event',
|
||||||
|
`${event.action}:${shortDate(event.createdAt)}`,
|
||||||
|
`${compactText(event.fromStatus)} -> ${event.toStatus} ${compactText(event.actorId)} ${compactText(event.note)}`
|
||||||
|
]),
|
||||||
|
...detailDialog.submissions.flatMap((submission) => [
|
||||||
|
[
|
||||||
|
'submission',
|
||||||
|
`revision_${submission.revision}`,
|
||||||
|
`${submission.status} ${submission.rejectReason || submission.note || ''}`
|
||||||
|
],
|
||||||
|
...submission.photoUrls.map((url, index) => [
|
||||||
|
'photo',
|
||||||
|
`revision_${submission.revision}_photo_${index + 1}`,
|
||||||
|
url
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTaskEvents() {
|
||||||
|
const task = detailDialog.task;
|
||||||
|
if (!task) return;
|
||||||
|
downloadCsv(`cleaning-task-events-${task.taskNo}-${Date.now()}.csv`, [
|
||||||
|
['任务号', '动作', '原状态', '新状态', '操作人', 'TraceId', '备注', '时间'],
|
||||||
|
...detailDialog.events.map((event) => [
|
||||||
|
task.taskNo,
|
||||||
|
event.action,
|
||||||
|
event.fromStatus || '',
|
||||||
|
event.toStatus,
|
||||||
|
event.actorId,
|
||||||
|
event.traceId,
|
||||||
|
event.note,
|
||||||
|
shortDate(event.createdAt)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTaskMembers() {
|
||||||
|
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
|
||||||
|
['任务号', '成员ID', '昵称', '角色', '分账金额(分)', '结算时间'],
|
||||||
|
...memberDialog.members.map((member) => [
|
||||||
|
memberDialog.taskNo,
|
||||||
|
member.userId,
|
||||||
|
member.nickname || '',
|
||||||
|
member.memberRole,
|
||||||
|
String(member.rewardCents),
|
||||||
|
shortDate(member.settledAt)
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(filename: string, rows: string[][]) {
|
||||||
|
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||||
|
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string) {
|
||||||
|
const normalized = value.replace(/\r?\n/g, ' ');
|
||||||
|
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
<template>
|
||||||
|
<section class="cleaning-workspace">
|
||||||
|
<section class="metric-grid" aria-label="保洁概览">
|
||||||
|
<div class="metric">
|
||||||
|
<span>待验收</span>
|
||||||
|
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>已完成</span>
|
||||||
|
<strong>{{ statistics.summary.completed }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>待发放</span>
|
||||||
|
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span>待结算</span>
|
||||||
|
<strong>{{ money(statistics.summary.pendingSettlementCents) }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="action-board" aria-label="运营待处理">
|
||||||
|
<button class="action-tile" type="button" @click="jumpToTaskStatus('SUBMITTED')">
|
||||||
|
<ClipboardCheck :size="20" />
|
||||||
|
<span>待验收任务</span>
|
||||||
|
<strong>{{ statistics.summary.pendingReview }}</strong>
|
||||||
|
</button>
|
||||||
|
<button class="action-tile" type="button" @click="jumpToTaskStatus('REJECTED')">
|
||||||
|
<RotateCcw :size="20" />
|
||||||
|
<span>驳回补做</span>
|
||||||
|
<strong>{{ statistics.summary.rejected }}</strong>
|
||||||
|
</button>
|
||||||
|
<button class="action-tile" type="button" @click="jumpToSettlementStatus('DRAFT')">
|
||||||
|
<ListTodo :size="20" />
|
||||||
|
<span>待确认结算</span>
|
||||||
|
<strong>{{ draftSettlementTotal }}</strong>
|
||||||
|
</button>
|
||||||
|
<button class="action-tile" type="button" @click="jumpToSettlementStatus('CONFIRMED')">
|
||||||
|
<Send :size="20" />
|
||||||
|
<span>待发放金额</span>
|
||||||
|
<strong>{{ money(statistics.summary.confirmedSettlementCents) }}</strong>
|
||||||
|
</button>
|
||||||
|
<button class="action-tile" type="button" @click="exportOperationalQueue">
|
||||||
|
<Download :size="20" />
|
||||||
|
<span>导出待处理</span>
|
||||||
|
<strong>{{ operationalQueueTotal }}</strong>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="lastError"
|
||||||
|
class="alert-line"
|
||||||
|
:title="lastError"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="lastError = ''"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-if="lastMessage"
|
||||||
|
class="alert-line"
|
||||||
|
:title="lastMessage"
|
||||||
|
type="success"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="lastMessage = ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="work-tabs">
|
||||||
|
<el-tab-pane label="任务" name="tasks">
|
||||||
|
<CleaningTasksPanel
|
||||||
|
:session="session"
|
||||||
|
:loading="loading.tasks"
|
||||||
|
:items="tasks.items"
|
||||||
|
:cleaners="cleaners.items"
|
||||||
|
:total="tasks.total"
|
||||||
|
:page="tasks.page"
|
||||||
|
:page-size="tasks.pageSize"
|
||||||
|
:status="taskStatus"
|
||||||
|
:filters="taskFilters"
|
||||||
|
@refresh="loadTasks"
|
||||||
|
@status-change="changeTaskStatus"
|
||||||
|
@filter="changeTaskFilters"
|
||||||
|
@page-change="changeTaskPage"
|
||||||
|
@assign="handleAssign"
|
||||||
|
@complete="handleComplete"
|
||||||
|
@complete-many="handleCompleteMany"
|
||||||
|
@reject="handleReject"
|
||||||
|
@reject-many="handleRejectMany"
|
||||||
|
@exempt="handleExempt"
|
||||||
|
@exempt-many="handleExemptMany"
|
||||||
|
@reclaim="handleReclaim"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="结算" name="settlements">
|
||||||
|
<CleaningSettlementsPanel
|
||||||
|
:session="session"
|
||||||
|
:loading="loading.settlements"
|
||||||
|
:items="settlements.items"
|
||||||
|
:cleaners="cleaners.items"
|
||||||
|
:total="settlements.total"
|
||||||
|
:page="settlements.page"
|
||||||
|
:page-size="settlements.pageSize"
|
||||||
|
:status="settlementStatus"
|
||||||
|
:filters="settlementFilters"
|
||||||
|
@refresh="loadSettlements"
|
||||||
|
@status-change="changeSettlementStatus"
|
||||||
|
@filter="changeSettlementFilters"
|
||||||
|
@page-change="changeSettlementPage"
|
||||||
|
@generate="handleGenerateSettlement"
|
||||||
|
@confirm="handleConfirmSettlement"
|
||||||
|
@transfer="handleTransfer"
|
||||||
|
@sync-transfer="handleSyncTransfer"
|
||||||
|
@sync-transfer-many="handleSyncTransferMany"
|
||||||
|
@failure="handleFailure"
|
||||||
|
@cancel="handleCancelSettlement"
|
||||||
|
@paid="handleManualPaid"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="统计" name="statistics">
|
||||||
|
<CleaningStatisticsPanel
|
||||||
|
:loading="loading.statistics"
|
||||||
|
:statistics="statistics"
|
||||||
|
:filters="statisticsFilters"
|
||||||
|
:cleaners="cleaners.items"
|
||||||
|
@refresh="loadStatistics"
|
||||||
|
@filter="changeStatisticsFilters"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="保洁员" name="cleaners">
|
||||||
|
<CleanersPanel
|
||||||
|
:loading="loading.cleaners"
|
||||||
|
:items="cleaners.items"
|
||||||
|
:total="cleaners.total"
|
||||||
|
:page="cleaners.page"
|
||||||
|
:page-size="cleaners.pageSize"
|
||||||
|
:status="cleanerStatus"
|
||||||
|
:search="cleanerSearch"
|
||||||
|
@refresh="loadCleaners"
|
||||||
|
@status-change="changeCleanerStatus"
|
||||||
|
@search-change="changeCleanerSearch"
|
||||||
|
@page-change="changeCleanerPage"
|
||||||
|
@create="handleCreateCleaner"
|
||||||
|
@update="handleUpdateCleaner"
|
||||||
|
@status-toggle="handleToggleCleanerStatus"
|
||||||
|
@reset-sessions="handleResetCleanerSessions"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="清洁规则" name="rules">
|
||||||
|
<CleaningRulesPanel :session="session" />
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="联调" name="handoff">
|
||||||
|
<CleaningFieldHandoffPanel />
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, toRef } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { ClipboardCheck, Download, ListTodo, RotateCcw, Send } from '@lucide/vue';
|
||||||
|
import CleaningTasksPanel from './CleaningTasksPanel.vue';
|
||||||
|
import CleaningSettlementsPanel from './CleaningSettlementsPanel.vue';
|
||||||
|
import CleaningStatisticsPanel from './CleaningStatisticsPanel.vue';
|
||||||
|
import CleanersPanel from './CleanersPanel.vue';
|
||||||
|
import CleaningRulesPanel from './CleaningRulesPanel.vue';
|
||||||
|
import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
assignCleaningTask,
|
||||||
|
cancelCleaningSettlement,
|
||||||
|
completeCleaningTask,
|
||||||
|
confirmCleaningSettlement,
|
||||||
|
createStaffUser,
|
||||||
|
exemptCleaningTask,
|
||||||
|
executeWechatTransfer,
|
||||||
|
generateCleaningSettlement,
|
||||||
|
getCleaningStatistics,
|
||||||
|
listCleaningSettlements,
|
||||||
|
listCleaningTasks,
|
||||||
|
listStaffUsers,
|
||||||
|
markCleaningSettlementPaid,
|
||||||
|
reclaimCleaningTimeouts,
|
||||||
|
recordPayoutFailure,
|
||||||
|
rejectCleaningTask,
|
||||||
|
resetStaffSessions,
|
||||||
|
syncWechatTransfer,
|
||||||
|
updateStaffUser,
|
||||||
|
type ApiSession
|
||||||
|
} from '../api';
|
||||||
|
import type {
|
||||||
|
CleaningSettlement,
|
||||||
|
CleaningStatistics,
|
||||||
|
CleaningTask,
|
||||||
|
ManagedUser,
|
||||||
|
PageResult,
|
||||||
|
PayoutStateFilter,
|
||||||
|
SettlementStatus,
|
||||||
|
TaskStatus,
|
||||||
|
TransferMode,
|
||||||
|
UserStatus
|
||||||
|
} from '../types';
|
||||||
|
import { money } from '../format';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const session = toRef(props, 'session');
|
||||||
|
const activeTab = ref('tasks');
|
||||||
|
const lastError = ref('');
|
||||||
|
const lastMessage = ref('');
|
||||||
|
const taskStatus = ref<TaskStatus | ''>('SUBMITTED');
|
||||||
|
const settlementStatus = ref<SettlementStatus | ''>('CONFIRMED');
|
||||||
|
const cleanerStatus = ref<UserStatus | ''>('ACTIVE');
|
||||||
|
const cleanerSearch = ref('');
|
||||||
|
const loading = reactive({ tasks: false, settlements: false, statistics: false, cleaners: false });
|
||||||
|
const tasks = reactive<PageResult<CleaningTask>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const settlements = reactive<PageResult<CleaningSettlement>>({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20
|
||||||
|
});
|
||||||
|
const cleaners = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const taskFilters = reactive({ storeId: '', cleanerUserId: '' });
|
||||||
|
const settlementFilters = reactive<{
|
||||||
|
storeId: string;
|
||||||
|
cleanerUserId: string;
|
||||||
|
payoutState: PayoutStateFilter | '';
|
||||||
|
}>({ storeId: '', cleanerUserId: '', payoutState: '' });
|
||||||
|
const statisticsFilters = reactive({ from: '', to: '', storeId: '', cleanerUserId: '' });
|
||||||
|
const statistics = reactive<CleaningStatistics>({
|
||||||
|
summary: {
|
||||||
|
taskTotal: 0,
|
||||||
|
pendingReview: 0,
|
||||||
|
active: 0,
|
||||||
|
rejected: 0,
|
||||||
|
exempted: 0,
|
||||||
|
completed: 0,
|
||||||
|
rewardCents: 0,
|
||||||
|
pendingSettlementCents: 0,
|
||||||
|
draftSettlementCents: 0,
|
||||||
|
confirmedSettlementCents: 0,
|
||||||
|
paidSettlementCents: 0
|
||||||
|
},
|
||||||
|
byStatus: [],
|
||||||
|
byStore: [],
|
||||||
|
settlements: [],
|
||||||
|
members: [],
|
||||||
|
trend: []
|
||||||
|
});
|
||||||
|
const draftSettlementTotal = computed(() => statistics.settlements
|
||||||
|
.find((item) => item.status === 'DRAFT')?.total || 0);
|
||||||
|
const operationalQueueTotal = computed(() => (
|
||||||
|
statistics.summary.pendingReview
|
||||||
|
+ statistics.summary.rejected
|
||||||
|
+ draftSettlementTotal.value
|
||||||
|
));
|
||||||
|
|
||||||
|
function loadCleaningWorkspace() {
|
||||||
|
return Promise.all([loadTasks(), loadSettlements(), loadStatistics(), loadCleaners()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAction(work: () => Promise<void>, success: string) {
|
||||||
|
lastError.value = '';
|
||||||
|
lastMessage.value = '';
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
lastMessage.value = success;
|
||||||
|
ElMessage.success(success);
|
||||||
|
} catch (error) {
|
||||||
|
lastError.value = error instanceof ApiError
|
||||||
|
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
|
||||||
|
: error instanceof Error ? error.message : '操作失败';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
loading.tasks = true;
|
||||||
|
await runAction(async () => {
|
||||||
|
Object.assign(tasks, await listCleaningTasks(session.value, {
|
||||||
|
page: tasks.page,
|
||||||
|
pageSize: tasks.pageSize,
|
||||||
|
status: taskStatus.value || undefined,
|
||||||
|
storeId: taskFilters.storeId || undefined,
|
||||||
|
cleanerUserId: taskFilters.cleanerUserId || undefined
|
||||||
|
}));
|
||||||
|
}, '任务已刷新');
|
||||||
|
loading.tasks = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettlements() {
|
||||||
|
loading.settlements = true;
|
||||||
|
await runAction(async () => {
|
||||||
|
Object.assign(settlements, await listCleaningSettlements(session.value, {
|
||||||
|
page: settlements.page,
|
||||||
|
pageSize: settlements.pageSize,
|
||||||
|
status: settlementStatus.value || undefined,
|
||||||
|
payoutState: settlementFilters.payoutState || undefined,
|
||||||
|
storeId: settlementFilters.storeId || undefined,
|
||||||
|
cleanerUserId: settlementFilters.cleanerUserId || undefined
|
||||||
|
}));
|
||||||
|
}, '结算已刷新');
|
||||||
|
loading.settlements = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatistics() {
|
||||||
|
loading.statistics = true;
|
||||||
|
await runAction(async () => {
|
||||||
|
const result = await getCleaningStatistics(session.value, {
|
||||||
|
from: statisticsFilters.from || undefined,
|
||||||
|
to: statisticsFilters.to || undefined,
|
||||||
|
storeId: statisticsFilters.storeId || undefined,
|
||||||
|
cleanerUserId: statisticsFilters.cleanerUserId || undefined
|
||||||
|
});
|
||||||
|
Object.assign(statistics.summary, result.summary);
|
||||||
|
statistics.byStatus = result.byStatus;
|
||||||
|
statistics.byStore = result.byStore;
|
||||||
|
statistics.settlements = result.settlements;
|
||||||
|
statistics.members = result.members;
|
||||||
|
statistics.trend = result.trend;
|
||||||
|
}, '统计已刷新');
|
||||||
|
loading.statistics = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCleaners() {
|
||||||
|
loading.cleaners = true;
|
||||||
|
await runAction(async () => {
|
||||||
|
const result = await listStaffUsers(session.value, {
|
||||||
|
page: cleaners.page,
|
||||||
|
pageSize: cleaners.pageSize,
|
||||||
|
role: 'CLEANER',
|
||||||
|
status: cleanerStatus.value || undefined,
|
||||||
|
search: cleanerSearch.value || undefined
|
||||||
|
});
|
||||||
|
Object.assign(cleaners, {
|
||||||
|
items: result.items,
|
||||||
|
total: result.total,
|
||||||
|
page: cleaners.page,
|
||||||
|
pageSize: cleaners.pageSize
|
||||||
|
});
|
||||||
|
}, '保洁员已刷新');
|
||||||
|
loading.cleaners = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTaskStatus(status: TaskStatus | '') {
|
||||||
|
taskStatus.value = status;
|
||||||
|
tasks.page = 1;
|
||||||
|
void loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToTaskStatus(status: TaskStatus) {
|
||||||
|
activeTab.value = 'tasks';
|
||||||
|
taskStatus.value = status;
|
||||||
|
tasks.page = 1;
|
||||||
|
void loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTaskFilters(input: { storeId: string; cleanerUserId: string }) {
|
||||||
|
Object.assign(taskFilters, input);
|
||||||
|
tasks.page = 1;
|
||||||
|
void loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSettlementStatus(status: SettlementStatus | '') {
|
||||||
|
settlementStatus.value = status;
|
||||||
|
settlements.page = 1;
|
||||||
|
void loadSettlements();
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToSettlementStatus(status: SettlementStatus) {
|
||||||
|
activeTab.value = 'settlements';
|
||||||
|
settlementStatus.value = status;
|
||||||
|
settlementFilters.payoutState = '';
|
||||||
|
settlements.page = 1;
|
||||||
|
void loadSettlements();
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportOperationalQueue() {
|
||||||
|
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
|
||||||
|
['队列', '数量', '金额(分)', '入口'],
|
||||||
|
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
|
||||||
|
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
|
||||||
|
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
|
||||||
|
['待发放金额', '', String(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
|
||||||
|
['待结算金额', '', String(statistics.summary.pendingSettlementCents), '统计/待结算'],
|
||||||
|
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(filename: string, rows: string[][]) {
|
||||||
|
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||||
|
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string) {
|
||||||
|
const normalized = value.replace(/\r?\n/g, ' ');
|
||||||
|
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSettlementFilters(input: {
|
||||||
|
storeId: string;
|
||||||
|
cleanerUserId: string;
|
||||||
|
payoutState: PayoutStateFilter | '';
|
||||||
|
}) {
|
||||||
|
Object.assign(settlementFilters, input);
|
||||||
|
settlements.page = 1;
|
||||||
|
void loadSettlements();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeTaskPage(page: number) {
|
||||||
|
tasks.page = page;
|
||||||
|
void loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeSettlementPage(page: number) {
|
||||||
|
settlements.page = page;
|
||||||
|
void loadSettlements();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeStatisticsFilters(input: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
storeId: string;
|
||||||
|
cleanerUserId: string;
|
||||||
|
}) {
|
||||||
|
Object.assign(statisticsFilters, input);
|
||||||
|
void loadStatistics();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCleanerStatus(status: UserStatus | '') {
|
||||||
|
cleanerStatus.value = status;
|
||||||
|
cleaners.page = 1;
|
||||||
|
void loadCleaners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCleanerSearch(search: string) {
|
||||||
|
cleanerSearch.value = search;
|
||||||
|
cleaners.page = 1;
|
||||||
|
void loadCleaners();
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeCleanerPage(page: number) {
|
||||||
|
cleaners.page = page;
|
||||||
|
void loadCleaners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAssign(input: { taskId: string; cleanerUserId: string; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await assignCleaningTask(session.value, input.taskId, input);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, '已指派');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete(input: { taskId: string; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await completeCleaningTask(session.value, input.taskId, input.note);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, '已验收');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCompleteMany(input: { taskIds: string[]; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
for (const taskId of input.taskIds) await completeCleaningTask(session.value, taskId, input.note);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, `已批量验收 ${input.taskIds.length} 个任务`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(input: { taskId: string; reason: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await rejectCleaningTask(session.value, input.taskId, input.reason);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, '已驳回');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRejectMany(input: { taskIds: string[]; reason: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
for (const taskId of input.taskIds) await rejectCleaningTask(session.value, taskId, input.reason);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, `已批量驳回 ${input.taskIds.length} 个任务`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExempt(input: { taskId: string; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await exemptCleaningTask(session.value, input.taskId, input.note);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, '已标记免清洁');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleExemptMany(input: { taskIds: string[]; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
for (const taskId of input.taskIds) await exemptCleaningTask(session.value, taskId, input.note);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, `已批量标记免清洁 ${input.taskIds.length} 个任务`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReclaim(input: { olderThanMinutes: number; limit: number }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await reclaimCleaningTimeouts(session.value, input);
|
||||||
|
await Promise.all([loadTasks(), loadStatistics()]);
|
||||||
|
}, '已回收超时任务');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerateSettlement(input: {
|
||||||
|
cleanerUserId: string;
|
||||||
|
storeId?: string;
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await generateCleaningSettlement(session.value, input);
|
||||||
|
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||||
|
}, '已生成结算单');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirmSettlement(input: { settlementId: string; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await confirmCleaningSettlement(session.value, input.settlementId, input.note);
|
||||||
|
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||||
|
}, '已确认结算单');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancelSettlement(input: { settlementId: string; reason: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await cancelCleaningSettlement(session.value, input.settlementId, input.reason);
|
||||||
|
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||||
|
}, '结算单已冲正');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleManualPaid(input: {
|
||||||
|
settlementId: string;
|
||||||
|
payoutChannel: string;
|
||||||
|
payoutReference: string;
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await markCleaningSettlementPaid(session.value, input.settlementId, {
|
||||||
|
payoutChannel: input.payoutChannel,
|
||||||
|
payoutReference: input.payoutReference,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
|
||||||
|
}, '人工付款已完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await executeWechatTransfer(session.value, input.settlementId, {
|
||||||
|
mode: input.mode,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||||
|
}, '已提交微信转账');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncTransfer(input: { settlementId: string; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await syncWechatTransfer(session.value, input.settlementId, input.note);
|
||||||
|
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||||
|
}, '已同步微信状态');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSyncTransferMany(input: { settlementIds: string[]; note?: string }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
for (const settlementId of input.settlementIds) {
|
||||||
|
await syncWechatTransfer(session.value, settlementId, input.note);
|
||||||
|
}
|
||||||
|
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||||
|
}, `已批量同步 ${input.settlementIds.length} 个结算单`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFailure(input: {
|
||||||
|
settlementId: string;
|
||||||
|
error: string;
|
||||||
|
payoutReference?: string;
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await recordPayoutFailure(session.value, input.settlementId, {
|
||||||
|
error: input.error,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: input.payoutReference,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
await Promise.all([loadSettlements(), loadStatistics()]);
|
||||||
|
}, '已记录失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateCleaner(input: {
|
||||||
|
nickname: string;
|
||||||
|
phone: string;
|
||||||
|
storeIds: string[];
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await createStaffUser(session.value, {
|
||||||
|
nickname: input.nickname,
|
||||||
|
phone: input.phone,
|
||||||
|
roles: ['CLEANER'],
|
||||||
|
storeIds: input.storeIds,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
await loadCleaners();
|
||||||
|
}, '已新增保洁员');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateCleaner(input: {
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
phone?: string;
|
||||||
|
storeIds: string[];
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await updateStaffUser(session.value, input.userId, {
|
||||||
|
nickname: input.nickname,
|
||||||
|
phone: input.phone,
|
||||||
|
roles: ['CLEANER'],
|
||||||
|
storeIds: input.storeIds,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
await loadCleaners();
|
||||||
|
}, '已更新保洁员');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleCleanerStatus(input: { userId: string; status: UserStatus }) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await updateStaffUser(session.value, input.userId, { status: input.status });
|
||||||
|
await loadCleaners();
|
||||||
|
}, input.status === 'ACTIVE' ? '已启用保洁员' : '已停用保洁员');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetCleanerSessions(userId: string) {
|
||||||
|
await runAction(async () => {
|
||||||
|
await resetStaffSessions(session.value, userId);
|
||||||
|
await loadCleaners();
|
||||||
|
}, '已重置保洁员会话');
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (session.value.token) void loadCleaningWorkspace();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
|
||||||
|
<section class="content-metrics">
|
||||||
|
<span><small>可用素材</small><strong>{{ assets.length }}</strong></span>
|
||||||
|
<span><small>装修版本</small><strong>{{ decorations.length }}</strong></span>
|
||||||
|
<span><small>已发布装修</small><strong>{{ publishedDecorationCount }}</strong></span>
|
||||||
|
<span><small>生效广告</small><strong>{{ activeAdvertisementCount }}</strong></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar content-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 内容运营</p><h3>媒体素材、门店装修与广告投放</h3></div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-select v-model="selectedStoreId" placeholder="选择门店" filterable @change="loadScopedData">
|
||||||
|
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="content-tabs">
|
||||||
|
<el-tab-pane label="媒体素材" name="assets">
|
||||||
|
<div class="content-tab-actions">
|
||||||
|
<p>图片会压缩并校验格式;门店素材仅能用于当前门店广告。</p>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-select v-model="uploadScope" class="compact-select">
|
||||||
|
<el-option label="当前门店" value="STORE" />
|
||||||
|
<el-option label="租户公共" value="TENANT" />
|
||||||
|
</el-select>
|
||||||
|
<label class="file-button" :class="{ disabled: uploading }">
|
||||||
|
<Upload :size="16" />{{ uploading ? '上传中…' : '上传图片' }}
|
||||||
|
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="uploading" @change="uploadImage" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-loading="loading" class="asset-gallery">
|
||||||
|
<article v-for="asset in assets" :key="asset.id" class="asset-card">
|
||||||
|
<el-image :src="asset.url" fit="cover" :preview-src-list="[asset.url]" preview-teleported />
|
||||||
|
<div><strong>#{{ asset.id }}</strong><el-tag size="small" :type="asset.storeId ? 'info' : 'success'">{{ asset.storeId ? '门店素材' : '租户公共' }}</el-tag></div>
|
||||||
|
<p>{{ asset.width }} × {{ asset.height }} · {{ fileSize(asset.byteSize) }}</p>
|
||||||
|
</article>
|
||||||
|
<el-empty v-if="!loading && assets.length === 0" description="当前范围暂无素材" />
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="门店装修" name="decorations">
|
||||||
|
<div class="content-tab-actions">
|
||||||
|
<p>每次保存生成不可变草稿版本;发布后自动归档上一版。</p>
|
||||||
|
<el-button type="primary" :icon="Plus" :disabled="!selectedStoreId" @click="openDecoration">新建草稿</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="decorations" row-key="id" class="data-table">
|
||||||
|
<el-table-column label="版本" width="100"><template #default="{ row }"><strong>V{{ row.version }}</strong></template></el-table-column>
|
||||||
|
<el-table-column label="模板" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.templateCode }}</strong><span>Schema {{ row.schemaVersion }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="组件" width="100"><template #default="{ row }">{{ row.content.components.length }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="decorationTag(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="创建时间" min-width="160"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="inspectDecoration = row">查看</el-button><el-button v-if="row.status !== 'PUBLISHED'" size="small" type="primary" :loading="publishingId === row.id" @click="publish(row)">发布</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="广告投放" name="advertisements">
|
||||||
|
<div class="content-tab-actions">
|
||||||
|
<p>按平台、租户或门店范围投放,支持排期、排序与停用。</p>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openAdvertisement()">新建广告</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="advertisements" row-key="id" class="data-table">
|
||||||
|
<el-table-column label="广告" min-width="240"><template #default="{ row }"><div class="ad-title"><el-image :src="row.imageUrl" fit="cover" /><div class="stack"><strong>{{ row.title }}</strong><span>#{{ row.id }} · {{ row.targetType }}</span></div></div></template></el-table-column>
|
||||||
|
<el-table-column label="范围" width="140"><template #default="{ row }"><div class="stack"><el-tag>{{ row.scopeType }}</el-tag><span>{{ row.storeId ? storeName(row.storeId) : '全部' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="排期" min-width="210"><template #default="{ row }"><div class="stack"><span>{{ row.startsAt ? shortDate(row.startsAt) : '立即开始' }}</span><span>至 {{ row.endsAt ? shortDate(row.endsAt) : '长期' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : row.status === 'DRAFT' ? 'warning' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="sortOrder" label="排序" width="80" />
|
||||||
|
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Pencil" @click="openAdvertisement(row)">编辑</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="decorationDialog" title="新建装修草稿" width="min(820px, 95vw)">
|
||||||
|
<el-form label-position="top" class="decoration-form">
|
||||||
|
<el-form-item label="模板代码"><el-input v-model="decorationForm.templateCode" /></el-form-item>
|
||||||
|
<el-form-item label="Schema 版本"><el-input-number v-model="decorationForm.schemaVersion" :min="1" :max="100" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div class="component-editor">
|
||||||
|
<header><strong>页面组件(按顺序渲染)</strong><el-button size="small" :icon="Plus" @click="addComponent">添加组件</el-button></header>
|
||||||
|
<article v-for="(component, index) in decorationForm.components" :key="component.key">
|
||||||
|
<el-select v-model="component.type"><el-option v-for="type in componentTypes" :key="type" :label="type" :value="type" /></el-select>
|
||||||
|
<el-input v-model="component.propsText" type="textarea" :rows="3" placeholder='组件属性 JSON,例如 {"title":"欢迎光临"}' />
|
||||||
|
<el-button text type="danger" :icon="Trash2" @click="decorationForm.components.splice(index, 1)">移除</el-button>
|
||||||
|
</article>
|
||||||
|
<el-empty v-if="decorationForm.components.length === 0" description="尚未添加组件" :image-size="70" />
|
||||||
|
</div>
|
||||||
|
<template #footer><el-button @click="decorationDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="createDecoration">保存草稿</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer v-model="decorationDrawer" title="装修版本内容" size="min(560px, 94vw)">
|
||||||
|
<template v-if="inspectDecoration"><el-descriptions :column="1" border><el-descriptions-item label="版本">V{{ inspectDecoration.version }}</el-descriptions-item><el-descriptions-item label="模板">{{ inspectDecoration.templateCode }}</el-descriptions-item><el-descriptions-item label="状态">{{ inspectDecoration.status }}</el-descriptions-item></el-descriptions><pre class="json-preview">{{ JSON.stringify(inspectDecoration.content, null, 2) }}</pre></template>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="advertisementDialog" :title="advertisementForm.id ? '编辑广告' : '新建广告'" width="min(760px, 95vw)">
|
||||||
|
<el-form label-position="top" class="asset-form">
|
||||||
|
<el-form-item label="广告标题"><el-input v-model="advertisementForm.title" maxlength="128" show-word-limit /></el-form-item>
|
||||||
|
<el-form-item label="投放范围"><el-select v-model="advertisementForm.scopeType" @change="syncAdvertisementScope"><el-option label="平台" value="PLATFORM" /><el-option label="租户" value="TENANT" /><el-option label="门店" value="STORE" /></el-select></el-form-item>
|
||||||
|
<el-form-item v-if="advertisementForm.scopeType === 'STORE'" label="投放门店"><el-select v-model="advertisementForm.storeId" filterable><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="广告图片"><el-select v-model="advertisementForm.imageAssetId" filterable><el-option v-for="asset in advertisementAssets" :key="asset.id" :label="`#${asset.id} · ${asset.width}×${asset.height}${asset.storeId ? ' · 门店' : ' · 公共'}`" :value="asset.id"><span class="asset-option"><img :src="asset.url" alt="" />#{{ asset.id }} · {{ asset.width }}×{{ asset.height }}</span></el-option></el-select></el-form-item>
|
||||||
|
<el-form-item label="跳转类型"><el-select v-model="advertisementForm.targetType"><el-option label="不跳转" value="NONE" /><el-option label="小程序页面" value="PAGE" /><el-option label="外部链接" value="URL" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="跳转目标"><el-input v-model="advertisementForm.targetValue" :disabled="advertisementForm.targetType === 'NONE'" placeholder="页面路径或 HTTPS URL" /></el-form-item>
|
||||||
|
<el-form-item label="开始时间"><el-date-picker v-model="advertisementForm.startsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item>
|
||||||
|
<el-form-item label="结束时间"><el-date-picker v-model="advertisementForm.endsAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item>
|
||||||
|
<el-form-item label="状态"><el-select v-model="advertisementForm.status"><el-option label="草稿" value="DRAFT" /><el-option label="生效" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="advertisementForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="advertisementDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="persistAdvertisement">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Pencil, Plus, RefreshCw, Trash2, Upload } from '@lucide/vue';
|
||||||
|
import {
|
||||||
|
ApiError, listAdvertisements, listDecorations, listManagedStores, listMediaAssets,
|
||||||
|
publishDecoration, saveAdvertisement, saveDecoration, updateAdvertisement,
|
||||||
|
uploadMediaImage, type ApiSession
|
||||||
|
} from '../api';
|
||||||
|
import { shortDate } from '../format';
|
||||||
|
import type {
|
||||||
|
Advertisement, AdvertisementInput, DecorationComponent, DecorationComponentType,
|
||||||
|
DecorationVersion, ManagedStore, MediaAsset
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const stores = ref<ManagedStore[]>([]); const assets = ref<MediaAsset[]>([]); const decorations = ref<DecorationVersion[]>([]); const advertisements = ref<Advertisement[]>([]);
|
||||||
|
const selectedStoreId = ref(''); const activeTab = ref('assets'); const uploadScope = ref<'STORE' | 'TENANT'>('STORE'); const loading = ref(false); const uploading = ref(false); const saving = ref(false); const publishingId = ref(''); const lastError = ref('');
|
||||||
|
const decorationDialog = ref(false); const advertisementDialog = ref(false); const inspectDecoration = ref<DecorationVersion | null>(null);
|
||||||
|
const decorationDrawer = computed({ get: () => Boolean(inspectDecoration.value), set: (value) => { if (!value) inspectDecoration.value = null; } });
|
||||||
|
const componentTypes: DecorationComponentType[] = ['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']; let componentKey = 0;
|
||||||
|
const decorationForm = reactive({ templateCode: 'STANDARD', schemaVersion: 1, components: [] as Array<{ key: number; type: DecorationComponentType; propsText: string }> });
|
||||||
|
const advertisementForm = reactive({ id: '', scopeType: 'STORE' as AdvertisementInput['scopeType'], storeId: '', title: '', imageAssetId: '', targetType: 'NONE' as AdvertisementInput['targetType'], targetValue: '', startsAt: '', endsAt: '', status: 'DRAFT' as AdvertisementInput['status'], sortOrder: 0 });
|
||||||
|
const publishedDecorationCount = computed(() => decorations.value.filter((item) => item.status === 'PUBLISHED').length);
|
||||||
|
const activeAdvertisementCount = computed(() => advertisements.value.filter((item) => item.status === 'ACTIVE').length);
|
||||||
|
const advertisementAssets = computed(() => assets.value.filter((asset) => advertisementForm.scopeType === 'STORE' ? !asset.storeId || asset.storeId === advertisementForm.storeId : !asset.storeId));
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) { return error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
|
||||||
|
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [storeRows, adRows] = await Promise.all([listManagedStores(props.session), listAdvertisements(props.session)]); stores.value = storeRows; advertisements.value = adRows; if (!selectedStoreId.value || !stores.value.some((item) => item.id === selectedStoreId.value)) selectedStoreId.value = stores.value[0]?.id || ''; await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { loading.value = false; } }
|
||||||
|
async function loadScopedData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [assetRows, decorationRows] = await Promise.all([listMediaAssets(props.session, selectedStoreId.value || undefined), selectedStoreId.value ? listDecorations(props.session, selectedStoreId.value) : Promise.resolve([])]); assets.value = assetRows; decorations.value = decorationRows; } catch (error) { lastError.value = errorMessage(error); } finally { loading.value = false; } }
|
||||||
|
async function uploadImage(event: Event) { const input = event.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; if (uploadScope.value === 'STORE' && !selectedStoreId.value) return ElMessage.warning('请先选择素材所属门店'); if (file.size > 8 * 1024 * 1024) return ElMessage.warning('图片不得超过 8 MB'); uploading.value = true; lastError.value = ''; try { await uploadMediaImage(props.session, file, uploadScope.value === 'STORE' ? selectedStoreId.value : undefined); ElMessage.success('素材上传成功'); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { uploading.value = false; } }
|
||||||
|
function addComponent() { decorationForm.components.push({ key: ++componentKey, type: 'HERO', propsText: '{}' }); }
|
||||||
|
function openDecoration() { decorationForm.templateCode = 'STANDARD'; decorationForm.schemaVersion = 1; decorationForm.components.splice(0); addComponent(); decorationDialog.value = true; }
|
||||||
|
async function createDecoration() { if (!selectedStoreId.value || !decorationForm.templateCode.trim()) return ElMessage.warning('请选择门店并填写模板代码'); const components: DecorationComponent[] = []; try { for (const item of decorationForm.components) { const parsed = JSON.parse(item.propsText || '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('组件属性必须是 JSON 对象'); components.push({ type: item.type, props: parsed }); } } catch (error) { return ElMessage.warning(error instanceof Error ? error.message : '组件属性 JSON 无效'); } saving.value = true; try { const result = await saveDecoration(props.session, { storeId: selectedStoreId.value, templateCode: decorationForm.templateCode.trim(), schemaVersion: decorationForm.schemaVersion, content: { components } }); decorationDialog.value = false; ElMessage.success(`装修草稿 V${result.version} 已保存`); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { saving.value = false; } }
|
||||||
|
async function publish(row: DecorationVersion) { publishingId.value = row.id; try { await publishDecoration(props.session, row.id, row.storeId); ElMessage.success(`装修 V${row.version} 已发布`); await loadScopedData(); } catch (error) { lastError.value = errorMessage(error); } finally { publishingId.value = ''; } }
|
||||||
|
function openAdvertisement(row?: Advertisement) { if (row?.storeId && row.storeId !== selectedStoreId.value) { selectedStoreId.value = row.storeId; void loadScopedData(); } Object.assign(advertisementForm, row ? { id: row.id, scopeType: row.scopeType, storeId: row.storeId || '', title: row.title, imageAssetId: row.imageAssetId, targetType: row.targetType, targetValue: row.targetValue, startsAt: row.startsAt ? row.startsAt.slice(0, 19).replace('T', ' ') : '', endsAt: row.endsAt ? row.endsAt.slice(0, 19).replace('T', ' ') : '', status: row.status, sortOrder: row.sortOrder } : { id: '', scopeType: 'STORE', storeId: selectedStoreId.value, title: '', imageAssetId: '', targetType: 'NONE', targetValue: '', startsAt: '', endsAt: '', status: 'DRAFT', sortOrder: 0 }); advertisementDialog.value = true; }
|
||||||
|
function syncAdvertisementScope() { if (advertisementForm.scopeType === 'STORE') advertisementForm.storeId ||= selectedStoreId.value; else advertisementForm.storeId = ''; advertisementForm.imageAssetId = ''; }
|
||||||
|
function advertisementInput(): AdvertisementInput | null { if (!advertisementForm.title.trim() || !advertisementForm.imageAssetId) { ElMessage.warning('请填写广告标题并选择图片'); return null; } if (advertisementForm.scopeType === 'STORE' && !advertisementForm.storeId) { ElMessage.warning('请选择投放门店'); return null; } if (advertisementForm.targetType !== 'NONE' && !advertisementForm.targetValue.trim()) { ElMessage.warning('请填写跳转目标'); return null; } if (advertisementForm.startsAt && advertisementForm.endsAt && advertisementForm.endsAt <= advertisementForm.startsAt) { ElMessage.warning('结束时间必须晚于开始时间'); return null; } return { scopeType: advertisementForm.scopeType, storeId: advertisementForm.scopeType === 'STORE' ? advertisementForm.storeId : null, title: advertisementForm.title.trim(), imageAssetId: advertisementForm.imageAssetId, targetType: advertisementForm.targetType, targetValue: advertisementForm.targetType === 'NONE' ? '' : advertisementForm.targetValue.trim(), startsAt: advertisementForm.startsAt || null, endsAt: advertisementForm.endsAt || null, status: advertisementForm.status, sortOrder: advertisementForm.sortOrder }; }
|
||||||
|
async function persistAdvertisement() { const input = advertisementInput(); if (!input) return; saving.value = true; try { if (advertisementForm.id) await updateAdvertisement(props.session, advertisementForm.id, input); else await saveAdvertisement(props.session, input); advertisementDialog.value = false; ElMessage.success('广告已保存'); advertisements.value = await listAdvertisements(props.session); } catch (error) { lastError.value = errorMessage(error); } finally { saving.value = false; } }
|
||||||
|
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
|
||||||
|
function fileSize(bytes: number) { return bytes >= 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB`; }
|
||||||
|
function decorationTag(status: string) { return status === 'PUBLISHED' ? 'success' : status === 'DRAFT' ? 'warning' : 'info'; }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<section class="devices-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<section class="device-metrics">
|
||||||
|
<span><small>设备总数</small><strong>{{ topology.assets.length }}</strong></span>
|
||||||
|
<span><small>在线</small><strong>{{ onlineCount }}</strong></span>
|
||||||
|
<span><small>开放告警</small><strong>{{ topology.openAlerts.length }}</strong></span>
|
||||||
|
<span><small>维护中</small><strong>{{ openMaintenance.length }}</strong></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar device-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 设备运营</p><h3>设备资产、拓扑与控制</h3></div>
|
||||||
|
<div class="toolbar-actions"><el-select v-model="storeId" filterable placeholder="选择授权门店" @change="changeStore"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select><el-button :icon="RefreshCw" :loading="loading" @click="loadTopology">刷新</el-button><el-button type="primary" :icon="Plus" :disabled="!storeId" @click="openAsset">设备入库</el-button></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="device-tabs">
|
||||||
|
<el-tab-pane label="设备资产" name="assets">
|
||||||
|
<el-table v-loading="loading" :data="topology.assets" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="设备" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ typeLabel(row.deviceType) }} · {{ row.model }}</strong><span>{{ row.deviceId }} · 资产 #{{ row.id }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="门店 / 房间" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ storeName(row.storeId) }}</strong><span>{{ roomName(row.roomId) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="130"><template #default="{ row }"><div class="stack"><el-tag :type="statusType(row.status)">{{ row.status }}</el-tag><span>{{ row.maintenanceStatus }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="固件 / 信号" min-width="140"><template #default="{ row }"><div class="stack"><strong>{{ row.firmwareVersion || '-' }}</strong><span>{{ row.signalStrength == null ? '-' : `${row.signalStrength} dBm` }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="能力" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="capability in row.capabilities" :key="capability" effect="plain">{{ capability }}</el-tag></div></template></el-table-column>
|
||||||
|
<el-table-column label="最近心跳" width="150"><template #default="{ row }">{{ shortDate(row.lastHeartbeatAt || row.lastSeenAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="270" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Wrench" @click="openMaintenanceDialog(row)">维护</el-button><el-button v-if="row.deviceType !== 'SUB_LOCK'" size="small" :icon="Cable" @click="openChannel(row)">通道</el-button><el-button v-if="row.roomId" size="small" :icon="Power" @click="openControl(row)">控制</el-button></div></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="通道与拓扑" name="topology">
|
||||||
|
<div class="tab-action-row"><span>同一房间控制目标保持唯一;Sub‑1G 门锁必须绑定控制箱父设备。</span><el-button :icon="Link2" @click="openLink">绑定门锁</el-button></div>
|
||||||
|
<section class="device-topology-grid"><article><h4>控制通道</h4><el-table :data="topology.channels" class="data-table"><el-table-column label="设备" min-width="120"><template #default="{ row }">{{ assetName(row.assetId) }}</template></el-table-column><el-table-column label="房间" min-width="120"><template #default="{ row }">{{ roomName(row.roomId) }}</template></el-table-column><el-table-column prop="channelCode" label="通道" width="90" /><el-table-column prop="purpose" label="用途" min-width="140" /></el-table></article><article><h4>Sub‑1G 父子链路</h4><el-table :data="topology.links" class="data-table"><el-table-column label="父设备" min-width="120"><template #default="{ row }">{{ assetName(row.parentAssetId) }}</template></el-table-column><el-table-column label="子设备" min-width="120"><template #default="{ row }">{{ assetName(row.childAssetId) }}</template></el-table-column><el-table-column prop="subId" label="Sub ID" min-width="110" /><el-table-column prop="subtype" label="型号" width="90" /><el-table-column prop="status" label="状态" width="90" /></el-table></article></section>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane :label="`开放告警 (${topology.openAlerts.length})`" name="alerts"><el-table :data="topology.openAlerts" class="data-table"><el-table-column label="设备 / 房间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ assetName(row.assetId) }}</strong><span>{{ roomName(row.roomId) }}</span></div></template></el-table-column><el-table-column prop="alertType" label="告警类型" min-width="140" /><el-table-column label="等级" width="100"><template #default="{ row }"><el-tag :type="severityType(row.severity)">{{ row.severity }}</el-tag></template></el-table-column><el-table-column prop="summary" label="摘要" min-width="240" /><el-table-column label="最后出现" width="150"><template #default="{ row }">{{ shortDate(row.lastSeenAt) }}</template></el-table-column></el-table></el-tab-pane>
|
||||||
|
<el-tab-pane label="维护记录" name="maintenance"><el-table :data="topology.maintenance" class="data-table"><el-table-column label="设备 / 房间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ assetName(row.assetId) }}</strong><span>{{ roomName(row.roomId) }}</span></div></template></el-table-column><el-table-column prop="recordType" label="类型" width="120" /><el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'OPEN' ? 'warning' : 'success'">{{ row.status }}</el-tag></template></el-table-column><el-table-column prop="description" label="说明" min-width="260" /><el-table-column label="创建 / 解决" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ shortDate(row.createdAt) }}</strong><span>{{ shortDate(row.resolvedAt) }}</span></div></template></el-table-column></el-table></el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="assetDialog" title="设备入库" width="min(700px, 94vw)"><el-form label-position="top" class="asset-form"><el-form-item label="设备类型"><el-select v-model="assetForm.deviceType"><el-option label="4G 控制箱" value="CONTROL_BOX" /><el-option label="Sub‑1G 门锁" value="SUB_LOCK" /><el-option label="4G 智慧插座" value="SMART_SOCKET" /></el-select></el-form-item><el-form-item label="房间"><el-select v-model="assetForm.roomId" clearable><el-option label="门店公共设备" value="" /><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="DeviceID"><el-input v-model="assetForm.deviceId" /></el-form-item><el-form-item label="型号"><el-input v-model="assetForm.model" /></el-form-item><el-form-item label="IMEI"><el-input v-model="assetForm.imei" /></el-form-item><el-form-item label="ICCID"><el-input v-model="assetForm.iccid" /></el-form-item><el-form-item label="固件版本"><el-input v-model="assetForm.firmwareVersion" /></el-form-item><el-form-item label="能力(逗号分隔)"><el-input v-model="assetForm.capabilitiesText" placeholder="POWER,LOCK,TTS" /></el-form-item></el-form><template #footer><el-button @click="assetDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAsset">入库</el-button></template></el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="channelDialog" title="绑定设备通道" width="min(580px, 94vw)"><el-form label-position="top"><el-form-item label="房间"><el-select v-model="channelForm.roomId"><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="通道"><el-select v-model="channelForm.channelCode"><el-option v-for="code in channelCodes" :key="code" :label="code" :value="code" /></el-select></el-form-item><el-form-item label="用途"><el-select v-model="channelForm.purpose"><el-option v-for="purpose in purposes" :key="purpose" :label="purpose" :value="purpose" /></el-select></el-form-item></el-form><template #footer><el-button @click="channelDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveChannel">保存</el-button></template></el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="linkDialog" title="绑定 Sub‑1G 门锁" width="min(620px, 94vw)"><el-form label-position="top"><el-form-item label="控制箱"><el-select v-model="linkForm.parentAssetId"><el-option v-for="item in controlBoxes" :key="item.id" :label="assetName(item.id)" :value="item.id" /></el-select></el-form-item><el-form-item label="门锁"><el-select v-model="linkForm.childAssetId"><el-option v-for="item in subLocks" :key="item.id" :label="assetName(item.id)" :value="item.id" /></el-select></el-form-item><el-form-item label="房间"><el-select v-model="linkForm.roomId"><el-option v-for="room in rooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item><el-form-item label="Sub ID"><el-input v-model="linkForm.subId" /></el-form-item><el-form-item label="型号"><el-select v-model="linkForm.subtype"><el-option label="701C" value="701C" /><el-option label="701G" value="701G" /></el-select></el-form-item></el-form><template #footer><el-button @click="linkDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveLink">保存</el-button></template></el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="maintenanceDialog" title="设备维护记录" width="min(600px, 94vw)"><el-form label-position="top"><el-form-item label="类型"><el-select v-model="maintenanceForm.recordType"><el-option label="巡检" value="INSPECTION" /><el-option label="维修" value="REPAIR" /><el-option label="更换" value="REPLACEMENT" /></el-select></el-form-item><el-form-item label="状态"><el-radio-group v-model="maintenanceForm.status"><el-radio value="OPEN">处理中</el-radio><el-radio value="RESOLVED">已解决</el-radio></el-radio-group></el-form-item><el-form-item label="说明"><el-input v-model="maintenanceForm.description" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="maintenanceDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMaintenance">保存</el-button></template></el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="controlDialog" title="设备控制" width="min(620px, 94vw)"><el-alert title="命令发布不等于设备已 ACK;请刷新状态或结合现场确认。" type="warning" show-icon :closable="false" /><div class="device-control-grid"><el-button :icon="DoorOpen" @click="runControl('door', { order: 'open', holdopen: 0, delayTime: 4 })">临时开门</el-button><el-button :icon="Power" @click="runControl('power', { slotall: 'on' })">全屋通电</el-button><el-button :icon="PowerOff" @click="runControl('power', { slotall: 'off' })">全屋断电</el-button><el-button :icon="PlugZap" @click="runControl('socket/switch', { on: true, slotNum: 1 })">插座通电</el-button><el-button :icon="Unplug" @click="runControl('socket/switch', { on: false, slotNum: 1 })">插座断电</el-button></div></el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Cable, DoorOpen, Link2, PlugZap, Plus, Power, PowerOff, RefreshCw, Unplug, Wrench } from '@lucide/vue';
|
||||||
|
import { ApiError, addDeviceMaintenance, bindDeviceChannel, bindSubDevice, controlDevice, createDeviceAsset, getDeviceTopology, listManagedRooms, listManagedStores, type ApiSession } from '../api';
|
||||||
|
import { shortDate } from '../format';
|
||||||
|
import type { DeviceAsset, DeviceTopology, DeviceType, ManagedRoom, ManagedStore } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const stores = ref<ManagedStore[]>([]); const rooms = ref<ManagedRoom[]>([]); const storeId = ref('');
|
||||||
|
const topology = reactive<DeviceTopology>({ assets: [], channels: [], links: [], openAlerts: [], maintenance: [] });
|
||||||
|
const activeTab = ref('assets'); const loading = ref(false); const saving = ref(false); const lastError = ref('');
|
||||||
|
const assetDialog = ref(false); const channelDialog = ref(false); const linkDialog = ref(false); const maintenanceDialog = ref(false); const controlDialog = ref(false);
|
||||||
|
const assetForm = reactive({ deviceType: 'CONTROL_BOX' as DeviceType, roomId: '', deviceId: '', model: '', imei: '', iccid: '', firmwareVersion: '', capabilitiesText: '' });
|
||||||
|
const channelForm = reactive({ assetId: '', roomId: '', channelCode: 'SLOT1', purpose: 'ROOM_POWER' });
|
||||||
|
const linkForm = reactive({ parentAssetId: '', childAssetId: '', roomId: '', subId: '', subtype: '701C' });
|
||||||
|
const maintenanceForm = reactive({ assetId: '', roomId: '', recordType: 'INSPECTION' as 'INSPECTION' | 'REPAIR' | 'REPLACEMENT', status: 'OPEN' as 'OPEN' | 'RESOLVED', description: '' });
|
||||||
|
const controlAsset = ref<DeviceAsset | null>(null);
|
||||||
|
const channelCodes = ['SLOT1', 'SLOT2', 'SLOT3', 'MAIN', 'LOCK', 'LED', 'TTS'];
|
||||||
|
const purposes = ['ROOM_POWER', 'AIR_CONDITIONER', 'LIGHTING', 'DOOR_MAGNET', 'STORE_DOOR', 'ROOM_DOOR', 'TTS', 'LED'];
|
||||||
|
const onlineCount = computed(() => topology.assets.filter((item) => item.status === 'ONLINE').length);
|
||||||
|
const openMaintenance = computed(() => topology.maintenance.filter((item) => item.status === 'OPEN'));
|
||||||
|
const controlBoxes = computed(() => topology.assets.filter((item) => item.deviceType === 'CONTROL_BOX'));
|
||||||
|
const subLocks = computed(() => topology.assets.filter((item) => item.deviceType === 'SUB_LOCK'));
|
||||||
|
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
|
||||||
|
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); storeId.value ||= stores.value[0]?.id || ''; await changeStore(); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||||
|
async function changeStore() { if (!storeId.value) return; loading.value = true; try { [rooms.value] = await Promise.all([capture(() => listManagedRooms(props.session, storeId.value)), loadTopology(false)]); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||||
|
async function loadTopology(show = true) { if (!storeId.value) return; if (show) loading.value = true; try { Object.assign(topology, await capture(() => getDeviceTopology(props.session, storeId.value))); } catch { /* displayed */ } finally { if (show) loading.value = false; } }
|
||||||
|
function openAsset() { Object.assign(assetForm, { deviceType: 'CONTROL_BOX', roomId: '', deviceId: '', model: '', imei: '', iccid: '', firmwareVersion: '', capabilitiesText: '' }); assetDialog.value = true; }
|
||||||
|
async function saveAsset() { if (!assetForm.deviceId || !assetForm.model) return ElMessage.warning('请填写 DeviceID 和型号'); saving.value = true; try { await capture(() => createDeviceAsset(props.session, { storeId: storeId.value, roomId: assetForm.roomId || null, deviceId: assetForm.deviceId.trim(), model: assetForm.model.trim(), imei: assetForm.imei.trim(), iccid: assetForm.iccid.trim() || null, deviceType: assetForm.deviceType, firmwareVersion: assetForm.firmwareVersion.trim(), signalStrength: null, capabilities: assetForm.capabilitiesText.split(/[,,\s]+/).map((item) => item.trim().toUpperCase()).filter(Boolean) })); assetDialog.value = false; ElMessage.success('设备已入库'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function openChannel(asset: DeviceAsset) { Object.assign(channelForm, { assetId: asset.id, roomId: asset.roomId || rooms.value[0]?.id || '', channelCode: 'SLOT1', purpose: 'ROOM_POWER' }); channelDialog.value = true; }
|
||||||
|
async function saveChannel() { if (!channelForm.roomId) return ElMessage.warning('请选择房间'); saving.value = true; try { await capture(() => bindDeviceChannel(props.session, { ...channelForm, storeId: storeId.value })); channelDialog.value = false; ElMessage.success('控制通道已绑定'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function openLink() { Object.assign(linkForm, { parentAssetId: controlBoxes.value[0]?.id || '', childAssetId: subLocks.value[0]?.id || '', roomId: rooms.value[0]?.id || '', subId: '', subtype: '701C' }); linkDialog.value = true; }
|
||||||
|
async function saveLink() { if (!linkForm.parentAssetId || !linkForm.childAssetId || !linkForm.roomId || !linkForm.subId) return ElMessage.warning('请完整填写父子设备、房间和 Sub ID'); saving.value = true; try { await capture(() => bindSubDevice(props.session, { ...linkForm, storeId: storeId.value })); linkDialog.value = false; ElMessage.success('门锁拓扑已绑定'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function openMaintenanceDialog(asset: DeviceAsset) { Object.assign(maintenanceForm, { assetId: asset.id, roomId: asset.roomId || '', recordType: 'INSPECTION', status: 'OPEN', description: '' }); maintenanceDialog.value = true; }
|
||||||
|
async function saveMaintenance() { saving.value = true; try { await capture(() => addDeviceMaintenance(props.session, maintenanceForm.assetId, { storeId: storeId.value, roomId: maintenanceForm.roomId || null, recordType: maintenanceForm.recordType, status: maintenanceForm.status, description: maintenanceForm.description.trim() })); maintenanceDialog.value = false; ElMessage.success('维护记录已保存'); await loadTopology(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function openControl(asset: DeviceAsset) { controlAsset.value = asset; controlDialog.value = true; }
|
||||||
|
async function runControl(action: 'door' | 'power' | 'socket/switch', command: Record<string, unknown>) { const asset = controlAsset.value; if (!asset?.roomId) return; try { await ElMessageBox.confirm(`确认对 ${roomName(asset.roomId)} 下发设备命令?`, '危险操作确认', { type: 'warning' }); const result = await capture(() => controlDevice(props.session, action, { storeId: storeId.value, roomId: asset.roomId, orderId: null, ...command })); ElMessage.success(`命令已受理${result.commandId ? ` · #${result.commandId}` : ''},等待设备 ACK`); controlDialog.value = false; } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||||
|
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
|
||||||
|
function roomName(id: string | null) { if (!id) return '门店公共设备'; const room = rooms.value.find((item) => item.id === id); return room ? `${room.roomNo} · ${room.name}` : `房间 #${id}`; }
|
||||||
|
function assetName(id: string) { const asset = topology.assets.find((item) => item.id === id); return asset ? `${typeLabel(asset.deviceType)} · ${asset.deviceId}` : `设备 #${id}`; }
|
||||||
|
function typeLabel(type: DeviceType) { return ({ CONTROL_BOX: '控制箱', SUB_LOCK: 'Sub‑1G 门锁', SMART_SOCKET: '智慧插座' })[type]; }
|
||||||
|
function statusType(status: string) { return status === 'ONLINE' ? 'success' : status === 'FAULT' ? 'danger' : 'info'; }
|
||||||
|
function severityType(value: string) { return ['CRITICAL', 'HIGH'].includes(value) ? 'danger' : value === 'MEDIUM' ? 'warning' : 'info'; }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<section class="franchise-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<section class="franchise-metrics"><span><small>新申请</small><strong>{{ count('NEW') }}</strong></span><span><small>已联系</small><strong>{{ count('CONTACTED') }}</strong></span><span><small>意向明确</small><strong>{{ count('QUALIFIED') }}</strong></span><span><small>本页待跟进</small><strong>{{ dueCount }}</strong></span></section>
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar"><div><p class="section-kicker">M08-D · 加盟运营</p><h3>加盟申请与跟进队列</h3></div><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button></header>
|
||||||
|
<div class="franchise-filters">
|
||||||
|
<el-input v-model="filters.search" clearable placeholder="申请号、城市、联系人或电话" @keyup.enter="applyFilters" />
|
||||||
|
<el-select v-model="filters.status" clearable placeholder="全部状态"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
||||||
|
<el-select v-model="filters.assigneeUserId" clearable filterable placeholder="全部负责人"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select>
|
||||||
|
<el-button type="primary" @click="applyFilters">筛选</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="page.items" row-key="id" class="data-table">
|
||||||
|
<el-table-column label="申请" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.applicationNo }}</strong><span>{{ shortDate(row.createdAt) }} · {{ row.source }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="联系人" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.contactName }} · {{ row.contactPhone }}</strong><span>{{ row.city }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="需求" min-width="220" show-overflow-tooltip prop="message" />
|
||||||
|
<el-table-column label="状态" width="120"><template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="负责人" min-width="130"><template #default="{ row }">{{ row.assigneeName || '待分派' }}</template></el-table-column>
|
||||||
|
<el-table-column label="下次跟进" min-width="150"><template #default="{ row }"><span :class="{ overdue: isOverdue(row.nextFollowUpAt) }">{{ row.nextFollowUpAt ? shortDate(row.nextFollowUpAt) : '-' }}</span></template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openDetail(row.id)">详情</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadData" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-drawer v-model="drawer" title="加盟申请详情" size="min(680px, 96vw)">
|
||||||
|
<div v-if="detail" class="franchise-detail">
|
||||||
|
<el-descriptions :column="2" border><el-descriptions-item label="申请号">{{ detail.application.applicationNo }}</el-descriptions-item><el-descriptions-item label="状态"><el-tag :type="statusTag(detail.application.status)">{{ statusLabel(detail.application.status) }}</el-tag></el-descriptions-item><el-descriptions-item label="联系人">{{ detail.application.contactName }}</el-descriptions-item><el-descriptions-item label="联系电话"><a :href="`tel:${detail.application.contactPhone}`">{{ detail.application.contactPhone }}</a></el-descriptions-item><el-descriptions-item label="意向城市">{{ detail.application.city }}</el-descriptions-item><el-descriptions-item label="提交时间">{{ shortDate(detail.application.createdAt) }}</el-descriptions-item><el-descriptions-item label="留言" :span="2">{{ detail.application.message || '无' }}</el-descriptions-item></el-descriptions>
|
||||||
|
<section class="franchise-assignment"><strong>负责人</strong><el-select :model-value="detail.application.assigneeUserId || ''" clearable filterable placeholder="待分派" @change="assign"><el-option v-for="user in staff" :key="user.id" :label="user.nickname || `员工 #${user.id}`" :value="user.id" /></el-select></section>
|
||||||
|
<section class="follow-up-form"><h4>记录跟进</h4><el-form label-position="top"><el-form-item label="方式"><el-select v-model="followForm.followUpType"><el-option label="电话" value="CALL" /><el-option label="微信" value="WECHAT" /><el-option label="面谈" value="MEETING" /><el-option label="备注" value="NOTE" /></el-select></el-form-item><el-form-item label="更新状态"><el-select v-model="followForm.status" clearable><el-option v-for="item in allowedStatusOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="下次跟进"><el-date-picker v-model="followForm.nextFollowUpAt" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" clearable /></el-form-item><el-form-item label="跟进内容"><el-input v-model="followForm.note" type="textarea" :rows="3" maxlength="1000" show-word-limit /></el-form-item></el-form><el-button type="primary" :loading="saving" @click="saveFollowUp">保存跟进</el-button></section>
|
||||||
|
<section><h4>跟进历史</h4><el-timeline><el-timeline-item v-for="item in detail.followUps" :key="item.id" :timestamp="shortDate(item.createdAt)" placement="top"><div class="follow-up-card"><strong>{{ followTypeLabel(item.followUpType) }} · {{ item.actorName }}</strong><p>{{ item.note }}</p><small v-if="item.fromStatus !== item.toStatus">{{ item.fromStatus ? statusLabel(item.fromStatus) : '-' }} → {{ item.toStatus ? statusLabel(item.toStatus) : '-' }}</small></div></el-timeline-item></el-timeline><el-empty v-if="detail.followUps.length === 0" description="尚无跟进记录" :image-size="70" /></section>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Eye, RefreshCw } from '@lucide/vue';
|
||||||
|
import { ApiError, addFranchiseFollowUp, assignFranchiseApplication, getFranchiseApplication, listFranchiseApplications, listStaffUsers, type ApiSession } from '../api';
|
||||||
|
import { shortDate } from '../format';
|
||||||
|
import type { FranchiseApplication, FranchiseApplicationDetail, FranchiseFollowUpType, FranchiseStatus, ManagedUser, PageResult } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>(); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const drawer = ref(false); const staff = ref<ManagedUser[]>([]); const detail = ref<FranchiseApplicationDetail | null>(null);
|
||||||
|
const page = reactive<PageResult<FranchiseApplication>>({ items: [], total: 0, page: 1, pageSize: 20 }); const filters = reactive({ search: '', status: '' as FranchiseStatus | '', assigneeUserId: '' }); const followForm = reactive({ followUpType: 'CALL' as Exclude<FranchiseFollowUpType, 'ASSIGNMENT' | 'STATUS'>, status: '' as FranchiseStatus | '', nextFollowUpAt: '', note: '' });
|
||||||
|
const statusOptions: Array<{ value: FranchiseStatus; label: string }> = [{ value: 'NEW', label: '新申请' }, { value: 'CONTACTED', label: '已联系' }, { value: 'QUALIFIED', label: '意向明确' }, { value: 'REJECTED', label: '已拒绝' }, { value: 'CONVERTED', label: '已签约待开通' }];
|
||||||
|
const transitions: Record<FranchiseStatus, FranchiseStatus[]> = { NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'], QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: [] };
|
||||||
|
const allowedStatusOptions = computed(() => detail.value ? statusOptions.filter((item) => transitions[detail.value!.application.status].includes(item.value)) : []); const dueCount = computed(() => page.items.filter((item) => isOverdue(item.nextFollowUpAt)).length);
|
||||||
|
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
|
||||||
|
async function loadData() { if (!props.session.token) return; loading.value = true; lastError.value = ''; try { const [result, users] = await Promise.all([listFranchiseApplications(props.session, { page: page.page, pageSize: page.pageSize, status: filters.status || undefined, assigneeUserId: filters.assigneeUserId || undefined, search: filters.search.trim() || undefined }), staff.value.length ? Promise.resolve({ items: staff.value }) : listStaffUsers(props.session, { page: 1, pageSize: 100, status: 'ACTIVE' })]); Object.assign(page, result); staff.value = users.items; } catch (error) { captureError(error); } finally { loading.value = false; } }
|
||||||
|
function applyFilters() { page.page = 1; void loadData(); } async function openDetail(id: string) { drawer.value = true; detail.value = null; try { detail.value = await getFranchiseApplication(props.session, id); Object.assign(followForm, { followUpType: 'CALL', status: '', nextFollowUpAt: '', note: '' }); } catch (error) { captureError(error); } }
|
||||||
|
async function assign(value: string) { if (!detail.value) return; saving.value = true; try { await assignFranchiseApplication(props.session, detail.value.application.id, value || null); ElMessage.success('负责人已更新'); await refreshDetail(); await loadData(); } catch (error) { captureError(error); } finally { saving.value = false; } }
|
||||||
|
async function saveFollowUp() { if (!detail.value || !followForm.note.trim()) return ElMessage.warning('请填写跟进内容'); saving.value = true; try { await addFranchiseFollowUp(props.session, detail.value.application.id, { followUpType: followForm.followUpType, note: followForm.note.trim(), nextFollowUpAt: followForm.nextFollowUpAt || null, status: followForm.status || undefined }); ElMessage.success('跟进记录已保存'); await refreshDetail(); await loadData(); Object.assign(followForm, { status: '', note: '' }); } catch (error) { captureError(error); } finally { saving.value = false; } }
|
||||||
|
async function refreshDetail() { if (detail.value) detail.value = await getFranchiseApplication(props.session, detail.value.application.id); }
|
||||||
|
function count(status: FranchiseStatus) { return page.items.filter((item) => item.status === status).length; } function isOverdue(value: string | null) { return Boolean(value && new Date(value).getTime() < Date.now()); } function statusLabel(value: FranchiseStatus) { return statusOptions.find((item) => item.value === value)?.label || value; } function statusTag(value: FranchiseStatus) { return value === 'QUALIFIED' || value === 'CONVERTED' ? 'success' : value === 'REJECTED' ? 'info' : value === 'NEW' ? 'warning' : 'primary'; } function followTypeLabel(value: FranchiseFollowUpType) { return ({ CALL: '电话', WECHAT: '微信', MEETING: '面谈', NOTE: '备注', STATUS: '状态变化', ASSIGNMENT: '负责人变更' } as Record<FranchiseFollowUpType, string>)[value]; }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<section class="system-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<el-tabs v-model="activeTab" class="panel system-tabs">
|
||||||
|
<el-tab-pane label="安全审计" name="audit">
|
||||||
|
<header class="panel-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 可追溯运营</p><h3>安全审计日志</h3></div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button :icon="Download" :disabled="page.items.length === 0" @click="exportCsv">导出当前页</el-button>
|
||||||
|
<el-button :icon="RefreshCw" :loading="auditLoading" @click="loadAudit">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="audit-filters">
|
||||||
|
<el-input v-model="filters.search" clearable placeholder="操作、资源、追踪号或操作人" @keyup.enter="applyFilters" />
|
||||||
|
<el-input v-model="filters.action" clearable placeholder="操作代码,如 ORDER_CREATED" @keyup.enter="applyFilters" />
|
||||||
|
<el-input v-model="filters.resourceType" clearable placeholder="资源类型,如 ORDER" @keyup.enter="applyFilters" />
|
||||||
|
<el-date-picker v-model="dateRange" type="datetimerange" range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" />
|
||||||
|
<el-button type="primary" @click="applyFilters">筛选</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="auditLoading" :data="page.items" row-key="id" class="data-table">
|
||||||
|
<el-table-column label="时间" min-width="168"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.action }}</strong><span>{{ row.resourceType }}{{ row.resourceId ? ` #${row.resourceId}` : '' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="操作人" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ row.actorName || row.actorType }}</strong><span>{{ row.actorId ? `#${row.actorId}` : '系统' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="来源" min-width="150"><template #default="{ row }"><div class="stack"><span>{{ row.ip || '-' }}</span><small>{{ compactAgent(row.userAgent) }}</small></div></template></el-table-column>
|
||||||
|
<el-table-column label="追踪号" min-width="180" show-overflow-tooltip prop="traceId" />
|
||||||
|
<el-table-column label="详情" width="92" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="selected = row; drawer = true">查看</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination v-model:current-page="page.page" :page-size="page.pageSize" :total="page.total" layout="prev, pager, next, total" @current-change="loadAudit" />
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="系统配置" name="system">
|
||||||
|
<div v-loading="systemLoading" class="system-workspace">
|
||||||
|
<section class="system-metrics">
|
||||||
|
<span><small>门店</small><strong>{{ overview?.counts.storeCount ?? 0 }}</strong></span>
|
||||||
|
<span><small>用户</small><strong>{{ overview?.counts.userCount ?? 0 }}</strong></span>
|
||||||
|
<span><small>活跃会话</small><strong>{{ overview?.counts.activeSessionCount ?? 0 }}</strong></span>
|
||||||
|
<span><small>今日审计</small><strong>{{ overview?.counts.auditTodayCount ?? 0 }}</strong></span>
|
||||||
|
</section>
|
||||||
|
<section class="system-config-grid">
|
||||||
|
<article class="config-card">
|
||||||
|
<header><div><p class="section-kicker">租户配置</p><h3>{{ overview?.tenant.code || '当前租户' }}</h3></div><el-tag :type="tenantStatusType">{{ overview?.tenant.status || '-' }}</el-tag></header>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="租户名称"><el-input v-model="tenantForm.name" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item label="默认时区"><el-input v-model="tenantForm.timezone" placeholder="Asia/Shanghai" /></el-form-item>
|
||||||
|
<el-form-item label="平台状态操作">
|
||||||
|
<div class="status-control"><el-switch v-model="applyStatus" active-text="本次更新状态" /><el-select v-model="tenantForm.status" :disabled="!applyStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></div>
|
||||||
|
<small class="field-help">只有平台管理员可以变更租户启停状态;普通租户管理员请保持关闭。</small>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveConfig">保存系统配置</el-button>
|
||||||
|
</article>
|
||||||
|
<article class="config-card migration-card">
|
||||||
|
<p class="section-kicker">数据库状态</p><h3>Schema 迁移</h3>
|
||||||
|
<el-descriptions :column="1" border>
|
||||||
|
<el-descriptions-item label="最新版本">{{ overview?.latestMigration?.version || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="迁移名称">{{ overview?.latestMigration?.name || '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="执行时间">{{ overview?.latestMigration?.appliedAt ? shortDate(overview.latestMigration.appliedAt) : '-' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="小程序绑定">{{ overview?.counts.appBindingCount ?? 0 }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-button :icon="RefreshCw" :loading="systemLoading" @click="loadSystem">刷新运行状态</el-button>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-drawer v-model="drawer" title="审计事件详情" size="min(640px, 96vw)">
|
||||||
|
<el-descriptions v-if="selected" :column="1" border>
|
||||||
|
<el-descriptions-item label="操作">{{ selected.action }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="资源">{{ selected.resourceType }} {{ selected.resourceId || '' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="追踪号">{{ selected.traceId }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="操作人">{{ selected.actorName || selected.actorType }} {{ selected.actorId || '' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="脱敏来源 IP">{{ selected.ip || '-' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<h4>脱敏元数据</h4><pre class="audit-metadata">{{ prettyMetadata }}</pre>
|
||||||
|
</el-drawer>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Download, Eye, RefreshCw } from '@lucide/vue';
|
||||||
|
import { ApiError, getSystemOverview, listAuditLogs, updateTenantSystemConfig, type ApiSession } from '../api';
|
||||||
|
import { shortDate } from '../format';
|
||||||
|
import type { AuditLog, PageResult, SystemOverview } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const activeTab = ref('audit'); const auditLoading = ref(false); const systemLoading = ref(false); const saving = ref(false);
|
||||||
|
const lastError = ref(''); const drawer = ref(false); const selected = ref<AuditLog | null>(null);
|
||||||
|
const overview = ref<SystemOverview | null>(null); const dateRange = ref<[Date, Date] | null>(null);
|
||||||
|
const applyStatus = ref(false);
|
||||||
|
const filters = reactive({ search: '', action: '', resourceType: '' });
|
||||||
|
const tenantForm = reactive({ name: '', timezone: 'Asia/Shanghai', status: 'ACTIVE' as 'ACTIVE' | 'DISABLED' });
|
||||||
|
const page = reactive<PageResult<AuditLog>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const prettyMetadata = computed(() => JSON.stringify(selected.value?.metadata ?? {}, null, 2));
|
||||||
|
const tenantStatusType = computed(() => overview.value?.tenant.status === 'ACTIVE' ? 'success' : 'danger');
|
||||||
|
|
||||||
|
function captureError(error: unknown) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; }
|
||||||
|
async function loadAudit() { if (!props.session.token) return; auditLoading.value = true; lastError.value = ''; try { const result = await listAuditLogs(props.session, { page: page.page, pageSize: page.pageSize, search: filters.search.trim() || undefined, action: filters.action.trim() || undefined, resourceType: filters.resourceType.trim() || undefined, from: dateRange.value?.[0].toISOString(), to: dateRange.value?.[1].toISOString() }); Object.assign(page, result); } catch (error) { captureError(error); } finally { auditLoading.value = false; } }
|
||||||
|
async function loadSystem() { if (!props.session.token) return; systemLoading.value = true; lastError.value = ''; try { overview.value = await getSystemOverview(props.session); Object.assign(tenantForm, { name: overview.value.tenant.name, timezone: overview.value.tenant.timezone, status: overview.value.tenant.status }); applyStatus.value = false; } catch (error) { captureError(error); } finally { systemLoading.value = false; } }
|
||||||
|
function applyFilters() { page.page = 1; void loadAudit(); }
|
||||||
|
async function saveConfig() { if (!tenantForm.name.trim() || !tenantForm.timezone.trim()) return ElMessage.warning('请填写租户名称和有效时区'); saving.value = true; try { await updateTenantSystemConfig(props.session, { name: tenantForm.name.trim(), timezone: tenantForm.timezone.trim(), status: applyStatus.value ? tenantForm.status : undefined }); ElMessage.success('系统配置已保存'); await loadSystem(); } catch (error) { captureError(error); } finally { saving.value = false; } }
|
||||||
|
function compactAgent(value: string) { return value ? value.slice(0, 36) : '-'; }
|
||||||
|
function csvCell(value: unknown) { return `"${String(value ?? '').replace(/"/g, '""')}"`; }
|
||||||
|
function exportCsv() { const rows = [['时间', '操作', '资源类型', '资源ID', '操作人', '来源IP', '追踪号'], ...page.items.map((item) => [item.createdAt, item.action, item.resourceType, item.resourceId, item.actorName || item.actorType, item.ip, item.traceId])]; const blob = new Blob([`\ufeff${rows.map((row) => row.map(csvCell).join(',')).join('\n')}`], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; anchor.click(); URL.revokeObjectURL(url); }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void Promise.all([loadAudit(), loadSystem()]); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<section class="people-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
|
||||||
|
<section class="people-metrics">
|
||||||
|
<span><small>当前页会员</small><strong>{{ members.items.length }}</strong></span>
|
||||||
|
<span><small>会员余额</small><strong>{{ money(memberBalance) }}</strong></span>
|
||||||
|
<span><small>累计消费</small><strong>{{ money(memberSpend) }}</strong></span>
|
||||||
|
<span><small>员工账号</small><strong>{{ staff.total }}</strong></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar people-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 用户运营</p><h3>会员与员工用户管理</h3></div>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" @click="loadActive">刷新</el-button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="people-tabs" @tab-change="loadActive">
|
||||||
|
<el-tab-pane label="会员" name="members">
|
||||||
|
<div class="people-filter-row">
|
||||||
|
<el-input v-model="memberFilters.search" class="search-input" clearable placeholder="昵称 / 手机号 / ID" @keyup.enter="applyMemberFilters" />
|
||||||
|
<el-select v-model="memberFilters.status" class="filter-select" placeholder="全部状态" @change="applyMemberFilters"><el-option label="全部状态" value="" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="members.items" class="data-table" row-key="memberId">
|
||||||
|
<el-table-column label="会员" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.nickname || `会员 #${row.memberId}` }}</strong><span>#{{ row.memberId }} · {{ row.maskedPhone || '未留手机号' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="余额" min-width="150"><template #default="{ row }"><div class="stack"><strong>{{ money(row.wallet.totalBalanceCents) }}</strong><span>现金 {{ money(row.wallet.cashBalanceCents) }} · 赠送 {{ money(row.wallet.giftBalanceCents) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="权益" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.benefits.availableCoupons }} 张券 · {{ row.benefits.activePackages }} 个套餐</strong><span>{{ row.benefits.packageMinutes }} 分钟 · {{ money(row.benefits.packageAmountCents) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="订单 / 消费" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.orders.orderCount }} 单 · {{ money(row.orders.paidAmountCents) }}</strong><span>最近 {{ shortDate(row.orders.lastOrderAt) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="注册 / 登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ shortDate(row.registeredAt) }}</strong><span>登录 {{ shortDate(row.lastLoginAt) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Eye" @click="openMember(row.memberId)">详情</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="members.page" :page-size="members.pageSize" :total="members.total" @current-change="changeMemberPage" /></div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="员工" name="staff">
|
||||||
|
<div class="people-filter-row">
|
||||||
|
<el-input v-model="staffFilters.search" class="search-input" clearable placeholder="姓名 / 手机号 / ID" @keyup.enter="applyStaffFilters" />
|
||||||
|
<el-select v-model="staffFilters.role" class="filter-select" placeholder="全部角色" @change="applyStaffFilters"><el-option label="全部角色" value="" /><el-option v-for="item in roleOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
||||||
|
<el-select v-model="staffFilters.status" class="filter-select" placeholder="全部状态" @change="applyStaffFilters"><el-option label="全部状态" value="" /><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select>
|
||||||
|
<el-button type="primary" :icon="UserPlus" @click="openCreate">新增员工</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table v-loading="loading" :data="staff.items" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="员工" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.nickname || `员工 #${row.id}` }}</strong><span>#{{ row.id }} · {{ row.maskedPhone || '未留手机号' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="role in row.roles" :key="role" effect="plain">{{ roleLabel(role) }}</el-tag></div></template></el-table-column>
|
||||||
|
<el-table-column label="门店范围" min-width="190"><template #default="{ row }">{{ storeScopeLabel(row.storeIds) }}</template></el-table-column>
|
||||||
|
<el-table-column label="微信 / 最近登录" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.wechatMiniappBound ? '已绑定小程序' : '未绑定小程序' }}</strong><span>{{ shortDate(row.lastLoginAt) }} · {{ row.maskedLastIp || '-' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column prop="note" label="运营备注" min-width="160" />
|
||||||
|
<el-table-column label="操作" width="335" fixed="right"><template #default="{ row }"><div class="row-actions"><el-button size="small" :icon="Pencil" @click="openEdit(row)">编辑</el-button><el-button size="small" :icon="KeyRound" @click="openCredential(row)">登录</el-button><el-button size="small" :type="row.status === 'ACTIVE' ? 'warning' : 'success'" @click="toggleStaff(row)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button size="small" :icon="ShieldX" @click="resetSessions(row)">会话</el-button></div></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="pager"><el-pagination layout="prev, pager, next, total" :current-page="staff.page" :page-size="staff.pageSize" :total="staff.total" @current-change="changeStaffPage" /></div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-drawer v-model="memberDrawer" title="会员详情" size="min(720px, 94vw)">
|
||||||
|
<div v-if="memberDetail" class="member-detail">
|
||||||
|
<section class="member-detail-grid">
|
||||||
|
<span><small>余额</small><strong>{{ money(memberDetail.wallet.totalBalanceCents) }}</strong></span>
|
||||||
|
<span><small>已支付订单</small><strong>{{ memberDetail.orders.paidOrderCount }}</strong></span>
|
||||||
|
<span><small>累计消费</small><strong>{{ money(memberDetail.orders.paidAmountCents) }}</strong></span>
|
||||||
|
<span><small>累计充值</small><strong>{{ money(memberDetail.recharge.creditedRechargeCents) }}</strong></span>
|
||||||
|
</section>
|
||||||
|
<el-descriptions :column="2" border><el-descriptions-item label="会员">{{ memberDetail.nickname || `#${memberDetail.memberId}` }}</el-descriptions-item><el-descriptions-item label="手机号">{{ memberDetail.maskedPhone || '-' }}</el-descriptions-item><el-descriptions-item label="可用券">{{ memberDetail.benefits.availableCoupons }}</el-descriptions-item><el-descriptions-item label="有效套餐">{{ memberDetail.benefits.activePackages }}</el-descriptions-item><el-descriptions-item label="注册时间">{{ shortDate(memberDetail.registeredAt) }}</el-descriptions-item><el-descriptions-item label="最近登录">{{ shortDate(memberDetail.lastLoginAt) }}</el-descriptions-item></el-descriptions>
|
||||||
|
<h4>最近余额流水</h4>
|
||||||
|
<el-table :data="memberDetail.recentLedger" class="data-table"><el-table-column prop="businessType" label="业务" min-width="120" /><el-table-column prop="entryType" label="类型" width="110" /><el-table-column label="现金变动" width="120"><template #default="{ row }">{{ signedMoney(row.cashDeltaCents) }}</template></el-table-column><el-table-column label="赠送变动" width="120"><template #default="{ row }">{{ signedMoney(row.giftDeltaCents) }}</template></el-table-column><el-table-column label="变动后余额" min-width="150"><template #default="{ row }">{{ money(row.cashBalanceAfterCents + row.giftBalanceAfterCents) }}</template></el-table-column><el-table-column label="时间" width="150"><template #default="{ row }">{{ shortDate(row.createdAt) }}</template></el-table-column></el-table>
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="staffDialog" :title="staffForm.userId ? '编辑员工' : '新增员工'" width="min(680px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form"><el-form-item label="姓名"><el-input v-model="staffForm.nickname" /></el-form-item><el-form-item :label="staffForm.userId ? '新手机号(留空保持原值)' : '手机号'"><el-input v-model="staffForm.phone" inputmode="tel" /></el-form-item><el-form-item label="角色" class="form-span-2"><el-checkbox-group v-model="staffForm.roles"><el-checkbox v-for="item in roleOptions" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox></el-checkbox-group></el-form-item><el-form-item label="门店授权" class="form-span-2"><el-select v-model="staffForm.storeIds" multiple filterable collapse-tags><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="运营备注" class="form-span-2"><el-input v-model="staffForm.note" type="textarea" :rows="3" /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="staffDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveStaff">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="credentialDialog" title="设置后台登录" width="min(480px, 94vw)">
|
||||||
|
<el-alert title="保存后将撤销该员工现有会话;密码不会在后台回显。" type="warning" show-icon :closable="false" />
|
||||||
|
<el-form label-position="top" class="credential-form">
|
||||||
|
<el-form-item label="员工"><el-input :model-value="credentialForm.nickname" disabled /></el-form-item>
|
||||||
|
<el-form-item label="登录账号"><el-input v-model="credentialForm.loginName" autocomplete="off" maxlength="64" placeholder="字母、数字、点、下划线或 @" /></el-form-item>
|
||||||
|
<el-form-item label="新密码"><el-input v-model="credentialForm.password" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item label="确认新密码"><el-input v-model="credentialForm.confirmPassword" type="password" show-password autocomplete="new-password" maxlength="128" /></el-form-item>
|
||||||
|
<small class="field-help">至少 12 位,同时包含字母、数字和特殊字符。</small>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="credentialDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveCredential">保存登录凭据</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Eye, KeyRound, Pencil, RefreshCw, ShieldX, UserPlus } from '@lucide/vue';
|
||||||
|
import { ApiError, createStaffUser, getMember, listManagedStores, listMembers, listStaffUsers, resetStaffSessions, setAdminCredential, updateStaffUser, type ApiSession } from '../api';
|
||||||
|
import { money, shortDate } from '../format';
|
||||||
|
import type { ManagedStore, ManagedUser, MemberCard, MemberDetail, PageResult, StaffRole, UserStatus } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const activeTab = ref('members');
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const lastError = ref('');
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const members = reactive<PageResult<MemberCard>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const staff = reactive<PageResult<ManagedUser>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const memberFilters = reactive({ search: '', status: '' as UserStatus | '' });
|
||||||
|
const staffFilters = reactive({ search: '', status: '' as UserStatus | '', role: '' as StaffRole | '' });
|
||||||
|
const memberDrawer = ref(false);
|
||||||
|
const memberDetail = ref<MemberDetail | null>(null);
|
||||||
|
const staffDialog = ref(false);
|
||||||
|
const credentialDialog = ref(false);
|
||||||
|
const staffForm = reactive({ userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'] as StaffRole[], storeIds: [] as string[] });
|
||||||
|
const credentialForm = reactive({ userId: '', nickname: '', loginName: '', password: '', confirmPassword: '' });
|
||||||
|
const roleOptions: Array<{ value: StaffRole; label: string }> = [{ value: 'STAFF', label: '普通员工' }, { value: 'CLEANER', label: '保洁员' }, { value: 'STORE_ADMIN', label: '门店管理员' }, { value: 'TENANT_ADMIN', label: '租户管理员' }];
|
||||||
|
const memberBalance = computed(() => members.items.reduce((sum, item) => sum + item.wallet.totalBalanceCents, 0));
|
||||||
|
const memberSpend = computed(() => members.items.reduce((sum, item) => sum + item.orders.paidAmountCents, 0));
|
||||||
|
|
||||||
|
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
|
||||||
|
async function loadWorkspace() { if (!props.session.token) return; loading.value = true; try { stores.value = await capture(() => listManagedStores(props.session)); await Promise.all([loadMembers(false), loadStaff(false)]); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||||
|
async function loadMembers(showLoading = true) { if (showLoading) loading.value = true; try { const result = await capture(() => listMembers(props.session, { page: members.page, pageSize: members.pageSize, status: memberFilters.status || undefined, search: memberFilters.search.trim() || undefined })); Object.assign(members, result); } catch { /* displayed */ } finally { if (showLoading) loading.value = false; } }
|
||||||
|
async function loadStaff(showLoading = true) { if (showLoading) loading.value = true; try { const result = await capture(() => listStaffUsers(props.session, { page: staff.page, pageSize: staff.pageSize, status: staffFilters.status || undefined, role: staffFilters.role || undefined, search: staffFilters.search.trim() || undefined })); Object.assign(staff, result); } catch { /* displayed */ } finally { if (showLoading) loading.value = false; } }
|
||||||
|
function loadActive() { return activeTab.value === 'members' ? loadMembers() : loadStaff(); }
|
||||||
|
function applyMemberFilters() { members.page = 1; void loadMembers(); }
|
||||||
|
function applyStaffFilters() { staff.page = 1; void loadStaff(); }
|
||||||
|
function changeMemberPage(page: number) { members.page = page; void loadMembers(); }
|
||||||
|
function changeStaffPage(page: number) { staff.page = page; void loadStaff(); }
|
||||||
|
async function openMember(memberId: string) { memberDrawer.value = true; memberDetail.value = null; try { memberDetail.value = await capture(() => getMember(props.session, memberId)); } catch { /* displayed */ } }
|
||||||
|
function openCreate() { Object.assign(staffForm, { userId: '', nickname: '', phone: '', note: '', roles: ['STAFF'], storeIds: [] }); staffDialog.value = true; }
|
||||||
|
function openEdit(row: ManagedUser) { Object.assign(staffForm, { userId: row.id, nickname: row.nickname, phone: '', note: row.note, roles: [...row.roles], storeIds: [...row.storeIds] }); staffDialog.value = true; }
|
||||||
|
function openCredential(row: ManagedUser) { Object.assign(credentialForm, { userId: row.id, nickname: row.nickname || `员工 #${row.id}`, loginName: '', password: '', confirmPassword: '' }); credentialDialog.value = true; }
|
||||||
|
async function saveCredential() { const valid = /^[\p{L}\p{N}._@+-]{3,64}$/u.test(credentialForm.loginName.trim()) && credentialForm.password.length >= 12 && /[A-Za-z]/.test(credentialForm.password) && /\d/.test(credentialForm.password) && /[^A-Za-z0-9]/.test(credentialForm.password); if (!valid) return ElMessage.warning('请填写有效登录账号和符合复杂度要求的密码'); if (credentialForm.password !== credentialForm.confirmPassword) return ElMessage.warning('两次输入的密码不一致'); saving.value = true; try { await capture(() => setAdminCredential(props.session, credentialForm.userId, { loginName: credentialForm.loginName.trim(), ['password']: credentialForm.password })); credentialDialog.value = false; ElMessage.success('后台登录凭据已更新,旧会话已撤销'); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
async function saveStaff() { if (!staffForm.nickname.trim() || (!staffForm.userId && !/^\+?[0-9]{6,20}$/.test(staffForm.phone.trim())) || staffForm.roles.length === 0) return ElMessage.warning('请填写姓名、有效手机号并至少选择一个角色'); saving.value = true; try { if (staffForm.userId) await capture(() => updateStaffUser(props.session, staffForm.userId, { nickname: staffForm.nickname.trim(), ...(staffForm.phone.trim() ? { phone: staffForm.phone.trim() } : {}), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); else await capture(() => createStaffUser(props.session, { nickname: staffForm.nickname.trim(), phone: staffForm.phone.trim(), note: staffForm.note.trim(), roles: staffForm.roles, storeIds: staffForm.storeIds })); staffDialog.value = false; ElMessage.success(staffForm.userId ? '员工资料已更新' : '员工账号已创建'); await loadStaff(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
async function toggleStaff(row: ManagedUser) { const status: UserStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; try { await ElMessageBox.confirm(`${status === 'DISABLED' ? '停用' : '启用'} ${row.nickname || `员工 #${row.id}`}?权限变更会撤销活动会话。`, '确认账号状态', { type: 'warning' }); await capture(() => updateStaffUser(props.session, row.id, { status })); ElMessage.success('员工状态已更新'); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||||
|
async function resetSessions(row: ManagedUser) { try { await ElMessageBox.confirm(`重置 ${row.nickname || `员工 #${row.id}`} 的全部活动会话?`, '确认重置会话', { type: 'warning' }); const result = await capture(() => resetStaffSessions(props.session, row.id)); ElMessage.success(`已撤销 ${result.revokedSessions} 个会话`); await loadStaff(); } catch (error) { if (error !== 'cancel' && error !== 'close') { /* displayed */ } } }
|
||||||
|
function roleLabel(role: StaffRole) { return roleOptions.find((item) => item.value === role)?.label || role; }
|
||||||
|
function storeScopeLabel(ids: string[]) { if (!ids.length) return '未配置'; return ids.map((id) => stores.value.find((item) => item.id === id)?.name || `门店 #${id}`).join('、'); }
|
||||||
|
function signedMoney(cents: number) { return `${cents > 0 ? '+' : ''}${money(cents)}`; }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<section class="overview-page" aria-label="平台运营总览">
|
||||||
|
<div class="overview-toolbar panel">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">授权数据范围</p>
|
||||||
|
<h3>门店经营快照</h3>
|
||||||
|
<p>实收只统计成功支付,房态为当前快照。</p>
|
||||||
|
</div>
|
||||||
|
<div class="overview-filters">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedStoreId"
|
||||||
|
aria-label="选择门店"
|
||||||
|
placeholder="选择授权门店"
|
||||||
|
filterable
|
||||||
|
:loading="loadingStores"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="store in stores"
|
||||||
|
:key="store.id"
|
||||||
|
:label="`${store.name} · ${store.city || '未配置城市'}`"
|
||||||
|
:value="store.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-radio-group v-model="periodDays" aria-label="统计周期">
|
||||||
|
<el-radio-button :value="7">近 7 日</el-radio-button>
|
||||||
|
<el-radio-button :value="30">近 30 日</el-radio-button>
|
||||||
|
<el-radio-button :value="90">近 90 日</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loadingStatistics" @click="loadStatistics">
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
:title="errorMessage"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-empty
|
||||||
|
v-else-if="!session.token.trim()"
|
||||||
|
description="请先在右上角保存后台访问令牌"
|
||||||
|
/>
|
||||||
|
<el-empty
|
||||||
|
v-else-if="!loadingStores && stores.length === 0"
|
||||||
|
description="当前账号没有可查看的门店"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="selectedStore && statistics">
|
||||||
|
<section class="overview-context">
|
||||||
|
<div>
|
||||||
|
<strong>{{ selectedStore.name }}</strong>
|
||||||
|
<span>{{ storeAddress }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tag :type="selectedStore.businessStatus === 'OPEN' ? 'success' : 'warning'">
|
||||||
|
{{ storeStatusText(selectedStore.businessStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
<span>{{ rangeText }}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overview-metrics" aria-label="经营核心指标">
|
||||||
|
<article class="overview-metric primary">
|
||||||
|
<span>成功实收</span>
|
||||||
|
<strong>{{ money(statistics.summary.collectedAmountCents) }}</strong>
|
||||||
|
<small>预订金额 {{ money(statistics.summary.bookedAmountCents) }}</small>
|
||||||
|
</article>
|
||||||
|
<article class="overview-metric">
|
||||||
|
<span>订单 / 活跃</span>
|
||||||
|
<strong>{{ statistics.summary.orderTotal }} / {{ statistics.summary.activeOrderTotal }}</strong>
|
||||||
|
<small>已完成 {{ statistics.summary.finishedOrderTotal }}</small>
|
||||||
|
</article>
|
||||||
|
<article class="overview-metric">
|
||||||
|
<span>下单会员</span>
|
||||||
|
<strong>{{ statistics.summary.payingMemberTotal }}</strong>
|
||||||
|
<small>按 OWNER 订单去重</small>
|
||||||
|
</article>
|
||||||
|
<article class="overview-metric">
|
||||||
|
<span>验券成功</span>
|
||||||
|
<strong>{{ statistics.summary.voucherSucceeded }}</strong>
|
||||||
|
<small>失败 {{ statistics.summary.voucherFailed }}</small>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="overview-grid">
|
||||||
|
<article class="panel overview-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">实时资源</p>
|
||||||
|
<h3>当前房态</h3>
|
||||||
|
</div>
|
||||||
|
<DoorOpen :size="22" />
|
||||||
|
</div>
|
||||||
|
<div class="room-state-grid">
|
||||||
|
<div><strong>{{ statistics.summary.roomTotal }}</strong><span>房间总数</span></div>
|
||||||
|
<div><strong>{{ statistics.summary.availableRoomTotal }}</strong><span>当前空闲</span></div>
|
||||||
|
<div><strong>{{ statistics.summary.attentionRoomTotal }}</strong><span>需要关注</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel overview-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">资金口径</p>
|
||||||
|
<h3>支付渠道</h3>
|
||||||
|
</div>
|
||||||
|
<Landmark :size="22" />
|
||||||
|
</div>
|
||||||
|
<div v-if="statistics.paymentChannels.length" class="channel-list">
|
||||||
|
<div v-for="channel in statistics.paymentChannels" :key="channel.channel">
|
||||||
|
<span>{{ channelText(channel.channel) }} · {{ channel.total }} 笔</span>
|
||||||
|
<strong>{{ money(channel.amountCents) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else :image-size="54" description="周期内暂无成功收款" />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel overview-card revenue-card">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">成功支付</p>
|
||||||
|
<h3>每日收入趋势</h3>
|
||||||
|
</div>
|
||||||
|
<TrendingUp :size="22" />
|
||||||
|
</div>
|
||||||
|
<div v-if="statistics.dailyRevenue.length" class="revenue-bars">
|
||||||
|
<div v-for="row in statistics.dailyRevenue" :key="row.date" class="revenue-row">
|
||||||
|
<span>{{ shortDate(row.date) }}</span>
|
||||||
|
<div><i :style="{ width: revenueWidth(row.amountCents) }" /></div>
|
||||||
|
<strong>{{ money(row.amountCents) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else :image-size="54" description="周期内暂无收入趋势" />
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel module-board" aria-label="核心业务导航">
|
||||||
|
<div class="section-heading">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">M08-D 工作区</p>
|
||||||
|
<h3>核心业务导航</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="module-grid">
|
||||||
|
<button type="button" @click="emit('open-cleaning')">
|
||||||
|
<Sparkles :size="22" />
|
||||||
|
<span><strong>保洁运营</strong><small>任务、结算、统计与现场联调</small></span>
|
||||||
|
<ArrowRight :size="18" />
|
||||||
|
</button>
|
||||||
|
<div v-for="item in upcomingModules" :key="item.name" class="module-placeholder">
|
||||||
|
<component :is="item.icon" :size="22" />
|
||||||
|
<span><strong>{{ item.name }}</strong><small>{{ item.description }}</small></span>
|
||||||
|
<el-tag size="small" type="info">后续增量</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, markRaw, onMounted, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
DoorOpen,
|
||||||
|
Landmark,
|
||||||
|
RadioTower,
|
||||||
|
RefreshCw,
|
||||||
|
ShoppingBag,
|
||||||
|
Sparkles,
|
||||||
|
Store,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
WalletCards
|
||||||
|
} from '@lucide/vue';
|
||||||
|
import { ApiError, getBusinessStatistics, listManagedStores, type ApiSession } from '../api';
|
||||||
|
import { money } from '../format';
|
||||||
|
import type { BusinessStatistics, ManagedStore } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const emit = defineEmits<{ (event: 'open-cleaning'): void }>();
|
||||||
|
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const periodDays = ref(30);
|
||||||
|
const statistics = ref<BusinessStatistics | null>(null);
|
||||||
|
const loadingStores = ref(false);
|
||||||
|
const loadingStatistics = ref(false);
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const selectedStore = computed(() => stores.value.find((item) => item.id === selectedStoreId.value));
|
||||||
|
const storeAddress = computed(() => {
|
||||||
|
const store = selectedStore.value;
|
||||||
|
return store ? [store.city, store.district, store.address].filter(Boolean).join(' · ') || '地址未配置' : '';
|
||||||
|
});
|
||||||
|
const rangeText = computed(() => statistics.value
|
||||||
|
? `${statistics.value.from.slice(0, 10)} 至 ${statistics.value.to.slice(0, 10)}` : '');
|
||||||
|
const maxRevenue = computed(() => Math.max(
|
||||||
|
1,
|
||||||
|
...(statistics.value?.dailyRevenue.map((item) => item.amountCents) ?? [1])
|
||||||
|
));
|
||||||
|
const upcomingModules = [
|
||||||
|
{ name: '门店与房间', description: '配置、房态和价格', icon: markRaw(Store) },
|
||||||
|
{ name: '订单与团购', description: '订单处置、核销和退款', icon: markRaw(ShoppingBag) },
|
||||||
|
{ name: '会员与员工', description: '画像、权益和账号权限', icon: markRaw(Users) },
|
||||||
|
{ name: '设备运营', description: '拓扑、告警和控制', icon: markRaw(RadioTower) },
|
||||||
|
{ name: '支付与分账', description: '收款、退款和分账记录', icon: markRaw(WalletCards) }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
if (!props.session.token.trim()) return;
|
||||||
|
loadingStores.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
try {
|
||||||
|
stores.value = await listManagedStores(props.session);
|
||||||
|
const nextStoreId = stores.value.some((item) => item.id === selectedStoreId.value)
|
||||||
|
? selectedStoreId.value : stores.value[0]?.id ?? '';
|
||||||
|
if (nextStoreId === selectedStoreId.value && nextStoreId) {
|
||||||
|
await loadStatistics();
|
||||||
|
} else {
|
||||||
|
selectedStoreId.value = nextStoreId;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
loadingStores.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatistics() {
|
||||||
|
if (!props.session.token.trim() || !selectedStoreId.value) return;
|
||||||
|
loadingStatistics.value = true;
|
||||||
|
errorMessage.value = '';
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to.getTime() - periodDays.value * 86400000);
|
||||||
|
try {
|
||||||
|
statistics.value = await getBusinessStatistics(props.session, {
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
statistics.value = null;
|
||||||
|
showError(error);
|
||||||
|
} finally {
|
||||||
|
loadingStatistics.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(error: unknown) {
|
||||||
|
errorMessage.value = error instanceof ApiError
|
||||||
|
? `${error.message}(${error.code}${error.traceId ? ` · ${error.traceId}` : ''})`
|
||||||
|
: error instanceof Error ? error.message : '加载运营总览失败';
|
||||||
|
}
|
||||||
|
|
||||||
|
function revenueWidth(amountCents: number) {
|
||||||
|
return `${Math.max(4, Math.round(amountCents / maxRevenue.value * 100))}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(value: string) {
|
||||||
|
return value.slice(5, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeStatusText(status: ManagedStore['businessStatus']) {
|
||||||
|
return ({ OPEN: '营业中', CLOSED: '已关闭', SUSPENDED: '已暂停' })[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
function channelText(channel: string) {
|
||||||
|
return ({ WECHAT: '微信支付', GROUP_BUY: '团购验券', BALANCE: '会员余额' } as Record<string, string>)[channel]
|
||||||
|
?? channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.session.token, (token, previous) => {
|
||||||
|
if (token.trim() && token !== previous) void loadStores();
|
||||||
|
});
|
||||||
|
watch([selectedStoreId, periodDays], ([storeId], previous) => {
|
||||||
|
if (storeId && previous) void loadStatistics();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.session.token.trim()) void loadStores();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<section class="orders-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar order-toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">M08-D · 订单运营</p>
|
||||||
|
<h3>订单筛选与处置</h3>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部授权门店" @change="applyFilters">
|
||||||
|
<el-option label="全部授权门店" value="" />
|
||||||
|
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-select v-model="status" class="filter-select" placeholder="全部状态" @change="applyFilters">
|
||||||
|
<el-option label="全部状态" value="" />
|
||||||
|
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="Download" :disabled="orders.items.length === 0" @click="exportOrders">导出当前页</el-button>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" @click="loadOrders">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="order-page-summary">
|
||||||
|
<span><small>当前筛选总数</small><strong>{{ orders.total }}</strong></span>
|
||||||
|
<span><small>当前页实收</small><strong>{{ money(pagePaidCents) }}</strong></span>
|
||||||
|
<span><small>当前页待支付</small><strong>{{ pendingCount }}</strong></span>
|
||||||
|
<span><small>当前页进行中</small><strong>{{ activeCount }}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="orders.items" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="订单" min-width="190">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack"><strong>{{ row.orderNo }}</strong><span>#{{ row.id }} · {{ formatDate(row.createdAt) }}</span></div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="门店 / 房间" min-width="190">
|
||||||
|
<template #default="{ row }"><div class="stack"><strong>{{ row.storeName }}</strong><span>{{ row.roomNo }} · {{ row.roomName }}</span></div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="使用时段" min-width="205">
|
||||||
|
<template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.startAt) }}</strong><span>至 {{ formatDate(row.endAt) }}</span></div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="金额" min-width="135">
|
||||||
|
<template #default="{ row }"><div class="stack"><strong>{{ money(row.paidAmountCents) }}</strong><span>应收 {{ money(row.totalAmountCents) }}</span></div></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="130">
|
||||||
|
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" min-width="260" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||||
|
<el-dropdown v-if="getAllowedActions(row.status).length" trigger="click" @command="openAction(row, $event)">
|
||||||
|
<el-button link type="primary">状态处置<ChevronDown :size="14" /></el-button>
|
||||||
|
<template #dropdown><el-dropdown-menu><el-dropdown-item v-for="action in getAllowedActions(row.status)" :key="action" :command="action">{{ actionLabel(action) }}</el-dropdown-item></el-dropdown-menu></template>
|
||||||
|
</el-dropdown>
|
||||||
|
<el-button link @click="openNote(row)">备注</el-button>
|
||||||
|
<el-button v-if="isAdjustable(row.status)" link @click="openAdjustment(row)">调整</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<footer class="pager"><el-pagination v-model:current-page="orders.page" :page-size="orders.pageSize" :total="orders.total" layout="prev, pager, next, total" @current-change="loadOrders" /></footer>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailDrawer" title="订单详情" size="min(720px, 94vw)">
|
||||||
|
<div v-if="detail" class="order-detail-stack">
|
||||||
|
<div class="detail-header"><div class="stack"><strong>{{ detail.orderNo }}</strong><span>#{{ detail.id }}</span></div><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag></div>
|
||||||
|
<div class="detail-grid">
|
||||||
|
<span><small>门店</small><strong>{{ detail.storeName }}</strong></span>
|
||||||
|
<span><small>房间</small><strong>{{ detail.roomNo }} · {{ detail.roomName }}</strong></span>
|
||||||
|
<span><small>开始</small><strong>{{ formatDate(detail.startAt) }}</strong></span>
|
||||||
|
<span><small>结束</small><strong>{{ formatDate(detail.endAt) }}</strong></span>
|
||||||
|
<span><small>应收</small><strong>{{ money(detail.totalAmountCents) }}</strong></span>
|
||||||
|
<span><small>实收</small><strong>{{ money(detail.paidAmountCents) }}</strong></span>
|
||||||
|
<span><small>支付渠道</small><strong>{{ detail.latestPayment?.provider || '-' }}</strong></span>
|
||||||
|
<span><small>支付状态</small><strong>{{ detail.latestPayment?.status || '-' }}</strong></span>
|
||||||
|
</div>
|
||||||
|
<header class="drawer-section-title"><div><p class="section-kicker">状态流水</p><h3>{{ history.length }} 条记录</h3></div><el-button :icon="RefreshCw" :loading="loadingDetail" @click="loadDetail">刷新</el-button></header>
|
||||||
|
<el-timeline>
|
||||||
|
<el-timeline-item v-for="item in history" :key="item.id" :timestamp="formatDate(item.createdAt)" placement="top">
|
||||||
|
<div class="history-card"><strong>{{ actionLabel(item.action) }} · {{ statusLabel(item.toStatus) }}</strong><span>{{ item.fromStatus ? `${statusLabel(item.fromStatus)} → ` : '' }}{{ statusLabel(item.toStatus) }}</span><small>{{ item.source }} · {{ item.actorId ? `操作人 ${item.actorId}` : item.actorType }} · {{ item.reason || '无备注' }}</small><code v-if="item.traceId">{{ item.traceId }}</code></div>
|
||||||
|
</el-timeline-item>
|
||||||
|
</el-timeline>
|
||||||
|
<el-empty v-if="!loadingDetail && history.length === 0" description="暂无状态流水" />
|
||||||
|
</div>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<el-dialog v-model="actionDialog.open" :title="actionLabel(actionDialog.action)" width="min(520px, 94vw)">
|
||||||
|
<el-form label-position="top"><el-form-item label="处置原因"><el-input v-model="actionDialog.reason" type="textarea" :rows="4" maxlength="512" show-word-limit /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="actionDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAction">确认处置</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="noteDialog.open" title="订单运营备注" width="min(520px, 94vw)">
|
||||||
|
<el-form label-position="top"><el-form-item label="备注"><el-input v-model="noteDialog.note" type="textarea" :rows="4" maxlength="512" show-word-limit /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="noteDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveNote">保存备注</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="adjustDialog.open" title="调整订单" width="min(620px, 94vw)">
|
||||||
|
<el-tabs v-model="adjustDialog.mode">
|
||||||
|
<el-tab-pane label="调整时段" name="time">
|
||||||
|
<el-form label-position="top"><el-form-item label="开始时间"><el-date-picker v-model="adjustDialog.startAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="结束时间"><el-date-picker v-model="adjustDialog.endAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item></el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="续费延时" name="renew">
|
||||||
|
<el-form label-position="top"><el-form-item label="新的结束时间"><el-date-picker v-model="adjustDialog.renewEndAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item><el-form-item label="计价策略"><el-radio-group v-model="adjustDialog.pricingPolicy"><el-radio value="LOCKED">沿用下单价</el-radio><el-radio value="CURRENT">使用当前价</el-radio></el-radio-group></el-form-item></el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="更换房间" name="room">
|
||||||
|
<el-form label-position="top"><el-form-item label="目标房间"><el-select v-model="adjustDialog.roomId" filterable><el-option v-for="room in availableRooms" :key="room.id" :label="`${room.roomNo} · ${room.name} · ${money(room.basePriceCents)}`" :value="room.id" /></el-select></el-form-item></el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
<el-form label-position="top"><el-form-item label="调整原因"><el-input v-model="adjustDialog.reason" type="textarea" maxlength="512" show-word-limit /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="adjustDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAdjustment">确认调整</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { ChevronDown, Download, RefreshCw } from '@lucide/vue';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
addManagedOrderNote,
|
||||||
|
adjustManagedOrderTime,
|
||||||
|
changeManagedOrderRoom,
|
||||||
|
executeManagedOrderAction,
|
||||||
|
getManagedOrder,
|
||||||
|
listManagedOrderHistory,
|
||||||
|
listManagedOrders,
|
||||||
|
listManagedRooms,
|
||||||
|
listManagedStores,
|
||||||
|
renewManagedOrder,
|
||||||
|
type ApiSession
|
||||||
|
} from '../api';
|
||||||
|
import { money } from '../format';
|
||||||
|
import type { ManagedOrder, ManagedRoom, ManagedStore, OrderAction, OrderHistoryItem, OrderStatus, PageResult } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const availableRooms = ref<ManagedRoom[]>([]);
|
||||||
|
const orders = reactive<PageResult<ManagedOrder>>({ items: [], total: 0, page: 1, pageSize: 20 });
|
||||||
|
const storeId = ref('');
|
||||||
|
const status = ref<OrderStatus | ''>('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const loadingDetail = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const lastError = ref('');
|
||||||
|
const detailDrawer = ref(false);
|
||||||
|
const detail = ref<ManagedOrder | null>(null);
|
||||||
|
const history = ref<OrderHistoryItem[]>([]);
|
||||||
|
const actionDialog = reactive<{ open: boolean; orderId: string; action: OrderAction; reason: string }>({ open: false, orderId: '', action: 'SUBMIT', reason: '' });
|
||||||
|
const noteDialog = reactive({ open: false, orderId: '', note: '' });
|
||||||
|
const adjustDialog = reactive({ open: false, orderId: '', storeId: '', currentRoomId: '', mode: 'time' as 'time' | 'renew' | 'room', startAt: '', endAt: '', renewEndAt: '', pricingPolicy: 'LOCKED' as 'CURRENT' | 'LOCKED', roomId: '', reason: '' });
|
||||||
|
|
||||||
|
const statusOptions: Array<{ value: OrderStatus; label: string }> = [
|
||||||
|
{ value: 'DRAFT', label: '草稿' }, { value: 'PENDING_PAYMENT', label: '待支付' },
|
||||||
|
{ value: 'PAID', label: '已支付' }, { value: 'RESERVED', label: '已预订' },
|
||||||
|
{ value: 'IN_PROGRESS', label: '使用中' }, { value: 'FINISHED', label: '已完成' },
|
||||||
|
{ value: 'CANCELLED', label: '已取消' }, { value: 'REFUNDING', label: '退款中' },
|
||||||
|
{ value: 'REFUNDED', label: '已退款' }, { value: 'CLOSED', label: '已关闭' }
|
||||||
|
];
|
||||||
|
const allowedActions: Record<OrderStatus, OrderAction[]> = {
|
||||||
|
DRAFT: ['SUBMIT', 'CANCEL', 'CLOSE'], PENDING_PAYMENT: ['CONFIRM_PAYMENT', 'CANCEL', 'CLOSE'],
|
||||||
|
PAID: ['RESERVE', 'START', 'CANCEL', 'BEGIN_REFUND'], RESERVED: ['START', 'CANCEL', 'BEGIN_REFUND'],
|
||||||
|
IN_PROGRESS: ['FINISH', 'BEGIN_REFUND'], FINISHED: ['BEGIN_REFUND', 'CLOSE'],
|
||||||
|
CANCELLED: ['BEGIN_REFUND', 'CLOSE'], REFUNDING: ['COMPLETE_REFUND'], REFUNDED: ['CLOSE'], CLOSED: []
|
||||||
|
};
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
SUBMIT: '提交待支付', CONFIRM_PAYMENT: '确认支付', RESERVE: '确认预订', START: '开始使用',
|
||||||
|
FINISH: '结束使用', CANCEL: '取消订单', BEGIN_REFUND: '发起退款', COMPLETE_REFUND: '完成退款',
|
||||||
|
CLOSE: '关闭订单', CREATED: '创建订单', EXPIRED: '订单过期', MIGRATED: '历史迁移'
|
||||||
|
};
|
||||||
|
const pagePaidCents = computed(() => orders.items.reduce((sum, item) => sum + Number(item.paidAmountCents || 0), 0));
|
||||||
|
const pendingCount = computed(() => orders.items.filter((item) => item.status === 'PENDING_PAYMENT').length);
|
||||||
|
const activeCount = computed(() => orders.items.filter((item) => ['PAID', 'RESERVED', 'IN_PROGRESS'].includes(item.status)).length);
|
||||||
|
|
||||||
|
async function capture<T>(work: () => Promise<T>) {
|
||||||
|
lastError.value = '';
|
||||||
|
try { return await work(); }
|
||||||
|
catch (error) {
|
||||||
|
lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkspace() {
|
||||||
|
try {
|
||||||
|
stores.value = await capture(() => listManagedStores(props.session));
|
||||||
|
await loadOrders();
|
||||||
|
} catch { /* displayed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrders() {
|
||||||
|
if (!props.session.token) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
Object.assign(orders, await capture(() => listManagedOrders(props.session, {
|
||||||
|
page: orders.page, pageSize: orders.pageSize, status: status.value || undefined, storeId: storeId.value || undefined
|
||||||
|
})));
|
||||||
|
} catch { /* displayed */ } finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() { orders.page = 1; void loadOrders(); }
|
||||||
|
|
||||||
|
async function openDetail(order: ManagedOrder) {
|
||||||
|
detail.value = order;
|
||||||
|
detailDrawer.value = true;
|
||||||
|
await loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
if (!detail.value) return;
|
||||||
|
loadingDetail.value = true;
|
||||||
|
try {
|
||||||
|
const [order, items] = await Promise.all([
|
||||||
|
capture(() => getManagedOrder(props.session, detail.value!.id)),
|
||||||
|
capture(() => listManagedOrderHistory(props.session, detail.value!.id))
|
||||||
|
]);
|
||||||
|
detail.value = order;
|
||||||
|
history.value = items;
|
||||||
|
} catch { /* displayed */ } finally { loadingDetail.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAction(order: ManagedOrder, command: unknown) {
|
||||||
|
Object.assign(actionDialog, { open: true, orderId: order.id, action: String(command) as OrderAction, reason: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAction() {
|
||||||
|
if (!actionDialog.reason.trim()) return ElMessage.warning('请填写处置原因');
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await capture(() => executeManagedOrderAction(props.session, actionDialog.orderId, actionDialog.action, actionDialog.reason.trim()));
|
||||||
|
actionDialog.open = false;
|
||||||
|
ElMessage.success('订单状态已更新');
|
||||||
|
await loadOrders();
|
||||||
|
if (detail.value?.id === actionDialog.orderId) await loadDetail();
|
||||||
|
} catch { /* displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNote(order: ManagedOrder) { Object.assign(noteDialog, { open: true, orderId: order.id, note: '' }); }
|
||||||
|
async function saveNote() {
|
||||||
|
if (!noteDialog.note.trim()) return ElMessage.warning('请填写备注');
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await capture(() => addManagedOrderNote(props.session, noteDialog.orderId, noteDialog.note.trim()));
|
||||||
|
noteDialog.open = false;
|
||||||
|
ElMessage.success('订单备注已保存');
|
||||||
|
if (detail.value?.id === noteDialog.orderId) await loadDetail();
|
||||||
|
} catch { /* displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdjustment(order: ManagedOrder) {
|
||||||
|
Object.assign(adjustDialog, {
|
||||||
|
open: true, orderId: order.id, storeId: order.storeId, currentRoomId: order.roomId,
|
||||||
|
mode: 'time', startAt: toPickerValue(order.startAt), endAt: toPickerValue(order.endAt),
|
||||||
|
renewEndAt: toPickerValue(order.endAt), pricingPolicy: 'LOCKED', roomId: '', reason: ''
|
||||||
|
});
|
||||||
|
try { availableRooms.value = (await capture(() => listManagedRooms(props.session, order.storeId))).filter((room) => room.id !== order.roomId && room.configurationStatus === 'ENABLED'); }
|
||||||
|
catch { availableRooms.value = []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdjustment() {
|
||||||
|
if (!adjustDialog.reason.trim()) return ElMessage.warning('请填写调整原因');
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (adjustDialog.mode === 'time') {
|
||||||
|
if (!adjustDialog.startAt && !adjustDialog.endAt) return ElMessage.warning('至少填写一个时间');
|
||||||
|
await capture(() => adjustManagedOrderTime(props.session, adjustDialog.orderId, {
|
||||||
|
startAt: adjustDialog.startAt || undefined, endAt: adjustDialog.endAt || undefined, reason: adjustDialog.reason.trim()
|
||||||
|
}));
|
||||||
|
} else if (adjustDialog.mode === 'renew') {
|
||||||
|
if (!adjustDialog.renewEndAt) return ElMessage.warning('请选择新的结束时间');
|
||||||
|
await capture(() => renewManagedOrder(props.session, adjustDialog.orderId, {
|
||||||
|
endAt: adjustDialog.renewEndAt, pricingPolicy: adjustDialog.pricingPolicy, reason: adjustDialog.reason.trim()
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
if (!adjustDialog.roomId) return ElMessage.warning('请选择目标房间');
|
||||||
|
await capture(() => changeManagedOrderRoom(props.session, adjustDialog.orderId, {
|
||||||
|
roomId: adjustDialog.roomId, reason: adjustDialog.reason.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
adjustDialog.open = false;
|
||||||
|
ElMessage.success('订单调整已完成');
|
||||||
|
await loadOrders();
|
||||||
|
if (detail.value?.id === adjustDialog.orderId) await loadDetail();
|
||||||
|
} catch { /* displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportOrders() {
|
||||||
|
const rows = [['订单号', '门店', '房间', '状态', '开始', '结束', '应收', '实收', '支付渠道', '支付状态']];
|
||||||
|
for (const order of orders.items) rows.push([order.orderNo, order.storeName, `${order.roomNo} ${order.roomName}`, statusLabel(order.status), formatDate(order.startAt), formatDate(order.endAt), money(order.totalAmountCents), money(order.paidAmountCents), order.latestPayment?.provider || '', order.latestPayment?.status || '']);
|
||||||
|
const csv = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
|
||||||
|
const url = URL.createObjectURL(new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' }));
|
||||||
|
const link = document.createElement('a'); link.href = url; link.download = `orders-${Date.now()}.csv`; link.click(); URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csvCell(value: string) { const safe = String(value).replace(/\r?\n/g, ' '); return /[",\r\n]/.test(safe) ? `"${safe.replace(/"/g, '""')}"` : safe; }
|
||||||
|
function statusLabel(value: OrderStatus) { return statusOptions.find((item) => item.value === value)?.label || value; }
|
||||||
|
function statusType(value: OrderStatus) { return value === 'IN_PROGRESS' ? 'success' : ['PENDING_PAYMENT', 'REFUNDING'].includes(value) ? 'warning' : ['CANCELLED', 'CLOSED'].includes(value) ? 'info' : 'primary'; }
|
||||||
|
function actionLabel(value: string) { return actionLabels[value] || value; }
|
||||||
|
function getAllowedActions(value: OrderStatus) { return allowedActions[value] || []; }
|
||||||
|
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
|
||||||
|
function toPickerValue(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? '' : date.toISOString(); }
|
||||||
|
function isAdjustable(value: OrderStatus) { return ['PENDING_PAYMENT', 'PAID', 'RESERVED', 'IN_PROGRESS'].includes(value); }
|
||||||
|
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<section class="payments-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<el-alert v-if="lastResult" :title="lastResult" type="success" show-icon closable @close="lastResult = ''" />
|
||||||
|
|
||||||
|
<section class="payment-metrics">
|
||||||
|
<span><small>收款账户</small><strong>{{ snapshot.accounts.length }}</strong></span>
|
||||||
|
<span><small>已授权账户</small><strong>{{ authorizedAccounts }}</strong></span>
|
||||||
|
<span><small>分账接收方</small><strong>{{ snapshot.receivers.length }}</strong></span>
|
||||||
|
<span><small>近百笔分账金额</small><strong>{{ money(sharedAmountCents) }}</strong></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar payment-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 资金运营</p><h3>支付、退款与分账</h3></div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部账户范围" @change="loadSnapshot">
|
||||||
|
<el-option label="全部账户范围" value="" />
|
||||||
|
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" @click="loadSnapshot">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="payment-tabs">
|
||||||
|
<el-tab-pane label="分账记录" name="shares">
|
||||||
|
<el-table v-loading="loading" :data="snapshot.shares" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="分账单" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.shareNo }}</strong><span>支付 #{{ row.paymentId }} · 订单 #{{ row.orderId }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="接收方" min-width="160"><template #default="{ row }"><div class="stack"><strong>{{ row.receiverMasked }}</strong><span>{{ row.receiverType }} · {{ percent(row.percentageBps) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="金额" width="130"><template #default="{ row }"><strong>{{ money(row.amountCents) }}</strong></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="130"><template #default="{ row }"><el-tag :type="shareStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="门店 / 时间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ storeName(row.storeId) }}</strong><span>{{ formatDate(row.createdAt) }}</span><span v-if="row.failureCode">{{ row.failureCode }}</span></div></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="收款账户" name="accounts">
|
||||||
|
<div class="tab-action-row"><span>凭据只保存环境引用,页面不读取或回显私钥。</span><el-button type="primary" :icon="Plus" @click="openAccountForm">配置账户</el-button></div>
|
||||||
|
<el-table :data="snapshot.accounts" class="data-table" row-key="id">
|
||||||
|
<el-table-column prop="id" label="账户 ID" width="100" />
|
||||||
|
<el-table-column label="范围" min-width="150"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '租户默认' }}</template></el-table-column>
|
||||||
|
<el-table-column prop="merchantId" label="商户号" min-width="160" />
|
||||||
|
<el-table-column label="授权" width="130"><template #default="{ row }"><el-tag :type="authorizationType(row.authorizationStatus)">{{ authorizationLabel(row.authorizationStatus) }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="分账 / 启用" min-width="150"><template #default="{ row }"><div class="profile-badges"><el-tag :type="truthy(row.profitSharingEnabled) ? 'success' : 'info'" size="small">{{ truthy(row.profitSharingEnabled) ? '分账开启' : '分账关闭' }}</el-tag><el-tag :type="truthy(row.enabled) ? 'success' : 'info'" size="small">{{ truthy(row.enabled) ? '已启用' : '已停用' }}</el-tag></div></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="接收方与策略" name="policies">
|
||||||
|
<div class="tab-action-row"><span>分账比例由服务端校验,总和不得超过 100%。</span><div class="row-actions"><el-button :icon="Plus" @click="openReceiverForm">新增接收方</el-button><el-button type="primary" :icon="Plus" @click="openPolicyForm">新增策略</el-button></div></div>
|
||||||
|
<el-table :data="snapshot.receivers" class="data-table" row-key="id">
|
||||||
|
<el-table-column prop="name" label="接收方" min-width="150" />
|
||||||
|
<el-table-column prop="receiverMasked" label="脱敏标识" min-width="170" />
|
||||||
|
<el-table-column prop="receiverType" label="类型" min-width="150" />
|
||||||
|
<el-table-column prop="relationType" label="关系" min-width="120" />
|
||||||
|
<el-table-column label="授权" width="120"><template #default="{ row }"><el-tag :type="authorizationType(row.authorizationStatus)">{{ authorizationLabel(row.authorizationStatus) }}</el-tag></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-table :data="snapshot.policies" class="data-table policy-table" row-key="id">
|
||||||
|
<el-table-column prop="id" label="策略 ID" width="100" />
|
||||||
|
<el-table-column label="账户" min-width="120"><template #default="{ row }">#{{ row.collectionAccountId }}</template></el-table-column>
|
||||||
|
<el-table-column label="范围" min-width="160"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '账户默认范围' }}</template></el-table-column>
|
||||||
|
<el-table-column label="接收方" min-width="160"><template #default="{ row }">{{ receiverName(row.receiverId) }}</template></el-table-column>
|
||||||
|
<el-table-column label="比例" width="120"><template #default="{ row }"><strong>{{ percent(row.percentageBps) }}</strong></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="资金工具" name="tools">
|
||||||
|
<div class="payment-tools-grid">
|
||||||
|
<article><header><RotateCcw :size="20" /><div><strong>微信退款</strong><span>金额由操作人明确填写,服务端校验可退余额与幂等键。</span></div></header><el-button type="primary" plain @click="toolDialog = 'refund'">发起退款</el-button></article>
|
||||||
|
<article><header><ReceiptText :size="20" /><div><strong>微信对账单</strong><span>按门店、账单日期与类型请求官方账单下载地址。</span></div></header><el-button type="primary" plain @click="toolDialog = 'reconciliation'">请求对账</el-button></article>
|
||||||
|
<article><header><Split :size="20" /><div><strong>执行分账</strong><span>生产 API 模式需账户与接收方均授权;Mock 仅非生产可用。</span></div></header><el-button type="primary" plain @click="toolDialog = 'share'">执行分账</el-button></article>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="accountDialog" title="配置收款账户" width="min(620px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form"><el-form-item label="门店范围"><el-select v-model="accountForm.storeId" clearable><el-option label="租户默认" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="商户号"><el-input v-model="accountForm.merchantId" /></el-form-item><el-form-item label="凭据环境引用" class="form-span-2"><el-input v-model="accountForm.credentialRef" placeholder="env:WECHAT_PAY_STORE_001" /></el-form-item><el-form-item label="授权状态"><el-select v-model="accountForm.authorizationStatus"><el-option v-for="item in authorizationOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="开关"><div class="row-actions"><el-switch v-model="accountForm.profitSharingEnabled" active-text="分账" /><el-switch v-model="accountForm.enabled" active-text="启用" /></div></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="accountDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveAccount">保存账户</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="receiverDialog" title="新增或更新分账接收方" width="min(620px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form"><el-form-item label="收款账户"><el-select v-model="receiverForm.collectionAccountId"><el-option v-for="item in snapshot.accounts" :key="item.id" :label="`#${item.id} ${item.merchantId}`" :value="item.id" /></el-select></el-form-item><el-form-item label="接收方类型"><el-select v-model="receiverForm.receiverType"><el-option label="商户号" value="MERCHANT_ID" /><el-option label="个人 OpenID" value="PERSONAL_OPENID" /></el-select></el-form-item><el-form-item label="接收方账户"><el-input v-model="receiverForm.receiverAccount" type="password" show-password /></el-form-item><el-form-item label="接收方凭据引用"><el-input v-model="receiverForm.receiverCredentialRef" /></el-form-item><el-form-item label="名称"><el-input v-model="receiverForm.name" /></el-form-item><el-form-item label="关系类型"><el-input v-model="receiverForm.relationType" placeholder="SERVICE_PROVIDER" /></el-form-item><el-form-item label="授权状态"><el-select v-model="receiverForm.authorizationStatus"><el-option v-for="item in authorizationOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="receiverForm.enabled" /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="receiverDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveReceiver">保存接收方</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="policyDialog" title="新增或更新分账策略" width="min(560px, 94vw)">
|
||||||
|
<el-form label-position="top"><el-form-item label="收款账户"><el-select v-model="policyForm.collectionAccountId"><el-option v-for="item in snapshot.accounts" :key="item.id" :label="`#${item.id} ${item.merchantId}`" :value="item.id" /></el-select></el-form-item><el-form-item label="门店范围"><el-select v-model="policyForm.storeId" clearable><el-option label="账户默认范围" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="接收方"><el-select v-model="policyForm.receiverId"><el-option v-for="item in snapshot.receivers" :key="item.id" :label="`${item.name} · ${item.receiverMasked}`" :value="item.id" /></el-select></el-form-item><el-form-item label="分账比例(%)"><el-input-number v-model="policyForm.percentage" :min="0.01" :max="100" :precision="2" /></el-form-item><el-form-item label="启用"><el-switch v-model="policyForm.enabled" /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="policyDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePolicy">保存策略</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog :model-value="toolDialog !== ''" :title="toolTitle" width="min(560px, 94vw)" @close="toolDialog = ''">
|
||||||
|
<el-form v-if="toolDialog === 'refund'" label-position="top"><el-form-item label="支付 ID"><el-input v-model="refundForm.paymentId" /></el-form-item><el-form-item label="退款金额(元)"><el-input-number v-model="refundForm.amountYuan" :min="0.01" :precision="2" /></el-form-item><el-form-item label="退款原因"><el-input v-model="refundForm.reason" type="textarea" /></el-form-item></el-form>
|
||||||
|
<el-form v-else-if="toolDialog === 'reconciliation'" label-position="top"><el-form-item label="门店"><el-select v-model="reconciliationForm.storeId"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="账单日期"><el-date-picker v-model="reconciliationForm.billDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账单类型"><el-radio-group v-model="reconciliationForm.billType"><el-radio value="ALL">全部</el-radio><el-radio value="SUCCESS">支付</el-radio><el-radio value="REFUND">退款</el-radio></el-radio-group></el-form-item></el-form>
|
||||||
|
<el-form v-else label-position="top"><el-form-item label="支付 ID"><el-input v-model="shareForm.paymentId" /></el-form-item><el-form-item label="执行模式"><el-radio-group v-model="shareForm.mode"><el-radio value="API">生产 API</el-radio><el-radio value="MOCK">非生产 Mock</el-radio></el-radio-group></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="toolDialog = ''">取消</el-button><el-button type="primary" :loading="saving" @click="executeTool">确认执行</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Plus, ReceiptText, RefreshCw, RotateCcw, Split } from '@lucide/vue';
|
||||||
|
import {
|
||||||
|
ApiError, createWechatRefund, executeProfitSharing, getProfitSharingSnapshot,
|
||||||
|
listManagedStores, requestWechatReconciliation, saveCollectionAccount,
|
||||||
|
saveProfitSharePolicy, saveProfitShareReceiver, type ApiSession
|
||||||
|
} from '../api';
|
||||||
|
import { money } from '../format';
|
||||||
|
import type { ManagedStore, PaymentAuthorizationStatus, ProfitSharingSnapshot } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const snapshot = reactive<ProfitSharingSnapshot>({ accounts: [], receivers: [], policies: [], shares: [] });
|
||||||
|
const storeId = ref('');
|
||||||
|
const activeTab = ref('shares');
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const lastError = ref('');
|
||||||
|
const lastResult = ref('');
|
||||||
|
const accountDialog = ref(false);
|
||||||
|
const receiverDialog = ref(false);
|
||||||
|
const policyDialog = ref(false);
|
||||||
|
const toolDialog = ref<'' | 'refund' | 'reconciliation' | 'share'>('');
|
||||||
|
const accountForm = reactive({ storeId: '', merchantId: '', credentialRef: '', authorizationStatus: 'UNAUTHORIZED' as PaymentAuthorizationStatus, profitSharingEnabled: false, enabled: true });
|
||||||
|
const receiverForm = reactive({ collectionAccountId: '', receiverType: 'MERCHANT_ID' as 'MERCHANT_ID' | 'PERSONAL_OPENID', receiverAccount: '', receiverCredentialRef: '', relationType: 'SERVICE_PROVIDER', name: '', authorizationStatus: 'UNAUTHORIZED' as PaymentAuthorizationStatus, enabled: true });
|
||||||
|
const policyForm = reactive({ collectionAccountId: '', storeId: '', receiverId: '', percentage: 1, enabled: true });
|
||||||
|
const refundForm = reactive({ paymentId: '', amountYuan: 0, reason: '' });
|
||||||
|
const reconciliationForm = reactive({ storeId: '', billDate: '', billType: 'ALL' as 'ALL' | 'SUCCESS' | 'REFUND' });
|
||||||
|
const shareForm = reactive({ paymentId: '', mode: 'API' as 'API' | 'MOCK' });
|
||||||
|
const authorizationOptions: Array<{ value: PaymentAuthorizationStatus; label: string }> = [{ value: 'UNAUTHORIZED', label: '未授权' }, { value: 'PENDING', label: '授权中' }, { value: 'AUTHORIZED', label: '已授权' }, { value: 'REVOKED', label: '已撤销' }];
|
||||||
|
const authorizedAccounts = computed(() => snapshot.accounts.filter((item) => item.authorizationStatus === 'AUTHORIZED').length);
|
||||||
|
const sharedAmountCents = computed(() => snapshot.shares.reduce((sum, item) => sum + Number(item.amountCents || 0), 0));
|
||||||
|
const toolTitle = computed(() => toolDialog.value === 'refund' ? '发起微信退款' : toolDialog.value === 'reconciliation' ? '请求微信对账单' : '执行微信分账');
|
||||||
|
|
||||||
|
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
|
||||||
|
async function loadWorkspace() { try { stores.value = await capture(() => listManagedStores(props.session)); await loadSnapshot(); } catch { /* displayed */ } }
|
||||||
|
async function loadSnapshot() { if (!props.session.token) return; loading.value = true; try { Object.assign(snapshot, await capture(() => getProfitSharingSnapshot(props.session, storeId.value || undefined))); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||||
|
function openAccountForm() { Object.assign(accountForm, { storeId: storeId.value, merchantId: '', credentialRef: '', authorizationStatus: 'UNAUTHORIZED', profitSharingEnabled: false, enabled: true }); accountDialog.value = true; }
|
||||||
|
function openReceiverForm() { Object.assign(receiverForm, { collectionAccountId: snapshot.accounts[0]?.id || '', receiverType: 'MERCHANT_ID', receiverAccount: '', receiverCredentialRef: '', relationType: 'SERVICE_PROVIDER', name: '', authorizationStatus: 'UNAUTHORIZED', enabled: true }); receiverDialog.value = true; }
|
||||||
|
function openPolicyForm() { Object.assign(policyForm, { collectionAccountId: snapshot.accounts[0]?.id || '', storeId: storeId.value, receiverId: snapshot.receivers[0]?.id || '', percentage: 1, enabled: true }); policyDialog.value = true; }
|
||||||
|
async function saveAccount() { if (!accountForm.merchantId || !accountForm.credentialRef) return ElMessage.warning('请填写商户号和凭据引用'); saving.value = true; try { await capture(() => saveCollectionAccount(props.session, { ...accountForm, storeId: accountForm.storeId || null })); accountDialog.value = false; ElMessage.success('收款账户已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
async function saveReceiver() { if (!receiverForm.collectionAccountId || !receiverForm.receiverAccount || !receiverForm.receiverCredentialRef || !receiverForm.name) return ElMessage.warning('请完整填写接收方资料'); saving.value = true; try { await capture(() => saveProfitShareReceiver(props.session, receiverForm)); receiverDialog.value = false; receiverForm.receiverAccount = ''; ElMessage.success('分账接收方已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
async function savePolicy() { if (!policyForm.collectionAccountId || !policyForm.receiverId) return ElMessage.warning('请选择账户和接收方'); saving.value = true; try { await capture(() => saveProfitSharePolicy(props.session, { collectionAccountId: policyForm.collectionAccountId, storeId: policyForm.storeId || null, receiverId: policyForm.receiverId, percentageBps: Math.round(policyForm.percentage * 100), enabled: policyForm.enabled })); policyDialog.value = false; ElMessage.success('分账策略已保存'); await loadSnapshot(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
async function executeTool() { saving.value = true; lastResult.value = ''; try { if (toolDialog.value === 'refund') { if (!refundForm.paymentId || !refundForm.reason || refundForm.amountYuan <= 0) return ElMessage.warning('请完整填写退款信息'); const result = await capture(() => createWechatRefund(props.session, { paymentId: refundForm.paymentId, amountCents: Math.round(refundForm.amountYuan * 100), reason: refundForm.reason, clientRequestId: requestId('refund') })); lastResult.value = `退款 ${result.refundNo} 已提交,状态 ${result.status},剩余可退 ${money(result.refundableCents)}`; } else if (toolDialog.value === 'reconciliation') { if (!reconciliationForm.storeId || !reconciliationForm.billDate) return ElMessage.warning('请选择门店和账单日期'); const result = await capture(() => requestWechatReconciliation(props.session, reconciliationForm)); lastResult.value = `对账请求 #${result.id} 状态 ${result.status}${result.downloadUrl ? ',下载地址已由服务端返回' : ''}`; } else { if (!shareForm.paymentId) return ElMessage.warning('请填写支付 ID'); const result = await capture(() => executeProfitSharing(props.session, { paymentId: shareForm.paymentId, clientRequestId: requestId('share'), mode: shareForm.mode })); lastResult.value = `分账已提交,共 ${result.shares.length} 条${result.idempotent ? '(幂等复用)' : ''}`; await loadSnapshot(); } toolDialog.value = ''; } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; }
|
||||||
|
function storeName(id: string) { return stores.value.find((item) => item.id === String(id))?.name || `门店 #${id}`; }
|
||||||
|
function receiverName(id: string) { const item = snapshot.receivers.find((receiver) => receiver.id === String(id)); return item ? `${item.name} · ${item.receiverMasked}` : `接收方 #${id}`; }
|
||||||
|
function authorizationLabel(value: PaymentAuthorizationStatus) { return authorizationOptions.find((item) => item.value === value)?.label || value; }
|
||||||
|
function authorizationType(value: PaymentAuthorizationStatus) { return value === 'AUTHORIZED' ? 'success' : value === 'PENDING' ? 'warning' : 'info'; }
|
||||||
|
function shareStatusType(value: string) { return value === 'SUCCEEDED' ? 'success' : value === 'FAILED' ? 'danger' : 'warning'; }
|
||||||
|
function truthy(value: boolean | number) { return value === true || Number(value) === 1; }
|
||||||
|
function percent(bps: number) { return `${(Number(bps || 0) / 100).toFixed(2)}%`; }
|
||||||
|
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<section class="platform-apps-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<el-alert title="AppSecret、微信支付密钥和证书只允许配置在服务端环境变量中,本页面不会读取、保存或回显。" type="info" show-icon :closable="false" />
|
||||||
|
<section class="platform-app-metrics"><span><small>已绑定应用</small><strong>{{ apps.length }}</strong></span><span><small>启用绑定</small><strong>{{ activeCount }}</strong></span><span><small>默认应用</small><strong>{{ defaultCount }}</strong></span><span><small>品牌配置完整</small><strong>{{ configuredCount }}</strong></span></section>
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar platform-app-toolbar"><div><p class="section-kicker">M08-D · 多应用租户</p><h3>多小程序与租户品牌配置</h3></div><div class="toolbar-actions"><el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button><el-button type="primary" :icon="Plus" @click="openBind">绑定应用</el-button></div></header>
|
||||||
|
<el-table v-loading="loading" :data="apps" class="data-table" row-key="platformAppId">
|
||||||
|
<el-table-column label="逻辑应用" min-width="210"><template #default="{ row }"><div class="stack"><strong>{{ row.appName }}</strong><span>{{ row.appId }} · #{{ row.platformAppId }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="绑定" width="130"><template #default="{ row }"><div class="stack"><el-tag :type="row.bindingStatus === 'ACTIVE' ? 'success' : 'info'">{{ row.bindingStatus }}</el-tag><span>{{ row.isDefault ? '租户默认' : '普通绑定' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="品牌" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.brandName }}</strong><span><i class="theme-dot" :style="{ background: row.themeColor }" />{{ row.themeColor }} · {{ row.servicePhone || '未留客服电话' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="默认门店" min-width="160"><template #default="{ row }">{{ row.defaultStoreId ? storeName(row.defaultStoreId) : '未配置' }}</template></el-table-column>
|
||||||
|
<el-table-column label="分享" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ row.shareTitle || '-' }}</strong><span>{{ row.shareImageUrl ? '已配置分享图' : '未配置分享图' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="更新时间" width="150"><template #default="{ row }">{{ shortDate(row.updatedAt) }}</template></el-table-column>
|
||||||
|
<el-table-column label="操作" width="100" fixed="right"><template #default="{ row }"><el-button size="small" :icon="Settings2" @click="openEdit(row)">配置</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialog" :title="form.platformAppId ? '编辑租户应用配置' : '绑定逻辑应用'" width="min(760px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form">
|
||||||
|
<template v-if="!form.platformAppId"><el-form-item label="微信 AppID"><el-input v-model="form.appId" autocomplete="off" /></el-form-item><el-form-item label="应用名称"><el-input v-model="form.appName" /></el-form-item><el-form-item label="全局应用状态"><el-select v-model="form.appStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item></template>
|
||||||
|
<el-form-item label="品牌名称"><el-input v-model="form.brandName" /></el-form-item><el-form-item label="主题色"><el-color-picker v-model="form.themeColor" /><el-input v-model="form.themeColor" class="color-input" /></el-form-item><el-form-item label="客服电话"><el-input v-model="form.servicePhone" /></el-form-item><el-form-item label="加盟电话"><el-input v-model="form.franchisePhone" /></el-form-item><el-form-item label="默认门店"><el-select v-model="form.defaultStoreId" clearable><el-option label="不指定" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="绑定状态"><el-select v-model="form.bindingStatus"><el-option label="启用" value="ACTIVE" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item><el-form-item label="租户默认应用"><el-switch v-model="form.isDefault" /></el-form-item><el-form-item label="Logo URL" class="form-span-2"><el-input v-model="form.logoUrl" /></el-form-item><el-form-item label="分享标题" class="form-span-2"><el-input v-model="form.shareTitle" /></el-form-item><el-form-item label="分享图片 URL" class="form-span-2"><el-input v-model="form.shareImageUrl" /></el-form-item><el-form-item label="非敏感扩展配置 JSON" class="form-span-2"><el-input v-model="form.extraConfigText" type="textarea" :rows="5" placeholder='{"bookingMode":"STANDARD"}' /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="dialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Plus, RefreshCw, Settings2 } from '@lucide/vue';
|
||||||
|
import { ApiError, bindPlatformApplication, listManagedStores, listPlatformApplications, updatePlatformApplicationConfig, type ApiSession } from '../api';
|
||||||
|
import { shortDate } from '../format';
|
||||||
|
import type { ManagedStore, PlatformApplication, TenantApplicationConfig } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const apps = ref<PlatformApplication[]>([]); const stores = ref<ManagedStore[]>([]); const loading = ref(false); const saving = ref(false); const lastError = ref(''); const dialog = ref(false);
|
||||||
|
const form = reactive({ platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE' as 'ACTIVE' | 'DISABLED', isDefault: false, extraConfigText: '{}' });
|
||||||
|
const activeCount = computed(() => apps.value.filter((item) => item.bindingStatus === 'ACTIVE').length); const defaultCount = computed(() => apps.value.filter((item) => item.isDefault).length); const configuredCount = computed(() => apps.value.filter((item) => item.brandName && item.themeColor && item.shareTitle).length);
|
||||||
|
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
|
||||||
|
async function loadData() { if (!props.session.token) return; loading.value = true; try { [apps.value, stores.value] = await Promise.all([capture(() => listPlatformApplications(props.session)), capture(() => listManagedStores(props.session))]); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||||
|
function defaults() { return { platformAppId: '', appId: '', appName: '', appStatus: 'ACTIVE', brandName: '', logoUrl: '', themeColor: '#1677ff', servicePhone: '', franchisePhone: '', shareTitle: '', shareImageUrl: '', defaultStoreId: '', bindingStatus: 'ACTIVE', isDefault: apps.value.length === 0, extraConfigText: '{}' }; }
|
||||||
|
function openBind() { Object.assign(form, defaults()); dialog.value = true; }
|
||||||
|
function openEdit(row: PlatformApplication) { Object.assign(form, { platformAppId: row.platformAppId, appId: row.appId, appName: row.appName, appStatus: row.appStatus, brandName: row.brandName, logoUrl: row.logoUrl, themeColor: row.themeColor, servicePhone: row.servicePhone, franchisePhone: row.franchisePhone, shareTitle: row.shareTitle, shareImageUrl: row.shareImageUrl, defaultStoreId: row.defaultStoreId || '', bindingStatus: row.bindingStatus, isDefault: row.isDefault, extraConfigText: JSON.stringify(row.extraConfig, null, 2) }); dialog.value = true; }
|
||||||
|
function configInput(): TenantApplicationConfig | null { let extraConfig: unknown; try { extraConfig = JSON.parse(form.extraConfigText || '{}'); } catch { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } if (!extraConfig || typeof extraConfig !== 'object' || Array.isArray(extraConfig)) { ElMessage.warning('扩展配置必须是 JSON 对象'); return null; } return { brandName: form.brandName.trim(), logoUrl: form.logoUrl.trim(), themeColor: form.themeColor, servicePhone: form.servicePhone.trim(), franchisePhone: form.franchisePhone.trim(), shareTitle: form.shareTitle.trim(), shareImageUrl: form.shareImageUrl.trim(), defaultStoreId: form.defaultStoreId || null, extraConfig: extraConfig as Record<string, unknown>, bindingStatus: form.bindingStatus, isDefault: form.isDefault }; }
|
||||||
|
async function save() { const config = configInput(); if (!config || !config.brandName || !/^#[0-9A-Fa-f]{6}$/.test(config.themeColor) || (!form.platformAppId && (!form.appId || !form.appName))) return ElMessage.warning('请填写 AppID、应用名称、品牌名称和有效主题色'); saving.value = true; try { if (form.platformAppId) await capture(() => updatePlatformApplicationConfig(props.session, form.platformAppId, config)); else await capture(() => bindPlatformApplication(props.session, { appId: form.appId.trim(), appName: form.appName.trim(), appStatus: form.appStatus, config })); dialog.value = false; ElMessage.success('应用配置已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function storeName(id: string) { return stores.value.find((item) => item.id === id)?.name || `门店 #${id}`; }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadData(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,545 @@
|
|||||||
|
<template>
|
||||||
|
<section class="stores-page">
|
||||||
|
<el-alert
|
||||||
|
v-if="lastError"
|
||||||
|
class="alert-line"
|
||||||
|
:title="lastError"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
@close="lastError = ''"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar store-toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">M08-D · 门店资产</p>
|
||||||
|
<h3>门店与房间管理</h3>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-input v-model="search" class="search-input" clearable placeholder="搜索门店、城市或地址" />
|
||||||
|
<el-select v-model="statusFilter" class="filter-select" placeholder="营业状态">
|
||||||
|
<el-option label="全部状态" value="" />
|
||||||
|
<el-option label="营业中" value="OPEN" />
|
||||||
|
<el-option label="已闭店" value="CLOSED" />
|
||||||
|
<el-option label="暂停营业" value="SUSPENDED" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loadingStores" @click="loadStores">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openStoreForm()">新增门店</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="store-layout">
|
||||||
|
<aside class="store-list" aria-label="门店列表">
|
||||||
|
<button
|
||||||
|
v-for="store in filteredStores"
|
||||||
|
:key="store.id"
|
||||||
|
class="store-card"
|
||||||
|
:class="{ active: selectedStoreId === store.id }"
|
||||||
|
type="button"
|
||||||
|
@click="selectStore(store.id)"
|
||||||
|
>
|
||||||
|
<span class="store-card-title">
|
||||||
|
<strong>{{ store.name }}</strong>
|
||||||
|
<el-tag :type="storeStatusType(store.businessStatus)" size="small">
|
||||||
|
{{ storeStatusLabel(store.businessStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
<span>{{ [store.city, store.district].filter(Boolean).join(' · ') || '未填写地区' }}</span>
|
||||||
|
<small>{{ store.address || '未填写地址' }}</small>
|
||||||
|
</button>
|
||||||
|
<el-empty v-if="!loadingStores && filteredStores.length === 0" description="没有匹配门店" />
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div v-if="selectedStore" class="store-detail">
|
||||||
|
<header class="store-detail-header">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">门店 #{{ selectedStore.id }}</p>
|
||||||
|
<h3>{{ selectedStore.name }}</h3>
|
||||||
|
<p>{{ selectedStore.address || '未填写地址' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button :icon="Edit3" @click="openStoreForm(selectedStore)">编辑门店</el-button>
|
||||||
|
<el-button type="danger" plain :icon="Archive" @click="archiveStore(selectedStore)">
|
||||||
|
归档
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="detail-grid store-summary-grid">
|
||||||
|
<span><small>联系电话</small><strong>{{ selectedStore.contactPhone || '-' }}</strong></span>
|
||||||
|
<span><small>时区</small><strong>{{ selectedStore.timezone }}</strong></span>
|
||||||
|
<span><small>访客 Wi-Fi</small><strong>{{ wifiSummary(selectedStore) }}</strong></span>
|
||||||
|
<span><small>通知地址</small><strong>{{ selectedStore.notificationUrl || '-' }}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="business-hours-board">
|
||||||
|
<strong>营业时段</strong>
|
||||||
|
<div class="hours-chip-list">
|
||||||
|
<span v-for="hour in normalizedHours(selectedStore.businessHours)" :key="hour.weekday">
|
||||||
|
{{ weekdayLabel(hour.weekday) }}
|
||||||
|
<b>{{ hour.isClosed ? '休息' : `${minuteLabel(hour.openMinute)}–${minuteLabel(hour.closeMinute)}` }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<header class="rooms-toolbar">
|
||||||
|
<div>
|
||||||
|
<p class="section-kicker">房间配置</p>
|
||||||
|
<h3>{{ rooms.length }} 个房间</h3>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-input v-model="roomSearch" class="search-input" clearable placeholder="搜索房号、名称、分类" />
|
||||||
|
<el-select v-model="roomStatusFilter" class="filter-select" placeholder="房态">
|
||||||
|
<el-option label="全部房态" value="" />
|
||||||
|
<el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<el-button :icon="RefreshCw" :loading="loadingRooms" @click="loadRooms">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Plus" @click="openRoomForm()">新增房间</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-table v-loading="loadingRooms" :data="filteredRooms" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="房间" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack">
|
||||||
|
<strong>{{ row.roomNo }} · {{ row.name }}</strong>
|
||||||
|
<span>{{ row.categoryName }} · {{ row.capacity }} 人</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="价格" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="stack">
|
||||||
|
<strong>{{ money(row.basePriceCents) }}/时段</strong>
|
||||||
|
<span>节假日 {{ money(row.holidayPriceCents) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="profile-badges">
|
||||||
|
<el-tag :type="row.configurationStatus === 'ENABLED' ? 'success' : 'info'" size="small">
|
||||||
|
{{ row.configurationStatus === 'ENABLED' ? '已启用' : '已停用' }}
|
||||||
|
</el-tag>
|
||||||
|
<el-tag :type="roomStatusType(row.operationalStatus)" size="small">
|
||||||
|
{{ roomStatusLabel(row.operationalStatus) }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="标签" min-width="150">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="profile-badges">
|
||||||
|
<el-tag v-for="tag in row.tags.slice(0, 3)" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
|
||||||
|
<span v-if="row.tags.length === 0">-</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" min-width="285" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="row-actions">
|
||||||
|
<el-button link type="primary" @click="openRoomForm(row)">编辑</el-button>
|
||||||
|
<el-button link type="primary" @click="openStatusForm(row)">改状态</el-button>
|
||||||
|
<el-button link @click="openDisabledPeriodForm(row)">停用时段</el-button>
|
||||||
|
<el-button link type="danger" @click="archiveRoom(row)">归档</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else class="store-detail-empty" description="请选择门店查看详情与房间" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="storeDialog.open" :title="storeDialog.id ? '编辑门店' : '新增门店'" width="min(860px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form">
|
||||||
|
<el-form-item label="门店名称"><el-input v-model="storeForm.name" maxlength="128" /></el-form-item>
|
||||||
|
<el-form-item label="营业状态">
|
||||||
|
<el-select v-model="storeForm.businessStatus">
|
||||||
|
<el-option label="营业中" value="OPEN" />
|
||||||
|
<el-option label="已闭店" value="CLOSED" />
|
||||||
|
<el-option label="暂停营业" value="SUSPENDED" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="城市"><el-input v-model="storeForm.city" /></el-form-item>
|
||||||
|
<el-form-item label="区县"><el-input v-model="storeForm.district" /></el-form-item>
|
||||||
|
<el-form-item label="详细地址" class="form-span-2"><el-input v-model="storeForm.address" /></el-form-item>
|
||||||
|
<el-form-item label="联系电话"><el-input v-model="storeForm.contactPhone" /></el-form-item>
|
||||||
|
<el-form-item label="时区"><el-input v-model="storeForm.timezone" /></el-form-item>
|
||||||
|
<el-form-item label="经度"><el-input-number v-model="storeForm.longitude" :min="-180" :max="180" :precision="6" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="纬度"><el-input-number v-model="storeForm.latitude" :min="-90" :max="90" :precision="6" controls-position="right" /></el-form-item>
|
||||||
|
<el-form-item label="Wi-Fi 名称"><el-input v-model="storeForm.wifiSsid" /></el-form-item>
|
||||||
|
<el-form-item :label="storeDialog.id ? 'Wi-Fi 新密码(留空保持原值)' : 'Wi-Fi 密码'">
|
||||||
|
<el-input v-model="storeForm.wifiPassword" type="password" show-password autocomplete="new-password" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="通知地址" class="form-span-2"><el-input v-model="storeForm.notificationUrl" placeholder="https://..." /></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="storeForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<section class="hours-editor">
|
||||||
|
<strong>每周营业时间</strong>
|
||||||
|
<div v-for="hour in storeForm.businessHours" :key="hour.weekday" class="hours-editor-row">
|
||||||
|
<span>{{ weekdayLabel(hour.weekday) }}</span>
|
||||||
|
<el-switch v-model="hour.isClosed" active-text="休息" inactive-text="营业" />
|
||||||
|
<el-time-select v-model="hour.openTime" :disabled="hour.isClosed" start="00:00" step="00:15" end="23:45" placeholder="开门" />
|
||||||
|
<el-time-select v-model="hour.closeTime" :disabled="hour.isClosed" start="00:00" step="00:15" end="23:45" placeholder="闭店" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="storeDialog.open = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveStore">保存门店</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="roomDialog.open" :title="roomDialog.id ? '编辑房间' : '新增房间'" width="min(900px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form room-form">
|
||||||
|
<el-form-item label="房号"><el-input v-model="roomForm.roomNo" /></el-form-item>
|
||||||
|
<el-form-item label="房间名称"><el-input v-model="roomForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="分类"><el-input v-model="roomForm.categoryName" /></el-form-item>
|
||||||
|
<el-form-item label="容纳人数"><el-input-number v-model="roomForm.capacity" :min="1" :max="100" /></el-form-item>
|
||||||
|
<el-form-item label="基础价(元)"><el-input-number v-model="roomForm.basePriceYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="工作日价(元)"><el-input-number v-model="roomForm.weekdayPriceYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="节假日价(元)"><el-input-number v-model="roomForm.holidayPriceYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="过夜价(元)"><el-input-number v-model="roomForm.overnightPriceYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="全天价(元)"><el-input-number v-model="roomForm.fullDayPriceYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="最低消费(元)"><el-input-number v-model="roomForm.minimumSpendYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="押金(元)"><el-input-number v-model="roomForm.depositYuan" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="最短预订(分钟)"><el-input-number v-model="roomForm.minimumMinutes" :min="15" :max="1440" :step="15" /></el-form-item>
|
||||||
|
<el-form-item label="最早提前入场(分钟)"><el-input-number v-model="roomForm.maxAdvanceStartMinutes" :min="0" :max="1440" /></el-form-item>
|
||||||
|
<el-form-item label="最多提前预订(天)"><el-input-number v-model="roomForm.maxAdvanceDays" :min="0" :max="365" /></el-form-item>
|
||||||
|
<el-form-item label="配置状态">
|
||||||
|
<el-select v-model="roomForm.configurationStatus"><el-option label="启用" value="ENABLED" /><el-option label="停用" value="DISABLED" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="运营房态">
|
||||||
|
<el-select v-model="roomForm.operationalStatus"><el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标签" class="form-span-2">
|
||||||
|
<el-select v-model="roomForm.tags" multiple filterable allow-create default-first-option placeholder="输入后回车添加标签" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="图片 URL(每行一个)" class="form-span-2"><el-input v-model="roomForm.imagesText" type="textarea" :rows="3" /></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="roomForm.sortOrder" :min="-100000" :max="100000" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="roomDialog.open = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveRoom">保存房间</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="statusDialog.open" title="变更房间状态" width="min(520px, 94vw)">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="配置状态"><el-select v-model="statusDialog.configurationStatus" clearable><el-option label="启用" value="ENABLED" /><el-option label="停用" value="DISABLED" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="运营房态"><el-select v-model="statusDialog.operationalStatus" clearable><el-option v-for="item in operationalStatuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||||
|
<el-form-item label="变更原因"><el-input v-model="statusDialog.reason" type="textarea" maxlength="255" show-word-limit /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="statusDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRoomStatus">确认变更</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="periodDialog.open" title="新增房间停用时段" width="min(560px, 94vw)">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="开始时间"><el-date-picker v-model="periodDialog.startsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||||
|
<el-form-item label="结束时间"><el-date-picker v-model="periodDialog.endsAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" /></el-form-item>
|
||||||
|
<el-form-item label="原因"><el-input v-model="periodDialog.reason" maxlength="255" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer><el-button @click="periodDialog.open = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveDisabledPeriod">保存时段</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { Archive, Edit3, Plus, RefreshCw } from '@lucide/vue';
|
||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
addManagedRoomDisabledPeriod,
|
||||||
|
archiveManagedRoom,
|
||||||
|
archiveManagedStore,
|
||||||
|
createManagedRoom,
|
||||||
|
createManagedStore,
|
||||||
|
listManagedRooms,
|
||||||
|
listManagedStores,
|
||||||
|
updateManagedRoom,
|
||||||
|
updateManagedRoomStatus,
|
||||||
|
updateManagedStore,
|
||||||
|
type ApiSession
|
||||||
|
} from '../api';
|
||||||
|
import { money } from '../format';
|
||||||
|
import type {
|
||||||
|
ManagedRoom,
|
||||||
|
ManagedStore,
|
||||||
|
RoomConfigurationStatus,
|
||||||
|
RoomInput,
|
||||||
|
RoomOperationalStatus,
|
||||||
|
StoreInput
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const rooms = ref<ManagedRoom[]>([]);
|
||||||
|
const selectedStoreId = ref('');
|
||||||
|
const search = ref('');
|
||||||
|
const statusFilter = ref<ManagedStore['businessStatus'] | ''>('');
|
||||||
|
const roomSearch = ref('');
|
||||||
|
const roomStatusFilter = ref<RoomOperationalStatus | ''>('');
|
||||||
|
const loadingStores = ref(false);
|
||||||
|
const loadingRooms = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const lastError = ref('');
|
||||||
|
const storeDialog = reactive({ open: false, id: '' });
|
||||||
|
const roomDialog = reactive({ open: false, id: '' });
|
||||||
|
const statusDialog = reactive<{
|
||||||
|
open: boolean; roomId: string; configurationStatus: RoomConfigurationStatus | '';
|
||||||
|
operationalStatus: RoomOperationalStatus | ''; reason: string;
|
||||||
|
}>({ open: false, roomId: '', configurationStatus: '', operationalStatus: '', reason: '' });
|
||||||
|
const periodDialog = reactive({ open: false, roomId: '', startsAt: '', endsAt: '', reason: '' });
|
||||||
|
|
||||||
|
const operationalStatuses: Array<{ value: RoomOperationalStatus; label: string }> = [
|
||||||
|
{ value: 'AVAILABLE', label: '空闲' },
|
||||||
|
{ value: 'MAINTENANCE', label: '维护中' },
|
||||||
|
{ value: 'RESERVED', label: '已预订' },
|
||||||
|
{ value: 'IN_USE', label: '使用中' },
|
||||||
|
{ value: 'CLEANING_REQUIRED', label: '待清洁' }
|
||||||
|
];
|
||||||
|
|
||||||
|
type StoreHourForm = { weekday: number; openTime: string; closeTime: string; isClosed: boolean };
|
||||||
|
const storeForm = reactive({
|
||||||
|
name: '', address: '', city: '', district: '', longitude: null as number | null,
|
||||||
|
latitude: null as number | null, contactPhone: '', timezone: 'Asia/Shanghai',
|
||||||
|
businessStatus: 'OPEN' as ManagedStore['businessStatus'], wifiSsid: '', wifiPassword: '',
|
||||||
|
notificationUrl: '', sortOrder: 0, businessHours: defaultHourForms() as StoreHourForm[]
|
||||||
|
});
|
||||||
|
const roomForm = reactive({
|
||||||
|
categoryName: '标准间', name: '', roomNo: '', capacity: 4,
|
||||||
|
basePriceYuan: 0, weekdayPriceYuan: 0, holidayPriceYuan: 0, overnightPriceYuan: 0,
|
||||||
|
fullDayPriceYuan: 0, minimumSpendYuan: 0, depositYuan: 0, minimumMinutes: 60,
|
||||||
|
maxAdvanceStartMinutes: 0, maxAdvanceDays: 30,
|
||||||
|
configurationStatus: 'ENABLED' as RoomConfigurationStatus,
|
||||||
|
operationalStatus: 'AVAILABLE' as RoomOperationalStatus,
|
||||||
|
tags: [] as string[], imagesText: '', sortOrder: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredStores = computed(() => {
|
||||||
|
const keyword = search.value.trim().toLowerCase();
|
||||||
|
return stores.value.filter((store) => {
|
||||||
|
const matchesStatus = !statusFilter.value || store.businessStatus === statusFilter.value;
|
||||||
|
const haystack = `${store.name} ${store.city} ${store.district} ${store.address}`.toLowerCase();
|
||||||
|
return matchesStatus && (!keyword || haystack.includes(keyword));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const selectedStore = computed(() => stores.value.find((store) => store.id === selectedStoreId.value));
|
||||||
|
const filteredRooms = computed(() => {
|
||||||
|
const keyword = roomSearch.value.trim().toLowerCase();
|
||||||
|
return rooms.value.filter((room) => {
|
||||||
|
const matchesStatus = !roomStatusFilter.value || room.operationalStatus === roomStatusFilter.value;
|
||||||
|
const haystack = `${room.roomNo} ${room.name} ${room.categoryName} ${room.tags.join(' ')}`.toLowerCase();
|
||||||
|
return matchesStatus && (!keyword || haystack.includes(keyword));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runAction<T>(work: () => Promise<T>, success?: string) {
|
||||||
|
lastError.value = '';
|
||||||
|
try {
|
||||||
|
const result = await work();
|
||||||
|
if (success) ElMessage.success(success);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
lastError.value = error instanceof ApiError
|
||||||
|
? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}`
|
||||||
|
: error instanceof Error ? error.message : '操作失败';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStores() {
|
||||||
|
if (!props.session.token) return;
|
||||||
|
loadingStores.value = true;
|
||||||
|
try {
|
||||||
|
const result = await runAction(() => listManagedStores(props.session));
|
||||||
|
stores.value = result;
|
||||||
|
if (!result.some((store) => store.id === selectedStoreId.value)) {
|
||||||
|
selectedStoreId.value = result[0]?.id || '';
|
||||||
|
}
|
||||||
|
if (selectedStoreId.value) await loadRooms();
|
||||||
|
} catch { /* error already displayed */ } finally { loadingStores.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRooms() {
|
||||||
|
if (!selectedStoreId.value || !props.session.token) { rooms.value = []; return; }
|
||||||
|
loadingRooms.value = true;
|
||||||
|
try { rooms.value = await runAction(() => listManagedRooms(props.session, selectedStoreId.value)); }
|
||||||
|
catch { rooms.value = []; }
|
||||||
|
finally { loadingRooms.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectStore(storeId: string) {
|
||||||
|
if (selectedStoreId.value === storeId) return;
|
||||||
|
selectedStoreId.value = storeId;
|
||||||
|
roomSearch.value = '';
|
||||||
|
roomStatusFilter.value = '';
|
||||||
|
void loadRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStoreForm(store?: ManagedStore) {
|
||||||
|
storeDialog.id = store?.id || '';
|
||||||
|
Object.assign(storeForm, {
|
||||||
|
name: store?.name || '', address: store?.address || '', city: store?.city || '',
|
||||||
|
district: store?.district || '', longitude: store?.longitude ?? null,
|
||||||
|
latitude: store?.latitude ?? null, contactPhone: store?.contactPhone || '',
|
||||||
|
timezone: store?.timezone || 'Asia/Shanghai', businessStatus: store?.businessStatus || 'OPEN',
|
||||||
|
wifiSsid: store?.wifiSsid || '', wifiPassword: '', notificationUrl: store?.notificationUrl || '',
|
||||||
|
sortOrder: store?.sortOrder || 0,
|
||||||
|
businessHours: normalizedHours(store?.businessHours || []).map((hour) => ({
|
||||||
|
weekday: hour.weekday, openTime: minuteLabel(hour.openMinute),
|
||||||
|
closeTime: minuteLabel(hour.closeMinute), isClosed: hour.isClosed
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
storeDialog.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStore() {
|
||||||
|
if (!storeForm.name.trim()) return ElMessage.warning('请填写门店名称');
|
||||||
|
saving.value = true;
|
||||||
|
const input: StoreInput = {
|
||||||
|
name: storeForm.name.trim(), address: storeForm.address.trim(), city: storeForm.city.trim(),
|
||||||
|
district: storeForm.district.trim(), longitude: storeForm.longitude,
|
||||||
|
latitude: storeForm.latitude, contactPhone: storeForm.contactPhone.trim(),
|
||||||
|
timezone: storeForm.timezone.trim() || 'Asia/Shanghai', businessStatus: storeForm.businessStatus,
|
||||||
|
wifiSsid: storeForm.wifiSsid.trim(), notificationUrl: storeForm.notificationUrl.trim(),
|
||||||
|
sortOrder: storeForm.sortOrder,
|
||||||
|
businessHours: storeForm.businessHours.map((hour) => ({
|
||||||
|
weekday: hour.weekday, openMinute: timeToMinute(hour.openTime),
|
||||||
|
closeMinute: timeToMinute(hour.closeTime), isClosed: hour.isClosed
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
if (storeForm.wifiPassword) input.wifiPassword = storeForm.wifiPassword;
|
||||||
|
try {
|
||||||
|
const result = storeDialog.id
|
||||||
|
? await runAction(() => updateManagedStore(props.session, storeDialog.id, input), '门店已更新')
|
||||||
|
: await runAction(() => createManagedStore(props.session, input), '门店已创建');
|
||||||
|
storeDialog.open = false;
|
||||||
|
selectedStoreId.value = result.storeId;
|
||||||
|
await loadStores();
|
||||||
|
} catch { /* error already displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveStore(store: ManagedStore) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('仅无未归档房间的门店可归档,操作会写入审计日志。', `归档 ${store.name}`, { type: 'warning' });
|
||||||
|
await runAction(() => archiveManagedStore(props.session, store.id), '门店已归档');
|
||||||
|
await loadStores();
|
||||||
|
} catch (error) { if (error !== 'cancel' && error !== 'close' && error instanceof ApiError) lastError.value = error.code; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRoomForm(room?: ManagedRoom) {
|
||||||
|
roomDialog.id = room?.id || '';
|
||||||
|
Object.assign(roomForm, {
|
||||||
|
categoryName: room?.categoryName || '标准间', name: room?.name || '', roomNo: room?.roomNo || '',
|
||||||
|
capacity: room?.capacity || 4, basePriceYuan: centsToYuan(room?.basePriceCents),
|
||||||
|
weekdayPriceYuan: centsToYuan(room?.weekdayPriceCents), holidayPriceYuan: centsToYuan(room?.holidayPriceCents),
|
||||||
|
overnightPriceYuan: centsToYuan(room?.overnightPriceCents), fullDayPriceYuan: centsToYuan(room?.fullDayPriceCents),
|
||||||
|
minimumSpendYuan: centsToYuan(room?.minimumSpendCents), depositYuan: centsToYuan(room?.depositCents),
|
||||||
|
minimumMinutes: room?.minimumMinutes || 60, maxAdvanceStartMinutes: room?.maxAdvanceStartMinutes || 0,
|
||||||
|
maxAdvanceDays: room?.maxAdvanceDays ?? 30, configurationStatus: room?.configurationStatus || 'ENABLED',
|
||||||
|
operationalStatus: room?.operationalStatus || 'AVAILABLE', tags: [...(room?.tags || [])],
|
||||||
|
imagesText: (room?.images || []).join('\n'), sortOrder: room?.sortOrder || 0
|
||||||
|
});
|
||||||
|
roomDialog.open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoom() {
|
||||||
|
if (!selectedStoreId.value || !roomForm.name.trim() || !roomForm.roomNo.trim()) {
|
||||||
|
return ElMessage.warning('请填写房号和房间名称');
|
||||||
|
}
|
||||||
|
const input: RoomInput = {
|
||||||
|
storeId: selectedStoreId.value, categoryName: roomForm.categoryName.trim(),
|
||||||
|
name: roomForm.name.trim(), roomNo: roomForm.roomNo.trim(), capacity: roomForm.capacity,
|
||||||
|
basePriceCents: yuanToCents(roomForm.basePriceYuan), weekdayPriceCents: yuanToCents(roomForm.weekdayPriceYuan),
|
||||||
|
holidayPriceCents: yuanToCents(roomForm.holidayPriceYuan), overnightPriceCents: yuanToCents(roomForm.overnightPriceYuan),
|
||||||
|
fullDayPriceCents: yuanToCents(roomForm.fullDayPriceYuan), minimumSpendCents: yuanToCents(roomForm.minimumSpendYuan),
|
||||||
|
depositCents: yuanToCents(roomForm.depositYuan), minimumMinutes: roomForm.minimumMinutes,
|
||||||
|
maxAdvanceStartMinutes: roomForm.maxAdvanceStartMinutes, maxAdvanceDays: roomForm.maxAdvanceDays,
|
||||||
|
configurationStatus: roomForm.configurationStatus, operationalStatus: roomForm.operationalStatus,
|
||||||
|
tags: roomForm.tags.map((item) => item.trim()).filter(Boolean),
|
||||||
|
images: roomForm.imagesText.split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
||||||
|
sortOrder: roomForm.sortOrder
|
||||||
|
};
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
if (roomDialog.id) await runAction(() => updateManagedRoom(props.session, roomDialog.id, input), '房间已更新');
|
||||||
|
else await runAction(() => createManagedRoom(props.session, input), '房间已创建');
|
||||||
|
roomDialog.open = false;
|
||||||
|
await loadRooms();
|
||||||
|
} catch { /* error already displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openStatusForm(room: ManagedRoom) {
|
||||||
|
Object.assign(statusDialog, {
|
||||||
|
open: true, roomId: room.id, configurationStatus: room.configurationStatus,
|
||||||
|
operationalStatus: room.operationalStatus, reason: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRoomStatus() {
|
||||||
|
if (!statusDialog.reason.trim()) return ElMessage.warning('请填写状态变更原因');
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await runAction(() => updateManagedRoomStatus(props.session, statusDialog.roomId, {
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
configurationStatus: statusDialog.configurationStatus || undefined,
|
||||||
|
operationalStatus: statusDialog.operationalStatus || undefined,
|
||||||
|
reason: statusDialog.reason.trim()
|
||||||
|
}), '房间状态已更新');
|
||||||
|
statusDialog.open = false;
|
||||||
|
await loadRooms();
|
||||||
|
} catch { /* error already displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDisabledPeriodForm(room: ManagedRoom) {
|
||||||
|
Object.assign(periodDialog, { open: true, roomId: room.id, startsAt: '', endsAt: '', reason: '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDisabledPeriod() {
|
||||||
|
if (!periodDialog.startsAt || !periodDialog.endsAt) return ElMessage.warning('请选择完整停用时段');
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await runAction(() => addManagedRoomDisabledPeriod(props.session, periodDialog.roomId, {
|
||||||
|
storeId: selectedStoreId.value, startsAt: periodDialog.startsAt,
|
||||||
|
endsAt: periodDialog.endsAt, reason: periodDialog.reason.trim()
|
||||||
|
}), '停用时段已添加');
|
||||||
|
periodDialog.open = false;
|
||||||
|
} catch { /* error already displayed */ } finally { saving.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveRoom(room: ManagedRoom) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('归档后该房间不再出现在可用房间列表。', `归档 ${room.roomNo}`, { type: 'warning' });
|
||||||
|
await runAction(() => archiveManagedRoom(props.session, room.id, selectedStoreId.value), '房间已归档');
|
||||||
|
await loadRooms();
|
||||||
|
} catch (error) { if (error !== 'cancel' && error !== 'close' && error instanceof ApiError) lastError.value = error.code; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultHourForms(): StoreHourForm[] {
|
||||||
|
return Array.from({ length: 7 }, (_, index) => ({
|
||||||
|
weekday: index + 1, openTime: '09:00', closeTime: '22:00', isClosed: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function normalizedHours(hours: ManagedStore['businessHours']) {
|
||||||
|
const byDay = new Map(hours.map((hour) => [hour.weekday, hour]));
|
||||||
|
return Array.from({ length: 7 }, (_, index) => byDay.get(index + 1) || {
|
||||||
|
weekday: index + 1, openMinute: 540, closeMinute: 1320, isClosed: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function weekdayLabel(weekday: number) { return ['一', '二', '三', '四', '五', '六', '日'][weekday - 1] ? `周${['一', '二', '三', '四', '五', '六', '日'][weekday - 1]}` : `周${weekday}`; }
|
||||||
|
function minuteLabel(value: number) { return `${String(Math.floor(value / 60)).padStart(2, '0')}:${String(value % 60).padStart(2, '0')}`; }
|
||||||
|
function timeToMinute(value: string) { const [hour, minute] = value.split(':').map(Number); return (hour || 0) * 60 + (minute || 0); }
|
||||||
|
function centsToYuan(value?: number) { return Number(((value || 0) / 100).toFixed(2)); }
|
||||||
|
function yuanToCents(value: number) { return Math.round(Number(value || 0) * 100); }
|
||||||
|
function storeStatusLabel(status: ManagedStore['businessStatus']) { return ({ OPEN: '营业中', CLOSED: '已闭店', SUSPENDED: '暂停营业' } as const)[status]; }
|
||||||
|
function storeStatusType(status: ManagedStore['businessStatus']) { return status === 'OPEN' ? 'success' : status === 'SUSPENDED' ? 'warning' : 'info'; }
|
||||||
|
function roomStatusLabel(status: RoomOperationalStatus) { return operationalStatuses.find((item) => item.value === status)?.label || status; }
|
||||||
|
function roomStatusType(status: RoomOperationalStatus) { return status === 'AVAILABLE' ? 'success' : status === 'MAINTENANCE' || status === 'CLEANING_REQUIRED' ? 'warning' : 'primary'; }
|
||||||
|
function wifiSummary(store: ManagedStore) { return store.wifiSsid ? `${store.wifiSsid}${store.wifiConfigured ? ' · 已设密码' : ''}` : '未配置'; }
|
||||||
|
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadStores(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<section class="third-party-page">
|
||||||
|
<el-alert v-if="lastError" :title="lastError" type="error" show-icon closable @close="lastError = ''" />
|
||||||
|
<el-alert v-if="lastMessage" :title="lastMessage" type="success" show-icon closable @close="lastMessage = ''" />
|
||||||
|
|
||||||
|
<section class="third-party-metrics">
|
||||||
|
<span><small>直订记录</small><strong>{{ records.bookings.length }}</strong></span>
|
||||||
|
<span><small>待映射直订</small><strong>{{ pendingMappingCount }}</strong></span>
|
||||||
|
<span><small>核销记录</small><strong>{{ records.redemptions.length }}</strong></span>
|
||||||
|
<span><small>失败 / 待处理</small><strong>{{ attentionCount }}</strong></span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<header class="panel-toolbar third-party-toolbar">
|
||||||
|
<div><p class="section-kicker">M08-D · 渠道运营</p><h3>团购与第三方平台</h3></div>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-select v-model="provider" class="filter-select" placeholder="全部平台" @change="applyFilters"><el-option label="全部平台" value="" /><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select>
|
||||||
|
<el-select v-model="storeId" class="filter-select" filterable placeholder="全部授权门店" @change="applyFilters"><el-option label="全部授权门店" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select>
|
||||||
|
<el-input v-model="status" class="search-input" clearable placeholder="状态精确筛选" @keyup.enter="applyFilters" />
|
||||||
|
<el-button :icon="RefreshCw" :loading="loading" @click="loadData">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab" class="third-party-tabs">
|
||||||
|
<el-tab-pane label="直订记录" name="bookings">
|
||||||
|
<el-table v-loading="loading" :data="records.bookings" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="平台订单" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ row.externalBookingNo }}</strong><span>{{ providerLabel(row.provider) }} · #{{ row.id }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="门店 / 房间" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.storeId ? storeName(row.storeId) : '待映射门店' }}</strong><span>{{ row.roomId ? roomName(row.roomId) : '待映射房间' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="使用时段" min-width="200"><template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.startsAt) }}</strong><span>至 {{ formatDate(row.endsAt) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="金额" width="120"><template #default="{ row }">{{ money(row.amountCents) }}</template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="recordStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="本地订单 / 异常" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.orderId ? `#${row.orderId}` : '-' }}</strong><span>{{ row.failureCode || formatDate(row.createdAt) }}</span></div></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="核销记录" name="redemptions">
|
||||||
|
<div class="tab-action-row"><span>券码只显示脱敏结果,真实券码不会进入列表或日志。</span><el-button type="primary" :icon="ScanLine" @click="openRedeem">验券</el-button></div>
|
||||||
|
<el-table v-loading="loading" :data="records.redemptions" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="券码" min-width="170"><template #default="{ row }"><div class="stack"><strong>{{ row.voucherMasked }}</strong><span>{{ providerLabel(row.provider) }} · {{ row.mode }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="订单 / 门店" min-width="180"><template #default="{ row }"><div class="stack"><strong>订单 #{{ row.orderId }}</strong><span>{{ storeName(row.storeId) }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="状态" width="150"><template #default="{ row }"><el-tag :type="recordStatusType(row.status)">{{ row.status }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="操作人" width="120"><template #default="{ row }">{{ row.actorId ? `#${row.actorId}` : '-' }}</template></el-table-column>
|
||||||
|
<el-table-column label="完成时间 / 异常" min-width="190"><template #default="{ row }"><div class="stack"><strong>{{ formatDate(row.completedAt || row.createdAt) }}</strong><span>{{ row.failureCode || '-' }}</span></div></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="平台配置" name="configs">
|
||||||
|
<div class="tab-action-row"><span>只登记环境凭据引用;敏感设置键由服务端过滤,不在读取接口返回。</span><el-button type="primary" :icon="Plus" @click="openConfig">配置平台</el-button></div>
|
||||||
|
<el-table :data="setup.configs" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="平台" min-width="130"><template #default="{ row }"><strong>{{ providerLabel(row.provider) }}</strong></template></el-table-column>
|
||||||
|
<el-table-column label="范围" min-width="160"><template #default="{ row }">{{ row.storeId ? storeName(row.storeId) : '租户默认' }}</template></el-table-column>
|
||||||
|
<el-table-column prop="mode" label="模式" width="110" />
|
||||||
|
<el-table-column label="凭据" min-width="180"><template #default="{ row }"><div class="stack"><strong>{{ row.credentialConfigured ? '已配置引用' : '未配置' }}</strong><span>{{ row.credentialRef || '-' }}</span></div></template></el-table-column>
|
||||||
|
<el-table-column label="安全设置" min-width="240"><template #default="{ row }"><code class="settings-preview">{{ JSON.stringify(row.settings) }}</code></template></el-table-column>
|
||||||
|
<el-table-column label="启用" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '是' : '否' }}</el-tag></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="资源映射" name="mappings">
|
||||||
|
<div class="tab-action-row"><span>外部门店与房间必须映射到同一租户下的真实资源。</span><el-button type="primary" :icon="Link2" @click="openMapping">新增映射</el-button></div>
|
||||||
|
<el-table :data="setup.mappings" class="data-table" row-key="id">
|
||||||
|
<el-table-column label="平台" min-width="130"><template #default="{ row }">{{ providerLabel(row.provider) }}</template></el-table-column>
|
||||||
|
<el-table-column prop="resourceType" label="资源类型" width="120" />
|
||||||
|
<el-table-column prop="externalRef" label="外部标识" min-width="220" />
|
||||||
|
<el-table-column label="本地资源" min-width="220"><template #default="{ row }">{{ mappingResourceName(row.resourceType, row.localResourceId) }}</template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-dialog v-model="redeemDialog" title="团购验券" width="min(580px, 94vw)">
|
||||||
|
<el-form label-position="top"><el-form-item label="平台"><el-select v-model="redeemForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="订单 ID"><el-input v-model="redeemForm.orderId" /></el-form-item><el-form-item label="券码"><el-input v-model="redeemForm.voucherCode" type="password" show-password autocomplete="off" /></el-form-item><el-form-item label="核销方式"><el-radio-group v-model="redeemForm.mode"><el-radio value="API">平台 API/Mock</el-radio><el-radio value="MANUAL">人工确认</el-radio></el-radio-group></el-form-item><template v-if="redeemForm.mode === 'MANUAL'"><el-form-item label="确认金额(元)"><el-input-number v-model="redeemForm.amountYuan" :min="0.01" :precision="2" /></el-form-item><el-form-item label="人工确认备注"><el-input v-model="redeemForm.note" /></el-form-item></template></el-form>
|
||||||
|
<template #footer><el-button @click="redeemDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRedemption">确认验券</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="configDialog" title="配置第三方平台" width="min(650px, 94vw)">
|
||||||
|
<el-form label-position="top" class="asset-form"><el-form-item label="平台"><el-select v-model="configForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="门店范围"><el-select v-model="configForm.storeId" clearable><el-option label="租户默认" value="" /><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item label="模式"><el-select v-model="configForm.mode"><el-option label="人工" value="MANUAL" /><el-option label="非生产 Mock" value="MOCK" /><el-option label="平台 API" value="API" /></el-select></el-form-item><el-form-item label="启用"><el-switch v-model="configForm.enabled" /></el-form-item><el-form-item label="凭据环境引用" class="form-span-2"><el-input v-model="configForm.credentialRef" placeholder="env:MEITUAN_STORE_001" /></el-form-item><el-form-item label="非敏感设置 JSON" class="form-span-2"><el-input v-model="configForm.settingsText" type="textarea" :rows="5" placeholder='{"redeemEndpoint":"https://..."}' /></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="configDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveConfig">保存配置</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="mappingDialog" title="新增或更新资源映射" width="min(620px, 94vw)">
|
||||||
|
<el-form label-position="top"><el-form-item label="平台"><el-select v-model="mappingForm.provider"><el-option v-for="item in providerOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item><el-form-item label="资源类型"><el-radio-group v-model="mappingForm.resourceType" @change="mappingForm.localResourceId = ''"><el-radio value="STORE">门店</el-radio><el-radio value="ROOM">房间</el-radio></el-radio-group></el-form-item><el-form-item label="外部资源标识"><el-input v-model="mappingForm.externalRef" /></el-form-item><el-form-item label="本地门店"><el-select v-model="mappingForm.storeId" filterable @change="loadMappingRooms"><el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" /></el-select></el-form-item><el-form-item v-if="mappingForm.resourceType === 'ROOM'" label="本地房间"><el-select v-model="mappingForm.localResourceId" filterable><el-option v-for="room in mappingRooms" :key="room.id" :label="`${room.roomNo} · ${room.name}`" :value="room.id" /></el-select></el-form-item></el-form>
|
||||||
|
<template #footer><el-button @click="mappingDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMapping">保存映射</el-button></template>
|
||||||
|
</el-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
import { Link2, Plus, RefreshCw, ScanLine } from '@lucide/vue';
|
||||||
|
import { ApiError, getThirdPartySetup, listManagedRooms, listManagedStores, listThirdPartyRecords, redeemGroupVoucher, redeemGroupVoucherManually, saveThirdPartyConfig, saveThirdPartyMapping, type ApiSession } from '../api';
|
||||||
|
import { money } from '../format';
|
||||||
|
import type { ManagedRoom, ManagedStore, ThirdPartyMode, ThirdPartyProvider, ThirdPartyRecords, ThirdPartySetup } from '../types';
|
||||||
|
|
||||||
|
const props = defineProps<{ session: ApiSession }>();
|
||||||
|
const stores = ref<ManagedStore[]>([]);
|
||||||
|
const allRooms = ref<ManagedRoom[]>([]);
|
||||||
|
const mappingRooms = ref<ManagedRoom[]>([]);
|
||||||
|
const records = reactive<ThirdPartyRecords>({ bookings: [], redemptions: [] });
|
||||||
|
const setup = reactive<ThirdPartySetup>({ configs: [], mappings: [] });
|
||||||
|
const provider = ref<ThirdPartyProvider | ''>('');
|
||||||
|
const storeId = ref('');
|
||||||
|
const status = ref('');
|
||||||
|
const activeTab = ref('bookings');
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const lastError = ref('');
|
||||||
|
const lastMessage = ref('');
|
||||||
|
const redeemDialog = ref(false);
|
||||||
|
const configDialog = ref(false);
|
||||||
|
const mappingDialog = ref(false);
|
||||||
|
const providerOptions: Array<{ value: ThirdPartyProvider; label: string }> = [{ value: 'MEITUAN', label: '美团' }, { value: 'DIANPING', label: '大众点评' }, { value: 'DOUYIN', label: '抖音' }, { value: 'KUAISHOU', label: '快手' }];
|
||||||
|
const redeemForm = reactive({ provider: 'MEITUAN' as ThirdPartyProvider, orderId: '', voucherCode: '', mode: 'API' as 'API' | 'MANUAL', amountYuan: 0, note: '' });
|
||||||
|
const configForm = reactive({ provider: 'MEITUAN' as ThirdPartyProvider, storeId: '', mode: 'MANUAL' as ThirdPartyMode, enabled: true, credentialRef: '', settingsText: '{}' });
|
||||||
|
const mappingForm = reactive({ provider: 'MEITUAN' as ThirdPartyProvider, resourceType: 'STORE' as 'STORE' | 'ROOM', externalRef: '', storeId: '', localResourceId: '' });
|
||||||
|
const pendingMappingCount = computed(() => records.bookings.filter((item) => item.status === 'PENDING_MAPPING' || !item.storeId || !item.roomId).length);
|
||||||
|
const attentionCount = computed(() => [...records.bookings, ...records.redemptions].filter((item) => ['FAILED', 'PENDING', 'PENDING_MAPPING', 'MANUAL_REVIEW'].includes(item.status)).length);
|
||||||
|
|
||||||
|
async function capture<T>(work: () => Promise<T>) { lastError.value = ''; try { return await work(); } catch (error) { lastError.value = error instanceof ApiError ? `${error.code}${error.traceId ? ` · ${error.traceId}` : ''}` : error instanceof Error ? error.message : '操作失败'; throw error; } }
|
||||||
|
async function loadWorkspace() { try { stores.value = await capture(() => listManagedStores(props.session)); const roomLists = await Promise.all(stores.value.map((store) => listManagedRooms(props.session, store.id))); allRooms.value = roomLists.flat(); await loadData(); } catch { /* displayed */ } }
|
||||||
|
async function loadData() { if (!props.session.token) return; loading.value = true; try { const [nextRecords, nextSetup] = await Promise.all([capture(() => listThirdPartyRecords(props.session, { provider: provider.value || undefined, status: status.value.trim() || undefined, storeId: storeId.value || undefined })), capture(() => getThirdPartySetup(props.session, provider.value || undefined))]); Object.assign(records, nextRecords); Object.assign(setup, nextSetup); } catch { /* displayed */ } finally { loading.value = false; } }
|
||||||
|
function applyFilters() { void loadData(); }
|
||||||
|
function openRedeem() { Object.assign(redeemForm, { provider: provider.value || 'MEITUAN', orderId: '', voucherCode: '', mode: 'API', amountYuan: 0, note: '' }); redeemDialog.value = true; }
|
||||||
|
async function saveRedemption() { if (!redeemForm.orderId || redeemForm.voucherCode.length < 4) return ElMessage.warning('请填写订单 ID 和券码'); saving.value = true; try { const clientRequestId = requestId('redeem'); const result = redeemForm.mode === 'MANUAL' ? await capture(() => redeemGroupVoucherManually(props.session, { provider: redeemForm.provider, orderId: redeemForm.orderId, voucherCode: redeemForm.voucherCode, amountCents: Math.round(redeemForm.amountYuan * 100), note: redeemForm.note, clientRequestId })) : await capture(() => redeemGroupVoucher(props.session, { provider: redeemForm.provider, orderId: redeemForm.orderId, voucherCode: redeemForm.voucherCode, clientRequestId })); redeemForm.voucherCode = ''; redeemDialog.value = false; lastMessage.value = `核销 #${result.redemptionId} 状态 ${result.status},券码 ${result.voucherMasked || '已脱敏'}`; await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function openConfig() { Object.assign(configForm, { provider: provider.value || 'MEITUAN', storeId: storeId.value, mode: 'MANUAL', enabled: true, credentialRef: '', settingsText: '{}' }); configDialog.value = true; }
|
||||||
|
async function saveConfig() { let settings: Record<string, unknown>; try { const parsed: unknown = JSON.parse(configForm.settingsText || '{}'); if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(); settings = parsed as Record<string, unknown>; } catch { return ElMessage.warning('设置必须是 JSON 对象'); } saving.value = true; try { await capture(() => saveThirdPartyConfig(props.session, { provider: configForm.provider, storeId: configForm.storeId || null, mode: configForm.mode, enabled: configForm.enabled, credentialRef: configForm.credentialRef.trim(), settings })); configDialog.value = false; ElMessage.success('第三方配置已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function openMapping() { Object.assign(mappingForm, { provider: provider.value || 'MEITUAN', resourceType: 'STORE', externalRef: '', storeId: storeId.value || stores.value[0]?.id || '', localResourceId: '' }); mappingDialog.value = true; void loadMappingRooms(); }
|
||||||
|
async function loadMappingRooms() { mappingRooms.value = allRooms.value.filter((room) => room.storeId === mappingForm.storeId); if (mappingForm.resourceType === 'STORE') mappingForm.localResourceId = mappingForm.storeId; }
|
||||||
|
async function saveMapping() { const localResourceId = mappingForm.resourceType === 'STORE' ? mappingForm.storeId : mappingForm.localResourceId; if (!mappingForm.externalRef.trim() || !localResourceId) return ElMessage.warning('请完整填写外部标识和本地资源'); saving.value = true; try { await capture(() => saveThirdPartyMapping(props.session, { provider: mappingForm.provider, resourceType: mappingForm.resourceType, externalRef: mappingForm.externalRef.trim(), localResourceId })); mappingDialog.value = false; ElMessage.success('资源映射已保存'); await loadData(); } catch { /* displayed */ } finally { saving.value = false; } }
|
||||||
|
function providerLabel(value: ThirdPartyProvider) { return providerOptions.find((item) => item.value === value)?.label || value; }
|
||||||
|
function storeName(id: string) { return stores.value.find((item) => item.id === String(id))?.name || `门店 #${id}`; }
|
||||||
|
function roomName(id: string) { const room = allRooms.value.find((item) => item.id === String(id)); return room ? `${room.roomNo} · ${room.name}` : `房间 #${id}`; }
|
||||||
|
function mappingResourceName(type: 'STORE' | 'ROOM', id: string) { return type === 'STORE' ? storeName(id) : roomName(id); }
|
||||||
|
function recordStatusType(value: string) { return ['SUCCEEDED', 'READY_TO_CLAIM', 'CLAIMED'].includes(value) ? 'success' : ['FAILED', 'REJECTED'].includes(value) ? 'danger' : 'warning'; }
|
||||||
|
function formatDate(value: string | null) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }).format(date); }
|
||||||
|
function requestId(prefix: string) { return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; }
|
||||||
|
watch(() => props.session.token, (token) => { if (token) void loadWorkspace(); }, { immediate: true });
|
||||||
|
</script>
|
||||||
@@ -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,6 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import './styles.css';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
createApp(App).use(router).mount('#app');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export type AdminMenuKey =
|
||||||
|
| 'overview'
|
||||||
|
| 'stores'
|
||||||
|
| 'orders'
|
||||||
|
| 'payments'
|
||||||
|
| 'thirdParty'
|
||||||
|
| 'people'
|
||||||
|
| 'cleaning'
|
||||||
|
| 'devices'
|
||||||
|
| 'platformApps'
|
||||||
|
| 'content'
|
||||||
|
| 'franchise'
|
||||||
|
| 'system';
|
||||||
|
|
||||||
|
export interface AdminNavigationItem {
|
||||||
|
menuKey: AdminMenuKey;
|
||||||
|
path: string;
|
||||||
|
stage: string;
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
capabilities: readonly string[];
|
||||||
|
roles: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminNavigationAccess {
|
||||||
|
menus: readonly string[];
|
||||||
|
capabilities: readonly string[];
|
||||||
|
roles: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_NAVIGATION: readonly AdminNavigationItem[];
|
||||||
|
export function isMenuAllowed(menus: readonly string[], menuKey: AdminMenuKey): boolean;
|
||||||
|
export function pathForMenu(menuKey: AdminMenuKey): string | null;
|
||||||
|
export function firstAllowedPath(menus: readonly string[]): string | null;
|
||||||
|
export function resolveProtectedPath(menus: readonly string[], requestedMenu: AdminMenuKey): string | null;
|
||||||
|
export function isNavigationAllowed(access: AdminNavigationAccess, item: AdminNavigationItem): boolean;
|
||||||
|
export function firstAuthorizedPath(access: AdminNavigationAccess): string | null;
|
||||||
|
export function resolveAuthorizedPath(access: AdminNavigationAccess, requestedMenu: AdminMenuKey): string | null;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
export const ADMIN_NAVIGATION = Object.freeze([
|
||||||
|
{ menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'payments', path: '/payments', stage: 'M08-D', title: '支付、退款与分账', label: '支付分账', icon: 'WalletCards', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'thirdParty', path: '/third-party', stage: 'M08-D', title: '团购与第三方平台运营', label: '团购平台', icon: 'TicketCheck', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'people', path: '/people', stage: 'M08-D', title: '会员与员工用户管理', label: '会员员工', icon: 'UsersRound', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'cleaning', path: '/cleaning', stage: 'M08-B/M09', title: '保洁任务与结算', label: '保洁运营', icon: 'Sparkles', capabilities: ['cleaning.task.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'devices', path: '/devices', stage: 'M08-D', title: '设备资产、拓扑与控制', label: '设备', icon: 'RadioTower', capabilities: ['device.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'platformApps', path: '/apps', stage: 'M08-D', title: '多小程序与租户品牌配置', label: '小程序租户', icon: 'PanelsTopLeft', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'content', path: '/content', stage: 'M08-D', title: '广告投放与门店装修', label: '广告装修', icon: 'Images', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'franchise', path: '/franchise', stage: 'M08-D', title: '加盟申请与跟进运营', label: '加盟跟进', icon: 'Handshake', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||||
|
{ menuKey: 'system', path: '/system', stage: 'M08-D', title: '安全审计与系统配置', label: '日志配置', icon: 'ScrollText', capabilities: ['tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] }
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isMenuAllowed(menus, menuKey) {
|
||||||
|
return menus.includes(menuKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pathForMenu(menuKey) {
|
||||||
|
return ADMIN_NAVIGATION.find((item) => item.menuKey === menuKey)?.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstAllowedPath(menus) {
|
||||||
|
return ADMIN_NAVIGATION.find((item) => menus.includes(item.menuKey))?.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProtectedPath(menus, requestedMenu) {
|
||||||
|
return isMenuAllowed(menus, requestedMenu)
|
||||||
|
? pathForMenu(requestedMenu)
|
||||||
|
: firstAllowedPath(menus);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNavigationAllowed(access, item) {
|
||||||
|
if (!isMenuAllowed(access.menus, item.menuKey)) return false;
|
||||||
|
return item.capabilities.some((capability) => access.capabilities.includes(capability))
|
||||||
|
|| item.roles.some((role) => access.roles.includes(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstAuthorizedPath(access) {
|
||||||
|
return ADMIN_NAVIGATION.find((item) => isNavigationAllowed(access, item))?.path ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAuthorizedPath(access, requestedMenu) {
|
||||||
|
const requested = ADMIN_NAVIGATION.find((item) => item.menuKey === requestedMenu);
|
||||||
|
return requested && isNavigationAllowed(access, requested)
|
||||||
|
? requested.path
|
||||||
|
: firstAuthorizedPath(access);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
|
||||||
|
import {
|
||||||
|
ADMIN_NAVIGATION,
|
||||||
|
firstAuthorizedPath,
|
||||||
|
isNavigationAllowed,
|
||||||
|
type AdminMenuKey
|
||||||
|
} from './navigation.mjs';
|
||||||
|
import { adminSessionState } from './session';
|
||||||
|
|
||||||
|
const routeComponents = {
|
||||||
|
overview: () => import('./components/OperationsOverviewPanel.vue'),
|
||||||
|
stores: () => import('./components/StoresRoomsPanel.vue'),
|
||||||
|
orders: () => import('./components/OrdersPanel.vue'),
|
||||||
|
payments: () => import('./components/PaymentsPanel.vue'),
|
||||||
|
thirdParty: () => import('./components/ThirdPartyPanel.vue'),
|
||||||
|
people: () => import('./components/MembersStaffPanel.vue'),
|
||||||
|
cleaning: () => import('./components/CleaningWorkspace.vue'),
|
||||||
|
devices: () => import('./components/DevicesPanel.vue'),
|
||||||
|
platformApps: () => import('./components/PlatformAppsPanel.vue'),
|
||||||
|
content: () => import('./components/ContentManagementPanel.vue'),
|
||||||
|
franchise: () => import('./components/FranchisePanel.vue'),
|
||||||
|
system: () => import('./components/LogsSystemPanel.vue')
|
||||||
|
} satisfies Record<AdminMenuKey, () => Promise<unknown>>;
|
||||||
|
|
||||||
|
const businessRoutes = ADMIN_NAVIGATION.map<RouteRecordRaw>((item) => ({
|
||||||
|
path: item.path,
|
||||||
|
name: item.menuKey,
|
||||||
|
component: routeComponents[item.menuKey],
|
||||||
|
meta: {
|
||||||
|
menuKey: item.menuKey,
|
||||||
|
stage: item.stage,
|
||||||
|
title: item.title
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', redirect: '/overview' },
|
||||||
|
...businessRoutes,
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/overview' }
|
||||||
|
],
|
||||||
|
scrollBehavior: () => ({ top: 0 })
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
const menuKey = to.meta.menuKey as AdminMenuKey | undefined;
|
||||||
|
if (!menuKey || !adminSessionState.token || !adminSessionState.ready) return true;
|
||||||
|
const navigation = ADMIN_NAVIGATION.find((item) => item.menuKey === menuKey);
|
||||||
|
if (navigation && isNavigationAllowed(adminSessionState.access, navigation)) return true;
|
||||||
|
const fallback = firstAuthorizedPath(adminSessionState.access);
|
||||||
|
return fallback ? { path: fallback, replace: true } : false;
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { reactive } from 'vue';
|
||||||
|
import type { AdminAccess, AdminIdentity, AdminSessionPayload } from './types';
|
||||||
|
|
||||||
|
const emptyAccess = (): AdminAccess => ({ roles: [], capabilities: [], storeIds: [], menus: [] });
|
||||||
|
|
||||||
|
export const adminSessionState = reactive<{
|
||||||
|
token: string;
|
||||||
|
user: AdminIdentity | null;
|
||||||
|
access: AdminAccess;
|
||||||
|
ready: boolean;
|
||||||
|
}>({
|
||||||
|
token: localStorage.getItem('qipai.admin.token') || '',
|
||||||
|
user: null,
|
||||||
|
access: emptyAccess(),
|
||||||
|
ready: !localStorage.getItem('qipai.admin.token')
|
||||||
|
});
|
||||||
|
|
||||||
|
export function applyAdminSession(payload: AdminSessionPayload) {
|
||||||
|
adminSessionState.token = payload.accessToken;
|
||||||
|
adminSessionState.user = payload.user;
|
||||||
|
adminSessionState.access = payload.access;
|
||||||
|
adminSessionState.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyAdminIdentity(user: AdminIdentity, access: AdminAccess) {
|
||||||
|
adminSessionState.user = user;
|
||||||
|
adminSessionState.access = access;
|
||||||
|
adminSessionState.ready = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAdminSessionState() {
|
||||||
|
adminSessionState.token = '';
|
||||||
|
adminSessionState.user = null;
|
||||||
|
adminSessionState.access = emptyAccess();
|
||||||
|
adminSessionState.ready = true;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,864 @@
|
|||||||
|
export type TaskStatus =
|
||||||
|
| 'WAITING'
|
||||||
|
| 'CLAIMED'
|
||||||
|
| 'STARTED'
|
||||||
|
| 'SUBMITTED'
|
||||||
|
| 'COMPLETED'
|
||||||
|
| 'REJECTED'
|
||||||
|
| 'EXEMPT'
|
||||||
|
| 'SETTLED'
|
||||||
|
| 'CANCELLED';
|
||||||
|
|
||||||
|
export type SettlementStatus = 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||||
|
export type PayoutStateFilter = 'NONE' | 'SUCCESS' | 'FAIL' | 'PROCESSING' | 'WAIT_USER_CONFIRM';
|
||||||
|
export type TransferMode = 'API' | 'MOCK';
|
||||||
|
export type UserStatus = 'ACTIVE' | 'DISABLED';
|
||||||
|
export type StaffRole = 'CLEANER' | 'STAFF' | 'STORE_ADMIN' | 'TENANT_ADMIN';
|
||||||
|
|
||||||
|
export interface PageResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedStore {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
longitude: number | null;
|
||||||
|
latitude: number | null;
|
||||||
|
contactPhone: string;
|
||||||
|
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||||
|
timezone: string;
|
||||||
|
wifiSsid: string;
|
||||||
|
wifiConfigured: boolean;
|
||||||
|
notificationUrl: string;
|
||||||
|
sortOrder: number;
|
||||||
|
businessHours: StoreBusinessHour[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreBusinessHour {
|
||||||
|
weekday: number;
|
||||||
|
openMinute: number;
|
||||||
|
closeMinute: number;
|
||||||
|
isClosed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoreInput {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
longitude?: number | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
contactPhone: string;
|
||||||
|
timezone: string;
|
||||||
|
businessStatus: ManagedStore['businessStatus'];
|
||||||
|
wifiSsid: string;
|
||||||
|
wifiPassword?: string;
|
||||||
|
notificationUrl: string;
|
||||||
|
sortOrder: number;
|
||||||
|
businessHours: StoreBusinessHour[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomConfigurationStatus = 'ENABLED' | 'DISABLED';
|
||||||
|
export type RoomOperationalStatus =
|
||||||
|
| 'AVAILABLE'
|
||||||
|
| 'MAINTENANCE'
|
||||||
|
| 'RESERVED'
|
||||||
|
| 'IN_USE'
|
||||||
|
| 'CLEANING_REQUIRED';
|
||||||
|
|
||||||
|
export interface ManagedRoom {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
categoryName: string;
|
||||||
|
name: string;
|
||||||
|
roomNo: string;
|
||||||
|
capacity: number;
|
||||||
|
basePriceCents: number;
|
||||||
|
weekdayPriceCents: number;
|
||||||
|
holidayPriceCents: number;
|
||||||
|
overnightPriceCents: number;
|
||||||
|
fullDayPriceCents: number;
|
||||||
|
minimumSpendCents: number;
|
||||||
|
depositCents: number;
|
||||||
|
minimumMinutes: number;
|
||||||
|
maxAdvanceStartMinutes: number;
|
||||||
|
maxAdvanceDays: number;
|
||||||
|
configurationStatus: RoomConfigurationStatus;
|
||||||
|
operationalStatus: RoomOperationalStatus;
|
||||||
|
tags: string[];
|
||||||
|
images: string[];
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoomInput = Omit<ManagedRoom, 'id'>;
|
||||||
|
|
||||||
|
export type OrderStatus =
|
||||||
|
| 'DRAFT'
|
||||||
|
| 'PENDING_PAYMENT'
|
||||||
|
| 'PAID'
|
||||||
|
| 'RESERVED'
|
||||||
|
| 'IN_PROGRESS'
|
||||||
|
| 'FINISHED'
|
||||||
|
| 'CANCELLED'
|
||||||
|
| 'REFUNDING'
|
||||||
|
| 'REFUNDED'
|
||||||
|
| 'CLOSED';
|
||||||
|
|
||||||
|
export type OrderAction =
|
||||||
|
| 'SUBMIT'
|
||||||
|
| 'CONFIRM_PAYMENT'
|
||||||
|
| 'RESERVE'
|
||||||
|
| 'START'
|
||||||
|
| 'FINISH'
|
||||||
|
| 'CANCEL'
|
||||||
|
| 'BEGIN_REFUND'
|
||||||
|
| 'COMPLETE_REFUND'
|
||||||
|
| 'CLOSE';
|
||||||
|
|
||||||
|
export interface ManagedOrder {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
roomNo: string;
|
||||||
|
status: OrderStatus;
|
||||||
|
startAt: string;
|
||||||
|
endAt: string;
|
||||||
|
totalAmountCents: number;
|
||||||
|
paidAmountCents: number;
|
||||||
|
latestPayment: null | { id: string; provider: string; status: string };
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderHistoryItem {
|
||||||
|
id: string;
|
||||||
|
fromStatus: OrderStatus | null;
|
||||||
|
toStatus: OrderStatus;
|
||||||
|
action: OrderAction | 'CREATED' | 'EXPIRED' | 'MIGRATED';
|
||||||
|
actorType: string;
|
||||||
|
actorId: string | null;
|
||||||
|
source: string;
|
||||||
|
reason: string;
|
||||||
|
traceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentAuthorizationStatus = 'UNAUTHORIZED' | 'PENDING' | 'AUTHORIZED' | 'REVOKED';
|
||||||
|
|
||||||
|
export interface CollectionAccount {
|
||||||
|
id: string;
|
||||||
|
platformAppId: string;
|
||||||
|
storeId: string | null;
|
||||||
|
provider: string;
|
||||||
|
merchantId: string;
|
||||||
|
authorizationStatus: PaymentAuthorizationStatus;
|
||||||
|
profitSharingEnabled: boolean | number;
|
||||||
|
enabled: boolean | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitShareReceiver {
|
||||||
|
id: string;
|
||||||
|
collectionAccountId: string;
|
||||||
|
receiverType: 'MERCHANT_ID' | 'PERSONAL_OPENID';
|
||||||
|
receiverMasked: string;
|
||||||
|
relationType: string;
|
||||||
|
name: string;
|
||||||
|
authorizationStatus: PaymentAuthorizationStatus;
|
||||||
|
enabled: boolean | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitSharePolicy {
|
||||||
|
id: string;
|
||||||
|
collectionAccountId: string;
|
||||||
|
storeId: string | null;
|
||||||
|
receiverId: string;
|
||||||
|
percentageBps: number;
|
||||||
|
enabled: boolean | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitShareRecord {
|
||||||
|
id: string;
|
||||||
|
paymentId: string;
|
||||||
|
orderId: string;
|
||||||
|
storeId: string;
|
||||||
|
shareNo: string;
|
||||||
|
receiverType: string;
|
||||||
|
receiverMasked: string;
|
||||||
|
percentageBps: number;
|
||||||
|
amountCents: number;
|
||||||
|
status: string;
|
||||||
|
failureCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfitSharingSnapshot {
|
||||||
|
accounts: CollectionAccount[];
|
||||||
|
receivers: ProfitShareReceiver[];
|
||||||
|
policies: ProfitSharePolicy[];
|
||||||
|
shares: ProfitShareRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ThirdPartyProvider = 'MEITUAN' | 'DIANPING' | 'DOUYIN' | 'KUAISHOU';
|
||||||
|
export type ThirdPartyMode = 'MANUAL' | 'MOCK' | 'API';
|
||||||
|
|
||||||
|
export interface DirectBookingRecord {
|
||||||
|
id: string;
|
||||||
|
provider: ThirdPartyProvider;
|
||||||
|
externalBookingNo: string;
|
||||||
|
storeId: string | null;
|
||||||
|
roomId: string | null;
|
||||||
|
startsAt: string;
|
||||||
|
endsAt: string;
|
||||||
|
amountCents: number;
|
||||||
|
status: string;
|
||||||
|
orderId: string | null;
|
||||||
|
failureCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupRedemptionRecord {
|
||||||
|
id: string;
|
||||||
|
provider: ThirdPartyProvider;
|
||||||
|
voucherMasked: string;
|
||||||
|
orderId: string;
|
||||||
|
storeId: string;
|
||||||
|
mode: ThirdPartyMode;
|
||||||
|
actorId: string | null;
|
||||||
|
status: string;
|
||||||
|
failureCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdPartyRecords {
|
||||||
|
bookings: DirectBookingRecord[];
|
||||||
|
redemptions: GroupRedemptionRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdPartyConfigSummary {
|
||||||
|
id: string;
|
||||||
|
provider: ThirdPartyProvider;
|
||||||
|
storeId: string | null;
|
||||||
|
mode: ThirdPartyMode;
|
||||||
|
enabled: boolean;
|
||||||
|
credentialRef: string;
|
||||||
|
credentialConfigured: boolean;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdPartyMappingSummary {
|
||||||
|
id: string;
|
||||||
|
provider: ThirdPartyProvider;
|
||||||
|
resourceType: 'STORE' | 'ROOM';
|
||||||
|
externalRef: string;
|
||||||
|
localResourceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThirdPartySetup {
|
||||||
|
configs: ThirdPartyConfigSummary[];
|
||||||
|
mappings: ThirdPartyMappingSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessStatistics {
|
||||||
|
storeId: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
summary: {
|
||||||
|
orderTotal: number;
|
||||||
|
activeOrderTotal: number;
|
||||||
|
finishedOrderTotal: number;
|
||||||
|
bookedAmountCents: number;
|
||||||
|
collectedAmountCents: number;
|
||||||
|
payingMemberTotal: number;
|
||||||
|
voucherTotal: number;
|
||||||
|
voucherSucceeded: number;
|
||||||
|
voucherFailed: number;
|
||||||
|
roomTotal: number;
|
||||||
|
availableRoomTotal: number;
|
||||||
|
attentionRoomTotal: number;
|
||||||
|
};
|
||||||
|
orderStatuses: Array<{ status: string; total: number; amountCents: number }>;
|
||||||
|
paymentChannels: Array<{ channel: string; total: number; amountCents: number }>;
|
||||||
|
dailyRevenue: Array<{ date: string; total: number; amountCents: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ManagedUser {
|
||||||
|
id: string;
|
||||||
|
userType: string;
|
||||||
|
status: UserStatus;
|
||||||
|
nickname: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
maskedPhone: string;
|
||||||
|
maskedLastIp: string;
|
||||||
|
note: string;
|
||||||
|
roles: StaffRole[];
|
||||||
|
storeIds: string[];
|
||||||
|
wechatMiniappBound: boolean;
|
||||||
|
registeredAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberWalletSummary {
|
||||||
|
accountCount: number;
|
||||||
|
cashBalanceCents: number;
|
||||||
|
giftBalanceCents: number;
|
||||||
|
totalBalanceCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberBenefitSummary {
|
||||||
|
availableCoupons: number;
|
||||||
|
frozenCoupons: number;
|
||||||
|
activePackages: number;
|
||||||
|
frozenPackages: number;
|
||||||
|
packageMinutes: number;
|
||||||
|
packageAmountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberCard {
|
||||||
|
memberId: string;
|
||||||
|
status: UserStatus;
|
||||||
|
nickname: string;
|
||||||
|
maskedPhone: string;
|
||||||
|
registeredAt: string;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
wallet: MemberWalletSummary;
|
||||||
|
benefits: MemberBenefitSummary;
|
||||||
|
recharge: {
|
||||||
|
rechargeOrderCount: number;
|
||||||
|
creditedRechargeCount: number;
|
||||||
|
creditedRechargeCents: number;
|
||||||
|
giftedRechargeCents: number;
|
||||||
|
lastRechargeAt: string | null;
|
||||||
|
};
|
||||||
|
orders: {
|
||||||
|
orderCount: number;
|
||||||
|
paidOrderCount: number;
|
||||||
|
paidAmountCents: number;
|
||||||
|
lastOrderAt: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberLedgerEntry {
|
||||||
|
ledgerId: string;
|
||||||
|
storeId: string | null;
|
||||||
|
businessType: string;
|
||||||
|
businessId: string;
|
||||||
|
entryType: string;
|
||||||
|
cashDeltaCents: number;
|
||||||
|
giftDeltaCents: number;
|
||||||
|
cashBalanceAfterCents: number;
|
||||||
|
giftBalanceAfterCents: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberDetail extends MemberCard {
|
||||||
|
recentLedger: MemberLedgerEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceType = 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
|
||||||
|
|
||||||
|
export interface DeviceAsset {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
roomId: string | null;
|
||||||
|
deviceId: string;
|
||||||
|
imei: string;
|
||||||
|
iccid: string | null;
|
||||||
|
deviceType: DeviceType;
|
||||||
|
model: string;
|
||||||
|
firmwareVersion: string;
|
||||||
|
status: 'ONLINE' | 'OFFLINE' | 'FAULT';
|
||||||
|
signalStrength: number | null;
|
||||||
|
capabilities: string[];
|
||||||
|
stateSnapshot: Record<string, unknown>;
|
||||||
|
lastSeenAt: string | null;
|
||||||
|
lastHeartbeatAt: string | null;
|
||||||
|
maintenanceStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceChannel {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
roomId: string;
|
||||||
|
channelCode: string;
|
||||||
|
purpose: string;
|
||||||
|
targetKey: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceLink {
|
||||||
|
id: string;
|
||||||
|
parentAssetId: string;
|
||||||
|
childAssetId: string;
|
||||||
|
roomId: string;
|
||||||
|
linkType: string;
|
||||||
|
subId: string;
|
||||||
|
subtype: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceAlert {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
roomId: string | null;
|
||||||
|
alertType: string;
|
||||||
|
severity: string;
|
||||||
|
status: string;
|
||||||
|
summary: string;
|
||||||
|
firstSeenAt: string;
|
||||||
|
lastSeenAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceMaintenance {
|
||||||
|
id: string;
|
||||||
|
assetId: string;
|
||||||
|
roomId: string | null;
|
||||||
|
recordType: 'INSPECTION' | 'REPAIR' | 'REPLACEMENT';
|
||||||
|
status: 'OPEN' | 'RESOLVED';
|
||||||
|
description: string;
|
||||||
|
createdAt: string;
|
||||||
|
resolvedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceTopology {
|
||||||
|
assets: DeviceAsset[];
|
||||||
|
channels: DeviceChannel[];
|
||||||
|
links: DeviceLink[];
|
||||||
|
openAlerts: DeviceAlert[];
|
||||||
|
maintenance: DeviceMaintenance[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformApplication {
|
||||||
|
platformAppId: string;
|
||||||
|
appId: string;
|
||||||
|
appName: string;
|
||||||
|
appStatus: 'ACTIVE' | 'DISABLED';
|
||||||
|
bindingStatus: 'ACTIVE' | 'DISABLED';
|
||||||
|
isDefault: boolean;
|
||||||
|
brandName: string;
|
||||||
|
logoUrl: string;
|
||||||
|
themeColor: string;
|
||||||
|
servicePhone: string;
|
||||||
|
franchisePhone: string;
|
||||||
|
shareTitle: string;
|
||||||
|
shareImageUrl: string;
|
||||||
|
defaultStoreId: string | null;
|
||||||
|
extraConfig: Record<string, unknown>;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TenantApplicationConfig {
|
||||||
|
brandName: string;
|
||||||
|
logoUrl: string;
|
||||||
|
themeColor: string;
|
||||||
|
servicePhone: string;
|
||||||
|
franchisePhone: string;
|
||||||
|
shareTitle: string;
|
||||||
|
shareImageUrl: string;
|
||||||
|
defaultStoreId: string | null;
|
||||||
|
extraConfig: Record<string, unknown>;
|
||||||
|
bindingStatus: 'ACTIVE' | 'DISABLED';
|
||||||
|
isDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaAsset {
|
||||||
|
id: string;
|
||||||
|
storeId: string | null;
|
||||||
|
url: string;
|
||||||
|
mimeType: string;
|
||||||
|
byteSize: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DecorationComponentType = 'HERO' | 'NOTICE' | 'GALLERY' | 'CONTACT' | 'ROOM_LIST';
|
||||||
|
|
||||||
|
export interface DecorationComponent {
|
||||||
|
type: DecorationComponentType;
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DecorationVersion {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
templateCode: string;
|
||||||
|
schemaVersion: number;
|
||||||
|
content: { components: DecorationComponent[] };
|
||||||
|
status: 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
version: number;
|
||||||
|
publishedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdvertisementInput {
|
||||||
|
scopeType: 'PLATFORM' | 'TENANT' | 'STORE';
|
||||||
|
storeId: string | null;
|
||||||
|
title: string;
|
||||||
|
imageAssetId: string;
|
||||||
|
targetType: 'NONE' | 'PAGE' | 'URL';
|
||||||
|
targetValue: string;
|
||||||
|
startsAt: string | null;
|
||||||
|
endsAt: string | null;
|
||||||
|
status: 'DRAFT' | 'ACTIVE' | 'INACTIVE';
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Advertisement extends AdvertisementInput {
|
||||||
|
id: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
|
||||||
|
export type FranchiseFollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
|
||||||
|
|
||||||
|
export interface FranchiseApplication {
|
||||||
|
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
|
||||||
|
contactPhone: string; message: string; source: string; status: FranchiseStatus;
|
||||||
|
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
|
||||||
|
nextFollowUpAt: string | null; closedAt: string | null; createdAt: string; updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FranchiseFollowUp {
|
||||||
|
id: string; actorUserId: string; actorName: string; followUpType: FranchiseFollowUpType;
|
||||||
|
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
|
||||||
|
note: string; nextFollowUpAt: string | null; createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FranchiseApplicationDetail {
|
||||||
|
application: FranchiseApplication;
|
||||||
|
followUps: FranchiseFollowUp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLog {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
actorType: string;
|
||||||
|
actorId: string | null;
|
||||||
|
actorName: string | null;
|
||||||
|
action: string;
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
traceId: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
metadata: unknown;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemOverview {
|
||||||
|
tenant: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
timezone: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
counts: {
|
||||||
|
storeCount: number;
|
||||||
|
userCount: number;
|
||||||
|
activeSessionCount: number;
|
||||||
|
appBindingCount: number;
|
||||||
|
auditTodayCount: number;
|
||||||
|
};
|
||||||
|
latestMigration: null | { version: string; name: string; appliedAt: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminIdentity {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userType: string;
|
||||||
|
nickname: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
roleVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAccess {
|
||||||
|
roles: string[];
|
||||||
|
capabilities: string[];
|
||||||
|
storeIds: string[];
|
||||||
|
menus: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminSessionPayload {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
user: AdminIdentity;
|
||||||
|
access: AdminAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningTask {
|
||||||
|
id: string;
|
||||||
|
taskNo: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
roomNo: string;
|
||||||
|
orderId: string | null;
|
||||||
|
orderNo: string | null;
|
||||||
|
status: TaskStatus;
|
||||||
|
cleanerUserId: string | null;
|
||||||
|
priority: number;
|
||||||
|
rewardCents: number;
|
||||||
|
requirement: string;
|
||||||
|
cleaningTemplateId: string | null;
|
||||||
|
cleaningTemplateVersion: number;
|
||||||
|
photoRequired: boolean;
|
||||||
|
minPhotoCount: number;
|
||||||
|
maxPhotoCount: number;
|
||||||
|
exemptPolicy: CleaningExemptPolicy;
|
||||||
|
photoUrls: string[];
|
||||||
|
rejectReason: string;
|
||||||
|
reworkCount: number;
|
||||||
|
photoRevision: number;
|
||||||
|
claimedAt?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
submittedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CleaningTemplateScope = 'TENANT' | 'STORE' | 'ROOM';
|
||||||
|
export type CleaningExemptPolicy = 'DISABLED' | 'BEFORE_START' | 'ANY_ACTIVE';
|
||||||
|
|
||||||
|
export interface CleaningTemplate {
|
||||||
|
id: string;
|
||||||
|
scopeKey: string;
|
||||||
|
scopeType: CleaningTemplateScope;
|
||||||
|
storeId: string | null;
|
||||||
|
roomId: string | null;
|
||||||
|
name: string;
|
||||||
|
requirement: string;
|
||||||
|
photoRequired: boolean;
|
||||||
|
minPhotoCount: number;
|
||||||
|
maxPhotoCount: number;
|
||||||
|
exemptPolicy: CleaningExemptPolicy;
|
||||||
|
version: number;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
updatedAt: string;
|
||||||
|
appliesToExistingTasks: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningTaskMember {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
userId: string;
|
||||||
|
nickname: string;
|
||||||
|
memberRole: 'LEAD' | 'ASSIST';
|
||||||
|
rewardCents: number;
|
||||||
|
joinedAt?: string;
|
||||||
|
removedAt?: string | null;
|
||||||
|
settledAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningTaskEvent {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
fromStatus: TaskStatus | null;
|
||||||
|
toStatus: TaskStatus;
|
||||||
|
action: string;
|
||||||
|
actorId: string;
|
||||||
|
actorType: 'USER' | 'SYSTEM';
|
||||||
|
traceId: string;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningTaskSubmission {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
revision: number;
|
||||||
|
status: 'SUBMITTED' | 'ACCEPTED' | 'REJECTED';
|
||||||
|
photoUrls: string[];
|
||||||
|
note: string;
|
||||||
|
rejectReason: string;
|
||||||
|
submittedBy: string;
|
||||||
|
reviewedBy: string | null;
|
||||||
|
submittedAt: string;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlement {
|
||||||
|
id: string;
|
||||||
|
settlementNo: string;
|
||||||
|
cleanerUserId: string;
|
||||||
|
cleanerName: string;
|
||||||
|
storeId: string | null;
|
||||||
|
storeName: string | null;
|
||||||
|
status: SettlementStatus;
|
||||||
|
taskCount: number;
|
||||||
|
totalRewardCents: number;
|
||||||
|
periodStart: string | null;
|
||||||
|
periodEnd: string | null;
|
||||||
|
paidBy: string | null;
|
||||||
|
cancelledBy: string | null;
|
||||||
|
confirmedAt: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
cancelledAt: string | null;
|
||||||
|
payoutChannel: string;
|
||||||
|
payoutRequestNo: string;
|
||||||
|
payoutReference: string;
|
||||||
|
payoutState: string;
|
||||||
|
payoutPackageInfo: string;
|
||||||
|
payoutError: string;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlementItem {
|
||||||
|
id: string;
|
||||||
|
settlementId: string;
|
||||||
|
taskId: string;
|
||||||
|
taskNo: string;
|
||||||
|
orderNo: string | null;
|
||||||
|
storeName: string;
|
||||||
|
roomName: string;
|
||||||
|
roomNo: string;
|
||||||
|
cleanerUserId: string;
|
||||||
|
cleanerName: string;
|
||||||
|
rewardCents: number;
|
||||||
|
reversedAt: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlementEvent {
|
||||||
|
id: string;
|
||||||
|
settlementId: string;
|
||||||
|
fromStatus: SettlementStatus | null;
|
||||||
|
toStatus: SettlementStatus;
|
||||||
|
action: string;
|
||||||
|
actorType: 'USER' | 'SYSTEM';
|
||||||
|
actorId: string;
|
||||||
|
traceId: string;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlementReversal {
|
||||||
|
id: string;
|
||||||
|
settlementId: string;
|
||||||
|
reversalNo: string;
|
||||||
|
amountDeltaCents: number;
|
||||||
|
reason: string;
|
||||||
|
reversedBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningSettlementDetail {
|
||||||
|
settlement: CleaningSettlement;
|
||||||
|
items: CleaningSettlementItem[];
|
||||||
|
events: CleaningSettlementEvent[];
|
||||||
|
reversals: CleaningSettlementReversal[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleaningStatistics {
|
||||||
|
summary: {
|
||||||
|
taskTotal: number;
|
||||||
|
pendingReview: number;
|
||||||
|
active: number;
|
||||||
|
rejected: number;
|
||||||
|
exempted: number;
|
||||||
|
completed: number;
|
||||||
|
rewardCents: number;
|
||||||
|
pendingSettlementCents: number;
|
||||||
|
draftSettlementCents: number;
|
||||||
|
confirmedSettlementCents: number;
|
||||||
|
paidSettlementCents: number;
|
||||||
|
};
|
||||||
|
byStatus: Array<{
|
||||||
|
status: TaskStatus;
|
||||||
|
total: number;
|
||||||
|
rewardCents: number;
|
||||||
|
}>;
|
||||||
|
byStore: Array<{
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
taskTotal: number;
|
||||||
|
pendingReview: number;
|
||||||
|
completed: number;
|
||||||
|
rejected: number;
|
||||||
|
exempted: number;
|
||||||
|
rewardCents: number;
|
||||||
|
}>;
|
||||||
|
settlements: Array<{
|
||||||
|
status: SettlementStatus;
|
||||||
|
total: number;
|
||||||
|
rewardCents: number;
|
||||||
|
}>;
|
||||||
|
members: Array<{
|
||||||
|
cleanerUserId: string;
|
||||||
|
cleanerName: string;
|
||||||
|
taskCount: number;
|
||||||
|
completedTaskCount: number;
|
||||||
|
rejectedTaskCount: number;
|
||||||
|
pendingSettlementCents: number;
|
||||||
|
settledRewardCents: number;
|
||||||
|
}>;
|
||||||
|
trend: Array<{
|
||||||
|
date: string;
|
||||||
|
taskTotal: number;
|
||||||
|
pendingReview: number;
|
||||||
|
completed: number;
|
||||||
|
rejected: number;
|
||||||
|
exempted: number;
|
||||||
|
rewardCents: number;
|
||||||
|
paidSettlementCents: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL';
|
||||||
|
|
||||||
|
export interface WechatTransferPreflightCheck {
|
||||||
|
key: string;
|
||||||
|
status: WechatTransferPreflightStatus;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WechatTransferPreflight {
|
||||||
|
ready: boolean;
|
||||||
|
settlement: {
|
||||||
|
id: string;
|
||||||
|
settlementNo: string;
|
||||||
|
status: SettlementStatus;
|
||||||
|
totalRewardCents: number;
|
||||||
|
cleanerUserId: string;
|
||||||
|
storeId: string | null;
|
||||||
|
};
|
||||||
|
account: {
|
||||||
|
configured: boolean;
|
||||||
|
id?: string;
|
||||||
|
storeScoped?: boolean;
|
||||||
|
merchantIdMasked?: string;
|
||||||
|
credentialRefMasked?: string;
|
||||||
|
authorizationStatus?: string;
|
||||||
|
};
|
||||||
|
credential: {
|
||||||
|
configured: boolean;
|
||||||
|
appIdPresent?: boolean;
|
||||||
|
serialNoPresent?: boolean;
|
||||||
|
transferSceneId?: string;
|
||||||
|
reportInfoCount?: number;
|
||||||
|
transferNotifyUrlConfigured?: boolean;
|
||||||
|
platformCertificateCount?: number;
|
||||||
|
};
|
||||||
|
cleaner: {
|
||||||
|
openidConfigured: boolean;
|
||||||
|
};
|
||||||
|
checks: WechatTransferPreflightCheck[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
ADMIN_NAVIGATION,
|
||||||
|
firstAuthorizedPath,
|
||||||
|
isNavigationAllowed,
|
||||||
|
pathForMenu,
|
||||||
|
resolveAuthorizedPath
|
||||||
|
} from '../src/navigation.mjs';
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
ADMIN_NAVIGATION.map(({ path }) => path),
|
||||||
|
['/overview', '/stores', '/orders', '/payments', '/third-party', '/people',
|
||||||
|
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
|
||||||
|
);
|
||||||
|
assert.equal(pathForMenu('platformApps'), '/apps');
|
||||||
|
assert.equal(pathForMenu('thirdParty'), '/third-party');
|
||||||
|
|
||||||
|
const storeOperator = {
|
||||||
|
roles: ['STORE_ADMIN'],
|
||||||
|
capabilities: ['store.operation.read'],
|
||||||
|
menus: ['overview', 'stores', 'orders', 'thirdParty']
|
||||||
|
};
|
||||||
|
assert.equal(resolveAuthorizedPath(storeOperator, 'orders'), '/orders');
|
||||||
|
assert.equal(resolveAuthorizedPath(storeOperator, 'system'), '/overview');
|
||||||
|
assert.equal(firstAuthorizedPath(storeOperator), '/overview');
|
||||||
|
|
||||||
|
const forgedSystemMenu = {
|
||||||
|
roles: ['STORE_ADMIN'],
|
||||||
|
capabilities: ['store.operation.read'],
|
||||||
|
menus: ['system']
|
||||||
|
};
|
||||||
|
const systemNavigation = ADMIN_NAVIGATION.find(({ menuKey }) => menuKey === 'system');
|
||||||
|
assert.ok(systemNavigation);
|
||||||
|
assert.equal(isNavigationAllowed(forgedSystemMenu, systemNavigation), false);
|
||||||
|
assert.equal(firstAuthorizedPath(forgedSystemMenu), null);
|
||||||
|
|
||||||
|
const cleaner = {
|
||||||
|
roles: ['CLEANER'],
|
||||||
|
capabilities: ['cleaning.task.read'],
|
||||||
|
menus: ['cleaning']
|
||||||
|
};
|
||||||
|
assert.equal(resolveAuthorizedPath(cleaner, 'cleaning'), '/cleaning');
|
||||||
|
assert.equal(resolveAuthorizedPath(cleaner, 'devices'), '/cleaning');
|
||||||
|
|
||||||
|
const platformAdmin = {
|
||||||
|
roles: ['PLATFORM_ADMIN'],
|
||||||
|
capabilities: [],
|
||||||
|
menus: ADMIN_NAVIGATION.map(({ menuKey }) => menuKey)
|
||||||
|
};
|
||||||
|
assert.equal(resolveAuthorizedPath(platformAdmin, 'system'), '/system');
|
||||||
|
|
||||||
|
console.log('PASS: M08-D-R1 deep links, menu/capability guards and refresh fallbacks are deterministic.');
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.mts", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import Components from 'unplugin-vue-components/vite';
|
||||||
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
Components({
|
||||||
|
dts: false,
|
||||||
|
resolvers: [ElementPlusResolver({ importStyle: 'css' })]
|
||||||
|
})
|
||||||
|
],
|
||||||
|
base: '/admin/',
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vue: ['vue', 'vue-router'],
|
||||||
|
icons: ['@lucide/vue']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/admin-api': {
|
||||||
|
target: 'http://127.0.0.1:3001',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -8,6 +8,18 @@ QIPAI_MYSQL_PORT=3306
|
|||||||
QIPAI_MYSQL_DATABASE=qipai
|
QIPAI_MYSQL_DATABASE=qipai
|
||||||
QIPAI_MYSQL_USER=qipai_app
|
QIPAI_MYSQL_USER=qipai_app
|
||||||
QIPAI_MYSQL_PASSWORD=
|
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_URL=mqtt://101.42.38.246:1883
|
||||||
|
QIPAI_MQTT_CLIENT_ID=qipai-backend
|
||||||
QIPAI_MQTT_USERNAME=
|
QIPAI_MQTT_USERNAME=
|
||||||
QIPAI_MQTT_PASSWORD=
|
QIPAI_MQTT_PASSWORD=
|
||||||
|
QIPAI_MQTT_RECONNECT_MS=3000
|
||||||
|
QIPAI_MQTT_CONNECT_TIMEOUT_MS=10000
|
||||||
|
QIPAI_MQTT_MAX_MESSAGE_BYTES=65536
|
||||||
|
|||||||
Generated
+914
File diff suppressed because it is too large
Load Diff
@@ -11,14 +11,22 @@
|
|||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs"
|
"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/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/rate-limit": "^11.0.0",
|
"@fastify/rate-limit": "^11.0.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
|
"mqtt": "^5.15.1",
|
||||||
"mysql2": "^3.11.3",
|
"mysql2": "^3.11.3",
|
||||||
"pino": "^9.4.0",
|
"pino": "^9.4.0",
|
||||||
|
"sharp": "^0.34.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+169
-2
@@ -3,15 +3,103 @@ import cors from '@fastify/cors';
|
|||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { loadConfig, type AppConfig } from './config.js';
|
import { loadConfig, type AppConfig } from './config.js';
|
||||||
import { registerHealthRoutes } from './routes/health.js';
|
import { registerHealthRoutes, type MqttHealthProvider } from './routes/health.js';
|
||||||
|
import {
|
||||||
|
registerPlatformBootstrapRoutes,
|
||||||
|
type PlatformConfigResolver
|
||||||
|
} from './routes/platform-bootstrap.js';
|
||||||
|
import { registerPlatformManagementRoutes, type PlatformManagementRouteOptions } from './routes/platform-management.js';
|
||||||
|
import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js';
|
||||||
|
import {
|
||||||
|
registerUserManagementRoutes,
|
||||||
|
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';
|
||||||
|
import {
|
||||||
|
registerBusinessStatisticsRoutes,
|
||||||
|
type BusinessStatisticsRouteOptions
|
||||||
|
} from './routes/business-statistics.js';
|
||||||
|
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
||||||
|
import {
|
||||||
|
registerSystemOperationsRoutes,
|
||||||
|
type SystemOperationsRouteOptions
|
||||||
|
} from './routes/system-operations.js';
|
||||||
|
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
|
||||||
|
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
|
||||||
|
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig;
|
config?: AppConfig;
|
||||||
|
platformConfigRepository?: PlatformConfigResolver;
|
||||||
|
platformManagement?: PlatformManagementRouteOptions;
|
||||||
|
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;
|
||||||
|
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||||
|
franchise?: FranchiseRouteOptions;
|
||||||
|
systemOperations?: SystemOperationsRouteOptions;
|
||||||
|
adminAuth?: AdminAuthRouteOptions;
|
||||||
|
products?: ProductRouteOptions;
|
||||||
|
inventory?: InventoryRouteOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
traceId: string;
|
traceId: string;
|
||||||
|
rawBody: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +113,7 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.decorateRequest('traceId', '');
|
app.decorateRequest('traceId', '');
|
||||||
|
app.decorateRequest('rawBody', '');
|
||||||
app.addHook('onRequest', async (request, reply) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
const traceHeader = request.headers['x-trace-id'];
|
const traceHeader = request.headers['x-trace-id'];
|
||||||
request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.id;
|
request.traceId = Array.isArray(traceHeader) ? traceHeader[0] : traceHeader || request.id;
|
||||||
@@ -48,7 +137,85 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerHealthRoutes(app, config);
|
await registerHealthRoutes(app, config, options.mqtt);
|
||||||
|
if (options.platformConfigRepository) {
|
||||||
|
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
|
||||||
|
}
|
||||||
|
if (options.platformManagement) {
|
||||||
|
await registerPlatformManagementRoutes(app, options.platformManagement);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
if (options.businessStatistics) {
|
||||||
|
await registerBusinessStatisticsRoutes(app, options.businessStatistics);
|
||||||
|
}
|
||||||
|
if (options.franchise) {
|
||||||
|
await registerFranchiseRoutes(app, options.franchise);
|
||||||
|
}
|
||||||
|
if (options.systemOperations) {
|
||||||
|
await registerSystemOperationsRoutes(app, options.systemOperations);
|
||||||
|
}
|
||||||
|
if (options.adminAuth) {
|
||||||
|
await registerAdminAuthRoutes(app, options.adminAuth);
|
||||||
|
}
|
||||||
|
if (options.products) {
|
||||||
|
await registerProductRoutes(app, options.products);
|
||||||
|
}
|
||||||
|
if (options.inventory) {
|
||||||
|
await registerInventoryRoutes(app, options.inventory);
|
||||||
|
}
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
import type { ManagementActor } from './user-management-repository.js';
|
||||||
|
import { verifyPassword } from './password.js';
|
||||||
|
import type { AuthSession, AuthUser } from './auth-repository.js';
|
||||||
|
|
||||||
|
interface CredentialRow extends RowDataPacket {
|
||||||
|
credentialId: string; tenantId: string; platformAppId: string; passwordHash: string;
|
||||||
|
credentialStatus: string; failedAttempts: number; lockedUntil: Date | null;
|
||||||
|
id: string; userType: string; status: string; roleVersion: number;
|
||||||
|
nickname: string; avatarUrl: string; phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RefreshRow extends RowDataPacket {
|
||||||
|
sessionId: string; tenantId: string; platformAppId: string; refreshTokenHash: string;
|
||||||
|
refreshExpiresAt: Date; id: string; userType: string; status: string; roleVersion: number;
|
||||||
|
nickname: string; avatarUrl: string; phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminAuthError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminAuthRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async loginWithPassword(input: {
|
||||||
|
tenantCode: string; loginName: string; password: string; sessionId: string;
|
||||||
|
refreshTokenHash: string; expiresAt: Date; ip: string; userAgent: string; traceId: string;
|
||||||
|
}): Promise<AuthSession> {
|
||||||
|
const loginName = normalizeLoginName(input.loginName);
|
||||||
|
const [rows] = await this.pool.execute<CredentialRow[]>(
|
||||||
|
`SELECT c.id AS credentialId, c.tenant_id AS tenantId,
|
||||||
|
ta.platform_app_id AS platformAppId, c.password_hash AS passwordHash,
|
||||||
|
c.status AS credentialStatus, c.failed_attempts AS failedAttempts,
|
||||||
|
c.locked_until AS lockedUntil, u.id, u.user_type AS userType, u.status,
|
||||||
|
u.role_version AS roleVersion, u.nickname, u.avatar_url AS avatarUrl, u.phone
|
||||||
|
FROM qipai_admin_credentials c
|
||||||
|
INNER JOIN qipai_tenants t ON t.id = c.tenant_id AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
|
||||||
|
INNER JOIN qipai_users u ON u.id = c.user_id AND u.tenant_id = c.tenant_id
|
||||||
|
AND u.user_type = 'STAFF' AND u.status = 'ACTIVE' AND u.deleted_at IS NULL
|
||||||
|
INNER JOIN qipai_tenant_apps ta ON ta.tenant_id = c.tenant_id
|
||||||
|
AND ta.status = 'ACTIVE' AND ta.deleted_at IS NULL
|
||||||
|
WHERE t.code = ? AND c.login_name = ? AND c.deleted_at IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM qipai_user_roles ur
|
||||||
|
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
|
||||||
|
AND r.status = 'ACTIVE' AND r.code IN ('STAFF', 'CLEANER', 'STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
|
WHERE ur.tenant_id = c.tenant_id AND ur.user_id = c.user_id)
|
||||||
|
ORDER BY ta.is_default DESC, ta.id ASC LIMIT 1`,
|
||||||
|
[input.tenantCode.trim(), loginName]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
const validPassword = await verifyPassword(input.password, row?.passwordHash);
|
||||||
|
if (!row || row.credentialStatus !== 'ACTIVE' || !validPassword) {
|
||||||
|
if (row) await this.recordFailure(row, loginName, input);
|
||||||
|
throw new AdminAuthError('ADMIN_LOGIN_INVALID');
|
||||||
|
}
|
||||||
|
if (row.lockedUntil && row.lockedUntil.getTime() > Date.now()) {
|
||||||
|
throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
|
||||||
|
}
|
||||||
|
const user = mapUser(row);
|
||||||
|
await this.transaction(async (connection) => {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_admin_credentials SET failed_attempts = 0, locked_until = NULL,
|
||||||
|
last_login_at = UTC_TIMESTAMP(3) WHERE id = ?`, [row.credentialId]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_auth_sessions
|
||||||
|
(id, tenant_id, platform_app_id, user_id, role_version, refresh_token_hash,
|
||||||
|
expires_at, refresh_expires_at, ip, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[input.sessionId, row.tenantId, row.platformAppId, user.id, user.roleVersion,
|
||||||
|
input.refreshTokenHash, input.expiresAt, input.expiresAt, input.ip,
|
||||||
|
input.userAgent.slice(0, 255)]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
'UPDATE qipai_users SET last_login_at = UTC_TIMESTAMP(3) WHERE tenant_id = ? AND id = ?',
|
||||||
|
[row.tenantId, user.id]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'USER', ?, 'ADMIN_LOGIN_SUCCEEDED', 'USER', ?, ?, ?, ?, '{}')`,
|
||||||
|
[row.tenantId, user.id, user.id, input.traceId, input.ip, input.userAgent.slice(0, 255)]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { id: input.sessionId, tenantId: String(row.tenantId),
|
||||||
|
platformAppId: String(row.platformAppId), user, expiresAt: input.expiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateRefreshToken(input: {
|
||||||
|
sessionId: string; currentHash: string; nextHash: string; ip: string; userAgent: string;
|
||||||
|
}): Promise<AuthSession> {
|
||||||
|
const [rows] = await this.pool.execute<RefreshRow[]>(
|
||||||
|
`SELECT s.id AS sessionId, s.tenant_id AS tenantId, s.platform_app_id AS platformAppId,
|
||||||
|
s.refresh_token_hash AS refreshTokenHash, s.refresh_expires_at AS refreshExpiresAt,
|
||||||
|
u.id, u.user_type AS userType, u.status, u.role_version AS roleVersion,
|
||||||
|
u.nickname, u.avatar_url AS avatarUrl, u.phone
|
||||||
|
FROM qipai_auth_sessions s INNER JOIN qipai_users u
|
||||||
|
ON u.id = s.user_id AND u.tenant_id = s.tenant_id AND u.deleted_at IS NULL
|
||||||
|
WHERE s.id = ? AND s.status = 'ACTIVE' AND s.revoked_at IS NULL
|
||||||
|
AND s.refresh_token_hash IS NOT NULL AND s.refresh_expires_at > UTC_TIMESTAMP(3)
|
||||||
|
AND u.status = 'ACTIVE' AND u.role_version = s.role_version LIMIT 1`, [input.sessionId]
|
||||||
|
);
|
||||||
|
const row = rows[0];
|
||||||
|
if (!row || !safeHashEqual(row.refreshTokenHash, input.currentHash)) {
|
||||||
|
throw new AdminAuthError('ADMIN_REFRESH_INVALID');
|
||||||
|
}
|
||||||
|
const [updated] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`UPDATE qipai_auth_sessions SET refresh_token_hash = ?, last_seen_at = UTC_TIMESTAMP(3),
|
||||||
|
ip = ?, user_agent = ? WHERE id = ? AND refresh_token_hash = ? AND status = 'ACTIVE'`,
|
||||||
|
[input.nextHash, input.ip, input.userAgent.slice(0, 255), input.sessionId, input.currentHash]
|
||||||
|
);
|
||||||
|
if (updated.affectedRows !== 1) throw new AdminAuthError('ADMIN_REFRESH_REUSED');
|
||||||
|
return { id: row.sessionId, tenantId: String(row.tenantId),
|
||||||
|
platformAppId: String(row.platformAppId), user: mapUser(row), expiresAt: row.refreshExpiresAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCredential(actor: ManagementActor, tenantId: string, input: {
|
||||||
|
userId: string; loginName: string; passwordHash: string;
|
||||||
|
}) {
|
||||||
|
const loginName = normalizeLoginName(input.loginName);
|
||||||
|
try {
|
||||||
|
return await this.transaction(async (connection) => {
|
||||||
|
const [users] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_users WHERE id = ? AND tenant_id = ? AND user_type = 'STAFF'
|
||||||
|
AND status = 'ACTIVE' AND deleted_at IS NULL FOR UPDATE`, [input.userId, tenantId]
|
||||||
|
);
|
||||||
|
if (!users[0]) throw new AdminAuthError('ADMIN_CREDENTIAL_USER_INVALID');
|
||||||
|
const [conflicts] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT user_id AS userId FROM qipai_admin_credentials
|
||||||
|
WHERE tenant_id = ? AND login_name = ? AND user_id <> ? AND deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[tenantId, loginName, input.userId]
|
||||||
|
);
|
||||||
|
if (conflicts[0]) throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
|
||||||
|
const [current] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_admin_credentials
|
||||||
|
WHERE tenant_id = ? AND user_id = ? FOR UPDATE`, [tenantId, input.userId]
|
||||||
|
);
|
||||||
|
if (current[0]) {
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_admin_credentials SET login_name = ?, password_hash = ?, status = 'ACTIVE',
|
||||||
|
failed_attempts = 0, locked_until = NULL, deleted_at = NULL,
|
||||||
|
password_changed_at = UTC_TIMESTAMP(3) WHERE id = ?`,
|
||||||
|
[loginName, input.passwordHash, current[0].id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_admin_credentials
|
||||||
|
(tenant_id, user_id, login_name, password_hash) VALUES (?, ?, ?, ?)`,
|
||||||
|
[tenantId, input.userId, loginName, input.passwordHash]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_auth_sessions SET status = 'REVOKED', revoked_at = UTC_TIMESTAMP(3),
|
||||||
|
revoke_reason = 'PASSWORD_CHANGED' WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE'`,
|
||||||
|
[tenantId, input.userId]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'USER', ?, 'ADMIN_CREDENTIAL_UPDATED', 'USER', ?, ?, ?, ?, ?)`,
|
||||||
|
[tenantId, actor.userId, input.userId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
|
||||||
|
JSON.stringify({ loginNameHash: hashToken(loginName) })]
|
||||||
|
);
|
||||||
|
return { userId: input.userId, configured: true };
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as { code?: string }).code === 'ER_DUP_ENTRY') {
|
||||||
|
throw new AdminAuthError('ADMIN_LOGIN_NAME_CONFLICT');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordFailure(row: CredentialRow, loginName: string, input: {
|
||||||
|
traceId: string; ip: string; userAgent: string;
|
||||||
|
}) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`UPDATE qipai_admin_credentials SET failed_attempts = failed_attempts + 1,
|
||||||
|
locked_until = CASE WHEN failed_attempts + 1 >= 5
|
||||||
|
THEN DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 15 MINUTE) ELSE locked_until END
|
||||||
|
WHERE id = ?`, [row.credentialId]
|
||||||
|
);
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'SYSTEM', NULL, 'ADMIN_LOGIN_FAILED', 'ADMIN_CREDENTIAL', ?, ?, ?, ?, ?)`,
|
||||||
|
[row.tenantId, row.credentialId, input.traceId, input.ip, input.userAgent.slice(0, 255),
|
||||||
|
JSON.stringify({ loginNameHash: hashToken(loginName) })]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
const connection = await this.pool.getConnection();
|
||||||
|
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||||
|
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLoginName(value: string) { return value.normalize('NFKC').trim().toLowerCase(); }
|
||||||
|
export function hashToken(value: string) { return createHash('sha256').update(value).digest('hex'); }
|
||||||
|
function safeHashEqual(left: string, right: string) {
|
||||||
|
const a = Buffer.from(left); const b = Buffer.from(right);
|
||||||
|
return a.length === b.length && timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
function mapUser(row: { id: string; tenantId: string; userType: string; status: string;
|
||||||
|
roleVersion: number; nickname: string; avatarUrl: string; phone: string }): AuthUser {
|
||||||
|
return { id: String(row.id), tenantId: String(row.tenantId), userType: row.userType,
|
||||||
|
status: row.status, roleVersion: row.roleVersion, nickname: row.nickname,
|
||||||
|
avatarUrl: row.avatarUrl, phone: row.phone };
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
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 = 'STAFF'
|
||||||
|
AND p.code IN ('profile.read', 'store.operation.read',
|
||||||
|
'product.catalog.read', 'inventory.read'))
|
||||||
|
OR (r.code = 'STORE_ADMIN'
|
||||||
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||||
|
'store.operation.read', 'store.operation.write',
|
||||||
|
'device.read', 'device.write',
|
||||||
|
'product.catalog.read', 'product.catalog.write',
|
||||||
|
'inventory.read', 'inventory.adjust'))
|
||||||
|
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||||
|
'device.read', 'device.write',
|
||||||
|
'product.catalog.read', 'product.catalog.write',
|
||||||
|
'inventory.read', 'inventory.adjust'))
|
||||||
|
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,46 @@
|
|||||||
|
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const algorithm = 'scrypt';
|
||||||
|
const cost = 16384;
|
||||||
|
const blockSize = 8;
|
||||||
|
const parallelization = 1;
|
||||||
|
const keyLength = 64;
|
||||||
|
const maxmem = 64 * 1024 * 1024;
|
||||||
|
const dummyHash = 'scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA$4fXWGR0BUXE6uC3v9sPYUjV_QlLQaq9EOrlgkxMVXngIWJ2unUaOv9OnZ47Z0RFKmK0LLBeMdNNB6W1RgPBplA';
|
||||||
|
|
||||||
|
export async function hashPassword(plainTextPassword: string): Promise<string> {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const derived = await derive(plainTextPassword, salt, cost, blockSize, parallelization);
|
||||||
|
return [algorithm, cost, blockSize, parallelization,
|
||||||
|
salt.toString('base64url'), derived.toString('base64url')].join('$');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(plainTextPassword: string, encoded = dummyHash): Promise<boolean> {
|
||||||
|
const parts = encoded.split('$');
|
||||||
|
if (parts.length !== 6 || parts[0] !== algorithm) {
|
||||||
|
await verifyPassword(plainTextPassword, dummyHash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const [, nValue, rValue, pValue, saltValue, hashValue] = parts;
|
||||||
|
const n = Number(nValue); const r = Number(rValue); const p = Number(pValue);
|
||||||
|
if (n !== cost || r !== blockSize || p !== parallelization) {
|
||||||
|
await verifyPassword(plainTextPassword, dummyHash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const salt = Buffer.from(saltValue, 'base64url');
|
||||||
|
const expected = Buffer.from(hashValue, 'base64url');
|
||||||
|
if (salt.length !== 16 || expected.length !== keyLength) {
|
||||||
|
await verifyPassword(plainTextPassword, dummyHash);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const actual = await derive(plainTextPassword, salt, n, r, p);
|
||||||
|
return timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function derive(plainTextPassword: string, salt: Buffer, N: number, r: number, p: number): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
scrypt(plainTextPassword, salt, keyLength, { N, r, p, maxmem }, (error, derivedKey) => {
|
||||||
|
if (error) reject(error); else resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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 = 'STAFF'
|
||||||
|
AND p.code IN ('profile.read', 'store.operation.read',
|
||||||
|
'product.catalog.read', 'inventory.read'))
|
||||||
|
OR (r.code = 'CLEANER'
|
||||||
|
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||||
|
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||||
|
OR (r.code = 'STORE_ADMIN'
|
||||||
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||||
|
'store.operation.read', 'store.operation.write',
|
||||||
|
'device.read', 'device.write',
|
||||||
|
'product.catalog.read', 'product.catalog.write',
|
||||||
|
'inventory.read', 'inventory.adjust',
|
||||||
|
'cleaning.task.read', 'cleaning.task.write',
|
||||||
|
'cleaning.statistics.read'))
|
||||||
|
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||||
|
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||||
|
'device.read', 'device.write',
|
||||||
|
'product.catalog.read', 'product.catalog.write',
|
||||||
|
'inventory.read', 'inventory.adjust',
|
||||||
|
'cleaning.task.read', 'cleaning.task.write',
|
||||||
|
'cleaning.statistics.read'))
|
||||||
|
WHERE r.tenant_id = ?`,
|
||||||
|
[tenantId, tenantId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,376 @@
|
|||||||
|
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;
|
||||||
|
userType?: 'STAFF';
|
||||||
|
}): Promise<{ items: ManagedUser[]; total: number }> {
|
||||||
|
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
|
||||||
|
const params: Array<string | number> = [input.actor.tenantId];
|
||||||
|
if (input.status) {
|
||||||
|
filters.push('u.status = ?');
|
||||||
|
params.push(input.status);
|
||||||
|
}
|
||||||
|
if (input.userType) {
|
||||||
|
filters.push('u.user_type = ?');
|
||||||
|
params.push(input.userType);
|
||||||
|
}
|
||||||
|
if (input.role) {
|
||||||
|
filters.push(`EXISTS (
|
||||||
|
SELECT 1 FROM qipai_user_roles fur
|
||||||
|
INNER JOIN qipai_roles fr ON fr.tenant_id = fur.tenant_id AND fr.id = fur.role_id
|
||||||
|
WHERE fur.tenant_id = u.tenant_id AND fur.user_id = u.id
|
||||||
|
AND fr.code = ? AND fr.status = 'ACTIVE' AND fr.deleted_at IS NULL
|
||||||
|
)`);
|
||||||
|
params.push(input.role);
|
||||||
|
}
|
||||||
|
if (input.search) {
|
||||||
|
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
|
||||||
|
const like = `%${input.search}%`;
|
||||||
|
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 pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||||
|
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||||
|
const [rows] = await this.pool.execute<UserRow[]>(
|
||||||
|
`SELECT u.id, u.user_type AS userType, u.status, u.nickname,
|
||||||
|
u.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 ${pageSize} OFFSET ${offset}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const items = await Promise.all(rows.map(async (row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
userType: row.userType,
|
||||||
|
status: row.status,
|
||||||
|
nickname: row.nickname,
|
||||||
|
avatarUrl: row.avatarUrl,
|
||||||
|
maskedPhone: maskPhone(row.phone),
|
||||||
|
maskedLastIp: maskIp(row.lastIp ?? ''),
|
||||||
|
note: row.note,
|
||||||
|
roles: await this.getCodes('role', input.actor.tenantId, String(row.id)),
|
||||||
|
storeIds: await this.getCodes('store', input.actor.tenantId, String(row.id)),
|
||||||
|
wechatMiniappBound: Boolean(row.wechatMiniappBound),
|
||||||
|
registeredAt: row.registeredAt,
|
||||||
|
lastLoginAt: row.lastLoginAt
|
||||||
|
})));
|
||||||
|
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,547 @@
|
|||||||
|
import type { RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
import {
|
||||||
|
WechatPayClient,
|
||||||
|
WechatPayError,
|
||||||
|
type WechatNotificationHeaders,
|
||||||
|
type WechatPayCredential
|
||||||
|
} from '../payments/wechat-pay-client.js';
|
||||||
|
import {
|
||||||
|
CleaningTaskError,
|
||||||
|
type CleaningActor,
|
||||||
|
type CleaningTaskRepository
|
||||||
|
} from './cleaning-task-repository.js';
|
||||||
|
|
||||||
|
interface SettlementPayoutRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
settlementNo: string;
|
||||||
|
cleanerUserId: string;
|
||||||
|
generatedBy: string;
|
||||||
|
storeId: string | null;
|
||||||
|
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||||
|
totalRewardCents: number;
|
||||||
|
payoutChannel: string;
|
||||||
|
payoutRequestNo: string;
|
||||||
|
payoutReference: string;
|
||||||
|
payoutState: string;
|
||||||
|
payoutPackageInfo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
platformAppId: string | null;
|
||||||
|
storeId: string | null;
|
||||||
|
merchantId: string;
|
||||||
|
credentialRef: string;
|
||||||
|
authorizationStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IdentityRow extends RowDataPacket {
|
||||||
|
openid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreflightStatus = 'PASS' | 'WARN' | 'FAIL';
|
||||||
|
|
||||||
|
interface PreflightCheck {
|
||||||
|
key: string;
|
||||||
|
status: PreflightStatus;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CleaningPayoutError extends Error {
|
||||||
|
constructor(public readonly code: string, message = code) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CleaningPayoutService {
|
||||||
|
constructor(
|
||||||
|
private readonly pool: MySqlPool,
|
||||||
|
private readonly repository: Pick<CleaningTaskRepository,
|
||||||
|
'beginSettlementPayout' | 'markSettlementPaid'
|
||||||
|
| 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
||||||
|
private readonly client: WechatPayClient,
|
||||||
|
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
||||||
|
private readonly mockEnabled: boolean
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async preflightWechatTransfer(input: CleaningActor & { settlementId: string }) {
|
||||||
|
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||||
|
const checks: PreflightCheck[] = [];
|
||||||
|
checks.push({
|
||||||
|
key: 'settlement_status',
|
||||||
|
status: settlement.status === 'CONFIRMED' ? 'PASS' : 'FAIL',
|
||||||
|
message: settlement.status === 'CONFIRMED'
|
||||||
|
? 'Settlement is confirmed and can start WeChat transfer.'
|
||||||
|
: `Settlement status must be CONFIRMED, current status is ${settlement.status}.`
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
key: 'settlement_amount',
|
||||||
|
status: Number(settlement.totalRewardCents) > 0 ? 'PASS' : 'FAIL',
|
||||||
|
message: Number(settlement.totalRewardCents) > 0
|
||||||
|
? 'Settlement amount is greater than zero.'
|
||||||
|
: 'Settlement amount must be greater than zero.'
|
||||||
|
});
|
||||||
|
|
||||||
|
let account: AccountRow | null = null;
|
||||||
|
try {
|
||||||
|
account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||||
|
checks.push({
|
||||||
|
key: 'collection_account',
|
||||||
|
status: 'PASS',
|
||||||
|
message: account.storeId ? 'Store scoped WeChat collection account is configured.'
|
||||||
|
: 'Tenant level WeChat collection account is configured.'
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
key: 'collection_account_authorized',
|
||||||
|
status: account.authorizationStatus === 'AUTHORIZED' ? 'PASS' : 'FAIL',
|
||||||
|
message: account.authorizationStatus === 'AUTHORIZED'
|
||||||
|
? 'Collection account is authorized.'
|
||||||
|
: `Collection account authorization status is ${account.authorizationStatus}.`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CleaningPayoutError)) throw error;
|
||||||
|
checks.push({
|
||||||
|
key: 'collection_account',
|
||||||
|
status: 'FAIL',
|
||||||
|
message: 'No enabled WeChat collection account matches this settlement store.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const credential = account ? this.resolveCredential(account.credentialRef) : null;
|
||||||
|
checks.push({
|
||||||
|
key: 'wechat_credential',
|
||||||
|
status: credential ? 'PASS' : 'FAIL',
|
||||||
|
message: credential ? 'WeChat Pay credential reference is resolvable.'
|
||||||
|
: 'WeChat Pay credential reference is missing or not loaded.'
|
||||||
|
});
|
||||||
|
if (account && credential) {
|
||||||
|
checks.push({
|
||||||
|
key: 'merchant_match',
|
||||||
|
status: credential.merchantId === account.merchantId ? 'PASS' : 'FAIL',
|
||||||
|
message: credential.merchantId === account.merchantId
|
||||||
|
? 'Credential merchant id matches the collection account.'
|
||||||
|
: 'Credential merchant id does not match the collection account.'
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
key: 'transfer_scene_id',
|
||||||
|
status: credential.transferSceneId ? 'PASS' : 'FAIL',
|
||||||
|
message: credential.transferSceneId
|
||||||
|
? 'Merchant transfer scene id is configured.'
|
||||||
|
: 'Merchant transfer scene id is required before real transfer.'
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
key: 'transfer_scene_report_infos',
|
||||||
|
status: credential.transferSceneReportInfos?.length ? 'PASS' : 'WARN',
|
||||||
|
message: credential.transferSceneReportInfos?.length
|
||||||
|
? `${credential.transferSceneReportInfos.length} transfer scene report info item(s) configured.`
|
||||||
|
: 'No transfer scene report info is configured; confirm whether the selected WeChat scene requires it.'
|
||||||
|
});
|
||||||
|
const notifyUrl = credential.transferNotifyUrl || '';
|
||||||
|
checks.push({
|
||||||
|
key: 'transfer_notify_url',
|
||||||
|
status: notifyUrl.startsWith('https://') && notifyUrl.includes('/app-api/cleaning/wechat-transfer/notify')
|
||||||
|
? 'PASS' : 'WARN',
|
||||||
|
message: notifyUrl
|
||||||
|
? 'Transfer notification URL is configured.'
|
||||||
|
: 'Transfer notification URL is not configured; active polling can still sync intermediate states.'
|
||||||
|
});
|
||||||
|
checks.push({
|
||||||
|
key: 'platform_certificates',
|
||||||
|
status: Object.keys(credential.platformCertificates).length > 0 ? 'PASS' : 'FAIL',
|
||||||
|
message: Object.keys(credential.platformCertificates).length > 0
|
||||||
|
? 'At least one WeChat platform certificate is loaded for notification verification.'
|
||||||
|
: 'No WeChat platform certificate is loaded.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanerOpenidConfigured = false;
|
||||||
|
if (account) {
|
||||||
|
try {
|
||||||
|
await this.resolveCleanerOpenid(input.tenantId, account.platformAppId, settlement.cleanerUserId);
|
||||||
|
cleanerOpenidConfigured = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof CleaningPayoutError)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checks.push({
|
||||||
|
key: 'cleaner_openid',
|
||||||
|
status: cleanerOpenidConfigured ? 'PASS' : 'FAIL',
|
||||||
|
message: cleanerOpenidConfigured
|
||||||
|
? 'Cleaner has a WeChat miniapp openid for this platform app.'
|
||||||
|
: 'Cleaner WeChat miniapp openid is missing.'
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: checks.every((check) => check.status !== 'FAIL'),
|
||||||
|
settlement: {
|
||||||
|
id: settlement.id,
|
||||||
|
settlementNo: settlement.settlementNo,
|
||||||
|
status: settlement.status,
|
||||||
|
totalRewardCents: Number(settlement.totalRewardCents),
|
||||||
|
cleanerUserId: settlement.cleanerUserId,
|
||||||
|
storeId: settlement.storeId
|
||||||
|
},
|
||||||
|
account: account ? {
|
||||||
|
configured: true,
|
||||||
|
id: account.id,
|
||||||
|
storeScoped: account.storeId !== null,
|
||||||
|
merchantIdMasked: maskIdentifier(account.merchantId),
|
||||||
|
credentialRefMasked: maskIdentifier(account.credentialRef),
|
||||||
|
authorizationStatus: account.authorizationStatus
|
||||||
|
} : { configured: false },
|
||||||
|
credential: credential ? {
|
||||||
|
configured: true,
|
||||||
|
appIdPresent: credential.appId.length > 0,
|
||||||
|
serialNoPresent: credential.serialNo.length > 0,
|
||||||
|
transferSceneId: credential.transferSceneId || '',
|
||||||
|
reportInfoCount: credential.transferSceneReportInfos?.length ?? 0,
|
||||||
|
transferNotifyUrlConfigured: Boolean(credential.transferNotifyUrl),
|
||||||
|
platformCertificateCount: Object.keys(credential.platformCertificates).length
|
||||||
|
} : { configured: false },
|
||||||
|
cleaner: { openidConfigured: cleanerOpenidConfigured },
|
||||||
|
checks
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeWechatTransfer(input: CleaningActor & {
|
||||||
|
settlementId: string;
|
||||||
|
mode: 'API' | 'MOCK';
|
||||||
|
note?: string;
|
||||||
|
}) {
|
||||||
|
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||||
|
if (settlement.status === 'PAID') {
|
||||||
|
return { settlement, idempotent: true, transferState: 'SUCCESS' };
|
||||||
|
}
|
||||||
|
if (settlement.status !== 'CONFIRMED') {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||||
|
}
|
||||||
|
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_ALREADY_STARTED');
|
||||||
|
}
|
||||||
|
if (Number(settlement.totalRewardCents) <= 0) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
|
||||||
|
}
|
||||||
|
if (input.mode === 'MOCK' && !this.mockEnabled) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_MOCK_DISABLED');
|
||||||
|
}
|
||||||
|
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||||
|
if (account.authorizationStatus !== 'AUTHORIZED') {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_AUTHORIZED');
|
||||||
|
}
|
||||||
|
const credential = this.resolveCredential(account.credentialRef);
|
||||||
|
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||||
|
if (credential.merchantId !== account.merchantId) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||||
|
}
|
||||||
|
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
|
||||||
|
if (input.mode === 'MOCK') {
|
||||||
|
const paid = await this.repository.markSettlementPaid({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER_MOCK',
|
||||||
|
payoutReference: outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
|
||||||
|
}
|
||||||
|
const openid = await this.resolveCleanerOpenid(
|
||||||
|
input.tenantId,
|
||||||
|
account.platformAppId,
|
||||||
|
settlement.cleanerUserId
|
||||||
|
);
|
||||||
|
const sceneId = credential.transferSceneId;
|
||||||
|
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
|
||||||
|
await this.repository.beginSettlementPayout({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: outBillNo,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await this.client.createMerchantTransfer(credential, {
|
||||||
|
outBillNo,
|
||||||
|
openid,
|
||||||
|
amountCents: Number(settlement.totalRewardCents),
|
||||||
|
remark: `Cleaning settlement ${settlement.id}`,
|
||||||
|
sceneId,
|
||||||
|
notifyUrl: credential.transferNotifyUrl || undefined,
|
||||||
|
reportInfos: credential.transferSceneReportInfos ?? []
|
||||||
|
});
|
||||||
|
if (result.state === 'SUCCESS') {
|
||||||
|
const paid = await this.repository.markSettlementPaid({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||||
|
}
|
||||||
|
if (result.state === 'FAIL') {
|
||||||
|
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||||
|
}
|
||||||
|
const pending = await this.repository.recordSettlementPayoutPending({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
payoutState: result.state,
|
||||||
|
payoutPackageInfo: result.packageInfo,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||||
|
} catch (error) {
|
||||||
|
// A transport/API exception is not proof that WeChat rejected the transfer.
|
||||||
|
// Keep PROCESSING so cancellation and duplicate payout are blocked until sync/reconciliation.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncWechatTransfer(input: CleaningActor & { settlementId: string; note?: string }) {
|
||||||
|
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||||
|
if (settlement.status === 'PAID') {
|
||||||
|
return { settlement, idempotent: true, transferState: 'SUCCESS' };
|
||||||
|
}
|
||||||
|
if (settlement.status !== 'CONFIRMED') {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||||
|
}
|
||||||
|
if (settlement.payoutChannel !== 'WECHAT_TRANSFER' || !settlement.payoutReference) {
|
||||||
|
throw new CleaningPayoutError('WECHAT_TRANSFER_NOT_STARTED');
|
||||||
|
}
|
||||||
|
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||||
|
const credential = this.resolveCredential(account.credentialRef);
|
||||||
|
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||||
|
if (credential.merchantId !== account.merchantId) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||||
|
}
|
||||||
|
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
|
||||||
|
const result = await this.client.queryMerchantTransferByOutBillNo(credential, outBillNo);
|
||||||
|
if (result.merchantId !== account.merchantId) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||||
|
}
|
||||||
|
if (result.amountCents > 0 && result.amountCents !== Number(settlement.totalRewardCents)) {
|
||||||
|
throw new CleaningPayoutError('WECHAT_TRANSFER_AMOUNT_MISMATCH');
|
||||||
|
}
|
||||||
|
if (result.state === 'SUCCESS') {
|
||||||
|
const paid = await this.repository.markSettlementPaid({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||||
|
}
|
||||||
|
if (result.state === 'FAIL') {
|
||||||
|
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||||
|
}
|
||||||
|
const pending = await this.repository.recordSettlementPayoutPending({
|
||||||
|
...input,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: result.transferBillNo || result.outBillNo,
|
||||||
|
payoutState: result.state,
|
||||||
|
payoutPackageInfo: settlement.payoutPackageInfo,
|
||||||
|
note: input.note
|
||||||
|
});
|
||||||
|
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async processWechatTransferNotification(
|
||||||
|
headers: WechatNotificationHeaders,
|
||||||
|
rawBody: string,
|
||||||
|
traceId: string
|
||||||
|
) {
|
||||||
|
const { credential, payload } = this.decryptNotification(headers, rawBody);
|
||||||
|
const merchantId = stringPayload(payload, 'mch_id');
|
||||||
|
if (merchantId !== credential.merchantId) {
|
||||||
|
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||||
|
}
|
||||||
|
const outBillNo = stringPayload(payload, 'out_bill_no');
|
||||||
|
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
|
||||||
|
const settlement = await this.findSettlementByTransferReference(
|
||||||
|
credential.merchantId,
|
||||||
|
outBillNo,
|
||||||
|
transferBillNo
|
||||||
|
);
|
||||||
|
const actor: CleaningActor = {
|
||||||
|
tenantId: settlement.tenantId,
|
||||||
|
userId: String(settlement.generatedBy),
|
||||||
|
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||||
|
traceId,
|
||||||
|
actorType: 'SYSTEM'
|
||||||
|
};
|
||||||
|
const state = stringPayload(payload, 'state');
|
||||||
|
if (settlement.status === 'PAID' && state === 'SUCCESS') {
|
||||||
|
return { settlement, transferState: state, idempotent: true };
|
||||||
|
}
|
||||||
|
if (state === 'SUCCESS') {
|
||||||
|
const paid = await this.repository.markSettlementPaid({
|
||||||
|
...actor,
|
||||||
|
settlementId: settlement.id,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: transferBillNo || outBillNo,
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: '微信转账回调确认成功'
|
||||||
|
});
|
||||||
|
return { settlement: paid, transferState: state, idempotent: false };
|
||||||
|
}
|
||||||
|
if (state === 'FAIL') {
|
||||||
|
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||||
|
...actor,
|
||||||
|
settlementId: settlement.id,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: transferBillNo || outBillNo,
|
||||||
|
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
|
||||||
|
providerConfirmed: true,
|
||||||
|
note: '微信转账回调确认失败'
|
||||||
|
});
|
||||||
|
return { settlement: failed, transferState: state, idempotent: false };
|
||||||
|
}
|
||||||
|
const pending = await this.repository.recordSettlementPayoutPending({
|
||||||
|
...actor,
|
||||||
|
settlementId: settlement.id,
|
||||||
|
payoutChannel: 'WECHAT_TRANSFER',
|
||||||
|
payoutReference: transferBillNo || outBillNo,
|
||||||
|
payoutState: state,
|
||||||
|
payoutPackageInfo: settlement.payoutPackageInfo,
|
||||||
|
note: '微信转账回调更新中间态'
|
||||||
|
});
|
||||||
|
return { settlement: pending, transferState: state, idempotent: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadSettlement(tenantId: string, settlementId: string) {
|
||||||
|
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
|
||||||
|
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
|
||||||
|
generated_by AS generatedBy,
|
||||||
|
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
|
||||||
|
payout_channel AS payoutChannel, payout_request_no AS payoutRequestNo,
|
||||||
|
payout_reference AS payoutReference,
|
||||||
|
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
|
||||||
|
FROM qipai_cleaning_settlements
|
||||||
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
||||||
|
[tenantId, settlementId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCollectionAccount(tenantId: string, storeId: string | null) {
|
||||||
|
const [rows] = await this.pool.execute<AccountRow[]>(
|
||||||
|
`SELECT id, platform_app_id AS platformAppId, store_id AS storeId,
|
||||||
|
merchant_id AS merchantId, credential_ref AS credentialRef,
|
||||||
|
authorization_status AS authorizationStatus
|
||||||
|
FROM qipai_collection_accounts
|
||||||
|
WHERE tenant_id = ? AND provider = 'WECHAT' AND enabled = 1
|
||||||
|
AND (store_id IS NULL OR store_id <=> ?)
|
||||||
|
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
|
||||||
|
[tenantId, storeId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_FOUND');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveCleanerOpenid(tenantId: string, platformAppId: string | null, cleanerUserId: string) {
|
||||||
|
const params: Array<string | number> = [tenantId, cleanerUserId];
|
||||||
|
const platformFilter = platformAppId ? 'AND platform_app_id = ?' : '';
|
||||||
|
if (platformAppId) params.push(platformAppId);
|
||||||
|
const [rows] = await this.pool.execute<IdentityRow[]>(
|
||||||
|
`SELECT openid FROM qipai_user_identities
|
||||||
|
WHERE tenant_id = ? AND user_id = ? AND provider = 'WECHAT_MINIAPP'
|
||||||
|
AND deleted_at IS NULL ${platformFilter}
|
||||||
|
ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
if (!rows[0]?.openid) throw new CleaningPayoutError('WECHAT_OPENID_NOT_FOUND');
|
||||||
|
return rows[0].openid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCredential(reference: string) {
|
||||||
|
return this.credentials.get(reference)
|
||||||
|
?? this.credentials.get(reference.replace(/^env:/, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
private decryptNotification(headers: WechatNotificationHeaders, rawBody: string) {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const credential of this.credentials.values()) {
|
||||||
|
if (!credential.platformCertificates[headers.serial]) continue;
|
||||||
|
try {
|
||||||
|
return { credential, payload: this.client.verifyAndDecrypt(credential, headers, rawBody) };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastError instanceof Error) throw lastError;
|
||||||
|
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findSettlementByTransferReference(
|
||||||
|
merchantId: string,
|
||||||
|
outBillNo: string,
|
||||||
|
transferBillNo: string
|
||||||
|
) {
|
||||||
|
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
|
||||||
|
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
|
||||||
|
`SELECT s.tenant_id AS tenantId, s.id, s.settlement_no AS settlementNo,
|
||||||
|
s.cleaner_user_id AS cleanerUserId, s.generated_by AS generatedBy,
|
||||||
|
s.store_id AS storeId, s.status,
|
||||||
|
s.total_reward_cents AS totalRewardCents, s.payout_channel AS payoutChannel,
|
||||||
|
s.payout_request_no AS payoutRequestNo,
|
||||||
|
s.payout_reference AS payoutReference, s.payout_state AS payoutState,
|
||||||
|
s.payout_package_info AS payoutPackageInfo
|
||||||
|
FROM qipai_cleaning_settlements s
|
||||||
|
INNER JOIN qipai_collection_accounts account
|
||||||
|
ON account.tenant_id = s.tenant_id
|
||||||
|
AND account.provider = 'WECHAT' AND account.merchant_id = ?
|
||||||
|
AND account.enabled = 1
|
||||||
|
AND (account.store_id IS NULL OR account.store_id <=> s.store_id)
|
||||||
|
WHERE s.payout_channel = 'WECHAT_TRANSFER'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND (s.payout_request_no IN (${references.map(() => '?').join(',')})
|
||||||
|
OR s.payout_reference IN (${references.map(() => '?').join(',')}))
|
||||||
|
ORDER BY (account.store_id IS NOT NULL) DESC,
|
||||||
|
s.updated_at DESC, s.id DESC LIMIT 1`,
|
||||||
|
[merchantId, ...references, ...references]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
|
||||||
|
void settlementNo;
|
||||||
|
return `CLP${settlementId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringPayload(payload: Record<string, unknown>, key: string) {
|
||||||
|
const value = payload[key];
|
||||||
|
if (typeof value !== 'string' || value.length === 0) {
|
||||||
|
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalStringPayload(payload: Record<string, unknown>, key: string) {
|
||||||
|
const value = payload[key];
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskIdentifier(value: string) {
|
||||||
|
if (value.length <= 4) return '****';
|
||||||
|
return `${value.slice(0, 2)}***${value.slice(-4)}`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
+54
-3
@@ -12,15 +12,42 @@ const configSchema = z.object({
|
|||||||
QIPAI_MYSQL_USER: z.string().min(1).default('qipai_app'),
|
QIPAI_MYSQL_USER: z.string().min(1).default('qipai_app'),
|
||||||
QIPAI_MYSQL_PASSWORD: z.string().default(''),
|
QIPAI_MYSQL_PASSWORD: z.string().default(''),
|
||||||
QIPAI_MYSQL_CONNECTION_LIMIT: z.coerce.number().int().min(1).max(50).default(10),
|
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_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_USERNAME: z.string().default(''),
|
||||||
QIPAI_MQTT_PASSWORD: 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 type AppConfig = ReturnType<typeof loadConfig>;
|
||||||
|
|
||||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||||
const parsed = configSchema.parse(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 {
|
return {
|
||||||
nodeEnv: parsed.NODE_ENV,
|
nodeEnv: parsed.NODE_ENV,
|
||||||
@@ -37,10 +64,34 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
|||||||
passwordConfigured: parsed.QIPAI_MYSQL_PASSWORD.length > 0,
|
passwordConfigured: parsed.QIPAI_MYSQL_PASSWORD.length > 0,
|
||||||
connectionLimit: parsed.QIPAI_MYSQL_CONNECTION_LIMIT
|
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: {
|
mqtt: {
|
||||||
url: parsed.QIPAI_MQTT_URL,
|
url: parsed.QIPAI_MQTT_URL,
|
||||||
usernameConfigured: parsed.QIPAI_MQTT_USERNAME.length > 0,
|
clientId: parsed.QIPAI_MQTT_CLIENT_ID,
|
||||||
passwordConfigured: parsed.QIPAI_MQTT_PASSWORD.length > 0
|
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,374 @@
|
|||||||
|
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;
|
||||||
|
imageAssetId: string; imageUrl: string;
|
||||||
|
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
|
||||||
|
status: string; sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetRow extends RowDataPacket {
|
||||||
|
id: string; storeId: string | null; url: string; mimeType: string; byteSize: number;
|
||||||
|
width: number; height: number; createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DecorationRow extends RowDataPacket {
|
||||||
|
id: string; storeId: string; templateCode: string; schemaVersion: number;
|
||||||
|
content: string | Record<string, unknown>; status: string; version: number;
|
||||||
|
publishedAt: Date | null; createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdvertisementScopeRow extends RowDataPacket {
|
||||||
|
scopeType: AdvertisementInput['scopeType']; storeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContentError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContentRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
|
||||||
|
this.assertAssetWriteScope(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 listAssets(actor: ManagementActor, storeId?: string) {
|
||||||
|
if (storeId) this.assertStoreScope(actor, storeId);
|
||||||
|
const scope = this.assetReadScope(actor, storeId);
|
||||||
|
const [rows] = await this.pool.execute<AssetRow[]>(
|
||||||
|
`SELECT id, store_id AS storeId, public_url AS url, mime_type AS mimeType,
|
||||||
|
byte_size AS byteSize, width, height, created_at AS createdAt
|
||||||
|
FROM qipai_media_assets
|
||||||
|
WHERE tenant_id = ? AND deleted_at IS NULL AND ${scope.sql}
|
||||||
|
ORDER BY id DESC LIMIT 200`,
|
||||||
|
[actor.tenantId, ...scope.params]
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: String(row.id),
|
||||||
|
storeId: row.storeId === null ? null : String(row.storeId)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
|
||||||
|
this.assertStoreScope(actor, input.storeId);
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
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 listDecorations(actor: ManagementActor, storeId: string) {
|
||||||
|
this.assertStoreScope(actor, storeId);
|
||||||
|
const [rows] = await this.pool.execute<DecorationRow[]>(
|
||||||
|
`SELECT id, store_id AS storeId, template_code AS templateCode,
|
||||||
|
schema_version AS schemaVersion, content_json AS content,
|
||||||
|
status, version, published_at AS publishedAt, created_at AS createdAt
|
||||||
|
FROM qipai_store_decorations
|
||||||
|
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL
|
||||||
|
ORDER BY version DESC LIMIT 100`,
|
||||||
|
[actor.tenantId, storeId]
|
||||||
|
);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: String(row.id),
|
||||||
|
storeId: String(row.storeId),
|
||||||
|
content: parseJson(row.content)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
|
||||||
|
this.assertStoreScope(actor, storeId);
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
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,
|
||||||
|
a.image_asset_id AS imageAssetId, m.public_url AS imageUrl,
|
||||||
|
a.target_type AS targetType,
|
||||||
|
a.target_value AS targetValue, a.starts_at AS startsAt, a.ends_at AS endsAt,
|
||||||
|
a.status, a.sort_order AS sortOrder
|
||||||
|
FROM qipai_advertisements a
|
||||||
|
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),
|
||||||
|
imageAssetId: String(row.imageAssetId),
|
||||||
|
storeId: row.storeId === null ? null : String(row.storeId)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
|
||||||
|
this.assertAdScope(actor, input);
|
||||||
|
const assetScope = this.adAssetScope(input);
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_advertisements
|
||||||
|
(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
|
||||||
|
AND ${assetScope.sql}`,
|
||||||
|
[actor.tenantId, input.scopeType, input.storeId ?? null, input.title,
|
||||||
|
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
|
||||||
|
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId,
|
||||||
|
...assetScope.params]
|
||||||
|
);
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAdvertisement(
|
||||||
|
actor: ManagementActor, advertisementId: string, input: AdvertisementInput
|
||||||
|
) {
|
||||||
|
this.assertAdScope(actor, input);
|
||||||
|
const assetScope = this.adAssetScope(input);
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const [advertisements] = await connection.execute<AdvertisementScopeRow[]>(
|
||||||
|
`SELECT scope_type AS scopeType, store_id AS storeId
|
||||||
|
FROM qipai_advertisements
|
||||||
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||||
|
[actor.tenantId, advertisementId]
|
||||||
|
);
|
||||||
|
const existing = advertisements[0];
|
||||||
|
if (!existing) throw new ContentError('ADVERTISEMENT_NOT_FOUND');
|
||||||
|
this.assertExistingAdScope(actor, existing);
|
||||||
|
|
||||||
|
const [assets] = await connection.execute<IdRow[]>(
|
||||||
|
`SELECT m.id FROM qipai_media_assets m
|
||||||
|
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
|
||||||
|
AND ${assetScope.sql}`,
|
||||||
|
[actor.tenantId, input.imageAssetId, ...assetScope.params]
|
||||||
|
);
|
||||||
|
if (!assets[0]) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
|
||||||
|
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_advertisements
|
||||||
|
SET scope_type = ?, store_id = ?, title = ?, image_asset_id = ?,
|
||||||
|
target_type = ?, target_value = ?, starts_at = ?, ends_at = ?,
|
||||||
|
status = ?, sort_order = ?
|
||||||
|
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||||
|
[input.scopeType, input.storeId ?? null, input.title, input.imageAssetId,
|
||||||
|
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
|
||||||
|
input.status, input.sortOrder, actor.tenantId, advertisementId]
|
||||||
|
);
|
||||||
|
await this.audit(connection, actor, 'ADVERTISEMENT_UPDATED', 'ADVERTISEMENT', advertisementId);
|
||||||
|
return { advertisementId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
|
||||||
|
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|
||||||
|
|| actor.access.roles.includes('PLATFORM_ADMIN');
|
||||||
|
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 assertExistingAdScope(actor: ManagementActor, advertisement: AdvertisementScopeRow) {
|
||||||
|
if (actor.access.capabilities.includes('tenant.manage')
|
||||||
|
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||||
|
if (advertisement.scopeType !== 'STORE' || !advertisement.storeId
|
||||||
|
|| !actor.access.storeIds.includes(String(advertisement.storeId))) {
|
||||||
|
throw new ContentError('ADVERTISEMENT_SCOPE_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAssetWriteScope(actor: ManagementActor, storeId?: string) {
|
||||||
|
if (storeId) {
|
||||||
|
this.assertStoreScope(actor, storeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!actor.access.capabilities.includes('tenant.manage')
|
||||||
|
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
throw new ContentError('GLOBAL_ASSET_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||||
|
if (actor.access.capabilities.includes('tenant.manage')
|
||||||
|
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||||
|
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 assetReadScope(actor: ManagementActor, storeId?: string) {
|
||||||
|
if (storeId) {
|
||||||
|
return { sql: '(store_id IS NULL OR store_id = ?)', params: [storeId] };
|
||||||
|
}
|
||||||
|
if (actor.access.capabilities.includes('tenant.manage')
|
||||||
|
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
return { sql: '1 = 1', params: [] as string[] };
|
||||||
|
}
|
||||||
|
if (actor.access.storeIds.length === 0) {
|
||||||
|
return { sql: 'store_id IS NULL', params: [] as string[] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
sql: `(store_id IS NULL OR store_id IN (${actor.access.storeIds.map(() => '?').join(',')}))`,
|
||||||
|
params: actor.access.storeIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private adAssetScope(input: AdvertisementInput) {
|
||||||
|
if (input.scopeType === 'STORE' && input.storeId) {
|
||||||
|
return { sql: '(m.store_id IS NULL OR m.store_id = ?)', params: [input.storeId] };
|
||||||
|
}
|
||||||
|
return { sql: 'm.store_id IS NULL', params: [] as string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
|
||||||
|
const [rows] = await connection.execute<IdRow[]>(
|
||||||
|
`SELECT id FROM qipai_stores
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value: string | Record<string, unknown>) {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
throw new ContentError('INVALID_DECORATION_CONTENT');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, unlink, 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');
|
||||||
|
}
|
||||||
|
const declaredFormat = {
|
||||||
|
'image/jpeg': 'jpeg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp'
|
||||||
|
}[input.contentType];
|
||||||
|
if (!declaredFormat) {
|
||||||
|
throw new MediaValidationError('IMAGE_TYPE_INVALID');
|
||||||
|
}
|
||||||
|
const declaredExtension = {
|
||||||
|
'.jpg': 'jpeg',
|
||||||
|
'.jpeg': 'jpeg',
|
||||||
|
'.png': 'png',
|
||||||
|
'.webp': 'webp'
|
||||||
|
}[extname(input.originalName).toLowerCase()];
|
||||||
|
if (!declaredExtension) {
|
||||||
|
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
|
||||||
|
}
|
||||||
|
if (declaredExtension !== declaredFormat) {
|
||||||
|
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
|
||||||
|
}
|
||||||
|
let result: Buffer;
|
||||||
|
let metadata: sharp.Metadata;
|
||||||
|
try {
|
||||||
|
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
|
||||||
|
metadata = await source.metadata();
|
||||||
|
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
|
||||||
|
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
|
||||||
|
result = await source
|
||||||
|
.rotate()
|
||||||
|
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
|
||||||
|
.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')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage(storagePath: string): Promise<void> {
|
||||||
|
const safeRoot = resolve(this.root);
|
||||||
|
const target = resolve(safeRoot, storagePath);
|
||||||
|
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
|
||||||
|
throw new MediaValidationError('IMAGE_PATH_INVALID');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await unlink(target);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,618 @@
|
|||||||
|
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[];
|
||||||
|
migrations?: readonly MigrationFilePlan[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationFilePlan {
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
file: string;
|
||||||
|
checksum: string;
|
||||||
|
statements: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MigrationExecutionResult extends MigrationPlan {
|
||||||
|
executed: boolean;
|
||||||
|
affectedRows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MigrationQueryExecutor = Pick<MySqlPool, 'query'>;
|
||||||
|
type MigrationPool = MigrationQueryExecutor & Partial<Pick<MySqlPool, 'getConnection'>>;
|
||||||
|
|
||||||
|
const migrationLockExpression =
|
||||||
|
"CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
|
||||||
|
|
||||||
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||||
|
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||||
|
up: [
|
||||||
|
'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',
|
||||||
|
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
|
||||||
|
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||||
|
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||||
|
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||||
|
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql',
|
||||||
|
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql'
|
||||||
|
],
|
||||||
|
verify: [
|
||||||
|
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||||
|
'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',
|
||||||
|
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
|
||||||
|
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||||
|
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||||
|
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||||
|
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql',
|
||||||
|
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql'
|
||||||
|
],
|
||||||
|
down: [
|
||||||
|
'database/migrations/2026081107_m09d1_product_inventory_foundation.down.sql',
|
||||||
|
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||||
|
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||||
|
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||||
|
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||||
|
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
|
||||||
|
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
|
||||||
|
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
|
||||||
|
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
|
||||||
|
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
|
||||||
|
'database/migrations/2026062626_m08b_cleaning_settlements.down.sql',
|
||||||
|
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
|
||||||
|
'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
|
||||||
|
'database/migrations/2026062423_m07c_benefits.down.sql',
|
||||||
|
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
||||||
|
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
||||||
|
'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');
|
||||||
|
const migrations = relativeFiles.map((file, index) => {
|
||||||
|
const match = /(?:^|\/)(\d+)_([a-z0-9_]+)\.(?:up|verify|down)\.sql$/iu.exec(file);
|
||||||
|
if (!match) throw new Error(`Invalid migration file name: ${file}`);
|
||||||
|
const migrationSql = sqlParts[index];
|
||||||
|
return {
|
||||||
|
version: match[1],
|
||||||
|
name: match[2],
|
||||||
|
file,
|
||||||
|
checksum: createHash('sha256').update(migrationSql).digest('hex'),
|
||||||
|
statements: splitSqlStatements(migrationSql)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction,
|
||||||
|
file: relativeFiles.join(','),
|
||||||
|
checksum: createHash('sha256').update(sql).digest('hex'),
|
||||||
|
statements: splitSqlStatements(sql),
|
||||||
|
migrations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeMigrationPlan(
|
||||||
|
pool: MigrationPool,
|
||||||
|
plan: MigrationPlan,
|
||||||
|
dryRun = false
|
||||||
|
): Promise<MigrationExecutionResult> {
|
||||||
|
if (dryRun) {
|
||||||
|
return { ...plan, executed: false, affectedRows: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pool.getConnection) {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
let lockAcquired = false;
|
||||||
|
let executionFailed = false;
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT GET_LOCK(${migrationLockExpression}, 30) AS acquired`
|
||||||
|
);
|
||||||
|
const acquired = Array.isArray(rows)
|
||||||
|
? Number((rows[0] as Record<string, unknown> | undefined)?.acquired ?? 0)
|
||||||
|
: 0;
|
||||||
|
if (acquired !== 1) {
|
||||||
|
throw new Error('MIGRATION_LOCK_TIMEOUT: another migration process is still running.');
|
||||||
|
}
|
||||||
|
lockAcquired = true;
|
||||||
|
return await executeMigrationPlanUnlocked(connection, plan);
|
||||||
|
} catch (error) {
|
||||||
|
executionFailed = true;
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
let releaseError: unknown;
|
||||||
|
if (lockAcquired) {
|
||||||
|
try {
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT RELEASE_LOCK(${migrationLockExpression}) AS released`
|
||||||
|
);
|
||||||
|
const released = Array.isArray(rows)
|
||||||
|
? Number((rows[0] as Record<string, unknown> | undefined)?.released ?? 0)
|
||||||
|
: 0;
|
||||||
|
if (released !== 1) {
|
||||||
|
releaseError = new Error('MIGRATION_LOCK_RELEASE_FAILED: migration lock was lost.');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
releaseError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connection.release();
|
||||||
|
if (!executionFailed && releaseError) throw releaseError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executeMigrationPlanUnlocked(pool, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeMigrationPlanUnlocked(
|
||||||
|
pool: MigrationQueryExecutor,
|
||||||
|
plan: MigrationPlan
|
||||||
|
): Promise<MigrationExecutionResult> {
|
||||||
|
|
||||||
|
if (plan.direction === 'up' && plan.migrations?.length) {
|
||||||
|
return executeVersionedUpPlan(pool, plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.direction === 'up') await assertTriggerConfiguration(pool, plan.statements);
|
||||||
|
|
||||||
|
let affectedRows = 0;
|
||||||
|
for (const [index, statement] of plan.statements.entries()) {
|
||||||
|
const result = await queryMigrationStatement(pool, 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,
|
||||||
|
5, 2,
|
||||||
|
2, 8, 4, 3, 1,
|
||||||
|
2, 9, 5, 2, 1,
|
||||||
|
1, 7, 5, 1,
|
||||||
|
4, 1, 1, 1,
|
||||||
|
2, 1, 1,
|
||||||
|
1, 1, 1,
|
||||||
|
1, 2, 3, 1,
|
||||||
|
1, 2, 3, 1,
|
||||||
|
3, 7, 3, 1,
|
||||||
|
2, 8, 4, 1,
|
||||||
|
9, 63, 18, 28, 15, 2, 4, 1, 1
|
||||||
|
][index] ?? 1;
|
||||||
|
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||||
|
throw new Error(
|
||||||
|
`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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppliedMigrationRow extends Record<string, unknown> {
|
||||||
|
version?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
checksum?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeVersionedUpPlan(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
plan: MigrationPlan
|
||||||
|
): Promise<MigrationExecutionResult> {
|
||||||
|
const migrations = plan.migrations ?? [];
|
||||||
|
const appliedRows = await readAppliedMigrations(pool);
|
||||||
|
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
|
||||||
|
for (const migration of migrations) {
|
||||||
|
const row = applied.get(migration.version);
|
||||||
|
if (!row) continue;
|
||||||
|
if (String(row.name) !== migration.name) {
|
||||||
|
throw new Error(
|
||||||
|
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const storedChecksum = String(row.checksum ?? '');
|
||||||
|
if (storedChecksum && storedChecksum !== migration.checksum) {
|
||||||
|
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = migrations.filter((migration) => !applied.has(migration.version));
|
||||||
|
await assertTriggerConfiguration(
|
||||||
|
pool,
|
||||||
|
pending.flatMap((migration) => [...migration.statements])
|
||||||
|
);
|
||||||
|
let affectedRows = 0;
|
||||||
|
for (const migration of pending) {
|
||||||
|
for (const statement of migration.statements) {
|
||||||
|
const result = await queryMigrationStatement(pool, statement);
|
||||||
|
if (result && typeof result === 'object' && 'affectedRows' in result) {
|
||||||
|
const value = Reflect.get(result, 'affectedRows');
|
||||||
|
if (typeof value === 'number') affectedRows += value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const [markerRows] = await pool.query(
|
||||||
|
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
|
||||||
|
[migration.version]
|
||||||
|
);
|
||||||
|
const marker = Array.isArray(markerRows)
|
||||||
|
? markerRows[0] as Record<string, unknown> | undefined
|
||||||
|
: undefined;
|
||||||
|
if (!marker || String(marker.name) !== migration.name) {
|
||||||
|
throw new Error(
|
||||||
|
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await storeMigrationChecksum(pool, migration);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const migration of migrations) {
|
||||||
|
await storeMigrationChecksum(pool, migration);
|
||||||
|
}
|
||||||
|
return { ...plan, executed: true, affectedRows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
|
||||||
|
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object'
|
||||||
|
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storeMigrationChecksum(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
migration: MigrationFilePlan
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE qipai_schema_migrations
|
||||||
|
SET checksum = ?
|
||||||
|
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
|
||||||
|
[migration.checksum, migration.version, migration.checksum]
|
||||||
|
);
|
||||||
|
if (result && typeof result === 'object'
|
||||||
|
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
|
||||||
|
[migration.version]
|
||||||
|
);
|
||||||
|
const stored = Array.isArray(rows)
|
||||||
|
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
|
||||||
|
: '';
|
||||||
|
if (stored && stored !== migration.checksum) {
|
||||||
|
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object'
|
||||||
|
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertTriggerConfiguration(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
statements: readonly string[]
|
||||||
|
) {
|
||||||
|
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT @@GLOBAL.log_bin AS logBin,
|
||||||
|
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
|
||||||
|
);
|
||||||
|
const setting = Array.isArray(rows)
|
||||||
|
? rows[0] as Record<string, unknown> | undefined
|
||||||
|
: undefined;
|
||||||
|
if (Number(setting?.logBin ?? 0) === 1
|
||||||
|
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
|
||||||
|
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryMigrationStatement(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
statement: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(statement);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|
||||||
|
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|
||||||
|
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isMissingM09D1DownObject(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
statement: string,
|
||||||
|
error: unknown
|
||||||
|
) {
|
||||||
|
if (!error || typeof error !== 'object'
|
||||||
|
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
|
||||||
|
|
||||||
|
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
|
||||||
|
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
|
||||||
|
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
|
||||||
|
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
|
||||||
|
: null;
|
||||||
|
if (indexTarget) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = '${indexTarget.table}'
|
||||||
|
AND index_name = '${indexTarget.index}'
|
||||||
|
LIMIT 1`
|
||||||
|
);
|
||||||
|
return Array.isArray(rows) && rows.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_schema_migrations'
|
||||||
|
AND column_name = 'checksum'
|
||||||
|
LIMIT 1`
|
||||||
|
);
|
||||||
|
return Array.isArray(rows) && rows.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isMatchingM09D1ChecksumColumn(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
statement: string,
|
||||||
|
error: unknown
|
||||||
|
) {
|
||||||
|
if (!error || typeof error !== 'object'
|
||||||
|
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|
||||||
|
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
|
||||||
|
is_nullable AS isNullable, column_default AS columnDefault,
|
||||||
|
collation_name AS collationName
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = 'qipai_schema_migrations'
|
||||||
|
AND column_name = 'checksum'`
|
||||||
|
);
|
||||||
|
const column = Array.isArray(rows)
|
||||||
|
? rows[0] as Record<string, unknown> | undefined
|
||||||
|
: undefined;
|
||||||
|
return column?.dataType === 'char'
|
||||||
|
&& Number(column.maxLength) === 64
|
||||||
|
&& column.isNullable === 'NO'
|
||||||
|
&& String(column.columnDefault ?? '') === ''
|
||||||
|
&& column.collationName === 'ascii_bin';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isMatchingM09D1ScopeIndex(
|
||||||
|
pool: Pick<MySqlPool, 'query'>,
|
||||||
|
statement: string,
|
||||||
|
error: unknown
|
||||||
|
) {
|
||||||
|
if (!error || typeof error !== 'object'
|
||||||
|
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
|
||||||
|
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
|
||||||
|
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
|
||||||
|
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
|
||||||
|
: null;
|
||||||
|
if (!target) return false;
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT non_unique AS nonUnique,
|
||||||
|
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = '${target.table}'
|
||||||
|
AND index_name = '${target.index}'
|
||||||
|
GROUP BY non_unique`
|
||||||
|
);
|
||||||
|
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
|
||||||
|
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
|
||||||
|
}
|
||||||
@@ -23,3 +23,7 @@ export function toPoolOptions(config: AppConfig): PoolOptions {
|
|||||||
dateStrings: false
|
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,364 @@
|
|||||||
|
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; model?: string | null; capabilities?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; firstPlay?: 0 | 1; loop?: number;
|
||||||
|
speaker?: number; style?: number; speed?: number; intonation?: 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: {
|
||||||
|
subtype: '14' | '15'; subID: string;
|
||||||
|
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
|
||||||
|
| 'factoryreset';
|
||||||
|
holdopen?: 0 | 1; delayTime?: number; value?: string;
|
||||||
|
dangerConfirmation?: string;
|
||||||
|
}) {
|
||||||
|
const credentialOrder = ['setkey', 'delkey', 'setcard', 'delcard', 'factoryreset']
|
||||||
|
.includes(input.order);
|
||||||
|
if (credentialOrder && !this.isManager(context.access)) {
|
||||||
|
throw new DeviceControlError('DEVICE_CREDENTIAL_ACTION_FORBIDDEN');
|
||||||
|
}
|
||||||
|
if (input.order === 'factoryreset') {
|
||||||
|
if (!context.access.roles.includes('PLATFORM_ADMIN')
|
||||||
|
|| input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') {
|
||||||
|
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (['delkey', 'delcard'].includes(input.order) && input.value === 'all') {
|
||||||
|
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, input: 'basicInfo' | 'workInfo' | {
|
||||||
|
target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
|
||||||
|
slotNum?: number; taskNum?: number;
|
||||||
|
}) {
|
||||||
|
const request = typeof input === 'string' ? { target: input } : input;
|
||||||
|
return this.issueSmartSocket(context, `socket:${request.target}`,
|
||||||
|
() => this.smartSocket.read(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
async switchSmartSocket(context: CommandContext, input: {
|
||||||
|
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: {
|
||||||
|
slotNum?: number; taskNum: number; action: 'on' | 'off';
|
||||||
|
mode: 'once' | 'daily' | 'weekly';
|
||||||
|
time: string; weekdays?: number[];
|
||||||
|
}) {
|
||||||
|
return this.issueSmartSocket(context, 'localtask',
|
||||||
|
(id) => this.smartSocket.localTask({ id, ...input }), context.orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearSmartSocketTask(context: CommandContext, input: number | {
|
||||||
|
slotNum?: number; taskNum: number; dangerConfirmation?: string;
|
||||||
|
}) {
|
||||||
|
const request = typeof input === 'number' ? { taskNum: input } : input;
|
||||||
|
if (request.taskNum === 0) {
|
||||||
|
this.assertDangerousSocketAction(
|
||||||
|
context, request.dangerConfirmation, 'CONFIRM_CLEAR_ALL_SOCKET_TASKS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { dangerConfirmation: _, ...vendorInput } = request;
|
||||||
|
return this.issueSmartSocket(context, 'clearTask',
|
||||||
|
(id) => this.smartSocket.clearTask({ id, ...vendorInput }), context.orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rebootSmartSocket(context: CommandContext, dangerConfirmation?: string) {
|
||||||
|
this.assertDangerousSocketAction(context, dangerConfirmation, 'CONFIRM_REBOOT_SOCKET');
|
||||||
|
return this.issueSmartSocket(context, 'reboot', (id) => this.smartSocket.reboot(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async emptySmartSocketEnergy(context: CommandContext, dangerConfirmation?: string) {
|
||||||
|
this.assertDangerousSocketAction(
|
||||||
|
context, dangerConfirmation, 'CONFIRM_EMPTY_SOCKET_ENERGY'
|
||||||
|
);
|
||||||
|
return this.issueSmartSocket(context, 'emptyPower',
|
||||||
|
(id) => this.smartSocket.emptyPower(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetSmartSocket(context: CommandContext, input: {
|
||||||
|
password: string; dangerConfirmation?: string;
|
||||||
|
}) {
|
||||||
|
this.assertDangerousSocketAction(
|
||||||
|
context, input.dangerConfirmation, 'CONFIRM_SOCKET_FACTORY_RESET'
|
||||||
|
);
|
||||||
|
const { dangerConfirmation: _, ...vendorInput } = input;
|
||||||
|
return this.issueSmartSocket(context, 'returnFactory',
|
||||||
|
(id) => this.smartSocket.returnFactory({ id, ...vendorInput }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeSmartSocketReturnKey(context: CommandContext, input: {
|
||||||
|
old: string; new: string; dangerConfirmation?: string;
|
||||||
|
}) {
|
||||||
|
this.assertDangerousSocketAction(
|
||||||
|
context, input.dangerConfirmation, 'CONFIRM_CHANGE_SOCKET_RETURN_KEY'
|
||||||
|
);
|
||||||
|
return this.issueSmartSocket(context, 'setReturnKey',
|
||||||
|
(id) => this.smartSocket.setReturnKey({ id, old: input.old, new: input.new }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async configureSmartSocketProtection(context: CommandContext, input: {
|
||||||
|
maxPower: number; maxCurrent: number; maxTemperature: number;
|
||||||
|
pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number;
|
||||||
|
chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number;
|
||||||
|
}) {
|
||||||
|
return this.issueSmartSocket(context, 'protectOff',
|
||||||
|
(id, device) => this.smartSocket.protectOff({
|
||||||
|
id, ratedAmps: readRatedAmps(device), ...input
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async configureSmartSocketParameter(context: CommandContext, input: {
|
||||||
|
resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1;
|
||||||
|
}) {
|
||||||
|
return this.issueSmartSocket(context, 'parameter',
|
||||||
|
(id) => this.smartSocket.parameter({ id, ...input }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueControlBox(
|
||||||
|
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');
|
||||||
|
await this.auditCommand(context, device, commandType, orderId);
|
||||||
|
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, device: DeviceRow) => Record<string, unknown>,
|
||||||
|
orderId?: string | null
|
||||||
|
) {
|
||||||
|
this.assertWriteScope(context.access, context.storeId);
|
||||||
|
const device = await this.resolveSmartSocket(context);
|
||||||
|
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||||
|
await this.auditCommand(context, device, commandType, orderId);
|
||||||
|
return this.commands.issue({
|
||||||
|
tenantId: context.tenantId,
|
||||||
|
assetId: String(device.id),
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
storeId: context.storeId,
|
||||||
|
roomId: context.roomId,
|
||||||
|
orderId,
|
||||||
|
commandType,
|
||||||
|
payloadFactory: (id) => payloadFactory(id, device),
|
||||||
|
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, model, capabilities
|
||||||
|
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 assertDangerousSocketAction(
|
||||||
|
context: CommandContext, actual: string | undefined, expected: string
|
||||||
|
) {
|
||||||
|
if (context.actorType === 'CUSTOMER'
|
||||||
|
|| !context.access.roles.includes('PLATFORM_ADMIN')
|
||||||
|
|| actual !== expected) {
|
||||||
|
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async auditCommand(
|
||||||
|
context: CommandContext,
|
||||||
|
device: DeviceRow,
|
||||||
|
commandType: string,
|
||||||
|
orderId?: string | null
|
||||||
|
) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, ?, ?, 'DEVICE_COMMAND_REQUESTED', 'DEVICE', ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
context.tenantId,
|
||||||
|
context.actorType ?? 'SYSTEM',
|
||||||
|
context.actorId ?? null,
|
||||||
|
device.id,
|
||||||
|
context.traceId,
|
||||||
|
(context.ip ?? '').slice(0, 64),
|
||||||
|
(context.userAgent ?? '').slice(0, 255),
|
||||||
|
JSON.stringify({
|
||||||
|
commandType,
|
||||||
|
storeId: context.storeId,
|
||||||
|
roomId: context.roomId,
|
||||||
|
orderId: orderId ?? context.orderId ?? null,
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
deviceType: device.deviceType
|
||||||
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isManager(access: AccessProfile) {
|
||||||
|
return access.roles.some((role) =>
|
||||||
|
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandContext {
|
||||||
|
tenantId: string;
|
||||||
|
storeId: string;
|
||||||
|
roomId: string;
|
||||||
|
orderId?: string | null;
|
||||||
|
traceId: string;
|
||||||
|
actorType?: 'CUSTOMER' | 'STAFF' | 'ADMIN' | 'SYSTEM';
|
||||||
|
actorId?: string | null;
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
expiresAt?: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRatedAmps(device: DeviceRow): 10 | 16 | 63 {
|
||||||
|
const source = `${device.model ?? ''} ${JSON.stringify(device.capabilities ?? '')}`;
|
||||||
|
const match = /(?:^|[^0-9])(10|16|63)A(?:$|[^0-9])/i.exec(source);
|
||||||
|
if (match?.[1] === '10') return 10;
|
||||||
|
if (match?.[1] === '16') return 16;
|
||||||
|
if (match?.[1] === '63') return 63;
|
||||||
|
throw new DeviceControlError('SOCKET_RATED_CURRENT_UNKNOWN');
|
||||||
|
}
|
||||||
@@ -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,271 @@
|
|||||||
|
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;
|
||||||
|
subLockSubtype: '14' | '15' | '';
|
||||||
|
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 ?? '',
|
||||||
|
subLockSubtype: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '15'
|
||||||
|
? '15'
|
||||||
|
: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '14' ? '14' : '',
|
||||||
|
timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHardwareSmokeCases(
|
||||||
|
config: Pick<HardwareSmokeConfig,
|
||||||
|
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId'
|
||||||
|
| 'subLockSubtype' | '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 && config.subLockSubtype) {
|
||||||
|
cases.push({
|
||||||
|
id: 'sub-lock-open',
|
||||||
|
deviceId: config.controlBoxDeviceId,
|
||||||
|
deviceType: 'SUB_LOCK',
|
||||||
|
commandType: 'CtrlDevice',
|
||||||
|
payload: lock.control({
|
||||||
|
id: generateCommandId(Date.now(), cases.length),
|
||||||
|
subtype: config.subLockSubtype,
|
||||||
|
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,434 @@
|
|||||||
|
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, sanitizePayloadTextForStorage(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, sanitizePayloadTextForStorage(payloadText),
|
||||||
|
'MQTT_DEVICE_UNKNOWN'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawPayload: unknown;
|
||||||
|
let normalized: NormalizedVendorMessage;
|
||||||
|
try {
|
||||||
|
if (topicMatch[1] === 'devicewill') {
|
||||||
|
rawPayload = parseWillPayload(payloadText);
|
||||||
|
normalized = normalizeWill(rawPayload);
|
||||||
|
} else {
|
||||||
|
rawPayload = JSON.parse(payloadText);
|
||||||
|
normalized = 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,
|
||||||
|
sanitizePayloadTextForStorage(payloadText),
|
||||||
|
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRawPayload = sanitizeSensitivePayload(rawPayload, normalized);
|
||||||
|
const safeNormalizedPayload = sanitizeSensitivePayload(normalized.payload, normalized);
|
||||||
|
normalized = { ...normalized, payload: safeNormalizedPayload };
|
||||||
|
const protocolCode = normalized.result === 'UNKNOWN_VENDOR_RESULT'
|
||||||
|
? 'UNKNOWN_VENDOR_RESULT'
|
||||||
|
: normalized.protocolIssues[0] ?? null;
|
||||||
|
const processingStatus = protocolCode
|
||||||
|
? 'PROTOCOL_ERROR'
|
||||||
|
: 'PROCESSED';
|
||||||
|
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_iot_device_events
|
||||||
|
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
|
||||||
|
payload_hash, raw_payload, normalized_payload, event_at, processing_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
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(safeRawPayload), JSON.stringify(normalized),
|
||||||
|
normalized.eventAt, processingStatus]
|
||||||
|
);
|
||||||
|
if (result.affectedRows !== 1) return;
|
||||||
|
|
||||||
|
await this.updateDevice(device, normalized, safeNormalizedPayload);
|
||||||
|
await this.applyAlerts(device, normalized);
|
||||||
|
if (protocolCode) {
|
||||||
|
await this.deadLetter(
|
||||||
|
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safeRawPayload),
|
||||||
|
protocolCode,
|
||||||
|
protocolCode === 'UNKNOWN_VENDOR_RESULT'
|
||||||
|
? `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
|
||||||
|
: `Protocol issue for ${normalized.eventType}: ${protocolCode}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (normalized.kind === 'ACK' && normalized.commandId) {
|
||||||
|
await this.applyAcknowledgement(device, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(sanitizeSensitivePayload(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';
|
||||||
|
if (successful && message.eventType === 'AddDevice'
|
||||||
|
&& (!readString(message.payload.subID ?? message.payload.subId)
|
||||||
|
|| !readString(message.payload.subtype))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = successful ? 'ACKED' : 'FAILED';
|
||||||
|
const failureCode = successful
|
||||||
|
? ''
|
||||||
|
: message.result === 'UNKNOWN_VENDOR_RESULT'
|
||||||
|
? 'UNKNOWN_VENDOR_RESULT'
|
||||||
|
: `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
|
||||||
|
await this.pool.execute(
|
||||||
|
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
|
||||||
|
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
|
||||||
|
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) {
|
||||||
|
if (message.result === 'UNKNOWN_VENDOR_RESULT') {
|
||||||
|
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
|
||||||
|
`Unsupported vendor result for ${message.eventType}.`);
|
||||||
|
}
|
||||||
|
for (const issue of message.protocolIssues) {
|
||||||
|
await this.upsertAlert(device, issue, 'MEDIUM',
|
||||||
|
`Device payload reported protocol issue ${issue}.`);
|
||||||
|
}
|
||||||
|
const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
|
||||||
|
? `DEVICE_${message.result?.toUpperCase()}`
|
||||||
|
: null;
|
||||||
|
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) {
|
||||||
|
if (message.eventType !== 'special') return;
|
||||||
|
const closeReason = readInteger(message.payload.closeReason);
|
||||||
|
const alerts: Record<number, [string, string]> = {
|
||||||
|
3: ['SOCKET_PULL_OUT_STOP', 'Smart socket stopped after appliance removal.'],
|
||||||
|
4: ['SOCKET_CHARGE_FULL_STOP', 'Smart socket stopped after full charge.'],
|
||||||
|
5: ['SOCKET_OVERPOWER', 'Smart socket stopped on maximum power protection.'],
|
||||||
|
6: ['SOCKET_OVERCURRENT', 'Smart socket stopped on maximum current protection.'],
|
||||||
|
7: ['SOCKET_OVERTEMPERATURE', 'Smart socket stopped on maximum temperature protection.']
|
||||||
|
};
|
||||||
|
const alert = closeReason === null ? undefined : alerts[closeReason];
|
||||||
|
if (alert) await this.upsertAlert(device, alert[0], 'HIGH', alert[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async upsertAlert(
|
||||||
|
device: DeviceRow, alertType: string, severity: string, summary: string
|
||||||
|
) {
|
||||||
|
await this.pool.execute(
|
||||||
|
`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 = typeof payload === 'string' ? { will: payload } : zRecord(payload);
|
||||||
|
return {
|
||||||
|
kind: 'EVENT',
|
||||||
|
commandId: null,
|
||||||
|
eventType: 'will',
|
||||||
|
result: null,
|
||||||
|
rawResult: null,
|
||||||
|
deviceId: readDeviceId(record),
|
||||||
|
eventAt: null,
|
||||||
|
payload: record,
|
||||||
|
protocolIssues: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWillPayload(payloadText: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(payloadText);
|
||||||
|
} catch {
|
||||||
|
const will = payloadText.trim();
|
||||||
|
if (!will || will.length > 64) throw new Error('MQTT will payload is invalid.');
|
||||||
|
return will;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zRecord(payload: unknown): Record<string, unknown> {
|
||||||
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
|
throw new Error('MQTT payload must be a JSON object.');
|
||||||
|
}
|
||||||
|
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: Record<string, unknown> = typeof payload === 'string'
|
||||||
|
? { will: payload }
|
||||||
|
: { ...zRecord(payload) };
|
||||||
|
const eventType = normalized?.eventType ?? readString(record.event);
|
||||||
|
if (eventType === 'record' && typeof record.content === 'string') {
|
||||||
|
const content = record.content;
|
||||||
|
addMaskedSecret(record, 'content', content);
|
||||||
|
delete record.content;
|
||||||
|
}
|
||||||
|
const sensitiveLockOrder = ['setkey', 'delkey', 'setcard', 'delcard']
|
||||||
|
.includes(readString(record.order) ?? '');
|
||||||
|
if (record.action === 'CtrlDevice' && sensitiveLockOrder
|
||||||
|
&& typeof record.value === 'string') {
|
||||||
|
addMaskedSecret(record, 'value', record.value);
|
||||||
|
delete record.value;
|
||||||
|
}
|
||||||
|
if (typeof record.password === 'string') {
|
||||||
|
addMaskedSecret(record, 'password', record.password);
|
||||||
|
record.password = '<redacted>';
|
||||||
|
}
|
||||||
|
if (typeof record.card === 'string') {
|
||||||
|
addMaskedSecret(record, 'card', record.card);
|
||||||
|
record.card = '<redacted>';
|
||||||
|
}
|
||||||
|
if (record.action === 'setReturnKey') {
|
||||||
|
for (const field of ['old', 'new']) {
|
||||||
|
if (typeof record[field] === 'string') {
|
||||||
|
addMaskedSecret(record, field, record[field]);
|
||||||
|
delete record[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMaskedSecret(
|
||||||
|
record: Record<string, unknown>, field: string, secret: string
|
||||||
|
): void {
|
||||||
|
record[`${field}Hash`] = createHash('sha256').update(secret).digest('hex');
|
||||||
|
record[`${field}Masked`] = secret.length <= 4
|
||||||
|
? '*'.repeat(secret.length)
|
||||||
|
: `${secret.slice(0, 2)}${'*'.repeat(Math.min(8, secret.length - 4))}${secret.slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePayloadTextForStorage(payloadText: string): string {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(sanitizeSensitivePayload(JSON.parse(payloadText)));
|
||||||
|
} catch {
|
||||||
|
return payloadText.slice(0, 1_000_000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 readInteger(value: unknown): number | null {
|
||||||
|
const number = readNumber(value);
|
||||||
|
return number !== null && Number.isInteger(number) ? number : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const commandId = z.string().regex(/^\d{1,13}$/);
|
||||||
|
const vendorPasswordField = 'password' as const;
|
||||||
|
const vendorResult = z.string().trim().min(1).max(64);
|
||||||
|
const knownResultCodes = [
|
||||||
|
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full',
|
||||||
|
'paraerror', 'error', 'update'
|
||||||
|
] as const;
|
||||||
|
const knownResultCode = z.enum(knownResultCodes);
|
||||||
|
const vendorMessage = z.object({
|
||||||
|
id: commandId.optional(),
|
||||||
|
DeviceID: z.string().min(1).max(64).optional(),
|
||||||
|
deviceID: z.string().min(1).max(64).optional(),
|
||||||
|
IMEI: z.string().max(64).optional(),
|
||||||
|
result: vendorResult.optional(),
|
||||||
|
event: z.string().max(64).optional(),
|
||||||
|
action: z.string().max(64).optional(),
|
||||||
|
read: z.string().max(64).optional(),
|
||||||
|
setting: z.string().max(64).optional(),
|
||||||
|
timestamp: z.union([z.string(), z.number()]).optional()
|
||||||
|
}).passthrough();
|
||||||
|
|
||||||
|
export type KnownVendorResult = z.infer<typeof knownResultCode>;
|
||||||
|
export type NormalizedVendorMessage = {
|
||||||
|
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
|
||||||
|
commandId: string | null;
|
||||||
|
eventType: string;
|
||||||
|
result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null;
|
||||||
|
rawResult: string | null;
|
||||||
|
deviceId: string | null;
|
||||||
|
eventAt: Date | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
protocolIssues: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
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; firstPlay?: 0 | 1; loop?: number;
|
||||||
|
speaker?: number; style?: number; speed?: number; intonation?: number;
|
||||||
|
}) {
|
||||||
|
const domain = z.object({
|
||||||
|
id: commandId,
|
||||||
|
content: z.string().trim().min(1).max(500),
|
||||||
|
volume: z.number().int().min(0).max(10).default(8),
|
||||||
|
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||||
|
loop: z.number().int().positive().max(100).default(1),
|
||||||
|
speaker: z.number().int().min(0).max(5).default(0),
|
||||||
|
style: z.number().int().min(0).max(2).default(0),
|
||||||
|
speed: z.number().int().min(0).max(10).default(5),
|
||||||
|
intonation: z.number().int().min(0).max(10).default(5)
|
||||||
|
}).parse(input);
|
||||||
|
return {
|
||||||
|
action: 'PlayTTS' as const,
|
||||||
|
content: domain.content,
|
||||||
|
vol: domain.volume,
|
||||||
|
firstPlay: domain.firstPlay,
|
||||||
|
loop: domain.loop,
|
||||||
|
speaker: domain.speaker,
|
||||||
|
style: domain.style,
|
||||||
|
speed: domain.speed,
|
||||||
|
intona: domain.intonation,
|
||||||
|
id: domain.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTts(id: string) {
|
||||||
|
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 }) {
|
||||||
|
const domain = z.object({
|
||||||
|
id: commandId,
|
||||||
|
timeout: z.number().int().min(10).max(300).default(60)
|
||||||
|
}).parse(input);
|
||||||
|
return { action: 'AddDevice' as const, time: domain.timeout, id: domain.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
control(input: {
|
||||||
|
id: string; subtype: '14' | '15'; subID: string;
|
||||||
|
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
|
||||||
|
| 'factoryreset';
|
||||||
|
holdopen?: 0 | 1; delayTime?: number; value?: string;
|
||||||
|
}) {
|
||||||
|
const schema = z.object({
|
||||||
|
id: commandId,
|
||||||
|
subtype: z.enum(['14', '15']),
|
||||||
|
subID: z.string().min(1).max(64),
|
||||||
|
order: z.enum([
|
||||||
|
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||||
|
]),
|
||||||
|
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
|
||||||
|
delayTime: z.number().int().min(1).max(14).optional(),
|
||||||
|
value: z.string().min(1).max(128).optional()
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
const passwordList = /^(?:\d{6})+$/;
|
||||||
|
const cardList = /^(?:[A-Fa-f0-9]{8})+$/;
|
||||||
|
if (value.order === 'setkey' && !passwordList.test(value.value ?? '')) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 6-digit passwords' });
|
||||||
|
}
|
||||||
|
if (value.order === 'delkey'
|
||||||
|
&& value.value !== 'all' && !passwordList.test(value.value ?? '')) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 6-digit passwords' });
|
||||||
|
}
|
||||||
|
if (value.order === 'setcard' && !cardList.test(value.value ?? '')) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 8-digit hex card IDs' });
|
||||||
|
}
|
||||||
|
if (value.order === 'delcard'
|
||||||
|
&& value.value !== 'all' && !cardList.test(value.value ?? '')) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 8-digit hex card IDs' });
|
||||||
|
}
|
||||||
|
if (['open', 'close', 'none', 'factoryreset'].includes(value.order) && value.value) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value is not allowed for this order' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const domain = schema.parse(input);
|
||||||
|
return {
|
||||||
|
action: 'CtrlDevice' as const,
|
||||||
|
subtype: domain.subtype,
|
||||||
|
subID: domain.subID,
|
||||||
|
order: domain.order,
|
||||||
|
...(domain.holdopen === undefined ? {} : { holdopen: domain.holdopen }),
|
||||||
|
...(domain.delayTime === undefined ? {} : { delayTime: domain.delayTime }),
|
||||||
|
...(domain.value === undefined ? {} : {
|
||||||
|
value: domain.value !== 'all' && ['setcard', 'delcard'].includes(domain.order)
|
||||||
|
? domain.value.toUpperCase()
|
||||||
|
: domain.value
|
||||||
|
}),
|
||||||
|
id: domain.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parseUplink(payload: unknown) {
|
||||||
|
return normalizeVendorMessage(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JilianSmartSocketAdapter implements ProtocolAdapter {
|
||||||
|
read(input: 'basicInfo' | 'workInfo' | {
|
||||||
|
target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
|
||||||
|
slotNum?: number; taskNum?: number;
|
||||||
|
}) {
|
||||||
|
const domain = typeof input === 'string' ? { target: input } : input;
|
||||||
|
if (domain.target === 'basicInfo' || domain.target === 'mqttConfig') {
|
||||||
|
return { read: domain.target };
|
||||||
|
}
|
||||||
|
if (domain.target === 'workInfo') {
|
||||||
|
return z.object({
|
||||||
|
read: z.literal('workInfo'),
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1)
|
||||||
|
}).parse({ read: 'workInfo', slotNum: domain.slotNum });
|
||||||
|
}
|
||||||
|
return z.object({
|
||||||
|
read: z.literal('localtask'),
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1),
|
||||||
|
taskNum: z.number().int().min(0).max(20)
|
||||||
|
}).parse({ read: 'localtask', slotNum: domain.slotNum, taskNum: domain.taskNum });
|
||||||
|
}
|
||||||
|
|
||||||
|
switch(input: { id: string; on: boolean; slotNum?: number }) {
|
||||||
|
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; slotNum?: number; taskNum: number; action: 'on' | 'off';
|
||||||
|
mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
|
||||||
|
}) {
|
||||||
|
const domain = z.object({
|
||||||
|
id: commandId,
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1),
|
||||||
|
taskNum: z.number().int().min(1).max(20),
|
||||||
|
action: z.enum(['on', 'off']),
|
||||||
|
mode: z.enum(['once', 'daily', 'weekly']),
|
||||||
|
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
|
||||||
|
.refine((items) => new Set(items).size === items.length, 'weekdays must be unique')
|
||||||
|
.optional()
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
if (value.mode === 'weekly' && !value.weekdays) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are required weekly' });
|
||||||
|
}
|
||||||
|
if (value.mode !== 'weekly' && value.weekdays) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are only valid weekly' });
|
||||||
|
}
|
||||||
|
}).parse(input);
|
||||||
|
const weekdaySet = new Set(domain.weekdays ?? []);
|
||||||
|
const weekdata = domain.mode === 'daily'
|
||||||
|
? '1111111'
|
||||||
|
: domain.mode === 'weekly'
|
||||||
|
? Array.from({ length: 7 }, (_, index) => weekdaySet.has(index + 1) ? '1' : '0').join('')
|
||||||
|
: '0000000';
|
||||||
|
return {
|
||||||
|
action: 'localtask' as const,
|
||||||
|
slotNum: domain.slotNum,
|
||||||
|
taskNum: domain.taskNum,
|
||||||
|
tasktype: domain.action,
|
||||||
|
cyctype: domain.mode === 'daily' ? 'daycyc' : domain.mode === 'weekly' ? 'weekcyc' : 'once',
|
||||||
|
weekdata,
|
||||||
|
actiontime: domain.time.replace(':', ''),
|
||||||
|
id: domain.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTask(input: { id: string; slotNum?: number; taskNum: number }) {
|
||||||
|
return z.object({
|
||||||
|
action: z.literal('clearTask'), id: commandId,
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1),
|
||||||
|
taskNum: z.number().int().min(0).max(20)
|
||||||
|
}).parse({ action: 'clearTask', ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
reboot(id: string) {
|
||||||
|
return z.object({ action: z.literal('reboot'), id: commandId })
|
||||||
|
.parse({ action: 'reboot', id });
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyPower(id: string) {
|
||||||
|
return z.object({ action: z.literal('emptyPower'), id: commandId })
|
||||||
|
.parse({ action: 'emptyPower', id });
|
||||||
|
}
|
||||||
|
|
||||||
|
returnFactory(input: { id: string; password: string }) {
|
||||||
|
return z.object({
|
||||||
|
action: z.literal('returnFactory'), id: commandId,
|
||||||
|
[vendorPasswordField]: z.string().min(1).max(32)
|
||||||
|
}).parse({ action: 'returnFactory', ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
setReturnKey(input: { id: string; old: string; new: string }) {
|
||||||
|
return z.object({
|
||||||
|
action: z.literal('setReturnKey'), id: commandId,
|
||||||
|
old: z.string().min(1).max(32), new: z.string().min(1).max(32)
|
||||||
|
}).parse({ action: 'setReturnKey', ...input });
|
||||||
|
}
|
||||||
|
|
||||||
|
protectOff(input: {
|
||||||
|
id: string; ratedAmps: 10 | 16 | 63;
|
||||||
|
maxPower: number; maxCurrent: number; maxTemperature: number;
|
||||||
|
pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number;
|
||||||
|
chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number;
|
||||||
|
}) {
|
||||||
|
const domain = z.object({
|
||||||
|
id: commandId, ratedAmps: z.union([z.literal(10), z.literal(16), z.literal(63)]),
|
||||||
|
maxPower: z.number().positive(), maxCurrent: z.number().positive(),
|
||||||
|
maxTemperature: z.number().min(1).max(125),
|
||||||
|
pullOutStop: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
pullOutPower: z.number().nonnegative(),
|
||||||
|
pullOutSec: z.number().int().min(1).max(86400),
|
||||||
|
chargeFullStop: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
chargeFullPower: z.number().nonnegative(),
|
||||||
|
chargeFullSec: z.number().int().min(1).max(86400)
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
const powerCeiling = value.ratedAmps * 250;
|
||||||
|
if (value.maxCurrent > value.ratedAmps) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxCurrent'],
|
||||||
|
message: `maxCurrent exceeds ${value.ratedAmps}A capability` });
|
||||||
|
}
|
||||||
|
if (value.maxPower > powerCeiling) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxPower'],
|
||||||
|
message: `maxPower exceeds ${powerCeiling}W capability` });
|
||||||
|
}
|
||||||
|
if (value.pullOutPower > value.maxPower || value.chargeFullPower > value.maxPower) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom,
|
||||||
|
message: 'auto-stop power cannot exceed maxPower' });
|
||||||
|
}
|
||||||
|
}).parse(input);
|
||||||
|
const { ratedAmps: _, ...wire } = domain;
|
||||||
|
return { setting: 'protectOff' as const, ...wire };
|
||||||
|
}
|
||||||
|
|
||||||
|
parameter(input: {
|
||||||
|
id: string; resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1;
|
||||||
|
}) {
|
||||||
|
const domain = z.object({
|
||||||
|
id: commandId,
|
||||||
|
resetHold: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
keyLock: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
keyOff: z.union([z.literal(0), z.literal(1)])
|
||||||
|
}).parse(input);
|
||||||
|
return {
|
||||||
|
setting: 'parameter' as const,
|
||||||
|
resetHold: domain.resetHold,
|
||||||
|
keyLock: domain.keyLock,
|
||||||
|
KeyOff: domain.keyOff,
|
||||||
|
id: domain.id
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
parseUplink(payload: unknown) {
|
||||||
|
return normalizeVendorMessage(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
|
||||||
|
const parsed = vendorMessage.parse(payload);
|
||||||
|
const protocolIssues: string[] = [];
|
||||||
|
const variants = normalizeReceiveVariants(
|
||||||
|
parsed as Record<string, unknown>, protocolIssues
|
||||||
|
);
|
||||||
|
const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot';
|
||||||
|
const eventName = canonicalMessageName(rawEventName);
|
||||||
|
const record = normalizeSocketStatus(variants, eventName, protocolIssues);
|
||||||
|
const rawResult = parsed.result ?? null;
|
||||||
|
const result = normalizeResult(eventName, rawResult);
|
||||||
|
return {
|
||||||
|
kind: parsed.event ? 'EVENT' : parsed.result ? 'ACK' : 'SNAPSHOT',
|
||||||
|
commandId: parsed.id ?? null,
|
||||||
|
eventType: eventName,
|
||||||
|
result,
|
||||||
|
rawResult,
|
||||||
|
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
|
||||||
|
eventAt: parseEventAt(parsed.timestamp),
|
||||||
|
payload: record,
|
||||||
|
protocolIssues
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(
|
||||||
|
eventName: string, result: string | null
|
||||||
|
): KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null {
|
||||||
|
if (result === null) return null;
|
||||||
|
const parsed = knownResultCode.safeParse(result);
|
||||||
|
if (!parsed.success) return 'UNKNOWN_VENDOR_RESULT';
|
||||||
|
const allowedByCommand: Record<string, readonly KnownVendorResult[]> = {
|
||||||
|
PlayTTS: ['ok', 'paraerror'],
|
||||||
|
stopTTS: ['ok'],
|
||||||
|
task: ['ok', 'busy', 'fail', 'unconfirm'],
|
||||||
|
addtask: ['ok', 'fail'],
|
||||||
|
canceltask: ['ok'],
|
||||||
|
AddDevice: ['ok', 'fail', 'timeout'],
|
||||||
|
CtrlDevice: ['ok', 'fail', 'timeout', 'full'],
|
||||||
|
on: ['ok'],
|
||||||
|
off: ['ok', 'update', 'paraerror'],
|
||||||
|
localtask: ['ok', 'paraerror', 'error', 'update'],
|
||||||
|
clearTask: ['ok', 'paraerror', 'error'],
|
||||||
|
reboot: ['ok', 'error'],
|
||||||
|
emptyPower: ['ok', 'paraerror', 'error'],
|
||||||
|
returnFactory: ['ok', 'paraerror', 'error'],
|
||||||
|
setReturnKey: ['ok', 'paraerror', 'error'],
|
||||||
|
protectOff: ['ok', 'paraerror', 'error', 'update'],
|
||||||
|
parameter: ['ok', 'paraerror', 'error', 'update'],
|
||||||
|
mqttConfig: ['ok', 'paraerror', 'error', 'update']
|
||||||
|
};
|
||||||
|
const allowed = allowedByCommand[eventName];
|
||||||
|
return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalMessageName(value: string): string {
|
||||||
|
return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReceiveVariants(
|
||||||
|
record: Record<string, unknown>, protocolIssues: string[]
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const normalized = { ...record };
|
||||||
|
if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) {
|
||||||
|
normalized.welcomevoice = normalized.welvoice;
|
||||||
|
}
|
||||||
|
if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig';
|
||||||
|
if (normalized.read === 'mqttconfig') normalized.read = 'mqttConfig';
|
||||||
|
if (normalized.keyOff === undefined && normalized.KeyOff !== undefined) {
|
||||||
|
normalized.keyOff = normalized.KeyOff;
|
||||||
|
}
|
||||||
|
if (normalized.keyOff !== undefined && normalized.KeyOff !== undefined
|
||||||
|
&& String(normalized.keyOff) !== String(normalized.KeyOff)) {
|
||||||
|
protocolIssues.push('SOCKET_KEY_OFF_CONFLICT');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSocketStatus(
|
||||||
|
record: Record<string, unknown>, eventName: string, protocolIssues: string[]
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const isWorkInfo = eventName === 'workInfo'
|
||||||
|
|| ['onOff', 'vol', 'cur', 'pow', 'temp', 'temperature', 'energy']
|
||||||
|
.some((field) => record[field] !== undefined);
|
||||||
|
if (!isWorkInfo) return record;
|
||||||
|
const normalized = { ...record };
|
||||||
|
const temperature = normalizeDecimalField(record.temp, 'temp', protocolIssues);
|
||||||
|
const temperatureVariant = normalizeDecimalField(
|
||||||
|
record.temperature, 'temperature', protocolIssues
|
||||||
|
);
|
||||||
|
if (temperature !== null && temperatureVariant !== null
|
||||||
|
&& temperature !== temperatureVariant) {
|
||||||
|
protocolIssues.push('SOCKET_TEMPERATURE_CONFLICT');
|
||||||
|
}
|
||||||
|
const switchOn = normalizeOnOff(record.onOff);
|
||||||
|
if (record.onOff !== undefined && switchOn === null) {
|
||||||
|
protocolIssues.push('SOCKET_ON_OFF_INVALID');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...normalized,
|
||||||
|
...(switchOn === null ? {} : { switchOn }),
|
||||||
|
...decimalProperty('voltage', record.vol, 'vol', protocolIssues),
|
||||||
|
...decimalProperty('current', record.cur, 'cur', protocolIssues),
|
||||||
|
...decimalProperty('power', record.pow, 'pow', protocolIssues),
|
||||||
|
...((temperature ?? temperatureVariant) === null
|
||||||
|
? {}
|
||||||
|
: { temperature: temperature ?? temperatureVariant }),
|
||||||
|
...decimalProperty('energy', record.energy, 'energy', protocolIssues)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function decimalProperty(
|
||||||
|
name: string, value: unknown, source: string, protocolIssues: string[]
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalized = normalizeDecimalField(value, source, protocolIssues);
|
||||||
|
return normalized === null ? {} : { [name]: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDecimalField(
|
||||||
|
value: unknown, source: string, protocolIssues: string[]
|
||||||
|
): string | null {
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
const text = typeof value === 'number' && Number.isFinite(value)
|
||||||
|
? String(value)
|
||||||
|
: typeof value === 'string' ? value.trim() : '';
|
||||||
|
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
|
||||||
|
if (!match) {
|
||||||
|
protocolIssues.push(`SOCKET_${source.toUpperCase()}_INVALID`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const integer = match[2].replace(/^0+(?=\d)/, '');
|
||||||
|
const fraction = (match[3] ?? '').replace(/0+$/, '');
|
||||||
|
const zero = integer === '0' && !fraction;
|
||||||
|
return `${zero ? '' : match[1]}${integer}${fraction ? `.${fraction}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOnOff(value: unknown): boolean | null {
|
||||||
|
if (value === true || value === 1 || value === '1' || value === 'on') return true;
|
||||||
|
if (value === false || value === 0 || value === '0' || value === 'off') return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEventAt(value: string | number | undefined): Date | null {
|
||||||
|
if (value === undefined) return null;
|
||||||
|
const date = typeof value === 'number'
|
||||||
|
? new Date(value < 10_000_000_000 ? value * 1000 : value)
|
||||||
|
: new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
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) {
|
||||||
|
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
|
||||||
|
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.startOrderDevices(order, payload.traceId);
|
||||||
|
return { orderId: order.id, action: 'ROOM_CHANGED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
|
||||||
|
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
|
||||||
|
}
|
||||||
|
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
|
||||||
|
return { orderId: order.id, action: 'CANCELLED' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
actorType: 'SYSTEM' as const,
|
||||||
|
access: systemDeviceAccess()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
|
||||||
|
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_orders
|
||||||
|
WHERE tenant_id = ? AND room_id = ? AND id <> ?
|
||||||
|
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
|
||||||
|
LIMIT 1`,
|
||||||
|
[tenantId, roomId, excludedOrderId]
|
||||||
|
);
|
||||||
|
return Boolean(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadOrder(tenantId: string, orderId: string) {
|
||||||
|
const [rows] = await this.pool.execute<OrderRow[]>(
|
||||||
|
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
|
||||||
|
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,232 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
|
||||||
|
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
|
||||||
|
export type FollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
|
||||||
|
|
||||||
|
export interface FranchiseApplicationInput {
|
||||||
|
tenantId: string;
|
||||||
|
submittedUserId?: string | null;
|
||||||
|
city: string;
|
||||||
|
contactName: string;
|
||||||
|
contactPhone: string;
|
||||||
|
message: string;
|
||||||
|
source: 'MINIAPP' | 'ADMIN' | 'IMPORT';
|
||||||
|
clientRequestId: string;
|
||||||
|
traceId: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApplicationRow extends RowDataPacket {
|
||||||
|
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
|
||||||
|
contactPhone: string; message: string; source: string; status: FranchiseStatus;
|
||||||
|
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
|
||||||
|
nextFollowUpAt: Date | null; closedAt: Date | null; createdAt: Date; updatedAt: Date;
|
||||||
|
}
|
||||||
|
interface FollowUpRow extends RowDataPacket {
|
||||||
|
id: string; actorUserId: string; actorName: string; followUpType: FollowUpType;
|
||||||
|
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
|
||||||
|
note: string; nextFollowUpAt: Date | null; createdAt: Date;
|
||||||
|
}
|
||||||
|
interface StatusRow extends RowDataPacket { status: FranchiseStatus }
|
||||||
|
|
||||||
|
export class FranchiseError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FranchiseRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async submitApplication(input: FranchiseApplicationInput) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const applicationNo = `FR${Date.now().toString(36).toUpperCase()}${randomUUID().slice(0, 6).toUpperCase()}`;
|
||||||
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_franchise_applications
|
||||||
|
(tenant_id, application_no, client_request_id, city, contact_name,
|
||||||
|
contact_phone, message, source, submitted_user_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
||||||
|
[input.tenantId, applicationNo, input.clientRequestId, input.city, input.contactName,
|
||||||
|
input.contactPhone, input.message, input.source, input.submittedUserId ?? null]
|
||||||
|
);
|
||||||
|
const applicationId = String(result.insertId);
|
||||||
|
const [rows] = await connection.execute<Array<RowDataPacket & { applicationNo: string }>>(
|
||||||
|
`SELECT application_no AS applicationNo FROM qipai_franchise_applications
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[input.tenantId, applicationId]
|
||||||
|
);
|
||||||
|
const created = String(rows[0]?.applicationNo ?? '') === applicationNo;
|
||||||
|
if (created) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, ?, ?, 'FRANCHISE_APPLICATION_SUBMITTED', 'FRANCHISE_APPLICATION',
|
||||||
|
?, ?, ?, ?, JSON_OBJECT('source', ?, 'city', ?))`,
|
||||||
|
[input.tenantId, input.submittedUserId ? 'USER' : 'ANONYMOUS',
|
||||||
|
input.submittedUserId ?? null, applicationId, input.traceId, input.ip,
|
||||||
|
input.userAgent.slice(0, 255), input.source, input.city]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { applicationId, applicationNo: String(rows[0]?.applicationNo ?? applicationNo), idempotent: !created };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listApplications(input: {
|
||||||
|
tenantId: string; page: number; pageSize: number; status?: FranchiseStatus;
|
||||||
|
assigneeUserId?: string; search?: string;
|
||||||
|
}) {
|
||||||
|
const where = ['a.tenant_id = ?', 'a.deleted_at IS NULL'];
|
||||||
|
const params: Array<string> = [input.tenantId];
|
||||||
|
if (input.status) { where.push('a.status = ?'); params.push(input.status); }
|
||||||
|
if (input.assigneeUserId) { where.push('a.assignee_user_id = ?'); params.push(input.assigneeUserId); }
|
||||||
|
if (input.search) {
|
||||||
|
where.push('(a.application_no LIKE ? OR a.city LIKE ? OR a.contact_name LIKE ? OR a.contact_phone LIKE ?)');
|
||||||
|
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||||
|
}
|
||||||
|
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_franchise_applications a WHERE ${where.join(' AND ')}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const offset = (input.page - 1) * input.pageSize;
|
||||||
|
const [rows] = await this.pool.execute<ApplicationRow[]>(
|
||||||
|
`${this.applicationSelect()} WHERE ${where.join(' AND ')}
|
||||||
|
ORDER BY FIELD(a.status, 'NEW', 'CONTACTED', 'QUALIFIED', 'CONVERTED', 'REJECTED'),
|
||||||
|
a.next_follow_up_at IS NULL, a.next_follow_up_at, a.id DESC
|
||||||
|
LIMIT ${input.pageSize} OFFSET ${offset}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return { items: rows.map(normalizeApplication), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getApplication(tenantId: string, applicationId: string) {
|
||||||
|
const [rows] = await this.pool.execute<ApplicationRow[]>(
|
||||||
|
`${this.applicationSelect()} WHERE a.tenant_id = ? AND a.id = ? AND a.deleted_at IS NULL`,
|
||||||
|
[tenantId, applicationId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
|
||||||
|
const [followUps] = await this.pool.execute<FollowUpRow[]>(
|
||||||
|
`SELECT f.id, f.actor_user_id AS actorUserId, u.nickname AS actorName,
|
||||||
|
f.follow_up_type AS followUpType, f.from_status AS fromStatus,
|
||||||
|
f.to_status AS toStatus, f.note, f.next_follow_up_at AS nextFollowUpAt,
|
||||||
|
f.created_at AS createdAt
|
||||||
|
FROM qipai_franchise_follow_ups f
|
||||||
|
INNER JOIN qipai_users u ON u.id = f.actor_user_id
|
||||||
|
WHERE f.tenant_id = ? AND f.application_id = ? ORDER BY f.id DESC`,
|
||||||
|
[tenantId, applicationId]
|
||||||
|
);
|
||||||
|
return { application: normalizeApplication(rows[0]), followUps: followUps.map((row) => ({ ...row, id: String(row.id), actorUserId: String(row.actorUserId) })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignApplication(actor: ManagementActor, tenantId: string, applicationId: string, assigneeUserId: string | null) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
await this.lockApplication(connection, tenantId, applicationId);
|
||||||
|
if (assigneeUserId) {
|
||||||
|
const [users] = await connection.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id FROM qipai_users WHERE tenant_id = ? AND id = ? AND user_type = 'STAFF'
|
||||||
|
AND status = 'ACTIVE' AND deleted_at IS NULL`,
|
||||||
|
[tenantId, assigneeUserId]
|
||||||
|
);
|
||||||
|
if (!users[0]) throw new FranchiseError('FRANCHISE_ASSIGNEE_INVALID');
|
||||||
|
}
|
||||||
|
await connection.execute(
|
||||||
|
'UPDATE qipai_franchise_applications SET assignee_user_id = ? WHERE tenant_id = ? AND id = ?',
|
||||||
|
[assigneeUserId, tenantId, applicationId]
|
||||||
|
);
|
||||||
|
await this.addEvent(connection, actor, tenantId, applicationId, 'ASSIGNMENT', null, null,
|
||||||
|
assigneeUserId ? '分派加盟线索负责人' : '取消加盟线索分派', null);
|
||||||
|
await this.audit(connection, actor, tenantId, 'FRANCHISE_APPLICATION_ASSIGNED', applicationId,
|
||||||
|
{ assigneeUserId });
|
||||||
|
return { applicationId, assigneeUserId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFollowUp(actor: ManagementActor, tenantId: string, applicationId: string, input: {
|
||||||
|
followUpType: Exclude<FollowUpType, 'ASSIGNMENT' | 'STATUS'>;
|
||||||
|
note: string; nextFollowUpAt?: Date | null; status?: FranchiseStatus;
|
||||||
|
}) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const current = await this.lockApplication(connection, tenantId, applicationId);
|
||||||
|
const nextStatus = input.status ?? current.status;
|
||||||
|
if (nextStatus !== current.status && !allowedTransitions[current.status].includes(nextStatus)) {
|
||||||
|
throw new FranchiseError('FRANCHISE_STATUS_TRANSITION_INVALID');
|
||||||
|
}
|
||||||
|
await connection.execute(
|
||||||
|
`UPDATE qipai_franchise_applications SET status = ?, next_follow_up_at = ?,
|
||||||
|
closed_at = CASE WHEN ? IN ('REJECTED', 'CONVERTED') THEN UTC_TIMESTAMP(3) ELSE NULL END
|
||||||
|
WHERE tenant_id = ? AND id = ?`,
|
||||||
|
[nextStatus, input.nextFollowUpAt ?? null, nextStatus, tenantId, applicationId]
|
||||||
|
);
|
||||||
|
const followUpId = await this.addEvent(connection, actor, tenantId, applicationId,
|
||||||
|
input.followUpType, current.status, nextStatus, input.note, input.nextFollowUpAt ?? null);
|
||||||
|
await this.audit(connection, actor, tenantId, 'FRANCHISE_FOLLOW_UP_ADDED', applicationId,
|
||||||
|
{ followUpId, followUpType: input.followUpType, fromStatus: current.status, toStatus: nextStatus });
|
||||||
|
return { applicationId, followUpId, status: nextStatus };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applicationSelect() {
|
||||||
|
return `SELECT a.id, a.tenant_id AS tenantId, a.application_no AS applicationNo,
|
||||||
|
a.city, a.contact_name AS contactName, a.contact_phone AS contactPhone,
|
||||||
|
a.message, a.source, a.status, a.assignee_user_id AS assigneeUserId,
|
||||||
|
assignee.nickname AS assigneeName, a.submitted_user_id AS submittedUserId,
|
||||||
|
a.next_follow_up_at AS nextFollowUpAt, a.closed_at AS closedAt,
|
||||||
|
a.created_at AS createdAt, a.updated_at AS updatedAt
|
||||||
|
FROM qipai_franchise_applications a
|
||||||
|
LEFT JOIN qipai_users assignee ON assignee.id = a.assignee_user_id AND assignee.tenant_id = a.tenant_id`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lockApplication(connection: PoolConnection, tenantId: string, id: string) {
|
||||||
|
const [rows] = await connection.execute<StatusRow[]>(
|
||||||
|
'SELECT status FROM qipai_franchise_applications WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE',
|
||||||
|
[tenantId, id]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addEvent(connection: PoolConnection, actor: ManagementActor, tenantId: string,
|
||||||
|
applicationId: string, type: FollowUpType, fromStatus: FranchiseStatus | null,
|
||||||
|
toStatus: FranchiseStatus | null, note: string, nextFollowUpAt: Date | null) {
|
||||||
|
const [result] = await connection.execute<ResultSetHeader>(
|
||||||
|
`INSERT INTO qipai_franchise_follow_ups
|
||||||
|
(tenant_id, application_id, actor_user_id, follow_up_type, from_status,
|
||||||
|
to_status, note, next_follow_up_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[tenantId, applicationId, actor.userId, type, fromStatus, toStatus, note, nextFollowUpAt]
|
||||||
|
);
|
||||||
|
return String(result.insertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async audit(connection: PoolConnection, actor: ManagementActor, tenantId: string,
|
||||||
|
action: string, resourceId: string, metadata: object) {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, ?,
|
||||||
|
'FRANCHISE_APPLICATION', ?, ?, ?, ?, ?)`,
|
||||||
|
[tenantId, actor.userId, action, resourceId, actor.traceId, actor.ip,
|
||||||
|
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
const connection = await this.pool.getConnection();
|
||||||
|
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||||
|
catch (error) { await connection.rollback(); throw error; }
|
||||||
|
finally { connection.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTransitions: Record<FranchiseStatus, FranchiseStatus[]> = {
|
||||||
|
NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'],
|
||||||
|
QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeApplication(row: ApplicationRow) {
|
||||||
|
return { ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||||
|
assigneeUserId: row.assigneeUserId === null ? null : String(row.assigneeUserId),
|
||||||
|
submittedUserId: row.submittedUserId === null ? null : String(row.submittedUserId) };
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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,180 @@
|
|||||||
|
import type { RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
|
||||||
|
interface OrderSummaryRow extends RowDataPacket {
|
||||||
|
orderTotal: number;
|
||||||
|
activeOrderTotal: number;
|
||||||
|
finishedOrderTotal: number;
|
||||||
|
bookedAmountCents: number;
|
||||||
|
payingMemberTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderStatusRow extends RowDataPacket {
|
||||||
|
status: string;
|
||||||
|
total: number;
|
||||||
|
amountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaymentChannelRow extends RowDataPacket {
|
||||||
|
channel: string;
|
||||||
|
total: number;
|
||||||
|
amountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyRevenueRow extends RowDataPacket {
|
||||||
|
date: string | Date;
|
||||||
|
total: number;
|
||||||
|
amountCents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomSummaryRow extends RowDataPacket {
|
||||||
|
roomTotal: number;
|
||||||
|
availableRoomTotal: number;
|
||||||
|
attentionRoomTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VoucherSummaryRow extends RowDataPacket {
|
||||||
|
voucherTotal: number;
|
||||||
|
voucherSucceeded: number;
|
||||||
|
voucherFailed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BusinessStatisticsError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BusinessStatisticsRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async overview(
|
||||||
|
actor: ManagementActor,
|
||||||
|
input: { storeId: string; from: Date; to: Date }
|
||||||
|
) {
|
||||||
|
this.assertStoreScope(actor, input.storeId);
|
||||||
|
const rangeParams = [actor.tenantId, input.storeId, input.from, input.to];
|
||||||
|
const [
|
||||||
|
orderSummaryResult,
|
||||||
|
orderStatusResult,
|
||||||
|
paymentChannelResult,
|
||||||
|
dailyRevenueResult,
|
||||||
|
roomSummaryResult,
|
||||||
|
voucherSummaryResult
|
||||||
|
] = await Promise.all([
|
||||||
|
this.pool.execute<OrderSummaryRow[]>(
|
||||||
|
`SELECT COUNT(*) AS orderTotal,
|
||||||
|
COALESCE(SUM(o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')), 0) AS activeOrderTotal,
|
||||||
|
COALESCE(SUM(o.status = 'FINISHED'), 0) AS finishedOrderTotal,
|
||||||
|
COALESCE(SUM(o.total_amount_cents), 0) AS bookedAmountCents,
|
||||||
|
COUNT(DISTINCT owner.user_id) AS payingMemberTotal
|
||||||
|
FROM qipai_orders o
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tenant_id, order_id, MIN(user_id) AS user_id
|
||||||
|
FROM qipai_order_user_access
|
||||||
|
WHERE access_type = 'OWNER'
|
||||||
|
GROUP BY tenant_id, order_id
|
||||||
|
) owner ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id
|
||||||
|
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
|
||||||
|
AND o.created_at >= ? AND o.created_at < ?`,
|
||||||
|
rangeParams
|
||||||
|
),
|
||||||
|
this.pool.execute<OrderStatusRow[]>(
|
||||||
|
`SELECT o.status, COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(o.total_amount_cents), 0) AS amountCents
|
||||||
|
FROM qipai_orders o
|
||||||
|
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
|
||||||
|
AND o.created_at >= ? AND o.created_at < ?
|
||||||
|
GROUP BY o.status ORDER BY total DESC, o.status`,
|
||||||
|
rangeParams
|
||||||
|
),
|
||||||
|
this.pool.execute<PaymentChannelRow[]>(
|
||||||
|
`SELECT p.channel, COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(p.amount_cents), 0) AS amountCents
|
||||||
|
FROM qipai_payments p
|
||||||
|
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
|
||||||
|
AND p.paid_at >= ? AND p.paid_at < ?
|
||||||
|
GROUP BY p.channel ORDER BY amountCents DESC, p.channel`,
|
||||||
|
rangeParams
|
||||||
|
),
|
||||||
|
this.pool.execute<DailyRevenueRow[]>(
|
||||||
|
`SELECT DATE_FORMAT(p.paid_at, '%Y-%m-%d') AS date, COUNT(*) AS total,
|
||||||
|
COALESCE(SUM(p.amount_cents), 0) AS amountCents
|
||||||
|
FROM qipai_payments p
|
||||||
|
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
|
||||||
|
AND p.paid_at >= ? AND p.paid_at < ?
|
||||||
|
GROUP BY DATE_FORMAT(p.paid_at, '%Y-%m-%d') ORDER BY date`,
|
||||||
|
rangeParams
|
||||||
|
),
|
||||||
|
this.pool.execute<RoomSummaryRow[]>(
|
||||||
|
`SELECT COUNT(*) AS roomTotal,
|
||||||
|
COALESCE(SUM(configuration_status = 'ENABLED'
|
||||||
|
AND operational_status = 'AVAILABLE'), 0) AS availableRoomTotal,
|
||||||
|
COALESCE(SUM(configuration_status = 'DISABLED'
|
||||||
|
OR operational_status IN ('MAINTENANCE', 'CLEANING_REQUIRED')), 0) AS attentionRoomTotal
|
||||||
|
FROM qipai_rooms
|
||||||
|
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`,
|
||||||
|
[actor.tenantId, input.storeId]
|
||||||
|
),
|
||||||
|
this.pool.execute<VoucherSummaryRow[]>(
|
||||||
|
`SELECT COUNT(*) AS voucherTotal,
|
||||||
|
COALESCE(SUM(status = 'SUCCEEDED'), 0) AS voucherSucceeded,
|
||||||
|
COALESCE(SUM(status = 'FAILED'), 0) AS voucherFailed
|
||||||
|
FROM qipai_group_redemptions
|
||||||
|
WHERE tenant_id = ? AND store_id = ? AND created_at >= ? AND created_at < ?`,
|
||||||
|
rangeParams
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
const order = orderSummaryResult[0][0];
|
||||||
|
const room = roomSummaryResult[0][0];
|
||||||
|
const voucher = voucherSummaryResult[0][0];
|
||||||
|
const paymentChannels = paymentChannelResult[0].map((row) => ({
|
||||||
|
channel: row.channel,
|
||||||
|
total: Number(row.total),
|
||||||
|
amountCents: Number(row.amountCents)
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
storeId: input.storeId,
|
||||||
|
from: input.from.toISOString(),
|
||||||
|
to: input.to.toISOString(),
|
||||||
|
summary: {
|
||||||
|
orderTotal: Number(order?.orderTotal ?? 0),
|
||||||
|
activeOrderTotal: Number(order?.activeOrderTotal ?? 0),
|
||||||
|
finishedOrderTotal: Number(order?.finishedOrderTotal ?? 0),
|
||||||
|
bookedAmountCents: Number(order?.bookedAmountCents ?? 0),
|
||||||
|
collectedAmountCents: paymentChannels.reduce((sum, row) => sum + row.amountCents, 0),
|
||||||
|
payingMemberTotal: Number(order?.payingMemberTotal ?? 0),
|
||||||
|
voucherTotal: Number(voucher?.voucherTotal ?? 0),
|
||||||
|
voucherSucceeded: Number(voucher?.voucherSucceeded ?? 0),
|
||||||
|
voucherFailed: Number(voucher?.voucherFailed ?? 0),
|
||||||
|
roomTotal: Number(room?.roomTotal ?? 0),
|
||||||
|
availableRoomTotal: Number(room?.availableRoomTotal ?? 0),
|
||||||
|
attentionRoomTotal: Number(room?.attentionRoomTotal ?? 0)
|
||||||
|
},
|
||||||
|
orderStatuses: orderStatusResult[0].map((row) => ({
|
||||||
|
status: row.status,
|
||||||
|
total: Number(row.total),
|
||||||
|
amountCents: Number(row.amountCents)
|
||||||
|
})),
|
||||||
|
paymentChannels,
|
||||||
|
dailyRevenue: dailyRevenueResult[0].map((row) => ({
|
||||||
|
date: formatDate(row.date),
|
||||||
|
total: Number(row.total),
|
||||||
|
amountCents: Number(row.amountCents)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||||
|
if (actor.access.capabilities.includes('tenant.manage')
|
||||||
|
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||||
|
if (!actor.access.capabilities.includes('store.operation.read')
|
||||||
|
|| !actor.access.storeIds.includes(storeId)) {
|
||||||
|
throw new BusinessStatisticsError('BUSINESS_STATISTICS_FORBIDDEN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | Date) {
|
||||||
|
if (typeof value === 'string') return value.slice(0, 10);
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
|
||||||
|
export interface AuditLogQuery {
|
||||||
|
tenantId: string; page: number; pageSize: number; action?: string; resourceType?: string;
|
||||||
|
actorId?: string; search?: string; from?: Date; to?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditRow extends RowDataPacket {
|
||||||
|
id: string; tenantId: string; actorType: string; actorId: string | null; actorName: string | null;
|
||||||
|
action: string; resourceType: string; resourceId: string | null; traceId: string;
|
||||||
|
ip: string; userAgent: string; metadata: unknown; createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemOperationsError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SystemOperationsRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async listAuditLogs(input: AuditLogQuery) {
|
||||||
|
const page = Number.isSafeInteger(input.page) && input.page > 0 ? input.page : 1;
|
||||||
|
const pageSize = Number.isSafeInteger(input.pageSize)
|
||||||
|
? Math.min(100, Math.max(1, input.pageSize)) : 20;
|
||||||
|
const where = ['l.tenant_id = ?']; const params: Array<string | Date> = [input.tenantId];
|
||||||
|
if (input.action) { where.push('l.action = ?'); params.push(input.action); }
|
||||||
|
if (input.resourceType) { where.push('l.resource_type = ?'); params.push(input.resourceType); }
|
||||||
|
if (input.actorId) { where.push('l.actor_id = ?'); params.push(input.actorId); }
|
||||||
|
if (input.from) { where.push('l.created_at >= ?'); params.push(input.from); }
|
||||||
|
if (input.to) { where.push('l.created_at < ?'); params.push(input.to); }
|
||||||
|
if (input.search) {
|
||||||
|
where.push('(l.action LIKE ? OR l.resource_type LIKE ? OR l.trace_id LIKE ? OR u.nickname LIKE ?)');
|
||||||
|
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||||
|
}
|
||||||
|
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||||
|
`SELECT COUNT(*) AS total FROM qipai_audit_logs l
|
||||||
|
LEFT JOIN qipai_users u ON u.id = l.actor_id WHERE ${where.join(' AND ')}`, params
|
||||||
|
);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const [rows] = await this.pool.execute<AuditRow[]>(
|
||||||
|
`SELECT l.id, l.tenant_id AS tenantId, l.actor_type AS actorType,
|
||||||
|
l.actor_id AS actorId, u.nickname AS actorName, l.action,
|
||||||
|
l.resource_type AS resourceType, l.resource_id AS resourceId,
|
||||||
|
l.trace_id AS traceId, l.ip, l.user_agent AS userAgent,
|
||||||
|
l.metadata, l.created_at AS createdAt
|
||||||
|
FROM qipai_audit_logs l LEFT JOIN qipai_users u ON u.id = l.actor_id
|
||||||
|
WHERE ${where.join(' AND ')} ORDER BY l.id DESC
|
||||||
|
LIMIT ${pageSize} OFFSET ${offset}`, params
|
||||||
|
);
|
||||||
|
return { items: rows.map((row) => ({ ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||||
|
actorId: row.actorId === null ? null : String(row.actorId),
|
||||||
|
resourceId: row.resourceId === null ? null : String(row.resourceId),
|
||||||
|
ip: maskIp(row.ip), metadata: sanitizeMetadata(parseJson(row.metadata)) })),
|
||||||
|
total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSystemOverview(tenantId: string) {
|
||||||
|
const [tenants] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT id, code, name, status, timezone, created_at AS createdAt, updated_at AS updatedAt
|
||||||
|
FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
|
||||||
|
);
|
||||||
|
if (!tenants[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||||
|
const [counts] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM qipai_stores WHERE tenant_id = ? AND deleted_at IS NULL) AS storeCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_users WHERE tenant_id = ? AND deleted_at IS NULL) AS userCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_auth_sessions WHERE tenant_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3)) AS activeSessionCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_tenant_apps WHERE tenant_id = ? AND deleted_at IS NULL) AS appBindingCount,
|
||||||
|
(SELECT COUNT(*) FROM qipai_audit_logs WHERE tenant_id = ? AND created_at >= UTC_DATE()) AS auditTodayCount`,
|
||||||
|
[tenantId, tenantId, tenantId, tenantId, tenantId]
|
||||||
|
);
|
||||||
|
const [migrations] = await this.pool.execute<RowDataPacket[]>(
|
||||||
|
'SELECT version, name, applied_at AS appliedAt FROM qipai_schema_migrations ORDER BY version DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
return { tenant: { ...tenants[0], id: String(tenants[0].id) },
|
||||||
|
counts: Object.fromEntries(Object.entries(counts[0] ?? {}).map(([key, value]) => [key, Number(value)])),
|
||||||
|
latestMigration: migrations[0] ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTenant(actor: ManagementActor, tenantId: string, input: {
|
||||||
|
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
|
||||||
|
}) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
const [rows] = await connection.execute<Array<RowDataPacket & { name: string; timezone: string; status: string }>>(
|
||||||
|
'SELECT name, timezone, status FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL FOR UPDATE', [tenantId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||||
|
await connection.execute<ResultSetHeader>(
|
||||||
|
`UPDATE qipai_tenants SET name = ?, timezone = ?, status = COALESCE(?, status)
|
||||||
|
WHERE id = ? AND deleted_at IS NULL`, [input.name, input.timezone, input.status ?? null, tenantId]
|
||||||
|
);
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'TENANT_SYSTEM_CONFIG_UPDATED',
|
||||||
|
'TENANT', ?, ?, ?, ?, ?)`,
|
||||||
|
[tenantId, actor.userId, tenantId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
|
||||||
|
JSON.stringify({ before: rows[0], after: input })]
|
||||||
|
);
|
||||||
|
return { tenantId, updated: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||||
|
const connection = await this.pool.getConnection();
|
||||||
|
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||||
|
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJson(value: unknown): unknown {
|
||||||
|
if (typeof value !== 'string') return value;
|
||||||
|
try { return JSON.parse(value); } catch { return {}; }
|
||||||
|
}
|
||||||
|
function sanitizeMetadata(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(sanitizeMetadata);
|
||||||
|
if (!value || typeof value !== 'object') return value;
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) =>
|
||||||
|
[key, /(secret|token|password|private|credential|certificate|api.?key|phone|openid|receiver|voucher)/i.test(key)
|
||||||
|
? '[REDACTED]' : sanitizeMetadata(item)]));
|
||||||
|
}
|
||||||
|
function maskIp(ip: string) {
|
||||||
|
if (!ip) return '';
|
||||||
|
if (ip.includes(':')) return `${ip.split(':').slice(0, 2).join(':')}::****`;
|
||||||
|
const parts = ip.split('.'); return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.***` : '***';
|
||||||
|
}
|
||||||
@@ -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,169 @@
|
|||||||
|
import type { RowDataPacket } from 'mysql2/promise';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { MySqlPool } from '../db/mysql.js';
|
||||||
|
import type { OrderStatus } from './order-state-repository.js';
|
||||||
|
|
||||||
|
export class OrderQueryError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderQueryAccess {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
access: AccessProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderListRow extends RowDataPacket {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
roomNo: string;
|
||||||
|
status: OrderStatus;
|
||||||
|
startAt: Date;
|
||||||
|
endAt: Date;
|
||||||
|
totalAmountCents: number;
|
||||||
|
paidAmountCents: number;
|
||||||
|
latestPaymentId: string | null;
|
||||||
|
latestPaymentProvider: string | null;
|
||||||
|
latestPaymentStatus: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountRow extends RowDataPacket { total: number }
|
||||||
|
|
||||||
|
export class OrderQueryRepository {
|
||||||
|
constructor(private readonly pool: MySqlPool) {}
|
||||||
|
|
||||||
|
async listMine(input: OrderQueryAccess & {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
status?: OrderStatus;
|
||||||
|
storeId?: string;
|
||||||
|
}) {
|
||||||
|
const where = [
|
||||||
|
'o.tenant_id = ?',
|
||||||
|
'o.deleted_at IS NULL',
|
||||||
|
'(a.user_id = ? OR ' + managerScopeSql(input.access, 'o.store_id') + ')'
|
||||||
|
];
|
||||||
|
const params: Array<string | number> = [input.tenantId, input.userId];
|
||||||
|
if (input.status) {
|
||||||
|
where.push('o.status = ?');
|
||||||
|
params.push(input.status);
|
||||||
|
}
|
||||||
|
if (input.storeId) {
|
||||||
|
where.push('o.store_id = ?');
|
||||||
|
params.push(input.storeId);
|
||||||
|
}
|
||||||
|
const whereSql = where.join(' AND ');
|
||||||
|
const pageSize = Math.max(1, Math.min(50, Math.trunc(input.pageSize)));
|
||||||
|
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||||
|
const [counts] = await this.pool.execute<CountRow[]>(
|
||||||
|
`SELECT COUNT(DISTINCT o.id) AS total
|
||||||
|
FROM qipai_orders o
|
||||||
|
LEFT JOIN qipai_order_user_access a
|
||||||
|
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
|
||||||
|
WHERE ${whereSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const [rows] = await this.pool.execute<OrderListRow[]>(
|
||||||
|
`SELECT DISTINCT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName,
|
||||||
|
o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
|
||||||
|
o.status, o.start_at AS startAt, o.end_at AS endAt,
|
||||||
|
o.total_amount_cents AS totalAmountCents,
|
||||||
|
o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId,
|
||||||
|
p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus,
|
||||||
|
o.created_at AS createdAt
|
||||||
|
FROM qipai_orders o
|
||||||
|
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
|
||||||
|
INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id
|
||||||
|
LEFT JOIN qipai_order_user_access a
|
||||||
|
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
|
||||||
|
LEFT JOIN qipai_payments p
|
||||||
|
ON p.tenant_id = o.tenant_id AND p.order_id = o.id
|
||||||
|
AND p.id = (
|
||||||
|
SELECT MAX(p2.id) FROM qipai_payments p2
|
||||||
|
WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id
|
||||||
|
AND p2.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY o.created_at DESC, o.id DESC
|
||||||
|
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: rows.map(publicOrder),
|
||||||
|
total: Number(counts[0]?.total ?? 0),
|
||||||
|
page: input.page,
|
||||||
|
pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMine(input: OrderQueryAccess & { orderId: string }) {
|
||||||
|
const result = await this.listMine({ ...input, page: 1, pageSize: 1 });
|
||||||
|
const order = result.items.find((item) => item.id === input.orderId);
|
||||||
|
if (order) return order;
|
||||||
|
const [rows] = await this.pool.execute<OrderListRow[]>(
|
||||||
|
`SELECT o.id, o.order_no AS orderNo, o.store_id AS storeId, s.name AS storeName,
|
||||||
|
o.room_id AS roomId, r.name AS roomName, r.room_no AS roomNo,
|
||||||
|
o.status, o.start_at AS startAt, o.end_at AS endAt,
|
||||||
|
o.total_amount_cents AS totalAmountCents,
|
||||||
|
o.paid_amount_cents AS paidAmountCents, p.id AS latestPaymentId,
|
||||||
|
p.provider AS latestPaymentProvider, p.status AS latestPaymentStatus,
|
||||||
|
o.created_at AS createdAt
|
||||||
|
FROM qipai_orders o
|
||||||
|
INNER JOIN qipai_stores s ON s.tenant_id = o.tenant_id AND s.id = o.store_id
|
||||||
|
INNER JOIN qipai_rooms r ON r.tenant_id = o.tenant_id AND r.id = o.room_id
|
||||||
|
LEFT JOIN qipai_order_user_access a
|
||||||
|
ON a.tenant_id = o.tenant_id AND a.order_id = o.id AND a.revoked_at IS NULL
|
||||||
|
LEFT JOIN qipai_payments p
|
||||||
|
ON p.tenant_id = o.tenant_id AND p.order_id = o.id
|
||||||
|
AND p.id = (
|
||||||
|
SELECT MAX(p2.id) FROM qipai_payments p2
|
||||||
|
WHERE p2.tenant_id = o.tenant_id AND p2.order_id = o.id
|
||||||
|
AND p2.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
WHERE o.tenant_id = ? AND o.id = ? AND o.deleted_at IS NULL
|
||||||
|
AND (a.user_id = ? OR ${managerScopeSql(input.access, 'o.store_id')})
|
||||||
|
LIMIT 1`,
|
||||||
|
[input.tenantId, input.orderId, input.userId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new OrderQueryError('ORDER_NOT_FOUND');
|
||||||
|
return publicOrder(rows[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicOrder(row: OrderListRow) {
|
||||||
|
return {
|
||||||
|
id: String(row.id),
|
||||||
|
orderNo: row.orderNo,
|
||||||
|
storeId: String(row.storeId),
|
||||||
|
storeName: row.storeName,
|
||||||
|
roomId: String(row.roomId),
|
||||||
|
roomName: row.roomName,
|
||||||
|
roomNo: row.roomNo,
|
||||||
|
status: row.status,
|
||||||
|
startAt: row.startAt,
|
||||||
|
endAt: row.endAt,
|
||||||
|
totalAmountCents: row.totalAmountCents,
|
||||||
|
paidAmountCents: row.paidAmountCents,
|
||||||
|
latestPayment: row.latestPaymentId === null ? null : {
|
||||||
|
id: String(row.latestPaymentId),
|
||||||
|
provider: row.latestPaymentProvider,
|
||||||
|
status: row.latestPaymentStatus
|
||||||
|
},
|
||||||
|
createdAt: row.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managerScopeSql(access: AccessProfile, storeExpression: string) {
|
||||||
|
if (access.capabilities.includes('tenant.manage') || access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
return '1 = 1';
|
||||||
|
}
|
||||||
|
if (!access.capabilities.includes('store.operation.read') || access.storeIds.length === 0) {
|
||||||
|
return '1 = 0';
|
||||||
|
}
|
||||||
|
return `${storeExpression} IN (${access.storeIds.map((id) => Number(id)).join(',')})`;
|
||||||
|
}
|
||||||
@@ -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,428 @@
|
|||||||
|
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 }
|
||||||
|
interface UserRow extends RowDataPacket { id: string }
|
||||||
|
|
||||||
|
export class PricingError extends Error {
|
||||||
|
constructor(public readonly code: string) { super(code); }
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PricingRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly pool: MySqlPool,
|
||||||
|
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;
|
||||||
|
actor?: {
|
||||||
|
userId: string;
|
||||||
|
source: 'APP' | 'ADMIN';
|
||||||
|
traceId: string;
|
||||||
|
ip: string;
|
||||||
|
userAgent: string;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return this.transaction(async (connection) => {
|
||||||
|
await this.assertActiveTenantUser(connection, input.tenantId, input.userId);
|
||||||
|
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
|
||||||
|
if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) {
|
||||||
|
throw new PricingError('STORE_SCOPE_FORBIDDEN');
|
||||||
|
}
|
||||||
|
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', ?,
|
||||||
|
?, ?, ?, JSON_OBJECT('statusVersion', 1, 'targetUserId', ?))`,
|
||||||
|
[input.tenantId, orderId, input.actor?.userId ?? input.userId,
|
||||||
|
input.actor?.source ?? 'APP',
|
||||||
|
input.actor?.source === 'ADMIN' ? 'Order created on behalf' : 'Room hold created',
|
||||||
|
input.actor?.traceId ?? `order-created-${orderId}`, input.userId]
|
||||||
|
);
|
||||||
|
if (input.actor?.source === 'ADMIN') {
|
||||||
|
await connection.execute(
|
||||||
|
`INSERT INTO qipai_audit_logs
|
||||||
|
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||||
|
trace_id, ip, user_agent, metadata)
|
||||||
|
VALUES (?, 'USER', ?, 'ORDER_CREATED_ON_BEHALF', 'ORDER', ?, ?, ?, ?,
|
||||||
|
JSON_OBJECT('targetUserId', ?, 'storeId', ?, 'roomId', ?))`,
|
||||||
|
[input.tenantId, input.actor.userId, orderId, input.actor.traceId,
|
||||||
|
input.actor.ip, input.actor.userAgent.slice(0, 255), input.userId,
|
||||||
|
String(room.storeId), input.roomId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
orderId,
|
||||||
|
orderNo,
|
||||||
|
storeId: String(room.storeId),
|
||||||
|
reservationId: String(reservationResult.insertId),
|
||||||
|
holdMinutes,
|
||||||
|
quote,
|
||||||
|
benefitReservation
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertActiveTenantUser(
|
||||||
|
connection: PoolConnection, tenantId: string, userId: string
|
||||||
|
) {
|
||||||
|
const [rows] = await connection.execute<UserRow[]>(
|
||||||
|
`SELECT id FROM qipai_users
|
||||||
|
WHERE tenant_id = ? AND id = ? AND status = 'ACTIVE' LIMIT 1`,
|
||||||
|
[tenantId, userId]
|
||||||
|
);
|
||||||
|
if (!rows[0]) throw new PricingError('USER_NOT_FOUND');
|
||||||
|
}
|
||||||
|
|
||||||
|
async releaseExpired(
|
||||||
|
tenantId?: string,
|
||||||
|
roomId?: string,
|
||||||
|
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.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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
|||||||
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import { AdminAuthError, hashToken, type AdminAuthRepository } from '../auth/admin-auth-repository.js';
|
||||||
|
import { signAccessToken } from '../auth/jwt.js';
|
||||||
|
import { hashPassword } from '../auth/password.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const loginName = z.string().trim().min(3).max(64).regex(/^[\p{L}\p{N}._@+-]+$/u);
|
||||||
|
const strongPassword = z.string().min(12).max(128)
|
||||||
|
.refine((value) => /[A-Za-z]/.test(value) && /\d/.test(value) && /[^A-Za-z0-9]/.test(value));
|
||||||
|
const loginSchema = z.object({ tenantCode: z.string().trim().min(2).max(64), loginName,
|
||||||
|
['password']: z.string().min(1).max(128) }).strict();
|
||||||
|
const refreshSchema = z.object({ refreshToken: z.string().min(60).max(160) }).strict();
|
||||||
|
const credentialSchema = z.object({ tenantId: id.optional(), loginName, ['password']: strongPassword }).strict();
|
||||||
|
|
||||||
|
export interface AdminAuthRouteOptions {
|
||||||
|
repository: Pick<AdminAuthRepository, 'loginWithPassword' | 'rotateRefreshToken' | 'setCredential'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession' | 'revokeSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
accessTokenTtlSeconds: number;
|
||||||
|
sessionTtlSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerAdminAuthRoutes(app: FastifyInstance, options: AdminAuthRouteOptions) {
|
||||||
|
app.post('/admin-api/auth/login', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = loginSchema.safeParse(request.body);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
const sessionId = randomUUID(); const refreshToken = createRefreshToken(sessionId);
|
||||||
|
try {
|
||||||
|
const session = await options.repository.loginWithPassword({ ...body.data, sessionId,
|
||||||
|
refreshTokenHash: hashToken(refreshToken),
|
||||||
|
expiresAt: new Date(Date.now() + options.sessionTtlSeconds * 1000),
|
||||||
|
ip: request.ip, userAgent: request.headers['user-agent'] ?? '', traceId: request.traceId });
|
||||||
|
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
|
||||||
|
return { code: 0, data: sessionResponse(session, access, refreshToken, options), traceId: request.traceId };
|
||||||
|
} catch (error) { return authError(reply, request.traceId, error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/auth/refresh', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
|
||||||
|
async (request, reply) => {
|
||||||
|
const body = refreshSchema.safeParse(request.body);
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
const sessionId = body.data.refreshToken.split('.', 1)[0];
|
||||||
|
if (!z.string().uuid().safeParse(sessionId).success) return invalid(reply, request.traceId);
|
||||||
|
const nextRefreshToken = createRefreshToken(sessionId);
|
||||||
|
try {
|
||||||
|
const session = await options.repository.rotateRefreshToken({ sessionId,
|
||||||
|
currentHash: hashToken(body.data.refreshToken), nextHash: hashToken(nextRefreshToken),
|
||||||
|
ip: request.ip, userAgent: request.headers['user-agent'] ?? '' });
|
||||||
|
const access = await options.accessControl.getAccessProfile(session.tenantId, session.user.id);
|
||||||
|
return { code: 0, data: sessionResponse(session, access, nextRefreshToken, options), traceId: request.traceId };
|
||||||
|
} catch (error) { return authError(reply, request.traceId, error); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/auth/me', async (request, reply) => {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
return { code: 0, data: { user: safeUser(auth.session.user), access: adminAccess(access) }, traceId: request.traceId };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/auth/logout', async (request, reply) => {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
await options.authRepository.revokeSession(auth.sessionId, 'ADMIN_LOGOUT');
|
||||||
|
return { code: 0, data: { revoked: true }, traceId: request.traceId };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/auth/credentials/:userId', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const params = z.object({ userId: id }).safeParse(request.params);
|
||||||
|
const body = credentialSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const tenantId = resolveTenant(actor, body.data.tenantId);
|
||||||
|
if (!tenantId) return reply.status(403).send({ code: 'ADMIN_AUTH_TENANT_FORBIDDEN',
|
||||||
|
message: 'The tenant is outside the allowed scope.', traceId: request.traceId });
|
||||||
|
try {
|
||||||
|
const passwordHash = await hashPassword(body.data.password);
|
||||||
|
return { code: 0, data: await options.repository.setCredential(actor, tenantId,
|
||||||
|
{ userId: params.data.userId, loginName: body.data.loginName, passwordHash }), traceId: request.traceId };
|
||||||
|
} catch (error) { return authError(reply, request.traceId, error); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: AdminAuthRouteOptions) {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) { unauthorized(reply, request.traceId); return null; }
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||||
|
reply.status(403).send({ code: 'ADMIN_CREDENTIAL_FORBIDDEN',
|
||||||
|
message: 'Tenant management permission is required.', traceId: request.traceId }); return null;
|
||||||
|
}
|
||||||
|
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||||
|
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
|
||||||
|
}
|
||||||
|
function createRefreshToken(sessionId: string) { return `${sessionId}.${randomBytes(32).toString('base64url')}`; }
|
||||||
|
function sessionResponse(session: { id: string; tenantId: string; platformAppId: string; user: Parameters<typeof safeUser>[0] },
|
||||||
|
access: AccessProfile, refreshToken: string, options: AdminAuthRouteOptions) {
|
||||||
|
return { accessToken: signAccessToken({ sub: session.user.id, sid: session.id, tid: session.tenantId,
|
||||||
|
aid: session.platformAppId, rv: session.user.roleVersion }, options.jwtSecret, options.accessTokenTtlSeconds),
|
||||||
|
refreshToken, expiresIn: options.accessTokenTtlSeconds, user: safeUser(session.user),
|
||||||
|
access: adminAccess(access) };
|
||||||
|
}
|
||||||
|
function adminAccess(access: AccessProfile) {
|
||||||
|
const tenant = access.capabilities.includes('tenant.manage') || isPlatform(access);
|
||||||
|
const storeRead = tenant || access.capabilities.includes('store.operation.read');
|
||||||
|
const menus = [
|
||||||
|
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
|
||||||
|
...(tenant || access.capabilities.includes('product.catalog.read')
|
||||||
|
|| access.capabilities.includes('inventory.read') ? ['products'] : []),
|
||||||
|
...(tenant ? ['platformApps', 'content', 'franchise', 'system', 'payments', 'people'] : []),
|
||||||
|
...(tenant || access.capabilities.includes('device.read') ? ['devices'] : []),
|
||||||
|
...(tenant || access.capabilities.includes('cleaning.task.read') ? ['cleaning'] : [])
|
||||||
|
];
|
||||||
|
return { ...access, menus: [...new Set(menus)] };
|
||||||
|
}
|
||||||
|
function safeUser(user: { id: string; tenantId: string; userType: string; nickname: string; avatarUrl: string; roleVersion: number }) {
|
||||||
|
return { id: user.id, tenantId: user.tenantId, userType: user.userType,
|
||||||
|
nickname: user.nickname, avatarUrl: user.avatarUrl, roleVersion: user.roleVersion };
|
||||||
|
}
|
||||||
|
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||||
|
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||||
|
return isPlatform(actor.access) ? requested : null;
|
||||||
|
}
|
||||||
|
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({
|
||||||
|
code: 'INVALID_ADMIN_AUTH_REQUEST', message: 'The admin authentication request is invalid.', traceId }); }
|
||||||
|
function unauthorized(reply: FastifyReply, traceId: string) { return reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'The admin session is invalid or expired.', traceId }); }
|
||||||
|
function authError(reply: FastifyReply, traceId: string, error: unknown) {
|
||||||
|
if (!(error instanceof AdminAuthError)) throw error;
|
||||||
|
const status = error.code === 'ADMIN_LOGIN_LOCKED' ? 423
|
||||||
|
: error.code.includes('CONFLICT') ? 409
|
||||||
|
: error.code.includes('USER_INVALID') ? 404 : 401;
|
||||||
|
return reply.status(status).send({ code: error.code,
|
||||||
|
message: 'The admin authentication request was rejected.', traceId });
|
||||||
|
}
|
||||||
@@ -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,96 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import {
|
||||||
|
BusinessStatisticsError,
|
||||||
|
type BusinessStatisticsRepository
|
||||||
|
} from '../operations/business-statistics-repository.js';
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
from: z.coerce.date().optional(),
|
||||||
|
to: z.coerce.date().optional()
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export interface BusinessStatisticsRouteOptions {
|
||||||
|
repository: Pick<BusinessStatisticsRepository, 'overview'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerBusinessStatisticsRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: BusinessStatisticsRouteOptions
|
||||||
|
) {
|
||||||
|
for (const path of ['/app-api/management/statistics', '/admin-api/statistics']) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options);
|
||||||
|
if (!actor) return;
|
||||||
|
const query = querySchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
const to = query.data.to ?? new Date();
|
||||||
|
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||||
|
const duration = to.getTime() - from.getTime();
|
||||||
|
if (duration <= 0 || duration > 93 * 86400000) return invalid(reply, request.traceId);
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.overview(actor, {
|
||||||
|
storeId: query.data.storeId,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof BusinessStatisticsError)) throw error;
|
||||||
|
return reply.status(403).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'Business statistics permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireActor(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: BusinessStatisticsRouteOptions
|
||||||
|
): Promise<ManagementActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID',
|
||||||
|
message: 'Authentication required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId, auth.session.user.id
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
access,
|
||||||
|
traceId: request.traceId,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_BUSINESS_STATISTICS_REQUEST',
|
||||||
|
message: 'The statistics request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
import { Transform } from 'node:stream';
|
||||||
|
import type {
|
||||||
|
FastifyInstance, FastifyReply, FastifyRequest, preParsingHookHandler
|
||||||
|
} from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import { MediaStorage, MediaValidationError } from '../content/media-storage.js';
|
||||||
|
import {
|
||||||
|
CleaningTaskError,
|
||||||
|
cleaningTaskStatuses,
|
||||||
|
type CleaningActor,
|
||||||
|
type CleaningTaskRepository
|
||||||
|
} from '../cleaning/cleaning-task-repository.js';
|
||||||
|
import {
|
||||||
|
CleaningPayoutError,
|
||||||
|
type CleaningPayoutService
|
||||||
|
} from '../cleaning/cleaning-payout-service.js';
|
||||||
|
import { WechatPayError, type WechatNotificationHeaders } from '../payments/wechat-pay-client.js';
|
||||||
|
|
||||||
|
const listSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||||
|
status: z.enum(cleaningTaskStatuses).optional()
|
||||||
|
});
|
||||||
|
const managerTaskListSchema = listSchema.extend({
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
|
}).strict();
|
||||||
|
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||||
|
const submitSchema = z.object({
|
||||||
|
photoUrls: z.array(z.string().url()).max(9).default([]),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
});
|
||||||
|
const assignSchema = z.object({
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const memberParamsSchema = z.object({
|
||||||
|
taskId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/)
|
||||||
|
});
|
||||||
|
const memberSchema = z.object({
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
rewardCents: z.coerce.number().int().min(0).max(1000000),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const memberRemoveSchema = z.object({
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
|
||||||
|
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
|
||||||
|
const exemptSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
|
||||||
|
const cleaningTemplateSchema = z.object({
|
||||||
|
scopeType: z.enum(['TENANT', 'STORE', 'ROOM']),
|
||||||
|
scopeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||||
|
name: z.string().trim().min(1).max(128),
|
||||||
|
requirement: z.string().trim().max(512).default(''),
|
||||||
|
photoRequired: z.boolean().default(true),
|
||||||
|
minPhotoCount: z.coerce.number().int().min(0).max(9).default(1),
|
||||||
|
maxPhotoCount: z.coerce.number().int().min(1).max(9).default(9),
|
||||||
|
exemptPolicy: z.enum(['DISABLED', 'BEFORE_START', 'ANY_ACTIVE']).default('ANY_ACTIVE'),
|
||||||
|
status: z.enum(['ACTIVE', 'DISABLED']).default('ACTIVE')
|
||||||
|
}).strict().superRefine((value, context) => {
|
||||||
|
if ((value.scopeType === 'TENANT') !== !value.scopeId) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['scopeId'], message: 'scope mismatch' });
|
||||||
|
}
|
||||||
|
if (value.minPhotoCount > value.maxPhotoCount
|
||||||
|
|| (value.photoRequired && value.minPhotoCount < 1)
|
||||||
|
|| (!value.photoRequired && value.minPhotoCount !== 0)) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['minPhotoCount'], message: 'photo rule mismatch' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
|
||||||
|
const settlementPayoutStateSchema = z.enum([
|
||||||
|
'NONE', 'SUCCESS', 'FAIL', 'PROCESSING', 'WAIT_USER_CONFIRM'
|
||||||
|
]);
|
||||||
|
const settlementListSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||||
|
status: settlementStatusSchema.optional(),
|
||||||
|
payoutState: settlementPayoutStateSchema.optional(),
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
|
}).strict();
|
||||||
|
const settlementExportSchema = settlementListSchema.omit({ page: true, pageSize: true });
|
||||||
|
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||||
|
const settlementGenerateSchema = z.object({
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const settlementConfirmSchema = z.object({
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const settlementCancelSchema = z.object({
|
||||||
|
reason: z.string().trim().min(1).max(512)
|
||||||
|
}).strict();
|
||||||
|
const settlementPaidSchema = z.object({
|
||||||
|
payoutChannel: z.string().trim().min(1).max(32),
|
||||||
|
payoutReference: z.string().trim().min(1).max(128),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const settlementPayoutFailureSchema = z.object({
|
||||||
|
payoutChannel: z.string().trim().max(32).optional(),
|
||||||
|
payoutReference: z.string().trim().max(128).optional(),
|
||||||
|
error: z.string().trim().min(1).max(512),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const settlementWechatTransferSchema = z.object({
|
||||||
|
mode: z.enum(['API', 'MOCK']).default('API'),
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const settlementWechatSyncSchema = z.object({
|
||||||
|
note: z.string().trim().max(512).optional()
|
||||||
|
}).strict();
|
||||||
|
const statisticsSchema = z.object({
|
||||||
|
from: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
|
||||||
|
to: z.string().regex(/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}:\d{2})?$/).optional(),
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||||
|
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
|
}).strict();
|
||||||
|
const reclaimSchema = z.object({
|
||||||
|
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||||
|
});
|
||||||
|
const photoCleanupSchema = z.object({
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(20)
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export interface CleaningRouteOptions {
|
||||||
|
repository: Pick<CleaningTaskRepository,
|
||||||
|
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||||
|
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||||
|
| 'listSettlements' | 'exportSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'cancelSettlement' | 'markSettlementPaid'
|
||||||
|
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||||
|
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
|
||||||
|
| 'listTemplates' | 'upsertTemplate'
|
||||||
|
| 'stats' | 'managerStatistics'>;
|
||||||
|
mediaStorage?: MediaStorage;
|
||||||
|
payoutService?: Pick<CleaningPayoutService,
|
||||||
|
'preflightWechatTransfer' | 'executeWechatTransfer' | 'syncWechatTransfer'
|
||||||
|
| 'processWechatTransferNotification'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerCleaningRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: CleaningRouteOptions
|
||||||
|
): Promise<void> {
|
||||||
|
if (options.mediaStorage && !app.hasContentTypeParser('application/octet-stream')) {
|
||||||
|
app.addContentTypeParser(
|
||||||
|
'application/octet-stream',
|
||||||
|
{ parseAs: 'buffer', bodyLimit: 8 * 1024 * 1024 },
|
||||||
|
(_request, body, done) => done(null, body)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/templates', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listTemplates(actor),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/cleaning/templates', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const body = cleaningTemplateSchema.safeParse(request.body ?? {});
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.upsertTemplate({ ...actor, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = listSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listHall({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = listSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listMine({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/cleaning/stats', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.stats(actor),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/cleaning/tasks',
|
||||||
|
'/app-api/management/cleaning/tasks'
|
||||||
|
]) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = managerTaskListSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listManage({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/cleaning/statistics',
|
||||||
|
'/app-api/management/cleaning/statistics'
|
||||||
|
]) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = statisticsSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.managerStatistics({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/settlement-candidates', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = managerTaskListSchema.omit({ status: true }).safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.settlementCandidates({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/settlements', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = settlementListSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listSettlements({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/settlements/export', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const query = settlementExportSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.exportSettlements({ ...actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.getSettlementDetail({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.claim({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/tasks/:taskId/assign', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = assignSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.assign({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
cleanerUserId: body.data.cleanerUserId,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listMembers({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/tasks/:taskId/events', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listEvents({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/tasks/:taskId/submissions', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listSubmissions({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = memberSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.addMember({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
cleanerUserId: body.data.cleanerUserId,
|
||||||
|
rewardCents: body.data.rewardCents,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/tasks/:taskId/members/:cleanerUserId/remove', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = memberParamsSchema.safeParse(request.params);
|
||||||
|
const body = memberRemoveSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.removeMember({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
cleanerUserId: params.data.cleanerUserId,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.start({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/rework', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.rework({ ...actor, taskId: params.data.taskId }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/photos', async (request, reply) => {
|
||||||
|
if (!options.mediaStorage) return reply.status(501).send({
|
||||||
|
code: 'CLEANING_PHOTO_UPLOAD_UNAVAILABLE',
|
||||||
|
message: 'Cleaning photo upload is not configured.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const originalName = singleHeader(request.headers['x-file-name']);
|
||||||
|
if (!params.success || !originalName || !Buffer.isBuffer(request.body)) {
|
||||||
|
return invalid(reply, request.traceId);
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => {
|
||||||
|
const context = await options.repository.assertCanUploadPhoto({
|
||||||
|
...actor, taskId: params.data.taskId
|
||||||
|
});
|
||||||
|
const image = await options.mediaStorage!.storeImage({
|
||||||
|
tenantId: actor.tenantId,
|
||||||
|
storeId: context.storeId,
|
||||||
|
originalName,
|
||||||
|
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
|
||||||
|
body: request.body as Buffer
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const recorded = await options.repository.recordPhotoUpload({
|
||||||
|
...actor, taskId: params.data.taskId, image
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ code: 0, data: recorded, traceId: request.traceId });
|
||||||
|
} catch (error) {
|
||||||
|
await options.mediaStorage!.deleteImage(image.storagePath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = submitSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.submit({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
photoUrls: body.data.photoUrls,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/cleaning/tasks/:taskId/complete',
|
||||||
|
'/app-api/management/cleaning/tasks/:taskId/complete'
|
||||||
|
]) {
|
||||||
|
app.post(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = completeSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.complete({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/cleaning/tasks/:taskId/reject',
|
||||||
|
'/app-api/management/cleaning/tasks/:taskId/reject'
|
||||||
|
]) {
|
||||||
|
app.post(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = rejectSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.reject({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
reason: body.data.reason
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of [
|
||||||
|
'/admin-api/cleaning/tasks/:taskId/exempt',
|
||||||
|
'/app-api/management/cleaning/tasks/:taskId/exempt'
|
||||||
|
]) {
|
||||||
|
app.post(path, async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = exemptSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.exempt({
|
||||||
|
...actor,
|
||||||
|
taskId: params.data.taskId,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const body = reclaimSchema.safeParse(request.body ?? {});
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.reclaimTimeouts({ ...actor, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/photos/cleanup', async (request, reply) => {
|
||||||
|
if (!options.mediaStorage) return reply.status(501).send({
|
||||||
|
code: 'CLEANING_PHOTO_CLEANUP_UNAVAILABLE',
|
||||||
|
message: 'Cleaning photo storage is not configured.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const body = photoCleanupSchema.safeParse(request.body ?? {});
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => {
|
||||||
|
const claimed = await options.repository.claimExpiredPhotoUploads({
|
||||||
|
...actor, limit: body.data.limit
|
||||||
|
});
|
||||||
|
const deletedIds: string[] = [];
|
||||||
|
const failedIds: string[] = [];
|
||||||
|
for (const photo of claimed) {
|
||||||
|
try {
|
||||||
|
await options.mediaStorage!.deleteImage(photo.storagePath);
|
||||||
|
deletedIds.push(photo.id);
|
||||||
|
} catch {
|
||||||
|
failedIds.push(photo.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await options.repository.markPhotoUploadsDeleted({
|
||||||
|
...actor, photoIds: deletedIds
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: { claimed: claimed.length, deleted: result.deleted, failedIds },
|
||||||
|
traceId: request.traceId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const body = settlementGenerateSchema.safeParse(request.body ?? {});
|
||||||
|
if (!body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.generateSettlement({ ...actor, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/confirm', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementConfirmSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.confirmSettlement({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/cancel', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementCancelSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.cancelSettlement({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
reason: body.data.reason
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementPaidSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.markSettlementPaid({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
payoutChannel: body.data.payoutChannel,
|
||||||
|
payoutReference: body.data.payoutReference,
|
||||||
|
note: body.data.note,
|
||||||
|
manualOverride: true
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/payout-failure', async (request, reply) => {
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementPayoutFailureSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.recordSettlementPayoutFailure({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
payoutChannel: body.data.payoutChannel,
|
||||||
|
payoutReference: body.data.payoutReference,
|
||||||
|
error: body.data.error,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer', async (request, reply) => {
|
||||||
|
if (!options.payoutService) return reply.status(501).send({
|
||||||
|
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||||
|
message: 'Cleaning payout service is not configured.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementWechatTransferSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.payoutService!.executeWechatTransfer({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
mode: body.data.mode,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/preflight', async (request, reply) => {
|
||||||
|
if (!options.payoutService) return reply.status(501).send({
|
||||||
|
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||||
|
message: 'Cleaning payout service is not configured.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
const actor = await requireActor(request, reply, options, 'read');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.payoutService!.preflightWechatTransfer({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/cleaning/settlements/:settlementId/wechat-transfer/sync', async (request, reply) => {
|
||||||
|
if (!options.payoutService) return reply.status(501).send({
|
||||||
|
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||||
|
message: 'Cleaning payout service is not configured.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
const actor = await requireActor(request, reply, options, 'write');
|
||||||
|
if (!actor) return;
|
||||||
|
const params = settlementParamsSchema.safeParse(request.params);
|
||||||
|
const body = settlementWechatSyncSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.payoutService!.syncWechatTransfer({
|
||||||
|
...actor,
|
||||||
|
settlementId: params.data.settlementId,
|
||||||
|
note: body.data.note
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/app-api/cleaning/wechat-transfer/notify', {
|
||||||
|
preParsing: captureRawBody
|
||||||
|
}, async (request, reply) => {
|
||||||
|
if (!options.payoutService) return reply.status(501).send({
|
||||||
|
code: 'CLEANING_PAYOUT_UNAVAILABLE',
|
||||||
|
message: 'Cleaning payout service is not configured.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
const headers = wechatHeaders(request);
|
||||||
|
if (!headers) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => {
|
||||||
|
await options.payoutService!.processWechatTransferNotification(
|
||||||
|
headers,
|
||||||
|
request.rawBody,
|
||||||
|
request.traceId
|
||||||
|
);
|
||||||
|
return { code: 'SUCCESS', message: '成功', traceId: request.traceId };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireActor(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: CleaningRouteOptions,
|
||||||
|
mode: 'read' | 'write'
|
||||||
|
): Promise<CleaningActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization,
|
||||||
|
options.authRepository,
|
||||||
|
options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
|
||||||
|
if (!access.capabilities.includes(permission)
|
||||||
|
&& !access.capabilities.includes('tenant.manage')
|
||||||
|
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'CLEANING_TASK_FORBIDDEN',
|
||||||
|
message: 'Cleaning task permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
access,
|
||||||
|
traceId: request.traceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof MediaValidationError) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The cleaning task request cannot be completed.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(error instanceof CleaningTaskError)
|
||||||
|
&& !(error instanceof CleaningPayoutError)
|
||||||
|
&& !(error instanceof WechatPayError)) throw error;
|
||||||
|
const code = error.code;
|
||||||
|
const statusCode = code.endsWith('_FORBIDDEN') ? 403 : 409;
|
||||||
|
return reply.status(statusCode).send({
|
||||||
|
code,
|
||||||
|
message: 'The cleaning task request cannot be completed.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function singleHeader(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_CLEANING_TASK_REQUEST',
|
||||||
|
message: 'The cleaning task request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function wechatHeaders(request: FastifyRequest): WechatNotificationHeaders | null {
|
||||||
|
const read = (key: string) => singleHeader(request.headers[key]);
|
||||||
|
const headers = {
|
||||||
|
timestamp: read('wechatpay-timestamp'),
|
||||||
|
nonce: read('wechatpay-nonce'),
|
||||||
|
serial: read('wechatpay-serial'),
|
||||||
|
signature: read('wechatpay-signature')
|
||||||
|
};
|
||||||
|
return headers.timestamp && headers.nonce && headers.serial && headers.signature
|
||||||
|
? headers as WechatNotificationHeaders
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureRawBody: preParsingHookHandler = (request, _reply, payload, done) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const capture = new Transform({
|
||||||
|
transform(chunk, _encoding, callback) {
|
||||||
|
chunks.push(Buffer.from(chunk));
|
||||||
|
callback(null, chunk);
|
||||||
|
},
|
||||||
|
flush(callback) {
|
||||||
|
request.rawBody = Buffer.concat(chunks).toString('utf8');
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const transformed = payload.pipe(capture) as typeof payload;
|
||||||
|
transformed.receivedEncodedLength = payload.receivedEncodedLength;
|
||||||
|
done(null, transformed);
|
||||||
|
};
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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 optionalStoreQuerySchema = z.object({
|
||||||
|
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||||
|
});
|
||||||
|
const componentSchema = z.object({
|
||||||
|
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
|
||||||
|
props: z.record(z.unknown())
|
||||||
|
});
|
||||||
|
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' | 'listAssets' | 'saveDecoration' | 'listDecorations'
|
||||||
|
| 'publishDecoration' | 'listAdvertisements' | 'saveAdvertisement'
|
||||||
|
| 'updateAdvertisement'>;
|
||||||
|
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.get('/admin-api/media/images', async (request, reply) => {
|
||||||
|
const actor = await requireContentManager(request, reply, options);
|
||||||
|
const query = optionalStoreQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
app.post('/admin-api/media/images', async (request, reply) => {
|
||||||
|
const actor = await requireContentManager(request, reply, options);
|
||||||
|
if (!actor) return;
|
||||||
|
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.get('/admin-api/decorations', async (request, reply) => {
|
||||||
|
const actor = await requireContentManager(request, reply, options);
|
||||||
|
const query = storeQuerySchema.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listDecorations(actor, query.data.storeId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
app.post('/admin-api/decorations', async (request, reply) => {
|
||||||
|
const actor = await requireContentManager(request, reply, options);
|
||||||
|
const body = decorationSchema.safeParse(request.body);
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
app.put('/admin-api/advertisements/:id', async (request, reply) => {
|
||||||
|
const actor = await requireContentManager(request, reply, options);
|
||||||
|
const params = idSchema.safeParse(request.params);
|
||||||
|
const body = adSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.updateAdvertisement(actor, params.data.id, body.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireContentManager(
|
||||||
|
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,341 @@
|
|||||||
|
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 vendorPasswordField = 'password' as const;
|
||||||
|
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(10).default(8),
|
||||||
|
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||||
|
loop: z.number().int().positive().max(100).default(1),
|
||||||
|
speaker: z.number().int().min(0).max(5).default(0),
|
||||||
|
style: z.number().int().min(0).max(2).default(0),
|
||||||
|
speed: z.number().int().min(0).max(10).default(5),
|
||||||
|
intonation: z.number().int().min(0).max(10).default(5)
|
||||||
|
});
|
||||||
|
const minuteSchema = contextSchema.extend({
|
||||||
|
minute: z.number().int().min(0).max(10080)
|
||||||
|
});
|
||||||
|
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({
|
||||||
|
subtype: z.enum(['14', '15']),
|
||||||
|
subID: z.string().min(1).max(64),
|
||||||
|
order: z.enum([
|
||||||
|
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||||
|
]),
|
||||||
|
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
|
||||||
|
delayTime: z.number().int().min(1).max(14).optional(),
|
||||||
|
value: z.string().min(1).max(128).optional(),
|
||||||
|
dangerConfirmation: z.string().max(64).optional()
|
||||||
|
});
|
||||||
|
const socketReadSchema = contextSchema.extend({
|
||||||
|
target: z.enum(['basicInfo', 'workInfo', 'localtask', 'mqttConfig']).default('workInfo'),
|
||||||
|
slotNum: z.number().int().min(1).max(20).optional(),
|
||||||
|
taskNum: z.number().int().min(0).max(20).optional()
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
if (value.target === 'localtask' && value.taskNum === undefined) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['taskNum'],
|
||||||
|
message: 'taskNum is required for localtask reads' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const socketSwitchSchema = contextSchema.extend({
|
||||||
|
on: z.boolean(),
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1)
|
||||||
|
});
|
||||||
|
const socketTaskSchema = contextSchema.extend({
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1),
|
||||||
|
taskNum: z.number().int().min(1).max(20),
|
||||||
|
action: z.enum(['on', 'off']),
|
||||||
|
mode: z.enum(['once', 'daily', 'weekly']),
|
||||||
|
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
|
||||||
|
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
|
||||||
|
.refine((items) => new Set(items).size === items.length).optional()
|
||||||
|
}).superRefine((value, context) => {
|
||||||
|
if (value.mode === 'weekly' && !value.weekdays) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'],
|
||||||
|
message: 'weekdays are required weekly' });
|
||||||
|
}
|
||||||
|
if (value.mode !== 'weekly' && value.weekdays) {
|
||||||
|
context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'],
|
||||||
|
message: 'weekdays are only valid weekly' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const socketClearTaskSchema = contextSchema.extend({
|
||||||
|
slotNum: z.number().int().min(1).max(20).default(1),
|
||||||
|
taskNum: z.number().int().min(0).max(20),
|
||||||
|
dangerConfirmation: z.string().max(64).optional()
|
||||||
|
});
|
||||||
|
const dangerSchema = contextSchema.extend({
|
||||||
|
dangerConfirmation: z.string().max(64)
|
||||||
|
});
|
||||||
|
const socketResetSchema = dangerSchema.extend({
|
||||||
|
[vendorPasswordField]: z.string().min(1).max(32)
|
||||||
|
});
|
||||||
|
const socketReturnKeySchema = dangerSchema.extend({
|
||||||
|
old: z.string().min(1).max(32),
|
||||||
|
new: z.string().min(1).max(32)
|
||||||
|
});
|
||||||
|
const socketProtectionSchema = contextSchema.extend({
|
||||||
|
maxPower: z.number().positive(),
|
||||||
|
maxCurrent: z.number().positive(),
|
||||||
|
maxTemperature: z.number().min(1).max(125),
|
||||||
|
pullOutStop: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
pullOutPower: z.number().nonnegative(),
|
||||||
|
pullOutSec: z.number().int().min(1).max(86400),
|
||||||
|
chargeFullStop: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
chargeFullPower: z.number().nonnegative(),
|
||||||
|
chargeFullSec: z.number().int().min(1).max(86400)
|
||||||
|
});
|
||||||
|
const socketParameterSchema = contextSchema.extend({
|
||||||
|
resetHold: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
keyLock: z.union([z.literal(0), z.literal(1)]),
|
||||||
|
keyOff: z.union([z.literal(0), z.literal(1)])
|
||||||
|
});
|
||||||
|
const orderParams = z.object({ orderId: id });
|
||||||
|
const customerOpenDoorSchema = z.object({
|
||||||
|
delayTime: z.number().int().min(1).max(14).default(4)
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
export interface DeviceControlRouteOptions {
|
||||||
|
service: Pick<DeviceControlService,
|
||||||
|
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
|
||||||
|
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
|
||||||
|
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
|
||||||
|
| 'clearSmartSocketTask' | 'rebootSmartSocket' | 'emptySmartSocketEnergy'
|
||||||
|
| 'resetSmartSocket' | 'changeSmartSocketReturnKey'
|
||||||
|
| 'configureSmartSocketProtection' | 'configureSmartSocketParameter'>;
|
||||||
|
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
|
||||||
|
) {
|
||||||
|
registerManagement('/power', powerSchema,
|
||||||
|
(context, body) => options.service.controlPower(context, body));
|
||||||
|
registerManagement('/door', doorSchema,
|
||||||
|
(context, body) => options.service.controlDoor(context, body));
|
||||||
|
registerManagement('/tts', ttsSchema,
|
||||||
|
(context, body) => options.service.playTts(context, body));
|
||||||
|
registerManagement('/tts/stop', contextSchema,
|
||||||
|
(context) => options.service.stopTts(context));
|
||||||
|
registerManagement('/led', minuteSchema,
|
||||||
|
(context, body) => options.service.controlLed(context, body.minute));
|
||||||
|
registerManagement('/task/start', taskSchema,
|
||||||
|
(context, body) => options.service.startTask(context, body));
|
||||||
|
registerManagement('/task/extend', extendSchema,
|
||||||
|
(context, body) => options.service.extendTask(context, body.addminute));
|
||||||
|
registerManagement('/task/cancel', contextSchema,
|
||||||
|
(context) => options.service.cancelTask(context));
|
||||||
|
registerManagement('/sub-lock/pair', pairSchema,
|
||||||
|
(context, body) => options.service.pairSubLock(context, body.timeout));
|
||||||
|
registerManagement('/sub-lock/action', subLockSchema,
|
||||||
|
(context, body) => options.service.controlSubLock(context, body));
|
||||||
|
registerManagement('/socket/read', socketReadSchema,
|
||||||
|
(context, body) => options.service.readSmartSocket(context, body));
|
||||||
|
registerManagement('/socket/switch', socketSwitchSchema,
|
||||||
|
(context, body) => options.service.switchSmartSocket(context, body));
|
||||||
|
registerManagement('/socket/task', socketTaskSchema,
|
||||||
|
(context, body) => options.service.scheduleSmartSocket(context, body));
|
||||||
|
registerManagement('/socket/task/clear', socketClearTaskSchema,
|
||||||
|
(context, body) => options.service.clearSmartSocketTask(context, body));
|
||||||
|
registerManagement('/socket/reboot', dangerSchema,
|
||||||
|
(context, body) => options.service.rebootSmartSocket(context, body.dangerConfirmation));
|
||||||
|
registerManagement('/socket/energy/clear', dangerSchema,
|
||||||
|
(context, body) => options.service.emptySmartSocketEnergy(
|
||||||
|
context, body.dangerConfirmation
|
||||||
|
));
|
||||||
|
registerManagement('/socket/factory-reset', socketResetSchema,
|
||||||
|
(context, body) => options.service.resetSmartSocket(context, body));
|
||||||
|
registerManagement('/socket/return-key', socketReturnKeySchema,
|
||||||
|
(context, body) => options.service.changeSmartSocketReturnKey(context, body));
|
||||||
|
registerManagement('/socket/protection', socketProtectionSchema,
|
||||||
|
(context, body) => options.service.configureSmartSocketProtection(context, body));
|
||||||
|
registerManagement('/socket/parameter', socketParameterSchema,
|
||||||
|
(context, body) => options.service.configureSmartSocketParameter(context, body));
|
||||||
|
|
||||||
|
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
|
||||||
|
if (!options.customerAccess) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
code: 'CUSTOMER_DEVICE_CONTROL_NOT_AVAILABLE',
|
||||||
|
message: 'Customer device control is not available.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const params = orderParams.safeParse(request.params);
|
||||||
|
const body = customerOpenDoorSchema.safeParse(request.body ?? {});
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
return reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const order = await options.customerAccess.getDoorContext({
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
orderId: params.data.orderId
|
||||||
|
});
|
||||||
|
const context: CommandContext = {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
storeId: order.storeId,
|
||||||
|
roomId: order.roomId,
|
||||||
|
orderId: order.orderId,
|
||||||
|
traceId: request.traceId,
|
||||||
|
actorType: 'CUSTOMER',
|
||||||
|
actorId: auth.session.user.id,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? '',
|
||||||
|
access: {
|
||||||
|
roles: ['CUSTOMER'],
|
||||||
|
capabilities: ['device.write'],
|
||||||
|
storeIds: [order.storeId]
|
||||||
|
},
|
||||||
|
expiresAt: new Date(Date.now() + 30000)
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.controlDoor(context, {
|
||||||
|
order: 'open',
|
||||||
|
holdopen: 0,
|
||||||
|
delayTime: body.data.delayTime
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CustomerDeviceAccessError) {
|
||||||
|
return reply.status(403).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The order does not allow door access.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!(error instanceof DeviceControlError)) throw error;
|
||||||
|
const status = error.code === 'DEVICE_OFFLINE' ? 409 : 400;
|
||||||
|
return reply.status(status).send({
|
||||||
|
code: error.code, message: 'Device control was rejected.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function register<T extends z.ZodTypeAny>(
|
||||||
|
url: string,
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerManagement<T extends z.ZodTypeAny>(
|
||||||
|
suffix: string,
|
||||||
|
schema: T,
|
||||||
|
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
|
||||||
|
) {
|
||||||
|
register(`/admin-api/device-control${suffix}`, schema, handler);
|
||||||
|
register(`/app-api/management/device-control${suffix}`, schema, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireContext(
|
||||||
|
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
|
||||||
|
);
|
||||||
|
const actorType: CommandContext['actorType'] = access.roles.some((role) =>
|
||||||
|
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||||
|
) ? 'ADMIN' : 'STAFF';
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
storeId: input.storeId,
|
||||||
|
roomId: input.roomId,
|
||||||
|
orderId: input.orderId,
|
||||||
|
traceId: request.traceId,
|
||||||
|
actorType,
|
||||||
|
actorId: auth.session.user.id,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? '',
|
||||||
|
access
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,198 @@
|
|||||||
|
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) {
|
||||||
|
for (const path of ['/admin-api/devices', '/app-api/management/devices']) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
|
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return mutate(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.post('/admin-api/devices', async (request, reply) => {
|
||||||
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
|
const body = assetSchema.safeParse(request.body);
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
for (const path of ['/admin-api/device-topology', '/app-api/management/device-topology']) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
|
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return mutate(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.post('/admin-api/device-channels', async (request, reply) => {
|
||||||
|
const actor = await requireDeviceOperator(request, reply, options);
|
||||||
|
const body = channelSchema.safeParse(request.body);
|
||||||
|
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,148 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import { FranchiseError, type FranchiseRepository } from '../franchise/franchise-repository.js';
|
||||||
|
import { AmbiguousAppTenantError } from '../tenancy/platform-config-repository.js';
|
||||||
|
import type { PlatformConfigResolver } from './platform-bootstrap.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const status = z.enum(['NEW', 'CONTACTED', 'QUALIFIED', 'REJECTED', 'CONVERTED']);
|
||||||
|
const contextHeaders = z.object({
|
||||||
|
'x-wechat-appid': z.string().trim().min(6).max(64),
|
||||||
|
'tenant-id': id.optional()
|
||||||
|
});
|
||||||
|
const applicationSchema = z.object({
|
||||||
|
city: z.string().trim().min(1).max(64),
|
||||||
|
contactName: z.string().trim().min(1).max(64),
|
||||||
|
contactPhone: z.string().trim().regex(/^\+?[0-9 -]{6,32}$/),
|
||||||
|
message: z.string().trim().max(1000).default(''),
|
||||||
|
clientRequestId: z.string().trim().min(8).max(128)
|
||||||
|
});
|
||||||
|
const listSchema = z.object({
|
||||||
|
tenantId: id.optional(), page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20), status: status.optional(),
|
||||||
|
assigneeUserId: id.optional(), search: z.string().trim().max(64).optional()
|
||||||
|
});
|
||||||
|
const tenantQuery = z.object({ tenantId: id.optional() });
|
||||||
|
const assignmentSchema = z.object({ tenantId: id.optional(), assigneeUserId: id.nullable() });
|
||||||
|
const followUpSchema = z.object({
|
||||||
|
tenantId: id.optional(), followUpType: z.enum(['CALL', 'WECHAT', 'MEETING', 'NOTE']),
|
||||||
|
note: z.string().trim().min(1).max(1000), nextFollowUpAt: z.coerce.date().nullable().optional(),
|
||||||
|
status: status.optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface FranchiseRouteOptions {
|
||||||
|
repository: Pick<FranchiseRepository, 'submitApplication' | 'listApplications'
|
||||||
|
| 'getApplication' | 'assignApplication' | 'addFollowUp'>;
|
||||||
|
platformConfig: PlatformConfigResolver;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerFranchiseRoutes(app: FastifyInstance, options: FranchiseRouteOptions) {
|
||||||
|
app.post('/app-api/franchise-applications', async (request, reply) => {
|
||||||
|
const headers = contextHeaders.safeParse(request.headers);
|
||||||
|
const body = applicationSchema.safeParse(request.body);
|
||||||
|
if (!headers.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => {
|
||||||
|
let bootstrap;
|
||||||
|
try {
|
||||||
|
bootstrap = await options.platformConfig.resolveBootstrap(
|
||||||
|
headers.data['x-wechat-appid'], headers.data['tenant-id']
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AmbiguousAppTenantError) throw new FranchiseError('FRANCHISE_TENANT_SELECTION_REQUIRED');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (!bootstrap) throw new FranchiseError('FRANCHISE_TENANT_NOT_FOUND');
|
||||||
|
const auth = request.headers.authorization
|
||||||
|
? await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret)
|
||||||
|
: null;
|
||||||
|
if (request.headers.authorization && !auth) throw new FranchiseError('FRANCHISE_SESSION_INVALID');
|
||||||
|
const submittedUserId = auth?.session.tenantId === bootstrap.tenantId ? auth.session.user.id : null;
|
||||||
|
return reply.status(201).send({ code: 0, data: await options.repository.submitApplication({
|
||||||
|
tenantId: bootstrap.tenantId, submittedUserId, ...body.data, source: 'MINIAPP',
|
||||||
|
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? ''
|
||||||
|
}), traceId: request.traceId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/franchise-applications', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const query = listSchema.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const targetTenantId = resolveTenant(actor, query.data.tenantId);
|
||||||
|
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({ code: 0,
|
||||||
|
data: await options.repository.listApplications({ ...query.data, tenantId: targetTenantId }),
|
||||||
|
traceId: request.traceId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/franchise-applications/:id', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const params = z.object({ id }).safeParse(request.params);
|
||||||
|
const query = tenantQuery.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const targetTenantId = resolveTenant(actor, query.data.tenantId);
|
||||||
|
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({ code: 0,
|
||||||
|
data: await options.repository.getApplication(targetTenantId, params.data.id), traceId: request.traceId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/admin-api/franchise-applications/:id/assignee', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const params = z.object({ id }).safeParse(request.params);
|
||||||
|
const body = assignmentSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const targetTenantId = resolveTenant(actor, body.data.tenantId);
|
||||||
|
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({ code: 0,
|
||||||
|
data: await options.repository.assignApplication(actor, targetTenantId, params.data.id,
|
||||||
|
body.data.assigneeUserId), traceId: request.traceId }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/franchise-applications/:id/follow-ups', async (request, reply) => {
|
||||||
|
const actor = await requireManager(request, reply, options);
|
||||||
|
const params = z.object({ id }).safeParse(request.params);
|
||||||
|
const body = followUpSchema.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
const targetTenantId = resolveTenant(actor, body.data.tenantId);
|
||||||
|
if (!targetTenantId) return forbidden(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => reply.status(201).send({ code: 0,
|
||||||
|
data: await options.repository.addFollowUp(actor, targetTenantId, params.data.id, body.data),
|
||||||
|
traceId: request.traceId }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireManager(request: FastifyRequest, reply: FastifyReply, options: FranchiseRouteOptions) {
|
||||||
|
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||||
|
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
|
||||||
|
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||||
|
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||||
|
reply.status(403).send({ code: 'FRANCHISE_MANAGEMENT_FORBIDDEN', message: 'Franchise management permission is required.', traceId: request.traceId }); return null;
|
||||||
|
}
|
||||||
|
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access,
|
||||||
|
traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||||
|
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||||
|
return isPlatform(actor.access) ? requested : null;
|
||||||
|
}
|
||||||
|
function isPlatform(access: AccessProfile) { return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage'); }
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try { return await work(); } catch (error) {
|
||||||
|
if (!(error instanceof FranchiseError)) throw error;
|
||||||
|
const statusCode = error.code === 'FRANCHISE_SESSION_INVALID' ? 401
|
||||||
|
: error.code.endsWith('_NOT_FOUND') ? 404
|
||||||
|
: error.code.includes('FORBIDDEN') ? 403
|
||||||
|
: error.code.endsWith('_SELECTION_REQUIRED') ? 409 : 400;
|
||||||
|
return reply.status(statusCode).send({ code: error.code, message: 'The franchise request is invalid or not allowed.', traceId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) { return reply.status(400).send({ code: 'INVALID_FRANCHISE_REQUEST', message: 'The franchise request is invalid.', traceId }); }
|
||||||
|
function forbidden(reply: FastifyReply, traceId: string) { return reply.status(403).send({ code: 'FRANCHISE_TENANT_FORBIDDEN', message: 'The tenant is outside the allowed scope.', traceId }); }
|
||||||
@@ -12,10 +12,24 @@ interface ReadyPayload extends HealthPayload {
|
|||||||
checks: {
|
checks: {
|
||||||
mysqlConfigured: boolean;
|
mysqlConfigured: boolean;
|
||||||
mqttConfigured: boolean;
|
mqttConfigured: boolean;
|
||||||
|
mqttConnected: boolean;
|
||||||
|
mqttSubscriptionsReady: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig): Promise<void> {
|
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> => ({
|
const health = async (request: { traceId: string }): Promise<HealthPayload> => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
service: 'qipai-api',
|
service: 'qipai-api',
|
||||||
@@ -23,13 +37,19 @@ export async function registerHealthRoutes(app: FastifyInstance, config: AppConf
|
|||||||
traceId: request.traceId
|
traceId: request.traceId
|
||||||
});
|
});
|
||||||
|
|
||||||
const ready = async (request: { traceId: string }): Promise<ReadyPayload> => ({
|
const ready = async (request: { traceId: string }): Promise<ReadyPayload> => {
|
||||||
|
const mqttHealth = mqtt?.health();
|
||||||
|
return {
|
||||||
...(await health(request)),
|
...(await health(request)),
|
||||||
checks: {
|
checks: {
|
||||||
mysqlConfigured: config.mysql.passwordConfigured,
|
mysqlConfigured: config.mysql.passwordConfigured,
|
||||||
mqttConfigured: config.mqtt.usernameConfigured && config.mqtt.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('/app-api/health', health);
|
||||||
app.get('/admin-api/health', health);
|
app.get('/admin-api/health', health);
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||||
|
import { InventoryError, type InventoryService } from '../inventory/inventory-service.js';
|
||||||
|
|
||||||
|
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||||
|
const page = z.coerce.number().int().min(1).max(1_000_000).default(1);
|
||||||
|
const pageSize = z.coerce.number().int().min(1).max(100).default(20);
|
||||||
|
const version = z.number().int().min(1).max(4_294_967_295);
|
||||||
|
const createVersion = z.number().int().min(0).max(4_294_967_295);
|
||||||
|
const quantity = z.number().int().min(1).max(1_000_000_000);
|
||||||
|
const nonNegativeQuantity = z.number().int().min(0).max(1_000_000_000);
|
||||||
|
const delta = z.number().int().min(-1_000_000_000).max(1_000_000_000);
|
||||||
|
const requestId = z.string().trim().regex(/^[A-Za-z0-9._:-]{1,64}$/);
|
||||||
|
const reason = z.string().trim().min(1).max(512);
|
||||||
|
const stockParams = z.object({ skuId: id }).strict();
|
||||||
|
const ledgerParams = z.object({ inventoryId: id }).strict();
|
||||||
|
const stockListQuery = z.object({
|
||||||
|
storeId: id,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
skuId: id.optional(),
|
||||||
|
productId: id.optional(),
|
||||||
|
search: z.string().trim().max(64).optional()
|
||||||
|
}).strict();
|
||||||
|
const ledgerQuery = z.object({ storeId: id, page, pageSize }).strict();
|
||||||
|
const mutationBase = {
|
||||||
|
storeId: id,
|
||||||
|
requestId,
|
||||||
|
reason,
|
||||||
|
expectedVersion: version.optional()
|
||||||
|
};
|
||||||
|
const policyBody = z.object({
|
||||||
|
...mutationBase,
|
||||||
|
expectedVersion: createVersion,
|
||||||
|
policyType: z.enum(['TRACKED', 'UNLIMITED']),
|
||||||
|
lowStockThreshold: nonNegativeQuantity
|
||||||
|
}).strict();
|
||||||
|
const inboundBody = z.object({ ...mutationBase, quantity }).strict();
|
||||||
|
const adjustmentBody = z.object({
|
||||||
|
...mutationBase,
|
||||||
|
availableDelta: delta,
|
||||||
|
lossDelta: delta.default(0)
|
||||||
|
}).strict().refine((value) => value.availableDelta !== 0 || value.lossDelta !== 0);
|
||||||
|
const stocktakeBody = z.object({
|
||||||
|
...mutationBase,
|
||||||
|
expectedVersion: version,
|
||||||
|
availableQuantity: nonNegativeQuantity,
|
||||||
|
lossQuantity: nonNegativeQuantity
|
||||||
|
}).strict();
|
||||||
|
const lossBody = z.object({ ...mutationBase, quantity }).strict();
|
||||||
|
|
||||||
|
export interface InventoryRouteOptions {
|
||||||
|
service: Pick<InventoryService,
|
||||||
|
'listStocks' | 'listLedger' | 'configurePolicy' | 'inbound' | 'adjust'
|
||||||
|
| 'stocktake' | 'recordLoss'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerInventoryRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: InventoryRouteOptions
|
||||||
|
) {
|
||||||
|
app.get('/admin-api/inventory/stocks', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, false);
|
||||||
|
const query = stockListQuery.safeParse(request.query);
|
||||||
|
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.listStocks(actor, query.data),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin-api/inventory/stocks/:inventoryId/ledger', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, false);
|
||||||
|
const params = ledgerParams.safeParse(request.params);
|
||||||
|
const query = ledgerQuery.safeParse(request.query);
|
||||||
|
if (!actor || !params.success || !query.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.listLedger(actor, {
|
||||||
|
inventoryId: params.data.inventoryId,
|
||||||
|
...query.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin-api/inventory/stocks/:skuId/policy', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = policyBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.configurePolicy(actor, {
|
||||||
|
skuId: params.data.skuId,
|
||||||
|
...body.data
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/inbound', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = inboundBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.inbound(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/adjust', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = adjustmentBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.adjust(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/stocktake', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = stocktakeBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.stocktake(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin-api/inventory/stocks/:skuId/loss', async (request, reply) => {
|
||||||
|
const actor = await requireInventoryActor(request, reply, options, true);
|
||||||
|
const params = stockParams.safeParse(request.params);
|
||||||
|
const body = lossBody.safeParse(request.body);
|
||||||
|
if (!actor || !params.success || !body.success) {
|
||||||
|
return actor ? invalid(reply, request.traceId) : undefined;
|
||||||
|
}
|
||||||
|
return inventoryResponse(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.recordLoss(actor, { skuId: params.data.skuId, ...body.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireInventoryActor(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: InventoryRouteOptions,
|
||||||
|
write: boolean
|
||||||
|
): Promise<ManagementActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization,
|
||||||
|
options.authRepository,
|
||||||
|
options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID',
|
||||||
|
message: 'Authentication required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId,
|
||||||
|
auth.session.user.id
|
||||||
|
);
|
||||||
|
const manager = access.capabilities.includes('tenant.manage')
|
||||||
|
|| access.roles.includes('PLATFORM_ADMIN');
|
||||||
|
const allowed = manager || (write
|
||||||
|
? access.capabilities.includes('inventory.adjust')
|
||||||
|
: access.capabilities.includes('inventory.read')
|
||||||
|
|| access.capabilities.includes('inventory.adjust'));
|
||||||
|
if (!allowed) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'INVENTORY_OPERATION_FORBIDDEN',
|
||||||
|
message: 'Inventory permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tenantId: auth.session.tenantId,
|
||||||
|
userId: auth.session.user.id,
|
||||||
|
access,
|
||||||
|
traceId: request.traceId,
|
||||||
|
ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inventoryResponse(
|
||||||
|
reply: FastifyReply,
|
||||||
|
traceId: string,
|
||||||
|
work: () => Promise<unknown>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof InventoryError)) throw error;
|
||||||
|
const status = inventoryErrorStatus(error.code);
|
||||||
|
return reply.status(status).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The inventory operation is not allowed.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryErrorStatus(code: string) {
|
||||||
|
if (code.endsWith('_FORBIDDEN')) return 403;
|
||||||
|
if (code.endsWith('_NOT_FOUND')) return 404;
|
||||||
|
if (code.includes('CONFLICT') || code.includes('INSUFFICIENT')
|
||||||
|
|| code === 'INVENTORY_POLICY_STOCK_NOT_ZERO') return 409;
|
||||||
|
return 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_INVENTORY_REQUEST',
|
||||||
|
message: 'The inventory request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||||
|
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||||
|
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||||
|
import {
|
||||||
|
MemberProfileError,
|
||||||
|
type MemberActor,
|
||||||
|
type MemberProfileService
|
||||||
|
} from '../wallets/member-profile-service.js';
|
||||||
|
|
||||||
|
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||||
|
const listSchema = z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
|
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
||||||
|
search: z.string().trim().max(128).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface MemberRouteOptions {
|
||||||
|
service: Pick<MemberProfileService, 'listMembers' | 'getMember' | 'getMyProfile' | 'getMyBenefits'>;
|
||||||
|
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||||
|
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||||
|
jwtSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerMemberRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
options: MemberRouteOptions
|
||||||
|
): Promise<void> {
|
||||||
|
app.get('/app-api/profile', async (request, reply) => {
|
||||||
|
const auth = await requireProfileReader(request, reply, options);
|
||||||
|
if (!auth) return;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.getMyProfile({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
userId: auth.userId
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/app-api/profile/benefits', async (request, reply) => {
|
||||||
|
const auth = await requireProfileReader(request, reply, options);
|
||||||
|
if (!auth) return;
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.getMyBenefits({
|
||||||
|
tenantId: auth.tenantId,
|
||||||
|
userId: auth.userId
|
||||||
|
}),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of ['/admin-api/members', '/app-api/management/members']) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireReader(request, reply, options);
|
||||||
|
if (!actor) return;
|
||||||
|
const query = listSchema.safeParse(request.query);
|
||||||
|
if (!query.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.listMembers({ actor, ...query.data }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const path of ['/admin-api/members/:id', '/app-api/management/members/:id']) {
|
||||||
|
app.get(path, async (request, reply) => {
|
||||||
|
const actor = await requireReader(request, reply, options);
|
||||||
|
if (!actor) return;
|
||||||
|
const params = idSchema.safeParse(request.params);
|
||||||
|
if (!params.success) return invalid(reply, request.traceId);
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await options.service.getMember({ actor, memberId: params.data.id }),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireProfileReader(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: MemberRouteOptions
|
||||||
|
): Promise<{ tenantId: string; userId: string } | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization,
|
||||||
|
options.authRepository,
|
||||||
|
options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId,
|
||||||
|
auth.session.user.id
|
||||||
|
);
|
||||||
|
if (!access.capabilities.includes('profile.read')) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'PROFILE_READ_FORBIDDEN', message: 'Profile read permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { tenantId: auth.session.tenantId, userId: auth.session.user.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireReader(
|
||||||
|
request: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
options: MemberRouteOptions
|
||||||
|
): Promise<MemberActor | null> {
|
||||||
|
const auth = await authenticateAccessToken(
|
||||||
|
request.headers.authorization,
|
||||||
|
options.authRepository,
|
||||||
|
options.jwtSecret
|
||||||
|
);
|
||||||
|
if (!auth) {
|
||||||
|
reply.status(401).send({
|
||||||
|
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const access = await options.accessControl.getAccessProfile(
|
||||||
|
auth.session.tenantId,
|
||||||
|
auth.session.user.id
|
||||||
|
);
|
||||||
|
if (!access.capabilities.includes('user.read')
|
||||||
|
&& !access.capabilities.includes('tenant.manage')
|
||||||
|
&& !access.roles.includes('PLATFORM_ADMIN')) {
|
||||||
|
reply.status(403).send({
|
||||||
|
code: 'MEMBER_READ_FORBIDDEN', message: 'Member read permission is required.',
|
||||||
|
traceId: request.traceId
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
return await work();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof MemberProfileError)) throw error;
|
||||||
|
return reply.status(404).send({
|
||||||
|
code: error.code,
|
||||||
|
message: 'The requested member is not available.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(reply: FastifyReply, traceId: string) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
code: 'INVALID_MEMBER_REQUEST',
|
||||||
|
message: 'The member request is invalid.',
|
||||||
|
traceId
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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) {
|
||||||
|
for (const prefix of ['/admin-api/orders', '/app-api/management/orders']) {
|
||||||
|
app.post(`${prefix}/:orderId/${path}`, async (request, reply) => {
|
||||||
|
const auth = await authenticate(request.headers.authorization, options);
|
||||||
|
const params = paramsSchema.safeParse(request.params);
|
||||||
|
const body = schema.safeParse(request.body);
|
||||||
|
if (!auth) return unauthorized(reply, request.traceId);
|
||||||
|
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||||
|
const actor = {
|
||||||
|
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
|
||||||
|
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
|
||||||
|
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||||
|
};
|
||||||
|
return handle(reply, request.traceId, async () => ({
|
||||||
|
code: 0,
|
||||||
|
data: await execute(actor, params.data.orderId, body.data as never),
|
||||||
|
traceId: request.traceId
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function customerActor(
|
||||||
|
auth: { tenantId: string; userId: string; access: AccessProfile },
|
||||||
|
request: FastifyRequest
|
||||||
|
): OrderActor {
|
||||||
|
return {
|
||||||
|
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||||
|
source: 'APP', traceId: request.traceId, ip: request.ip,
|
||||||
|
userAgent: String(request.headers['user-agent'] ?? ''), access: auth.access
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate(
|
||||||
|
authorization: string | undefined, options: OrderManagementRouteOptions
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user