Compare commits
170 Commits
f7e0c8d063
...
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 |
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.4.md`](./V5.4.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
|
||||
本仓库是 `panda/qipai.git` 的单一 Monorepo 工作区。Windows 固定开发路径为 `D:\qipai`,当前权威开发总纲为 [`V5.7.md`](./V5.7.md)。详细功能、模块顺序、验收标准和 Codex 纪律以总纲及 `docs/` 为准。
|
||||
|
||||
## 固定约束
|
||||
|
||||
- 当前权威总纲:`V5.4.md`
|
||||
- 当前权威总纲:`V5.7.md`
|
||||
- 固定远端:`ssh://git@git.txyundm.cn:2222/panda/qipai.git`
|
||||
- 默认分支:`main`
|
||||
- 生产 API:`https://api.txyundm.cn`
|
||||
@@ -19,8 +19,8 @@
|
||||
## 目录
|
||||
|
||||
- `backend/`:Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
|
||||
- `admin/`:Vue3 后台管理端,必须适配桌面、平板和手机
|
||||
- `miniapp/`:微信原生小程序
|
||||
- `admin/`:Vue3 后台管理端,已完成租户账号登录、短期访问令牌、轮换刷新令牌、当前会话、注销和 401 自动清理,并接入 M08-B 保洁运营工作台及 M08-D 计划内运营工作区;必须适配桌面、平板和手机
|
||||
- `miniapp/`:微信原生小程序,已接入顾客端主链路与保洁端任务大厅、我的任务、照片上传、驳回补做和保洁员统计看板
|
||||
- `database/`:迁移、种子和兼容 SQL
|
||||
- `deploy/`:Nginx、PM2、EMQX 模板与部署版本
|
||||
- `scripts/`:Windows、WSL 和 Ubuntu 辅助脚本
|
||||
@@ -35,7 +35,7 @@
|
||||
| 用途 | 位置 |
|
||||
|---|---|
|
||||
| 项目入口 | `D:\qipai\README.md` |
|
||||
| 当前总纲 | `D:\qipai\V5.4.md` |
|
||||
| 当前总纲 | `D:\qipai\V5.7.md` |
|
||||
| 完整配置索引 | `D:\qipai\docs\configuration.md` |
|
||||
| 仓库目录映射 | `D:\qipai\docs\repository-map.md` |
|
||||
| 后端开发配置 | `D:\qipai\backend\.env.development` |
|
||||
@@ -108,21 +108,37 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
||||
|
||||
## 持续开发规则
|
||||
|
||||
### 一次性启用 Git 中文提交门禁
|
||||
|
||||
每次新克隆仓库后必须执行一次安装脚本;Git hook 不会仅因克隆仓库自动启用。Windows 在仓库内执行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\setup-git-hooks.ps1
|
||||
```
|
||||
|
||||
WSL/Ubuntu 在仓库内执行:
|
||||
|
||||
```bash
|
||||
sh scripts/setup-git-hooks.sh
|
||||
```
|
||||
|
||||
脚本会把当前克隆的 `core.hooksPath` 配置为 `.githooks`。之后每次提交都会校验首行符合 `<type>(<scope>): 中文摘要`;可单独运行 `node scripts/tests/check-commit-message.test.mjs` 验证门禁。
|
||||
|
||||
- 唯一模块顺序:`M00 → M01 → M02 → … → M10`,模块内按 `A → B → C → …`。
|
||||
- `docs/module-status.md` 顶部的 `execution_cursor` 是唯一续接游标;Codex 不重新规划、不从 M00 重来。
|
||||
- 一个 commit 只完成一个子阶段;同一次 Codex 会话可连续完成多个相邻子阶段。
|
||||
- 每个子阶段必须完成工程编码、真实测试、增量文档、commit、push,并验证 `HEAD == origin/main`。
|
||||
- 完成一个子阶段后,在会话资源允许时自动继续下一子阶段,不询问“是否继续”。
|
||||
- 纯 Markdown 变更不计业务进度;V5.4 文档提交后的下一次普通开发必须产生工程增量。
|
||||
- 纯 Markdown 变更不计业务进度;状态文档只随对应工程阶段伴随更新。
|
||||
- 当前游标、最近工程提交和下一工程目标以 `docs/module-status.md`、`docs/current-baseline.md` 为准,不在 README 中猜测。
|
||||
|
||||
## 当前进度
|
||||
|
||||
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md`、`docs/module-status.md`、`docs/feature-status.md` 为准。
|
||||
|
||||
- 当前执行游标:`M08-B`(PARTIAL)
|
||||
- 最近工程提交:`2a4dcd0`,完成保洁任务端基础流程。
|
||||
- 下一工程目标:继续 M08-B,补照片上传、驳回补做、订单结束自动建任务和结算前置能力。
|
||||
- 当前执行游标:`M09-D2`(TODO);`M09-D1` 已在当前工作树完成商品目录与库存底座收口,真实团购商家授权、微信和硬件现场验收保持 `BLOCKED_EXTERNAL`。
|
||||
- 最近工程结果:M09-D1 已建立 9 张商品/库存表、分类/商品/SKU/门店售卖与跨日营业时段、可用/锁定/损耗库存、幂等请求和不可变流水;增量/崩溃重试迁移、数据库级迁移互斥、生产触发器前置、API/RBAC 与 MySQL 8.4.10 往返门禁通过,随本次中文工程提交固化。
|
||||
- 下一工程目标:执行 M09-D2,建立商品订单、订单项快照、支付状态衔接,以及库存锁定、扣减、释放和取消回补闭环。
|
||||
|
||||
## 版本递进
|
||||
|
||||
@@ -130,10 +146,19 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
|
||||
- **V5.2**:增加工程编码优先、反文档循环、增量审计和工程完成证据。
|
||||
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
|
||||
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
|
||||
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
|
||||
- **V5.7**:增加中文 Git 提交门禁,恢复并收口 M09-D1 商品目录/库存工作树,游标推进至 M09-D2。
|
||||
|
||||
## Codex 入口
|
||||
|
||||
```text
|
||||
请阅读 V5.4.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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-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-route.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
registerPlatformBootstrapRoutes,
|
||||
type PlatformConfigResolver
|
||||
} from './routes/platform-bootstrap.js';
|
||||
import { registerPlatformManagementRoutes, type PlatformManagementRouteOptions } from './routes/platform-management.js';
|
||||
import { registerAuthRoutes, type AuthRouteOptions } from './routes/auth.js';
|
||||
import {
|
||||
registerUserManagementRoutes,
|
||||
@@ -51,10 +52,23 @@ import {
|
||||
import { registerMemberRoutes, type MemberRouteOptions } from './routes/members.js';
|
||||
import { registerRechargeRoutes, type RechargeRouteOptions } from './routes/recharge.js';
|
||||
import { registerCleaningRoutes, type CleaningRouteOptions } from './routes/cleaning.js';
|
||||
import {
|
||||
registerBusinessStatisticsRoutes,
|
||||
type BusinessStatisticsRouteOptions
|
||||
} from './routes/business-statistics.js';
|
||||
import { registerFranchiseRoutes, type FranchiseRouteOptions } from './routes/franchise.js';
|
||||
import {
|
||||
registerSystemOperationsRoutes,
|
||||
type SystemOperationsRouteOptions
|
||||
} from './routes/system-operations.js';
|
||||
import { registerAdminAuthRoutes, type AdminAuthRouteOptions } from './routes/admin-auth.js';
|
||||
import { registerProductRoutes, type ProductRouteOptions } from './routes/products.js';
|
||||
import { registerInventoryRoutes, type InventoryRouteOptions } from './routes/inventory.js';
|
||||
|
||||
export interface BuildAppOptions {
|
||||
config?: AppConfig;
|
||||
platformConfigRepository?: PlatformConfigResolver;
|
||||
platformManagement?: PlatformManagementRouteOptions;
|
||||
auth?: AuthRouteOptions;
|
||||
userManagement?: UserManagementRouteOptions;
|
||||
storeRoom?: StoreRoomRouteOptions;
|
||||
@@ -74,6 +88,12 @@ export interface BuildAppOptions {
|
||||
members?: MemberRouteOptions;
|
||||
recharge?: RechargeRouteOptions;
|
||||
cleaning?: CleaningRouteOptions;
|
||||
businessStatistics?: BusinessStatisticsRouteOptions;
|
||||
franchise?: FranchiseRouteOptions;
|
||||
systemOperations?: SystemOperationsRouteOptions;
|
||||
adminAuth?: AdminAuthRouteOptions;
|
||||
products?: ProductRouteOptions;
|
||||
inventory?: InventoryRouteOptions;
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
@@ -121,6 +141,9 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
if (options.platformConfigRepository) {
|
||||
await registerPlatformBootstrapRoutes(app, options.platformConfigRepository);
|
||||
}
|
||||
if (options.platformManagement) {
|
||||
await registerPlatformManagementRoutes(app, options.platformManagement);
|
||||
}
|
||||
if (options.auth) {
|
||||
await registerAuthRoutes(app, options.auth);
|
||||
}
|
||||
@@ -175,6 +198,24 @@ export async function buildApp(options: BuildAppOptions = {}): Promise<FastifyIn
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -140,13 +140,20 @@ export class AuthRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
OR (r.code = 'STORE_ADMIN'
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset',
|
||||
'store.operation.read', 'store.operation.write',
|
||||
'device.read', 'device.write'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
|
||||
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
|
||||
'device.read', 'device.write'))
|
||||
'device.read', 'device.write',
|
||||
'product.catalog.read', 'product.catalog.write',
|
||||
'inventory.read', 'inventory.adjust'))
|
||||
WHERE r.tenant_id = ?`,
|
||||
[input.context.tenantId, input.context.tenantId]
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,9 @@ export class RbacRepository {
|
||||
SELECT ?, r.id, p.id FROM qipai_roles r
|
||||
INNER JOIN qipai_permissions p ON
|
||||
(r.code = 'CUSTOMER' AND p.code IN ('profile.read', 'order.self.read'))
|
||||
OR (r.code = 'STAFF'
|
||||
AND p.code IN ('profile.read', 'store.operation.read',
|
||||
'product.catalog.read', 'inventory.read'))
|
||||
OR (r.code = 'CLEANER'
|
||||
AND p.code IN ('profile.read', 'cleaning.task.read',
|
||||
'cleaning.task.write', 'cleaning.statistics.read'))
|
||||
@@ -43,11 +46,15 @@ export class RbacRepository {
|
||||
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 = ?`,
|
||||
|
||||
@@ -10,11 +10,13 @@ export interface ManagedUser {
|
||||
userType: string;
|
||||
status: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
maskedPhone: string;
|
||||
maskedLastIp: string;
|
||||
note: string;
|
||||
roles: string[];
|
||||
storeIds: string[];
|
||||
wechatMiniappBound: boolean;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
@@ -42,9 +44,11 @@ interface UserRow extends RowDataPacket {
|
||||
userType: string;
|
||||
status: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
phone: string;
|
||||
lastIp: string | null;
|
||||
note: string;
|
||||
wechatMiniappBound: number;
|
||||
registeredAt: Date;
|
||||
lastLoginAt: Date | null;
|
||||
}
|
||||
@@ -66,7 +70,9 @@ export class UserManagementRepository {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
role?: AssignableRole;
|
||||
search?: string;
|
||||
userType?: 'STAFF';
|
||||
}): Promise<{ items: ManagedUser[]; total: number }> {
|
||||
const filters = ['u.tenant_id = ?', 'u.deleted_at IS NULL'];
|
||||
const params: Array<string | number> = [input.actor.tenantId];
|
||||
@@ -74,6 +80,19 @@ export class UserManagementRepository {
|
||||
filters.push('u.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.userType) {
|
||||
filters.push('u.user_type = ?');
|
||||
params.push(input.userType);
|
||||
}
|
||||
if (input.role) {
|
||||
filters.push(`EXISTS (
|
||||
SELECT 1 FROM qipai_user_roles fur
|
||||
INNER JOIN qipai_roles fr ON fr.tenant_id = fur.tenant_id AND fr.id = fur.role_id
|
||||
WHERE fur.tenant_id = u.tenant_id AND fur.user_id = u.id
|
||||
AND fr.code = ? AND fr.status = 'ACTIVE' AND fr.deleted_at IS NULL
|
||||
)`);
|
||||
params.push(input.role);
|
||||
}
|
||||
if (input.search) {
|
||||
filters.push('(u.nickname LIKE ? OR u.phone LIKE ? OR CAST(u.id AS CHAR) = ?)');
|
||||
const like = `%${input.search}%`;
|
||||
@@ -88,10 +107,18 @@ export class UserManagementRepository {
|
||||
WHERE ${filters.join(' AND ')}`,
|
||||
params
|
||||
);
|
||||
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [rows] = await this.pool.execute<UserRow[]>(
|
||||
`SELECT u.id, u.user_type AS userType, u.status, u.nickname, u.phone,
|
||||
`SELECT u.id, u.user_type AS userType, u.status, u.nickname,
|
||||
u.avatar_url AS avatarUrl, u.phone,
|
||||
u.created_at AS registeredAt, u.last_login_at AS lastLoginAt,
|
||||
COALESCE(p.note, '') AS note,
|
||||
EXISTS (
|
||||
SELECT 1 FROM qipai_user_identities i
|
||||
WHERE i.tenant_id = u.tenant_id AND i.user_id = u.id
|
||||
AND i.provider = 'WECHAT_MINIAPP' AND i.deleted_at IS NULL
|
||||
) AS wechatMiniappBound,
|
||||
(SELECT s.ip FROM qipai_auth_sessions s
|
||||
WHERE s.tenant_id = u.tenant_id AND s.user_id = u.id
|
||||
ORDER BY s.created_at DESC LIMIT 1) AS lastIp
|
||||
@@ -99,19 +126,21 @@ export class UserManagementRepository {
|
||||
LEFT JOIN qipai_user_admin_profiles p
|
||||
ON p.tenant_id = u.tenant_id AND p.user_id = u.id
|
||||
WHERE ${filters.join(' AND ')}
|
||||
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, (input.page - 1) * input.pageSize]
|
||||
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
const items = await Promise.all(rows.map(async (row) => ({
|
||||
id: String(row.id),
|
||||
userType: row.userType,
|
||||
status: row.status,
|
||||
nickname: row.nickname,
|
||||
avatarUrl: row.avatarUrl,
|
||||
maskedPhone: maskPhone(row.phone),
|
||||
maskedLastIp: maskIp(row.lastIp ?? ''),
|
||||
note: row.note,
|
||||
roles: await this.getCodes('role', input.actor.tenantId, String(row.id)),
|
||||
storeIds: await this.getCodes('store', input.actor.tenantId, String(row.id)),
|
||||
wechatMiniappBound: Boolean(row.wechatMiniappBound),
|
||||
registeredAt: row.registeredAt,
|
||||
lastLoginAt: row.lastLoginAt
|
||||
})));
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
import {
|
||||
WechatPayClient,
|
||||
WechatPayError,
|
||||
type WechatNotificationHeaders,
|
||||
type WechatPayCredential
|
||||
} from '../payments/wechat-pay-client.js';
|
||||
import {
|
||||
CleaningTaskError,
|
||||
type CleaningActor,
|
||||
type CleaningTaskRepository
|
||||
} from './cleaning-task-repository.js';
|
||||
|
||||
interface SettlementPayoutRow extends RowDataPacket {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
cleanerUserId: string;
|
||||
generatedBy: string;
|
||||
storeId: string | null;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
totalRewardCents: number;
|
||||
payoutChannel: string;
|
||||
payoutRequestNo: string;
|
||||
payoutReference: string;
|
||||
payoutState: string;
|
||||
payoutPackageInfo: string;
|
||||
}
|
||||
|
||||
interface AccountRow extends RowDataPacket {
|
||||
id: string;
|
||||
platformAppId: string | null;
|
||||
storeId: string | null;
|
||||
merchantId: string;
|
||||
credentialRef: string;
|
||||
authorizationStatus: string;
|
||||
}
|
||||
|
||||
interface IdentityRow extends RowDataPacket {
|
||||
openid: string;
|
||||
}
|
||||
|
||||
type PreflightStatus = 'PASS' | 'WARN' | 'FAIL';
|
||||
|
||||
interface PreflightCheck {
|
||||
key: string;
|
||||
status: PreflightStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class CleaningPayoutError extends Error {
|
||||
constructor(public readonly code: string, message = code) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class CleaningPayoutService {
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly repository: Pick<CleaningTaskRepository,
|
||||
'beginSettlementPayout' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
||||
private readonly client: WechatPayClient,
|
||||
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
||||
private readonly mockEnabled: boolean
|
||||
) {}
|
||||
|
||||
async preflightWechatTransfer(input: CleaningActor & { settlementId: string }) {
|
||||
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||
const checks: PreflightCheck[] = [];
|
||||
checks.push({
|
||||
key: 'settlement_status',
|
||||
status: settlement.status === 'CONFIRMED' ? 'PASS' : 'FAIL',
|
||||
message: settlement.status === 'CONFIRMED'
|
||||
? 'Settlement is confirmed and can start WeChat transfer.'
|
||||
: `Settlement status must be CONFIRMED, current status is ${settlement.status}.`
|
||||
});
|
||||
checks.push({
|
||||
key: 'settlement_amount',
|
||||
status: Number(settlement.totalRewardCents) > 0 ? 'PASS' : 'FAIL',
|
||||
message: Number(settlement.totalRewardCents) > 0
|
||||
? 'Settlement amount is greater than zero.'
|
||||
: 'Settlement amount must be greater than zero.'
|
||||
});
|
||||
|
||||
let account: AccountRow | null = null;
|
||||
try {
|
||||
account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||
checks.push({
|
||||
key: 'collection_account',
|
||||
status: 'PASS',
|
||||
message: account.storeId ? 'Store scoped WeChat collection account is configured.'
|
||||
: 'Tenant level WeChat collection account is configured.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'collection_account_authorized',
|
||||
status: account.authorizationStatus === 'AUTHORIZED' ? 'PASS' : 'FAIL',
|
||||
message: account.authorizationStatus === 'AUTHORIZED'
|
||||
? 'Collection account is authorized.'
|
||||
: `Collection account authorization status is ${account.authorizationStatus}.`
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof CleaningPayoutError)) throw error;
|
||||
checks.push({
|
||||
key: 'collection_account',
|
||||
status: 'FAIL',
|
||||
message: 'No enabled WeChat collection account matches this settlement store.'
|
||||
});
|
||||
}
|
||||
|
||||
const credential = account ? this.resolveCredential(account.credentialRef) : null;
|
||||
checks.push({
|
||||
key: 'wechat_credential',
|
||||
status: credential ? 'PASS' : 'FAIL',
|
||||
message: credential ? 'WeChat Pay credential reference is resolvable.'
|
||||
: 'WeChat Pay credential reference is missing or not loaded.'
|
||||
});
|
||||
if (account && credential) {
|
||||
checks.push({
|
||||
key: 'merchant_match',
|
||||
status: credential.merchantId === account.merchantId ? 'PASS' : 'FAIL',
|
||||
message: credential.merchantId === account.merchantId
|
||||
? 'Credential merchant id matches the collection account.'
|
||||
: 'Credential merchant id does not match the collection account.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'transfer_scene_id',
|
||||
status: credential.transferSceneId ? 'PASS' : 'FAIL',
|
||||
message: credential.transferSceneId
|
||||
? 'Merchant transfer scene id is configured.'
|
||||
: 'Merchant transfer scene id is required before real transfer.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'transfer_scene_report_infos',
|
||||
status: credential.transferSceneReportInfos?.length ? 'PASS' : 'WARN',
|
||||
message: credential.transferSceneReportInfos?.length
|
||||
? `${credential.transferSceneReportInfos.length} transfer scene report info item(s) configured.`
|
||||
: 'No transfer scene report info is configured; confirm whether the selected WeChat scene requires it.'
|
||||
});
|
||||
const notifyUrl = credential.transferNotifyUrl || '';
|
||||
checks.push({
|
||||
key: 'transfer_notify_url',
|
||||
status: notifyUrl.startsWith('https://') && notifyUrl.includes('/app-api/cleaning/wechat-transfer/notify')
|
||||
? 'PASS' : 'WARN',
|
||||
message: notifyUrl
|
||||
? 'Transfer notification URL is configured.'
|
||||
: 'Transfer notification URL is not configured; active polling can still sync intermediate states.'
|
||||
});
|
||||
checks.push({
|
||||
key: 'platform_certificates',
|
||||
status: Object.keys(credential.platformCertificates).length > 0 ? 'PASS' : 'FAIL',
|
||||
message: Object.keys(credential.platformCertificates).length > 0
|
||||
? 'At least one WeChat platform certificate is loaded for notification verification.'
|
||||
: 'No WeChat platform certificate is loaded.'
|
||||
});
|
||||
}
|
||||
|
||||
let cleanerOpenidConfigured = false;
|
||||
if (account) {
|
||||
try {
|
||||
await this.resolveCleanerOpenid(input.tenantId, account.platformAppId, settlement.cleanerUserId);
|
||||
cleanerOpenidConfigured = true;
|
||||
} catch (error) {
|
||||
if (!(error instanceof CleaningPayoutError)) throw error;
|
||||
}
|
||||
}
|
||||
checks.push({
|
||||
key: 'cleaner_openid',
|
||||
status: cleanerOpenidConfigured ? 'PASS' : 'FAIL',
|
||||
message: cleanerOpenidConfigured
|
||||
? 'Cleaner has a WeChat miniapp openid for this platform app.'
|
||||
: 'Cleaner WeChat miniapp openid is missing.'
|
||||
});
|
||||
|
||||
return {
|
||||
ready: checks.every((check) => check.status !== 'FAIL'),
|
||||
settlement: {
|
||||
id: settlement.id,
|
||||
settlementNo: settlement.settlementNo,
|
||||
status: settlement.status,
|
||||
totalRewardCents: Number(settlement.totalRewardCents),
|
||||
cleanerUserId: settlement.cleanerUserId,
|
||||
storeId: settlement.storeId
|
||||
},
|
||||
account: account ? {
|
||||
configured: true,
|
||||
id: account.id,
|
||||
storeScoped: account.storeId !== null,
|
||||
merchantIdMasked: maskIdentifier(account.merchantId),
|
||||
credentialRefMasked: maskIdentifier(account.credentialRef),
|
||||
authorizationStatus: account.authorizationStatus
|
||||
} : { configured: false },
|
||||
credential: credential ? {
|
||||
configured: true,
|
||||
appIdPresent: credential.appId.length > 0,
|
||||
serialNoPresent: credential.serialNo.length > 0,
|
||||
transferSceneId: credential.transferSceneId || '',
|
||||
reportInfoCount: credential.transferSceneReportInfos?.length ?? 0,
|
||||
transferNotifyUrlConfigured: Boolean(credential.transferNotifyUrl),
|
||||
platformCertificateCount: Object.keys(credential.platformCertificates).length
|
||||
} : { configured: false },
|
||||
cleaner: { openidConfigured: cleanerOpenidConfigured },
|
||||
checks
|
||||
};
|
||||
}
|
||||
|
||||
async executeWechatTransfer(input: CleaningActor & {
|
||||
settlementId: string;
|
||||
mode: 'API' | 'MOCK';
|
||||
note?: string;
|
||||
}) {
|
||||
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||
if (settlement.status === 'PAID') {
|
||||
return { settlement, idempotent: true, transferState: 'SUCCESS' };
|
||||
}
|
||||
if (settlement.status !== 'CONFIRMED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||
}
|
||||
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_ALREADY_STARTED');
|
||||
}
|
||||
if (Number(settlement.totalRewardCents) <= 0) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
|
||||
}
|
||||
if (input.mode === 'MOCK' && !this.mockEnabled) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MOCK_DISABLED');
|
||||
}
|
||||
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||
if (account.authorizationStatus !== 'AUTHORIZED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_AUTHORIZED');
|
||||
}
|
||||
const credential = this.resolveCredential(account.credentialRef);
|
||||
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||
if (credential.merchantId !== account.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
|
||||
if (input.mode === 'MOCK') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER_MOCK',
|
||||
payoutReference: outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
|
||||
}
|
||||
const openid = await this.resolveCleanerOpenid(
|
||||
input.tenantId,
|
||||
account.platformAppId,
|
||||
settlement.cleanerUserId
|
||||
);
|
||||
const sceneId = credential.transferSceneId;
|
||||
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
|
||||
await this.repository.beginSettlementPayout({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: outBillNo,
|
||||
note: input.note
|
||||
});
|
||||
try {
|
||||
const result = await this.client.createMerchantTransfer(credential, {
|
||||
outBillNo,
|
||||
openid,
|
||||
amountCents: Number(settlement.totalRewardCents),
|
||||
remark: `Cleaning settlement ${settlement.id}`,
|
||||
sceneId,
|
||||
notifyUrl: credential.transferNotifyUrl || undefined,
|
||||
reportInfos: credential.transferSceneReportInfos ?? []
|
||||
});
|
||||
if (result.state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||
}
|
||||
if (result.state === 'FAIL') {
|
||||
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||
}
|
||||
const pending = await this.repository.recordSettlementPayoutPending({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
payoutState: result.state,
|
||||
payoutPackageInfo: result.packageInfo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||
} catch (error) {
|
||||
// A transport/API exception is not proof that WeChat rejected the transfer.
|
||||
// Keep PROCESSING so cancellation and duplicate payout are blocked until sync/reconciliation.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async syncWechatTransfer(input: CleaningActor & { settlementId: string; note?: string }) {
|
||||
const settlement = await this.loadSettlement(input.tenantId, input.settlementId);
|
||||
if (settlement.status === 'PAID') {
|
||||
return { settlement, idempotent: true, transferState: 'SUCCESS' };
|
||||
}
|
||||
if (settlement.status !== 'CONFIRMED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||
}
|
||||
if (settlement.payoutChannel !== 'WECHAT_TRANSFER' || !settlement.payoutReference) {
|
||||
throw new CleaningPayoutError('WECHAT_TRANSFER_NOT_STARTED');
|
||||
}
|
||||
const account = await this.resolveCollectionAccount(input.tenantId, settlement.storeId);
|
||||
const credential = this.resolveCredential(account.credentialRef);
|
||||
if (!credential) throw new CleaningPayoutError('WECHAT_CREDENTIAL_NOT_CONFIGURED');
|
||||
if (credential.merchantId !== account.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
const outBillNo = normalizeOutBillNo(settlement.settlementNo, settlement.id);
|
||||
const result = await this.client.queryMerchantTransferByOutBillNo(credential, outBillNo);
|
||||
if (result.merchantId !== account.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
if (result.amountCents > 0 && result.amountCents !== Number(settlement.totalRewardCents)) {
|
||||
throw new CleaningPayoutError('WECHAT_TRANSFER_AMOUNT_MISMATCH');
|
||||
}
|
||||
if (result.state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||
}
|
||||
if (result.state === 'FAIL') {
|
||||
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||
}
|
||||
const pending = await this.repository.recordSettlementPayoutPending({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
payoutState: result.state,
|
||||
payoutPackageInfo: settlement.payoutPackageInfo,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||
}
|
||||
|
||||
async processWechatTransferNotification(
|
||||
headers: WechatNotificationHeaders,
|
||||
rawBody: string,
|
||||
traceId: string
|
||||
) {
|
||||
const { credential, payload } = this.decryptNotification(headers, rawBody);
|
||||
const merchantId = stringPayload(payload, 'mch_id');
|
||||
if (merchantId !== credential.merchantId) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_MERCHANT_MISMATCH');
|
||||
}
|
||||
const outBillNo = stringPayload(payload, 'out_bill_no');
|
||||
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
|
||||
const settlement = await this.findSettlementByTransferReference(
|
||||
credential.merchantId,
|
||||
outBillNo,
|
||||
transferBillNo
|
||||
);
|
||||
const actor: CleaningActor = {
|
||||
tenantId: settlement.tenantId,
|
||||
userId: String(settlement.generatedBy),
|
||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId,
|
||||
actorType: 'SYSTEM'
|
||||
};
|
||||
const state = stringPayload(payload, 'state');
|
||||
if (settlement.status === 'PAID' && state === 'SUCCESS') {
|
||||
return { settlement, transferState: state, idempotent: true };
|
||||
}
|
||||
if (state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: '微信转账回调确认成功'
|
||||
});
|
||||
return { settlement: paid, transferState: state, idempotent: false };
|
||||
}
|
||||
if (state === 'FAIL') {
|
||||
const failed = await this.repository.recordSettlementPayoutFailure({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
|
||||
providerConfirmed: true,
|
||||
note: '微信转账回调确认失败'
|
||||
});
|
||||
return { settlement: failed, transferState: state, idempotent: false };
|
||||
}
|
||||
const pending = await this.repository.recordSettlementPayoutPending({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
payoutState: state,
|
||||
payoutPackageInfo: settlement.payoutPackageInfo,
|
||||
note: '微信转账回调更新中间态'
|
||||
});
|
||||
return { settlement: pending, transferState: state, idempotent: false };
|
||||
}
|
||||
|
||||
private async loadSettlement(tenantId: string, settlementId: string) {
|
||||
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
|
||||
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
|
||||
generated_by AS generatedBy,
|
||||
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
|
||||
payout_channel AS payoutChannel, payout_request_no AS payoutRequestNo,
|
||||
payout_reference AS payoutReference,
|
||||
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
||||
[tenantId, settlementId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async resolveCollectionAccount(tenantId: string, storeId: string | null) {
|
||||
const [rows] = await this.pool.execute<AccountRow[]>(
|
||||
`SELECT id, platform_app_id AS platformAppId, store_id AS storeId,
|
||||
merchant_id AS merchantId, credential_ref AS credentialRef,
|
||||
authorization_status AS authorizationStatus
|
||||
FROM qipai_collection_accounts
|
||||
WHERE tenant_id = ? AND provider = 'WECHAT' AND enabled = 1
|
||||
AND (store_id IS NULL OR store_id <=> ?)
|
||||
ORDER BY (store_id IS NOT NULL) DESC, id DESC LIMIT 1`,
|
||||
[tenantId, storeId]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningPayoutError('CLEANING_PAYOUT_ACCOUNT_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async resolveCleanerOpenid(tenantId: string, platformAppId: string | null, cleanerUserId: string) {
|
||||
const params: Array<string | number> = [tenantId, cleanerUserId];
|
||||
const platformFilter = platformAppId ? 'AND platform_app_id = ?' : '';
|
||||
if (platformAppId) params.push(platformAppId);
|
||||
const [rows] = await this.pool.execute<IdentityRow[]>(
|
||||
`SELECT openid FROM qipai_user_identities
|
||||
WHERE tenant_id = ? AND user_id = ? AND provider = 'WECHAT_MINIAPP'
|
||||
AND deleted_at IS NULL ${platformFilter}
|
||||
ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||
params
|
||||
);
|
||||
if (!rows[0]?.openid) throw new CleaningPayoutError('WECHAT_OPENID_NOT_FOUND');
|
||||
return rows[0].openid;
|
||||
}
|
||||
|
||||
private resolveCredential(reference: string) {
|
||||
return this.credentials.get(reference)
|
||||
?? this.credentials.get(reference.replace(/^env:/, ''));
|
||||
}
|
||||
|
||||
private decryptNotification(headers: WechatNotificationHeaders, rawBody: string) {
|
||||
let lastError: unknown;
|
||||
for (const credential of this.credentials.values()) {
|
||||
if (!credential.platformCertificates[headers.serial]) continue;
|
||||
try {
|
||||
return { credential, payload: this.client.verifyAndDecrypt(credential, headers, rawBody) };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError instanceof Error) throw lastError;
|
||||
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async findSettlementByTransferReference(
|
||||
merchantId: string,
|
||||
outBillNo: string,
|
||||
transferBillNo: string
|
||||
) {
|
||||
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
|
||||
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
|
||||
`SELECT s.tenant_id AS tenantId, s.id, s.settlement_no AS settlementNo,
|
||||
s.cleaner_user_id AS cleanerUserId, s.generated_by AS generatedBy,
|
||||
s.store_id AS storeId, s.status,
|
||||
s.total_reward_cents AS totalRewardCents, s.payout_channel AS payoutChannel,
|
||||
s.payout_request_no AS payoutRequestNo,
|
||||
s.payout_reference AS payoutReference, s.payout_state AS payoutState,
|
||||
s.payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements s
|
||||
INNER JOIN qipai_collection_accounts account
|
||||
ON account.tenant_id = s.tenant_id
|
||||
AND account.provider = 'WECHAT' AND account.merchant_id = ?
|
||||
AND account.enabled = 1
|
||||
AND (account.store_id IS NULL OR account.store_id <=> s.store_id)
|
||||
WHERE s.payout_channel = 'WECHAT_TRANSFER'
|
||||
AND s.deleted_at IS NULL
|
||||
AND (s.payout_request_no IN (${references.map(() => '?').join(',')})
|
||||
OR s.payout_reference IN (${references.map(() => '?').join(',')}))
|
||||
ORDER BY (account.store_id IS NOT NULL) DESC,
|
||||
s.updated_at DESC, s.id DESC LIMIT 1`,
|
||||
[merchantId, ...references, ...references]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
|
||||
void settlementNo;
|
||||
return `CLP${settlementId}`;
|
||||
}
|
||||
|
||||
function stringPayload(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new WechatPayError('WECHAT_RESOURCE_INVALID');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalStringPayload(payload: Record<string, unknown>, key: string) {
|
||||
const value = payload[key];
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function maskIdentifier(value: string) {
|
||||
if (value.length <= 4) return '****';
|
||||
return `${value.slice(0, 2)}***${value.slice(-4)}`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ const configSchema = z.object({
|
||||
QIPAI_TEST_PAYMENT_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
QIPAI_WECHAT_PAY_CREDENTIALS: z.string().default('{}'),
|
||||
QIPAI_PROFIT_SHARE_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: z.enum(['true', 'false']).default('false'),
|
||||
QIPAI_THIRD_PARTY_CREDENTIALS: z.string().default('{}'),
|
||||
QIPAI_MQTT_URL: z.string().url().default('mqtt://101.42.38.246:1883'),
|
||||
QIPAI_MQTT_CLIENT_ID: z.string().min(1).max(128).default('qipai-backend'),
|
||||
@@ -74,7 +75,9 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env) {
|
||||
&& parsed.QIPAI_TEST_PAYMENT_ENABLED === 'true',
|
||||
wechatCredentialsJson: parsed.QIPAI_WECHAT_PAY_CREDENTIALS,
|
||||
profitShareMockEnabled: parsed.NODE_ENV !== 'production'
|
||||
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true'
|
||||
&& parsed.QIPAI_PROFIT_SHARE_MOCK_ENABLED === 'true',
|
||||
cleaningPayoutMockEnabled: parsed.NODE_ENV !== 'production'
|
||||
&& parsed.QIPAI_CLEANING_PAYOUT_MOCK_ENABLED === 'true'
|
||||
},
|
||||
thirdParty: {
|
||||
credentialsJson: parsed.QIPAI_THIRD_PARTY_CREDENTIALS
|
||||
|
||||
@@ -31,11 +31,27 @@ export interface AdvertisementInput {
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface VersionRow extends RowDataPacket { nextVersion: number }
|
||||
interface ContentRow extends RowDataPacket {
|
||||
id: string; scopeType: string; storeId: string | null; title: string; imageUrl: string;
|
||||
id: string; scopeType: string; storeId: string | null; title: string;
|
||||
imageAssetId: string; imageUrl: string;
|
||||
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
|
||||
status: string; sortOrder: number;
|
||||
}
|
||||
|
||||
interface AssetRow extends RowDataPacket {
|
||||
id: string; storeId: string | null; url: string; mimeType: string; byteSize: number;
|
||||
width: number; height: number; createdAt: Date;
|
||||
}
|
||||
|
||||
interface DecorationRow extends RowDataPacket {
|
||||
id: string; storeId: string; templateCode: string; schemaVersion: number;
|
||||
content: string | Record<string, unknown>; status: string; version: number;
|
||||
publishedAt: Date | null; createdAt: Date;
|
||||
}
|
||||
|
||||
interface AdvertisementScopeRow extends RowDataPacket {
|
||||
scopeType: AdvertisementInput['scopeType']; storeId: string | null;
|
||||
}
|
||||
|
||||
export class ContentError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
@@ -44,7 +60,7 @@ export class ContentRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
|
||||
if (storeId) this.assertStoreScope(actor, storeId);
|
||||
this.assertAssetWriteScope(actor, storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_media_assets
|
||||
@@ -61,6 +77,24 @@ export class ContentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async listAssets(actor: ManagementActor, storeId?: string) {
|
||||
if (storeId) this.assertStoreScope(actor, storeId);
|
||||
const scope = this.assetReadScope(actor, storeId);
|
||||
const [rows] = await this.pool.execute<AssetRow[]>(
|
||||
`SELECT id, store_id AS storeId, public_url AS url, mime_type AS mimeType,
|
||||
byte_size AS byteSize, width, height, created_at AS createdAt
|
||||
FROM qipai_media_assets
|
||||
WHERE tenant_id = ? AND deleted_at IS NULL AND ${scope.sql}
|
||||
ORDER BY id DESC LIMIT 200`,
|
||||
[actor.tenantId, ...scope.params]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
storeId: row.storeId === null ? null : String(row.storeId)
|
||||
}));
|
||||
}
|
||||
|
||||
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
|
||||
this.assertStoreScope(actor, input.storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -85,6 +119,25 @@ export class ContentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async listDecorations(actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
const [rows] = await this.pool.execute<DecorationRow[]>(
|
||||
`SELECT id, store_id AS storeId, template_code AS templateCode,
|
||||
schema_version AS schemaVersion, content_json AS content,
|
||||
status, version, published_at AS publishedAt, created_at AS createdAt
|
||||
FROM qipai_store_decorations
|
||||
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL
|
||||
ORDER BY version DESC LIMIT 100`,
|
||||
[actor.tenantId, storeId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
storeId: String(row.storeId),
|
||||
content: parseJson(row.content)
|
||||
}));
|
||||
}
|
||||
|
||||
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -114,7 +167,8 @@ export class ContentRepository {
|
||||
const scope = this.adScope(actor);
|
||||
const [rows] = await this.pool.execute<ContentRow[]>(
|
||||
`SELECT a.id, a.scope_type AS scopeType, a.store_id AS storeId, a.title,
|
||||
m.public_url AS imageUrl, a.target_type AS targetType,
|
||||
a.image_asset_id AS imageAssetId, m.public_url AS imageUrl,
|
||||
a.target_type AS targetType,
|
||||
a.target_value AS targetValue, a.starts_at AS startsAt, a.ends_at AS endsAt,
|
||||
a.status, a.sort_order AS sortOrder
|
||||
FROM qipai_advertisements a
|
||||
@@ -123,11 +177,17 @@ export class ContentRepository {
|
||||
ORDER BY a.sort_order, a.id DESC`,
|
||||
[actor.tenantId, ...scope.params]
|
||||
);
|
||||
return rows.map((row) => ({ ...row, id: String(row.id), storeId: row.storeId && String(row.storeId) }));
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
imageAssetId: String(row.imageAssetId),
|
||||
storeId: row.storeId === null ? null : String(row.storeId)
|
||||
}));
|
||||
}
|
||||
|
||||
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
|
||||
this.assertAdScope(actor, input);
|
||||
const assetScope = this.adAssetScope(input);
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_advertisements
|
||||
@@ -135,10 +195,12 @@ export class ContentRepository {
|
||||
target_value, starts_at, ends_at, status, sort_order, created_by)
|
||||
SELECT ?, ?, ?, ?, m.id, ?, ?, ?, ?, ?, ?, ?
|
||||
FROM qipai_media_assets m
|
||||
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL`,
|
||||
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
|
||||
AND ${assetScope.sql}`,
|
||||
[actor.tenantId, input.scopeType, input.storeId ?? null, input.title,
|
||||
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
|
||||
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId]
|
||||
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId,
|
||||
...assetScope.params]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
|
||||
const advertisementId = String(result.insertId);
|
||||
@@ -147,6 +209,45 @@ export class ContentRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateAdvertisement(
|
||||
actor: ManagementActor, advertisementId: string, input: AdvertisementInput
|
||||
) {
|
||||
this.assertAdScope(actor, input);
|
||||
const assetScope = this.adAssetScope(input);
|
||||
return this.transaction(async (connection) => {
|
||||
const [advertisements] = await connection.execute<AdvertisementScopeRow[]>(
|
||||
`SELECT scope_type AS scopeType, store_id AS storeId
|
||||
FROM qipai_advertisements
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||
[actor.tenantId, advertisementId]
|
||||
);
|
||||
const existing = advertisements[0];
|
||||
if (!existing) throw new ContentError('ADVERTISEMENT_NOT_FOUND');
|
||||
this.assertExistingAdScope(actor, existing);
|
||||
|
||||
const [assets] = await connection.execute<IdRow[]>(
|
||||
`SELECT m.id FROM qipai_media_assets m
|
||||
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
|
||||
AND ${assetScope.sql}`,
|
||||
[actor.tenantId, input.imageAssetId, ...assetScope.params]
|
||||
);
|
||||
if (!assets[0]) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
|
||||
|
||||
await connection.execute(
|
||||
`UPDATE qipai_advertisements
|
||||
SET scope_type = ?, store_id = ?, title = ?, image_asset_id = ?,
|
||||
target_type = ?, target_value = ?, starts_at = ?, ends_at = ?,
|
||||
status = ?, sort_order = ?
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
|
||||
[input.scopeType, input.storeId ?? null, input.title, input.imageAssetId,
|
||||
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
|
||||
input.status, input.sortOrder, actor.tenantId, advertisementId]
|
||||
);
|
||||
await this.audit(connection, actor, 'ADVERTISEMENT_UPDATED', 'ADVERTISEMENT', advertisementId);
|
||||
return { advertisementId };
|
||||
});
|
||||
}
|
||||
|
||||
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
|
||||
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN');
|
||||
@@ -160,6 +261,26 @@ export class ContentRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private assertExistingAdScope(actor: ManagementActor, advertisement: AdvertisementScopeRow) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
if (advertisement.scopeType !== 'STORE' || !advertisement.storeId
|
||||
|| !actor.access.storeIds.includes(String(advertisement.storeId))) {
|
||||
throw new ContentError('ADVERTISEMENT_SCOPE_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private assertAssetWriteScope(actor: ManagementActor, storeId?: string) {
|
||||
if (storeId) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
return;
|
||||
}
|
||||
if (!actor.access.capabilities.includes('tenant.manage')
|
||||
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
throw new ContentError('GLOBAL_ASSET_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
@@ -180,6 +301,30 @@ export class ContentRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private assetReadScope(actor: ManagementActor, storeId?: string) {
|
||||
if (storeId) {
|
||||
return { sql: '(store_id IS NULL OR store_id = ?)', params: [storeId] };
|
||||
}
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
return { sql: '1 = 1', params: [] as string[] };
|
||||
}
|
||||
if (actor.access.storeIds.length === 0) {
|
||||
return { sql: 'store_id IS NULL', params: [] as string[] };
|
||||
}
|
||||
return {
|
||||
sql: `(store_id IS NULL OR store_id IN (${actor.access.storeIds.map(() => '?').join(',')}))`,
|
||||
params: actor.access.storeIds
|
||||
};
|
||||
}
|
||||
|
||||
private adAssetScope(input: AdvertisementInput) {
|
||||
if (input.scopeType === 'STORE' && input.storeId) {
|
||||
return { sql: '(m.store_id IS NULL OR m.store_id = ?)', params: [input.storeId] };
|
||||
}
|
||||
return { sql: 'm.store_id IS NULL', params: [] as string[] };
|
||||
}
|
||||
|
||||
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
|
||||
const [rows] = await connection.execute<IdRow[]>(
|
||||
`SELECT id FROM qipai_stores
|
||||
@@ -218,3 +363,12 @@ export class ContentRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value: string | Record<string, unknown>) {
|
||||
if (typeof value !== 'string') return value;
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new ContentError('INVALID_DECORATION_CONTENT');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { mkdir, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
@@ -33,18 +33,33 @@ export class MediaStorage {
|
||||
if (input.body.length === 0 || input.body.length > 8 * 1024 * 1024) {
|
||||
throw new MediaValidationError('IMAGE_SIZE_INVALID');
|
||||
}
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(input.contentType)) {
|
||||
const declaredFormat = {
|
||||
'image/jpeg': 'jpeg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp'
|
||||
}[input.contentType];
|
||||
if (!declaredFormat) {
|
||||
throw new MediaValidationError('IMAGE_TYPE_INVALID');
|
||||
}
|
||||
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(extname(input.originalName).toLowerCase())) {
|
||||
const declaredExtension = {
|
||||
'.jpg': 'jpeg',
|
||||
'.jpeg': 'jpeg',
|
||||
'.png': 'png',
|
||||
'.webp': 'webp'
|
||||
}[extname(input.originalName).toLowerCase()];
|
||||
if (!declaredExtension) {
|
||||
throw new MediaValidationError('IMAGE_EXTENSION_INVALID');
|
||||
}
|
||||
if (declaredExtension !== declaredFormat) {
|
||||
throw new MediaValidationError('IMAGE_TYPE_MISMATCH');
|
||||
}
|
||||
let result: Buffer;
|
||||
let metadata: sharp.Metadata;
|
||||
try {
|
||||
const source = sharp(input.body, { failOn: 'warning', limitInputPixels: 40_000_000 });
|
||||
metadata = await source.metadata();
|
||||
if (!metadata.width || !metadata.height) throw new Error('missing dimensions');
|
||||
if (metadata.format !== declaredFormat) throw new Error('declared image type mismatch');
|
||||
result = await source
|
||||
.rotate()
|
||||
.resize({ width: 1920, height: 1920, fit: 'inside', withoutEnlargement: true })
|
||||
@@ -74,4 +89,17 @@ export class MediaStorage {
|
||||
checksumSha256: createHash('sha256').update(result).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
async deleteImage(storagePath: string): Promise<void> {
|
||||
const safeRoot = resolve(this.root);
|
||||
const target = resolve(safeRoot, storagePath);
|
||||
if (target === safeRoot || !target.startsWith(`${safeRoot}${sep}`)) {
|
||||
throw new MediaValidationError('IMAGE_PATH_INVALID');
|
||||
}
|
||||
try {
|
||||
await unlink(target);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ export interface MigrationPlan {
|
||||
file: string;
|
||||
checksum: string;
|
||||
statements: readonly string[];
|
||||
migrations?: readonly MigrationFilePlan[];
|
||||
}
|
||||
|
||||
export interface MigrationFilePlan {
|
||||
version: string;
|
||||
name: string;
|
||||
file: string;
|
||||
checksum: string;
|
||||
statements: readonly string[];
|
||||
}
|
||||
|
||||
export interface MigrationExecutionResult extends MigrationPlan {
|
||||
@@ -18,6 +27,12 @@ export interface MigrationExecutionResult extends MigrationPlan {
|
||||
affectedRows: number;
|
||||
}
|
||||
|
||||
type MigrationQueryExecutor = Pick<MySqlPool, 'query'>;
|
||||
type MigrationPool = MigrationQueryExecutor & Partial<Pick<MySqlPool, 'getConnection'>>;
|
||||
|
||||
const migrationLockExpression =
|
||||
"CONCAT('qipai:migrate:', LEFT(SHA2(DATABASE(), 256), 32))";
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
|
||||
const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
up: [
|
||||
@@ -45,7 +60,18 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'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/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',
|
||||
@@ -72,9 +98,31 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'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/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',
|
||||
@@ -193,17 +241,30 @@ export async function loadMigrationPlan(direction: MigrationDirection): Promise<
|
||||
relativeFiles.map((relativeFile) => readFile(resolve(repoRoot, relativeFile), 'utf8'))
|
||||
);
|
||||
const sql = sqlParts.join('\n');
|
||||
const migrations = relativeFiles.map((file, index) => {
|
||||
const match = /(?:^|\/)(\d+)_([a-z0-9_]+)\.(?:up|verify|down)\.sql$/iu.exec(file);
|
||||
if (!match) throw new Error(`Invalid migration file name: ${file}`);
|
||||
const migrationSql = sqlParts[index];
|
||||
return {
|
||||
version: match[1],
|
||||
name: match[2],
|
||||
file,
|
||||
checksum: createHash('sha256').update(migrationSql).digest('hex'),
|
||||
statements: splitSqlStatements(migrationSql)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
direction,
|
||||
file: relativeFiles.join(','),
|
||||
checksum: createHash('sha256').update(sql).digest('hex'),
|
||||
statements: splitSqlStatements(sql)
|
||||
statements: splitSqlStatements(sql),
|
||||
migrations
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeMigrationPlan(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
pool: MigrationPool,
|
||||
plan: MigrationPlan,
|
||||
dryRun = false
|
||||
): Promise<MigrationExecutionResult> {
|
||||
@@ -211,9 +272,64 @@ export async function executeMigrationPlan(
|
||||
return { ...plan, executed: false, affectedRows: 0 };
|
||||
}
|
||||
|
||||
if (pool.getConnection) {
|
||||
const connection = await pool.getConnection();
|
||||
let lockAcquired = false;
|
||||
let executionFailed = false;
|
||||
try {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT GET_LOCK(${migrationLockExpression}, 30) AS acquired`
|
||||
);
|
||||
const acquired = Array.isArray(rows)
|
||||
? Number((rows[0] as Record<string, unknown> | undefined)?.acquired ?? 0)
|
||||
: 0;
|
||||
if (acquired !== 1) {
|
||||
throw new Error('MIGRATION_LOCK_TIMEOUT: another migration process is still running.');
|
||||
}
|
||||
lockAcquired = true;
|
||||
return await executeMigrationPlanUnlocked(connection, plan);
|
||||
} catch (error) {
|
||||
executionFailed = true;
|
||||
throw error;
|
||||
} finally {
|
||||
let releaseError: unknown;
|
||||
if (lockAcquired) {
|
||||
try {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT RELEASE_LOCK(${migrationLockExpression}) AS released`
|
||||
);
|
||||
const released = Array.isArray(rows)
|
||||
? Number((rows[0] as Record<string, unknown> | undefined)?.released ?? 0)
|
||||
: 0;
|
||||
if (released !== 1) {
|
||||
releaseError = new Error('MIGRATION_LOCK_RELEASE_FAILED: migration lock was lost.');
|
||||
}
|
||||
} catch (error) {
|
||||
releaseError = error;
|
||||
}
|
||||
}
|
||||
connection.release();
|
||||
if (!executionFailed && releaseError) throw releaseError;
|
||||
}
|
||||
}
|
||||
|
||||
return executeMigrationPlanUnlocked(pool, plan);
|
||||
}
|
||||
|
||||
async function executeMigrationPlanUnlocked(
|
||||
pool: MigrationQueryExecutor,
|
||||
plan: MigrationPlan
|
||||
): Promise<MigrationExecutionResult> {
|
||||
|
||||
if (plan.direction === 'up' && plan.migrations?.length) {
|
||||
return executeVersionedUpPlan(pool, plan);
|
||||
}
|
||||
|
||||
if (plan.direction === 'up') await assertTriggerConfiguration(pool, plan.statements);
|
||||
|
||||
let affectedRows = 0;
|
||||
for (const [index, statement] of plan.statements.entries()) {
|
||||
const [result] = await pool.query(statement);
|
||||
const result = await queryMigrationStatement(pool, statement);
|
||||
if (plan.direction === 'verify') {
|
||||
const minimumRows = [
|
||||
10, 26, 1,
|
||||
@@ -239,7 +355,18 @@ export async function executeMigrationPlan(
|
||||
1, 1, 1, 4, 7, 1,
|
||||
1, 1, 7, 7, 1,
|
||||
5, 10, 7, 3, 1,
|
||||
2, 8, 4, 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(
|
||||
@@ -257,3 +384,235 @@ export async function executeMigrationPlan(
|
||||
|
||||
return { ...plan, executed: true, affectedRows };
|
||||
}
|
||||
|
||||
interface AppliedMigrationRow extends Record<string, unknown> {
|
||||
version?: unknown;
|
||||
name?: unknown;
|
||||
checksum?: unknown;
|
||||
}
|
||||
|
||||
async function executeVersionedUpPlan(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
plan: MigrationPlan
|
||||
): Promise<MigrationExecutionResult> {
|
||||
const migrations = plan.migrations ?? [];
|
||||
const appliedRows = await readAppliedMigrations(pool);
|
||||
const applied = new Map(appliedRows.map((row) => [String(row.version), row]));
|
||||
for (const migration of migrations) {
|
||||
const row = applied.get(migration.version);
|
||||
if (!row) continue;
|
||||
if (String(row.name) !== migration.name) {
|
||||
throw new Error(
|
||||
`MIGRATION_NAME_MISMATCH: ${migration.version} is ${String(row.name)}, expected ${migration.name}.`
|
||||
);
|
||||
}
|
||||
const storedChecksum = String(row.checksum ?? '');
|
||||
if (storedChecksum && storedChecksum !== migration.checksum) {
|
||||
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = migrations.filter((migration) => !applied.has(migration.version));
|
||||
await assertTriggerConfiguration(
|
||||
pool,
|
||||
pending.flatMap((migration) => [...migration.statements])
|
||||
);
|
||||
let affectedRows = 0;
|
||||
for (const migration of pending) {
|
||||
for (const statement of migration.statements) {
|
||||
const result = await queryMigrationStatement(pool, statement);
|
||||
if (result && typeof result === 'object' && 'affectedRows' in result) {
|
||||
const value = Reflect.get(result, 'affectedRows');
|
||||
if (typeof value === 'number') affectedRows += value;
|
||||
}
|
||||
}
|
||||
const [markerRows] = await pool.query(
|
||||
'SELECT version, name FROM qipai_schema_migrations WHERE version = ?',
|
||||
[migration.version]
|
||||
);
|
||||
const marker = Array.isArray(markerRows)
|
||||
? markerRows[0] as Record<string, unknown> | undefined
|
||||
: undefined;
|
||||
if (!marker || String(marker.name) !== migration.name) {
|
||||
throw new Error(
|
||||
`MIGRATION_MARKER_MISSING: ${migration.version} ${migration.name} did not record completion.`
|
||||
);
|
||||
}
|
||||
await storeMigrationChecksum(pool, migration);
|
||||
}
|
||||
|
||||
for (const migration of migrations) {
|
||||
await storeMigrationChecksum(pool, migration);
|
||||
}
|
||||
return { ...plan, executed: true, affectedRows };
|
||||
}
|
||||
|
||||
async function readAppliedMigrations(pool: Pick<MySqlPool, 'query'>) {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM qipai_schema_migrations ORDER BY version');
|
||||
return Array.isArray(rows) ? rows as AppliedMigrationRow[] : [];
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object'
|
||||
&& Reflect.get(error, 'code') === 'ER_NO_SUCH_TABLE') return [];
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function storeMigrationChecksum(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
migration: MigrationFilePlan
|
||||
) {
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
`UPDATE qipai_schema_migrations
|
||||
SET checksum = ?
|
||||
WHERE version = ? AND (checksum = '' OR checksum = ?)`,
|
||||
[migration.checksum, migration.version, migration.checksum]
|
||||
);
|
||||
if (result && typeof result === 'object'
|
||||
&& Number(Reflect.get(result, 'affectedRows') ?? 0) === 0) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT checksum FROM qipai_schema_migrations WHERE version = ?',
|
||||
[migration.version]
|
||||
);
|
||||
const stored = Array.isArray(rows)
|
||||
? String((rows[0] as Record<string, unknown> | undefined)?.checksum ?? '')
|
||||
: '';
|
||||
if (stored && stored !== migration.checksum) {
|
||||
throw new Error(`MIGRATION_CHECKSUM_MISMATCH: ${migration.version} ${migration.name}.`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object'
|
||||
&& Reflect.get(error, 'code') === 'ER_BAD_FIELD_ERROR') return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertTriggerConfiguration(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statements: readonly string[]
|
||||
) {
|
||||
if (!statements.some((statement) => /^CREATE\s+TRIGGER\b/iu.test(statement))) return;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT @@GLOBAL.log_bin AS logBin,
|
||||
@@GLOBAL.log_bin_trust_function_creators AS trustFunctionCreators`
|
||||
);
|
||||
const setting = Array.isArray(rows)
|
||||
? rows[0] as Record<string, unknown> | undefined
|
||||
: undefined;
|
||||
if (Number(setting?.logBin ?? 0) === 1
|
||||
&& Number(setting?.trustFunctionCreators ?? 0) !== 1) {
|
||||
throw new Error(
|
||||
'MIGRATION_TRIGGER_PRIVILEGE_REQUIRED: binary logging is enabled; '
|
||||
+ 'run migrations with log_bin_trust_function_creators=1 under a dedicated migration identity.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryMigrationStatement(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string
|
||||
) {
|
||||
try {
|
||||
const [result] = await pool.query(statement);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (await isMatchingM09D1ScopeIndex(pool, statement, error)
|
||||
|| await isMatchingM09D1ChecksumColumn(pool, statement, error)
|
||||
|| await isMissingM09D1DownObject(pool, statement, error)) return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function isMissingM09D1DownObject(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string,
|
||||
error: unknown
|
||||
) {
|
||||
if (!error || typeof error !== 'object'
|
||||
|| Reflect.get(error, 'code') !== 'ER_CANT_DROP_FIELD_OR_KEY') return false;
|
||||
|
||||
const indexTarget = /DROP\s+INDEX\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
|
||||
: /DROP\s+INDEX\s+uq_qipai_users_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
|
||||
: null;
|
||||
if (indexTarget) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = '${indexTarget.table}'
|
||||
AND index_name = '${indexTarget.index}'
|
||||
LIMIT 1`
|
||||
);
|
||||
return Array.isArray(rows) && rows.length === 0;
|
||||
}
|
||||
|
||||
if (!/DROP\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_schema_migrations'
|
||||
AND column_name = 'checksum'
|
||||
LIMIT 1`
|
||||
);
|
||||
return Array.isArray(rows) && rows.length === 0;
|
||||
}
|
||||
|
||||
async function isMatchingM09D1ChecksumColumn(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string,
|
||||
error: unknown
|
||||
) {
|
||||
if (!error || typeof error !== 'object'
|
||||
|| Reflect.get(error, 'code') !== 'ER_DUP_FIELDNAME'
|
||||
|| !/ADD\s+COLUMN\s+checksum\b/iu.test(statement)) return false;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT data_type AS dataType, character_maximum_length AS maxLength,
|
||||
is_nullable AS isNullable, column_default AS columnDefault,
|
||||
collation_name AS collationName
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_schema_migrations'
|
||||
AND column_name = 'checksum'`
|
||||
);
|
||||
const column = Array.isArray(rows)
|
||||
? rows[0] as Record<string, unknown> | undefined
|
||||
: undefined;
|
||||
return column?.dataType === 'char'
|
||||
&& Number(column.maxLength) === 64
|
||||
&& column.isNullable === 'NO'
|
||||
&& String(column.columnDefault ?? '') === ''
|
||||
&& column.collationName === 'ascii_bin';
|
||||
}
|
||||
|
||||
async function isMatchingM09D1ScopeIndex(
|
||||
pool: Pick<MySqlPool, 'query'>,
|
||||
statement: string,
|
||||
error: unknown
|
||||
) {
|
||||
if (!error || typeof error !== 'object'
|
||||
|| Reflect.get(error, 'code') !== 'ER_DUP_KEYNAME') {
|
||||
return false;
|
||||
}
|
||||
const target = /ADD\s+UNIQUE\s+KEY\s+uq_qipai_stores_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_stores', index: 'uq_qipai_stores_tenant_id' }
|
||||
: /ADD\s+UNIQUE\s+KEY\s+uq_qipai_users_tenant_id\b/iu.test(statement)
|
||||
? { table: 'qipai_users', index: 'uq_qipai_users_tenant_id' }
|
||||
: null;
|
||||
if (!target) return false;
|
||||
const [rows] = await pool.query(
|
||||
`SELECT non_unique AS nonUnique,
|
||||
GROUP_CONCAT(column_name ORDER BY seq_in_index SEPARATOR ',') AS columns
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = '${target.table}'
|
||||
AND index_name = '${target.index}'
|
||||
GROUP BY non_unique`
|
||||
);
|
||||
const index = Array.isArray(rows) ? rows[0] as Record<string, unknown> | undefined : undefined;
|
||||
return Number(index?.nonUnique ?? 1) === 0 && index?.columns === 'tenant_id,id';
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
interface DeviceRow extends RowDataPacket {
|
||||
id: string; deviceId: string; storeId: string; roomId: string | null;
|
||||
deviceType: 'CONTROL_BOX' | 'SUB_LOCK' | 'SMART_SOCKET';
|
||||
status: string;
|
||||
status: string; model?: string | null; capabilities?: unknown;
|
||||
}
|
||||
|
||||
export class DeviceControlError extends Error {
|
||||
@@ -47,7 +47,8 @@ export class DeviceControlService {
|
||||
}
|
||||
|
||||
async playTts(context: CommandContext, input: {
|
||||
content: string; volume?: number; playCount?: number; priority?: number;
|
||||
content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
|
||||
speaker?: number; style?: number; speed?: number; intonation?: number;
|
||||
}) {
|
||||
return this.issueControlBox(context, 'PlayTTS',
|
||||
(id) => this.controlBox.playTts({ id, ...input }));
|
||||
@@ -86,18 +87,24 @@ export class DeviceControlService {
|
||||
}
|
||||
|
||||
async controlSubLock(context: CommandContext, input: {
|
||||
subID: string;
|
||||
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
|
||||
holdopen?: 0 | 1; delayTime?: number; content?: string;
|
||||
subtype: '14' | '15'; subID: string;
|
||||
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
|
||||
| 'factoryreset';
|
||||
holdopen?: 0 | 1; delayTime?: number; value?: string;
|
||||
dangerConfirmation?: string;
|
||||
}) {
|
||||
if (['factoryreset'].includes(input.order)) {
|
||||
const credentialOrder = ['setkey', 'delkey', 'setcard', 'delcard', 'factoryreset']
|
||||
.includes(input.order);
|
||||
if (credentialOrder && !this.isManager(context.access)) {
|
||||
throw new DeviceControlError('DEVICE_CREDENTIAL_ACTION_FORBIDDEN');
|
||||
}
|
||||
if (input.order === 'factoryreset') {
|
||||
if (!context.access.roles.includes('PLATFORM_ADMIN')
|
||||
|| input.dangerConfirmation !== 'CONFIRM_FACTORY_RESET') {
|
||||
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
if (['delkey', 'delcard'].includes(input.order) && !input.content) {
|
||||
if (['delkey', 'delcard'].includes(input.order) && input.value === 'all') {
|
||||
if (!context.access.roles.includes('PLATFORM_ADMIN')
|
||||
|| input.dangerConfirmation !== 'CONFIRM_CLEAR_CREDENTIALS') {
|
||||
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
|
||||
@@ -108,9 +115,13 @@ export class DeviceControlService {
|
||||
(id) => this.subLock.control({ id, ...vendorInput }));
|
||||
}
|
||||
|
||||
async readSmartSocket(context: CommandContext, target: 'basicInfo' | 'workInfo') {
|
||||
return this.issueSmartSocket(context, `socket:${target}`,
|
||||
() => this.smartSocket.read(target));
|
||||
async readSmartSocket(context: CommandContext, input: 'basicInfo' | 'workInfo' | {
|
||||
target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
|
||||
slotNum?: number; taskNum?: number;
|
||||
}) {
|
||||
const request = typeof input === 'string' ? { target: input } : input;
|
||||
return this.issueSmartSocket(context, `socket:${request.target}`,
|
||||
() => this.smartSocket.read(request));
|
||||
}
|
||||
|
||||
async switchSmartSocket(context: CommandContext, input: {
|
||||
@@ -122,16 +133,78 @@ export class DeviceControlService {
|
||||
}
|
||||
|
||||
async scheduleSmartSocket(context: CommandContext, input: {
|
||||
taskNum: number; action: 'on' | 'off'; mode: 'once' | 'daily' | 'weekly';
|
||||
slotNum?: number; taskNum: number; action: 'on' | 'off';
|
||||
mode: 'once' | 'daily' | 'weekly';
|
||||
time: string; weekdays?: number[];
|
||||
}) {
|
||||
return this.issueSmartSocket(context, 'localtask',
|
||||
(id) => this.smartSocket.localTask({ id, ...input }), context.orderId);
|
||||
}
|
||||
|
||||
async clearSmartSocketTask(context: CommandContext, taskNum: number) {
|
||||
async clearSmartSocketTask(context: CommandContext, input: number | {
|
||||
slotNum?: number; taskNum: number; dangerConfirmation?: string;
|
||||
}) {
|
||||
const request = typeof input === 'number' ? { taskNum: input } : input;
|
||||
if (request.taskNum === 0) {
|
||||
this.assertDangerousSocketAction(
|
||||
context, request.dangerConfirmation, 'CONFIRM_CLEAR_ALL_SOCKET_TASKS'
|
||||
);
|
||||
}
|
||||
const { dangerConfirmation: _, ...vendorInput } = request;
|
||||
return this.issueSmartSocket(context, 'clearTask',
|
||||
(id) => this.smartSocket.clearTask({ id, taskNum }), context.orderId);
|
||||
(id) => this.smartSocket.clearTask({ id, ...vendorInput }), context.orderId);
|
||||
}
|
||||
|
||||
async rebootSmartSocket(context: CommandContext, dangerConfirmation?: string) {
|
||||
this.assertDangerousSocketAction(context, dangerConfirmation, 'CONFIRM_REBOOT_SOCKET');
|
||||
return this.issueSmartSocket(context, 'reboot', (id) => this.smartSocket.reboot(id));
|
||||
}
|
||||
|
||||
async emptySmartSocketEnergy(context: CommandContext, dangerConfirmation?: string) {
|
||||
this.assertDangerousSocketAction(
|
||||
context, dangerConfirmation, 'CONFIRM_EMPTY_SOCKET_ENERGY'
|
||||
);
|
||||
return this.issueSmartSocket(context, 'emptyPower',
|
||||
(id) => this.smartSocket.emptyPower(id));
|
||||
}
|
||||
|
||||
async resetSmartSocket(context: CommandContext, input: {
|
||||
password: string; dangerConfirmation?: string;
|
||||
}) {
|
||||
this.assertDangerousSocketAction(
|
||||
context, input.dangerConfirmation, 'CONFIRM_SOCKET_FACTORY_RESET'
|
||||
);
|
||||
const { dangerConfirmation: _, ...vendorInput } = input;
|
||||
return this.issueSmartSocket(context, 'returnFactory',
|
||||
(id) => this.smartSocket.returnFactory({ id, ...vendorInput }));
|
||||
}
|
||||
|
||||
async changeSmartSocketReturnKey(context: CommandContext, input: {
|
||||
old: string; new: string; dangerConfirmation?: string;
|
||||
}) {
|
||||
this.assertDangerousSocketAction(
|
||||
context, input.dangerConfirmation, 'CONFIRM_CHANGE_SOCKET_RETURN_KEY'
|
||||
);
|
||||
return this.issueSmartSocket(context, 'setReturnKey',
|
||||
(id) => this.smartSocket.setReturnKey({ id, old: input.old, new: input.new }));
|
||||
}
|
||||
|
||||
async configureSmartSocketProtection(context: CommandContext, input: {
|
||||
maxPower: number; maxCurrent: number; maxTemperature: number;
|
||||
pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number;
|
||||
chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number;
|
||||
}) {
|
||||
return this.issueSmartSocket(context, 'protectOff',
|
||||
(id, device) => this.smartSocket.protectOff({
|
||||
id, ratedAmps: readRatedAmps(device), ...input
|
||||
}));
|
||||
}
|
||||
|
||||
async configureSmartSocketParameter(context: CommandContext, input: {
|
||||
resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1;
|
||||
}) {
|
||||
return this.issueSmartSocket(context, 'parameter',
|
||||
(id) => this.smartSocket.parameter({ id, ...input }));
|
||||
}
|
||||
|
||||
private async issueControlBox(
|
||||
@@ -143,6 +216,7 @@ export class DeviceControlService {
|
||||
this.assertWriteScope(context.access, context.storeId);
|
||||
const device = await this.resolveControlBox(context);
|
||||
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||
await this.auditCommand(context, device, commandType, orderId);
|
||||
return this.commands.issue({
|
||||
tenantId: context.tenantId,
|
||||
assetId: String(device.id),
|
||||
@@ -160,12 +234,13 @@ export class DeviceControlService {
|
||||
private async issueSmartSocket(
|
||||
context: CommandContext,
|
||||
commandType: string,
|
||||
payloadFactory: (id: string) => Record<string, unknown>,
|
||||
payloadFactory: (id: string, device: DeviceRow) => Record<string, unknown>,
|
||||
orderId?: string | null
|
||||
) {
|
||||
this.assertWriteScope(context.access, context.storeId);
|
||||
const device = await this.resolveSmartSocket(context);
|
||||
if (device.status === 'OFFLINE') throw new DeviceControlError('DEVICE_OFFLINE');
|
||||
await this.auditCommand(context, device, commandType, orderId);
|
||||
return this.commands.issue({
|
||||
tenantId: context.tenantId,
|
||||
assetId: String(device.id),
|
||||
@@ -174,7 +249,7 @@ export class DeviceControlService {
|
||||
roomId: context.roomId,
|
||||
orderId,
|
||||
commandType,
|
||||
payloadFactory,
|
||||
payloadFactory: (id) => payloadFactory(id, device),
|
||||
traceId: context.traceId,
|
||||
expiresAt: context.expiresAt
|
||||
});
|
||||
@@ -183,7 +258,7 @@ export class DeviceControlService {
|
||||
private async resolveControlBox(context: CommandContext) {
|
||||
const [rows] = await this.pool.execute<DeviceRow[]>(
|
||||
`SELECT id, device_id AS deviceId, store_id AS storeId, room_id AS roomId,
|
||||
device_type AS deviceType, status
|
||||
device_type AS deviceType, status, model, capabilities
|
||||
FROM qipai_devices
|
||||
WHERE tenant_id = ? AND store_id = ? AND room_id = ?
|
||||
AND device_type = 'CONTROL_BOX' AND deleted_at IS NULL
|
||||
@@ -217,6 +292,47 @@ export class DeviceControlService {
|
||||
}
|
||||
}
|
||||
|
||||
private assertDangerousSocketAction(
|
||||
context: CommandContext, actual: string | undefined, expected: string
|
||||
) {
|
||||
if (context.actorType === 'CUSTOMER'
|
||||
|| !context.access.roles.includes('PLATFORM_ADMIN')
|
||||
|| actual !== expected) {
|
||||
throw new DeviceControlError('DEVICE_DANGEROUS_ACTION_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
private async auditCommand(
|
||||
context: CommandContext,
|
||||
device: DeviceRow,
|
||||
commandType: string,
|
||||
orderId?: string | null
|
||||
) {
|
||||
await this.pool.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, ?, ?, 'DEVICE_COMMAND_REQUESTED', 'DEVICE', ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
context.tenantId,
|
||||
context.actorType ?? 'SYSTEM',
|
||||
context.actorId ?? null,
|
||||
device.id,
|
||||
context.traceId,
|
||||
(context.ip ?? '').slice(0, 64),
|
||||
(context.userAgent ?? '').slice(0, 255),
|
||||
JSON.stringify({
|
||||
commandType,
|
||||
storeId: context.storeId,
|
||||
roomId: context.roomId,
|
||||
orderId: orderId ?? context.orderId ?? null,
|
||||
deviceId: device.deviceId,
|
||||
deviceType: device.deviceType
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
private isManager(access: AccessProfile) {
|
||||
return access.roles.some((role) =>
|
||||
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||
@@ -230,6 +346,19 @@ export interface CommandContext {
|
||||
roomId: string;
|
||||
orderId?: string | null;
|
||||
traceId: string;
|
||||
actorType?: 'CUSTOMER' | 'STAFF' | 'ADMIN' | 'SYSTEM';
|
||||
actorId?: string | null;
|
||||
ip?: string;
|
||||
userAgent?: string;
|
||||
access: AccessProfile;
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
|
||||
function readRatedAmps(device: DeviceRow): 10 | 16 | 63 {
|
||||
const source = `${device.model ?? ''} ${JSON.stringify(device.capabilities ?? '')}`;
|
||||
const match = /(?:^|[^0-9])(10|16|63)A(?:$|[^0-9])/i.exec(source);
|
||||
if (match?.[1] === '10') return 10;
|
||||
if (match?.[1] === '16') return 16;
|
||||
if (match?.[1] === '63') return 63;
|
||||
throw new DeviceControlError('SOCKET_RATED_CURRENT_UNKNOWN');
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ interface HardwareSmokeConfig {
|
||||
controlBoxDeviceId: string;
|
||||
smartSocketDeviceId: string;
|
||||
subLockSubId: string;
|
||||
subLockSubtype: '14' | '15' | '';
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
@@ -49,13 +50,17 @@ export function loadHardwareSmokeConfig(env: NodeJS.ProcessEnv = process.env): H
|
||||
controlBoxDeviceId: env.QIPAI_HARDWARE_CONTROL_BOX_DEVICE_ID ?? '',
|
||||
smartSocketDeviceId: env.QIPAI_HARDWARE_SMART_SOCKET_DEVICE_ID ?? '',
|
||||
subLockSubId: env.QIPAI_HARDWARE_SUB_LOCK_SUB_ID ?? '',
|
||||
subLockSubtype: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '15'
|
||||
? '15'
|
||||
: env.QIPAI_HARDWARE_SUB_LOCK_SUBTYPE === '14' ? '14' : '',
|
||||
timeoutMs: Number(env.QIPAI_HARDWARE_SMOKE_TIMEOUT_MS ?? 8000)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildHardwareSmokeCases(
|
||||
config: Pick<HardwareSmokeConfig,
|
||||
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId' | 'allowActions'>
|
||||
'controlBoxDeviceId' | 'smartSocketDeviceId' | 'subLockSubId'
|
||||
| 'subLockSubtype' | 'allowActions'>
|
||||
): HardwareSmokeCase[] {
|
||||
const controlBox = new JilianControlBoxAdapter();
|
||||
const socket = new JilianSmartSocketAdapter();
|
||||
@@ -86,7 +91,7 @@ export function buildHardwareSmokeCases(
|
||||
destructive: true
|
||||
});
|
||||
}
|
||||
if (config.allowActions && config.subLockSubId) {
|
||||
if (config.allowActions && config.subLockSubId && config.subLockSubtype) {
|
||||
cases.push({
|
||||
id: 'sub-lock-open',
|
||||
deviceId: config.controlBoxDeviceId,
|
||||
@@ -94,6 +99,7 @@ export function buildHardwareSmokeCases(
|
||||
commandType: 'CtrlDevice',
|
||||
payload: lock.control({
|
||||
id: generateCommandId(Date.now(), cases.length),
|
||||
subtype: config.subLockSubtype,
|
||||
subID: config.subLockSubId,
|
||||
order: 'open',
|
||||
delayTime: 4
|
||||
|
||||
@@ -32,7 +32,10 @@ export class IotMessageService {
|
||||
const payloadHash = createHash('sha256').update(payload).digest('hex');
|
||||
const topicMatch = /^\/(devicesend|devicewill)\/([A-Za-z0-9_-]{1,64})$/.exec(topic);
|
||||
if (!topicMatch) {
|
||||
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_TOPIC_INVALID');
|
||||
await this.deadLetter(
|
||||
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
|
||||
'MQTT_TOPIC_INVALID'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -46,46 +49,69 @@ export class IotMessageService {
|
||||
);
|
||||
const device = devices[0];
|
||||
if (!device) {
|
||||
await this.deadLetter(null, null, topic, payloadHash, payloadText, 'MQTT_DEVICE_UNKNOWN');
|
||||
await this.deadLetter(
|
||||
null, null, topic, payloadHash, sanitizePayloadTextForStorage(payloadText),
|
||||
'MQTT_DEVICE_UNKNOWN'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let rawPayload: unknown;
|
||||
let normalized: NormalizedVendorMessage;
|
||||
try {
|
||||
rawPayload = JSON.parse(payloadText);
|
||||
normalized = topicMatch[1] === 'devicewill'
|
||||
? normalizeWill(rawPayload)
|
||||
: this.adapters[device.deviceType].parseUplink(rawPayload);
|
||||
if (topicMatch[1] === 'devicewill') {
|
||||
rawPayload = parseWillPayload(payloadText);
|
||||
normalized = normalizeWill(rawPayload);
|
||||
} else {
|
||||
rawPayload = JSON.parse(payloadText);
|
||||
normalized = this.adapters[device.deviceType].parseUplink(rawPayload);
|
||||
}
|
||||
if (normalized.deviceId && normalized.deviceId !== device.deviceId) {
|
||||
throw new Error('Payload DeviceID does not match MQTT topic.');
|
||||
}
|
||||
} catch (error) {
|
||||
await this.deadLetter(
|
||||
device.tenantId, device.id, topic, payloadHash, payloadText,
|
||||
device.tenantId, device.id, topic, payloadHash,
|
||||
sanitizePayloadTextForStorage(payloadText),
|
||||
'MQTT_PAYLOAD_INVALID', error instanceof Error ? error.message : 'Invalid payload'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const safePayload = sanitizeSensitivePayload(rawPayload, normalized);
|
||||
normalized = { ...normalized, payload: safePayload };
|
||||
const safeRawPayload = sanitizeSensitivePayload(rawPayload, normalized);
|
||||
const safeNormalizedPayload = sanitizeSensitivePayload(normalized.payload, normalized);
|
||||
normalized = { ...normalized, payload: safeNormalizedPayload };
|
||||
const protocolCode = normalized.result === 'UNKNOWN_VENDOR_RESULT'
|
||||
? 'UNKNOWN_VENDOR_RESULT'
|
||||
: normalized.protocolIssues[0] ?? null;
|
||||
const processingStatus = protocolCode
|
||||
? 'PROTOCOL_ERROR'
|
||||
: 'PROCESSED';
|
||||
const [result] = await this.pool.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_iot_device_events
|
||||
(tenant_id, device_id, store_id, room_id, command_id, topic, event_type,
|
||||
payload_hash, raw_payload, normalized_payload, event_at, processing_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'PROCESSED')
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE receive_count = receive_count + 1,
|
||||
received_at = UTC_TIMESTAMP(3)`,
|
||||
[device.tenantId, device.id, device.storeId, device.roomId,
|
||||
normalized.commandId, topic, normalized.eventType, payloadHash,
|
||||
JSON.stringify(safePayload), JSON.stringify(normalized),
|
||||
normalized.eventAt]
|
||||
JSON.stringify(safeRawPayload), JSON.stringify(normalized),
|
||||
normalized.eventAt, processingStatus]
|
||||
);
|
||||
if (result.affectedRows !== 1) return;
|
||||
|
||||
await this.updateDevice(device, normalized, safePayload);
|
||||
await this.updateDevice(device, normalized, safeNormalizedPayload);
|
||||
await this.applyAlerts(device, normalized);
|
||||
if (protocolCode) {
|
||||
await this.deadLetter(
|
||||
device.tenantId, device.id, topic, payloadHash, JSON.stringify(safeRawPayload),
|
||||
protocolCode,
|
||||
protocolCode === 'UNKNOWN_VENDOR_RESULT'
|
||||
? `Unsupported result for ${normalized.eventType}: ${normalized.rawResult ?? '<missing>'}`
|
||||
: `Protocol issue for ${normalized.eventType}: ${protocolCode}`
|
||||
);
|
||||
}
|
||||
if (normalized.kind === 'ACK' && normalized.commandId) {
|
||||
await this.applyAcknowledgement(device, normalized);
|
||||
}
|
||||
@@ -106,7 +132,8 @@ export class IotMessageService {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[input.tenantId, input.assetId, input.storeId, input.roomId ?? null,
|
||||
input.orderId ?? null, input.commandId, input.commandType,
|
||||
JSON.stringify(input.payload), input.traceId, input.expiresAt ?? null]
|
||||
JSON.stringify(sanitizeSensitivePayload(input.payload)),
|
||||
input.traceId, input.expiresAt ?? null]
|
||||
);
|
||||
return { recordId: String(result.insertId), commandId: input.commandId };
|
||||
}
|
||||
@@ -141,8 +168,17 @@ export class IotMessageService {
|
||||
|
||||
private async applyAcknowledgement(device: DeviceRow, message: NormalizedVendorMessage) {
|
||||
const successful = message.result === 'ok';
|
||||
if (successful && message.eventType === 'AddDevice'
|
||||
&& (!readString(message.payload.subID ?? message.payload.subId)
|
||||
|| !readString(message.payload.subtype))) {
|
||||
return;
|
||||
}
|
||||
const status = successful ? 'ACKED' : 'FAILED';
|
||||
const failureCode = successful ? '' : `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
|
||||
const failureCode = successful
|
||||
? ''
|
||||
: message.result === 'UNKNOWN_VENDOR_RESULT'
|
||||
? 'UNKNOWN_VENDOR_RESULT'
|
||||
: `DEVICE_${(message.result ?? 'unknown').toUpperCase()}`;
|
||||
await this.pool.execute(
|
||||
`UPDATE qipai_iot_commands SET status = ?, response_payload = ?,
|
||||
acknowledged_at = UTC_TIMESTAMP(3), failure_code = ?
|
||||
@@ -198,6 +234,14 @@ export class IotMessageService {
|
||||
}
|
||||
|
||||
private async applyAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
|
||||
if (message.result === 'UNKNOWN_VENDOR_RESULT') {
|
||||
await this.upsertAlert(device, 'UNKNOWN_VENDOR_RESULT', 'MEDIUM',
|
||||
`Unsupported vendor result for ${message.eventType}.`);
|
||||
}
|
||||
for (const issue of message.protocolIssues) {
|
||||
await this.upsertAlert(device, issue, 'MEDIUM',
|
||||
`Device payload reported protocol issue ${issue}.`);
|
||||
}
|
||||
const alertResult = ['timeout', 'full', 'unconfirm'].includes(message.result ?? '')
|
||||
? `DEVICE_${message.result?.toUpperCase()}`
|
||||
: null;
|
||||
@@ -217,41 +261,17 @@ export class IotMessageService {
|
||||
}
|
||||
|
||||
private async applySmartSocketAlerts(device: DeviceRow, message: NormalizedVendorMessage) {
|
||||
const payload = message.payload;
|
||||
const eventType = message.eventType.toLowerCase();
|
||||
const triggered = (names: string[]) => names.some((name) =>
|
||||
eventType === name.toLowerCase() || readBoolean(payload[name])
|
||||
);
|
||||
if (triggered(['overload', 'overLoad', 'overpower', 'overPower'])) {
|
||||
await this.upsertAlert(device, 'SOCKET_OVERLOAD', 'HIGH',
|
||||
'Smart socket reported overload or overpower protection.');
|
||||
}
|
||||
if (triggered(['overheat', 'overHeat', 'overTemperature'])) {
|
||||
await this.upsertAlert(device, 'SOCKET_OVERHEAT', 'HIGH',
|
||||
'Smart socket reported over-temperature protection.');
|
||||
}
|
||||
if (triggered(['overcurrent', 'overCurrent'])) {
|
||||
await this.upsertAlert(device, 'SOCKET_OVERCURRENT', 'HIGH',
|
||||
'Smart socket reported over-current protection.');
|
||||
}
|
||||
if (triggered(['overvoltage', 'overVoltage'])) {
|
||||
await this.upsertAlert(device, 'SOCKET_OVERVOLTAGE', 'MEDIUM',
|
||||
'Smart socket reported over-voltage protection.');
|
||||
}
|
||||
if (triggered(['undervoltage', 'underVoltage'])) {
|
||||
await this.upsertAlert(device, 'SOCKET_UNDERVOLTAGE', 'MEDIUM',
|
||||
'Smart socket reported under-voltage protection.');
|
||||
}
|
||||
const powerWatts = readNumber(payload.powerW ?? payload.power ?? payload.watt);
|
||||
if (powerWatts !== null && powerWatts > 3500) {
|
||||
await this.upsertAlert(device, 'SOCKET_POWER_LIMIT', 'HIGH',
|
||||
`Smart socket power is ${powerWatts}W.`);
|
||||
}
|
||||
const temperature = readNumber(payload.temperature ?? payload.temp);
|
||||
if (temperature !== null && temperature >= 75) {
|
||||
await this.upsertAlert(device, 'SOCKET_TEMPERATURE_LIMIT', 'HIGH',
|
||||
`Smart socket temperature is ${temperature}C.`);
|
||||
}
|
||||
if (message.eventType !== 'special') return;
|
||||
const closeReason = readInteger(message.payload.closeReason);
|
||||
const alerts: Record<number, [string, string]> = {
|
||||
3: ['SOCKET_PULL_OUT_STOP', 'Smart socket stopped after appliance removal.'],
|
||||
4: ['SOCKET_CHARGE_FULL_STOP', 'Smart socket stopped after full charge.'],
|
||||
5: ['SOCKET_OVERPOWER', 'Smart socket stopped on maximum power protection.'],
|
||||
6: ['SOCKET_OVERCURRENT', 'Smart socket stopped on maximum current protection.'],
|
||||
7: ['SOCKET_OVERTEMPERATURE', 'Smart socket stopped on maximum temperature protection.']
|
||||
};
|
||||
const alert = closeReason === null ? undefined : alerts[closeReason];
|
||||
if (alert) await this.upsertAlert(device, alert[0], 'HIGH', alert[1]);
|
||||
}
|
||||
|
||||
private async upsertAlert(
|
||||
@@ -305,18 +325,30 @@ export function generateCommandId(now = Date.now(), sequence = 0): string {
|
||||
}
|
||||
|
||||
function normalizeWill(payload: unknown): NormalizedVendorMessage {
|
||||
const record = zRecord(payload);
|
||||
const record = typeof payload === 'string' ? { will: payload } : zRecord(payload);
|
||||
return {
|
||||
kind: 'EVENT',
|
||||
commandId: null,
|
||||
eventType: 'will',
|
||||
result: null,
|
||||
rawResult: null,
|
||||
deviceId: readDeviceId(record),
|
||||
eventAt: null,
|
||||
payload: record
|
||||
payload: record,
|
||||
protocolIssues: []
|
||||
};
|
||||
}
|
||||
|
||||
function parseWillPayload(payloadText: string): unknown {
|
||||
try {
|
||||
return JSON.parse(payloadText);
|
||||
} catch {
|
||||
const will = payloadText.trim();
|
||||
if (!will || will.length > 64) throw new Error('MQTT will payload is invalid.');
|
||||
return will;
|
||||
}
|
||||
}
|
||||
|
||||
function zRecord(payload: unknown): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
throw new Error('MQTT payload must be a JSON object.');
|
||||
@@ -330,26 +362,60 @@ function readDeviceId(record: Record<string, unknown>): string | null {
|
||||
}
|
||||
|
||||
function sanitizeSensitivePayload(
|
||||
payload: unknown, normalized: NormalizedVendorMessage
|
||||
payload: unknown, normalized?: NormalizedVendorMessage
|
||||
): Record<string, unknown> {
|
||||
const record = { ...zRecord(payload) };
|
||||
if (normalized.eventType === 'record' && typeof record.content === 'string') {
|
||||
const record: Record<string, unknown> = typeof payload === 'string'
|
||||
? { will: payload }
|
||||
: { ...zRecord(payload) };
|
||||
const eventType = normalized?.eventType ?? readString(record.event);
|
||||
if (eventType === 'record' && typeof record.content === 'string') {
|
||||
const content = record.content;
|
||||
record.contentHash = createHash('sha256').update(content).digest('hex');
|
||||
record.contentMasked = content.length <= 4
|
||||
? '*'.repeat(content.length)
|
||||
: `${content.slice(0, 2)}${'*'.repeat(Math.min(8, content.length - 4))}${content.slice(-2)}`;
|
||||
addMaskedSecret(record, 'content', content);
|
||||
delete record.content;
|
||||
}
|
||||
const sensitiveLockOrder = ['setkey', 'delkey', 'setcard', 'delcard']
|
||||
.includes(readString(record.order) ?? '');
|
||||
if (record.action === 'CtrlDevice' && sensitiveLockOrder
|
||||
&& typeof record.value === 'string') {
|
||||
addMaskedSecret(record, 'value', record.value);
|
||||
delete record.value;
|
||||
}
|
||||
if (typeof record.password === 'string') {
|
||||
addMaskedSecret(record, 'password', record.password);
|
||||
record.password = '<redacted>';
|
||||
}
|
||||
if (typeof record.card === 'string') {
|
||||
addMaskedSecret(record, 'card', record.card);
|
||||
record.card = '<redacted>';
|
||||
}
|
||||
if (record.action === 'setReturnKey') {
|
||||
for (const field of ['old', 'new']) {
|
||||
if (typeof record[field] === 'string') {
|
||||
addMaskedSecret(record, field, record[field]);
|
||||
delete record[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function addMaskedSecret(
|
||||
record: Record<string, unknown>, field: string, secret: string
|
||||
): void {
|
||||
record[`${field}Hash`] = createHash('sha256').update(secret).digest('hex');
|
||||
record[`${field}Masked`] = secret.length <= 4
|
||||
? '*'.repeat(secret.length)
|
||||
: `${secret.slice(0, 2)}${'*'.repeat(Math.min(8, secret.length - 4))}${secret.slice(-2)}`;
|
||||
}
|
||||
|
||||
function sanitizePayloadTextForStorage(payloadText: string): string {
|
||||
try {
|
||||
return JSON.stringify(sanitizeSensitivePayload(JSON.parse(payloadText)));
|
||||
} catch {
|
||||
return payloadText.slice(0, 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
@@ -362,11 +428,7 @@ function readNumber(value: unknown): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown): boolean {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return value !== 0;
|
||||
if (typeof value === 'string') {
|
||||
return ['1', 'true', 'yes', 'on', 'alarm'].includes(value.trim().toLowerCase());
|
||||
}
|
||||
return false;
|
||||
function readInteger(value: unknown): number | null {
|
||||
const number = readNumber(value);
|
||||
return number !== null && Number.isInteger(number) ? number : null;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const commandId = z.string().regex(/^\d{1,13}$/);
|
||||
const resultCode = z.enum([
|
||||
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full', 'unknown'
|
||||
]);
|
||||
const vendorPasswordField = 'password' as const;
|
||||
const vendorResult = z.string().trim().min(1).max(64);
|
||||
const knownResultCodes = [
|
||||
'ok', 'fail', 'busy', 'unconfirm', 'timeout', 'full',
|
||||
'paraerror', 'error', 'update'
|
||||
] as const;
|
||||
const knownResultCode = z.enum(knownResultCodes);
|
||||
const vendorMessage = z.object({
|
||||
id: commandId.optional(),
|
||||
DeviceID: z.string().min(1).max(64).optional(),
|
||||
deviceID: z.string().min(1).max(64).optional(),
|
||||
IMEI: z.string().max(64).optional(),
|
||||
result: resultCode.optional(),
|
||||
result: vendorResult.optional(),
|
||||
event: z.string().max(64).optional(),
|
||||
action: z.string().max(64).optional(),
|
||||
read: z.string().max(64).optional(),
|
||||
setting: z.string().max(64).optional(),
|
||||
timestamp: z.union([z.string(), z.number()]).optional()
|
||||
}).passthrough();
|
||||
|
||||
export type KnownVendorResult = z.infer<typeof knownResultCode>;
|
||||
export type NormalizedVendorMessage = {
|
||||
kind: 'ACK' | 'EVENT' | 'SNAPSHOT';
|
||||
commandId: string | null;
|
||||
eventType: string;
|
||||
result: z.infer<typeof resultCode> | null;
|
||||
result: KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null;
|
||||
rawResult: string | null;
|
||||
deviceId: string | null;
|
||||
eventAt: Date | null;
|
||||
payload: Record<string, unknown>;
|
||||
protocolIssues: string[];
|
||||
};
|
||||
|
||||
export interface ProtocolAdapter {
|
||||
@@ -60,20 +68,32 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
|
||||
}
|
||||
|
||||
playTts(input: {
|
||||
id: string; content: string; volume?: number; playCount?: number;
|
||||
priority?: number; speaker?: number; style?: number; speed?: number; pitch?: number;
|
||||
id: string; content: string; volume?: number; firstPlay?: 0 | 1; loop?: number;
|
||||
speaker?: number; style?: number; speed?: number; intonation?: number;
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('PlayTTS'), id: commandId,
|
||||
const domain = z.object({
|
||||
id: commandId,
|
||||
content: z.string().trim().min(1).max(500),
|
||||
volume: z.number().int().min(0).max(100).default(80),
|
||||
playCount: z.number().int().min(1).max(10).default(1),
|
||||
priority: z.number().int().min(0).max(10).default(0),
|
||||
speaker: z.number().int().min(0).max(20).default(0),
|
||||
style: z.number().int().min(0).max(20).default(0),
|
||||
speed: z.number().int().min(-500).max(500).default(0),
|
||||
pitch: z.number().int().min(-500).max(500).default(0)
|
||||
}).parse({ action: 'PlayTTS', ...input });
|
||||
volume: z.number().int().min(0).max(10).default(8),
|
||||
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||
loop: z.number().int().positive().max(100).default(1),
|
||||
speaker: z.number().int().min(0).max(5).default(0),
|
||||
style: z.number().int().min(0).max(2).default(0),
|
||||
speed: z.number().int().min(0).max(10).default(5),
|
||||
intonation: z.number().int().min(0).max(10).default(5)
|
||||
}).parse(input);
|
||||
return {
|
||||
action: 'PlayTTS' as const,
|
||||
content: domain.content,
|
||||
vol: domain.volume,
|
||||
firstPlay: domain.firstPlay,
|
||||
loop: domain.loop,
|
||||
speaker: domain.speaker,
|
||||
style: domain.style,
|
||||
speed: domain.speed,
|
||||
intona: domain.intonation,
|
||||
id: domain.id
|
||||
};
|
||||
}
|
||||
|
||||
stopTts(id: string) {
|
||||
@@ -121,31 +141,65 @@ export class JilianControlBoxAdapter implements ProtocolAdapter {
|
||||
|
||||
export class JilianSub1GLockAdapter implements ProtocolAdapter {
|
||||
pair(input: { id: string; timeout?: number }) {
|
||||
return z.object({
|
||||
action: z.literal('AddDevice'), id: commandId,
|
||||
const domain = z.object({
|
||||
id: commandId,
|
||||
timeout: z.number().int().min(10).max(300).default(60)
|
||||
}).parse({ action: 'AddDevice', ...input });
|
||||
}).parse(input);
|
||||
return { action: 'AddDevice' as const, time: domain.timeout, id: domain.id };
|
||||
}
|
||||
|
||||
control(input: {
|
||||
id: string; subID: string;
|
||||
order: 'open' | 'close' | 'setkey' | 'delkey' | 'setcard' | 'delcard' | 'factoryreset';
|
||||
holdopen?: 0 | 1; delayTime?: number; content?: string;
|
||||
id: string; subtype: '14' | '15'; subID: string;
|
||||
order: 'open' | 'close' | 'none' | 'setkey' | 'delkey' | 'setcard' | 'delcard'
|
||||
| 'factoryreset';
|
||||
holdopen?: 0 | 1; delayTime?: number; value?: string;
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('CtrlDevice'), id: commandId,
|
||||
const schema = z.object({
|
||||
id: commandId,
|
||||
subtype: z.enum(['14', '15']),
|
||||
subID: z.string().min(1).max(64),
|
||||
order: z.enum([
|
||||
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||
]),
|
||||
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
|
||||
delayTime: z.number().int().min(1).max(14).optional(),
|
||||
content: z.string().min(1).max(128).optional()
|
||||
value: z.string().min(1).max(128).optional()
|
||||
}).superRefine((value, context) => {
|
||||
if (['setkey', 'setcard'].includes(value.order) && !value.content) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'content is required' });
|
||||
const passwordList = /^(?:\d{6})+$/;
|
||||
const cardList = /^(?:[A-Fa-f0-9]{8})+$/;
|
||||
if (value.order === 'setkey' && !passwordList.test(value.value ?? '')) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 6-digit passwords' });
|
||||
}
|
||||
}).parse({ action: 'CtrlDevice', ...input });
|
||||
if (value.order === 'delkey'
|
||||
&& value.value !== 'all' && !passwordList.test(value.value ?? '')) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 6-digit passwords' });
|
||||
}
|
||||
if (value.order === 'setcard' && !cardList.test(value.value ?? '')) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must contain 8-digit hex card IDs' });
|
||||
}
|
||||
if (value.order === 'delcard'
|
||||
&& value.value !== 'all' && !cardList.test(value.value ?? '')) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value must be all or 8-digit hex card IDs' });
|
||||
}
|
||||
if (['open', 'close', 'none', 'factoryreset'].includes(value.order) && value.value) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'value is not allowed for this order' });
|
||||
}
|
||||
});
|
||||
const domain = schema.parse(input);
|
||||
return {
|
||||
action: 'CtrlDevice' as const,
|
||||
subtype: domain.subtype,
|
||||
subID: domain.subID,
|
||||
order: domain.order,
|
||||
...(domain.holdopen === undefined ? {} : { holdopen: domain.holdopen }),
|
||||
...(domain.delayTime === undefined ? {} : { delayTime: domain.delayTime }),
|
||||
...(domain.value === undefined ? {} : {
|
||||
value: domain.value !== 'all' && ['setcard', 'delcard'].includes(domain.order)
|
||||
? domain.value.toUpperCase()
|
||||
: domain.value
|
||||
}),
|
||||
id: domain.id
|
||||
};
|
||||
}
|
||||
|
||||
parseUplink(payload: unknown) {
|
||||
@@ -154,8 +208,25 @@ export class JilianSub1GLockAdapter implements ProtocolAdapter {
|
||||
}
|
||||
|
||||
export class JilianSmartSocketAdapter implements ProtocolAdapter {
|
||||
read(target: 'basicInfo' | 'workInfo') {
|
||||
return z.object({ read: z.literal(target) }).parse({ read: target });
|
||||
read(input: 'basicInfo' | 'workInfo' | {
|
||||
target: 'basicInfo' | 'workInfo' | 'localtask' | 'mqttConfig';
|
||||
slotNum?: number; taskNum?: number;
|
||||
}) {
|
||||
const domain = typeof input === 'string' ? { target: input } : input;
|
||||
if (domain.target === 'basicInfo' || domain.target === 'mqttConfig') {
|
||||
return { read: domain.target };
|
||||
}
|
||||
if (domain.target === 'workInfo') {
|
||||
return z.object({
|
||||
read: z.literal('workInfo'),
|
||||
slotNum: z.number().int().min(1).max(20).default(1)
|
||||
}).parse({ read: 'workInfo', slotNum: domain.slotNum });
|
||||
}
|
||||
return z.object({
|
||||
read: z.literal('localtask'),
|
||||
slotNum: z.number().int().min(1).max(20).default(1),
|
||||
taskNum: z.number().int().min(0).max(20)
|
||||
}).parse({ read: 'localtask', slotNum: domain.slotNum, taskNum: domain.taskNum });
|
||||
}
|
||||
|
||||
switch(input: { id: string; on: boolean; slotNum?: number }) {
|
||||
@@ -166,29 +237,130 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
|
||||
}
|
||||
|
||||
localTask(input: {
|
||||
id: string; taskNum: number; action: 'on' | 'off';
|
||||
id: string; slotNum?: number; taskNum: number; action: 'on' | 'off';
|
||||
mode: 'once' | 'daily' | 'weekly'; time: string; weekdays?: number[];
|
||||
}) {
|
||||
return z.object({
|
||||
action: z.literal('localtask'), id: commandId,
|
||||
const domain = z.object({
|
||||
id: commandId,
|
||||
slotNum: z.number().int().min(1).max(20).default(1),
|
||||
taskNum: z.number().int().min(1).max(20),
|
||||
switch: z.enum(['on', 'off']),
|
||||
action: z.enum(['on', 'off']),
|
||||
mode: z.enum(['once', 'daily', 'weekly']),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional()
|
||||
}).parse({
|
||||
action: 'localtask', id: input.id, taskNum: input.taskNum,
|
||||
switch: input.action, mode: input.mode, time: input.time, weekdays: input.weekdays
|
||||
});
|
||||
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
|
||||
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
|
||||
.refine((items) => new Set(items).size === items.length, 'weekdays must be unique')
|
||||
.optional()
|
||||
}).superRefine((value, context) => {
|
||||
if (value.mode === 'weekly' && !value.weekdays) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are required weekly' });
|
||||
}
|
||||
if (value.mode !== 'weekly' && value.weekdays) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, message: 'weekdays are only valid weekly' });
|
||||
}
|
||||
}).parse(input);
|
||||
const weekdaySet = new Set(domain.weekdays ?? []);
|
||||
const weekdata = domain.mode === 'daily'
|
||||
? '1111111'
|
||||
: domain.mode === 'weekly'
|
||||
? Array.from({ length: 7 }, (_, index) => weekdaySet.has(index + 1) ? '1' : '0').join('')
|
||||
: '0000000';
|
||||
return {
|
||||
action: 'localtask' as const,
|
||||
slotNum: domain.slotNum,
|
||||
taskNum: domain.taskNum,
|
||||
tasktype: domain.action,
|
||||
cyctype: domain.mode === 'daily' ? 'daycyc' : domain.mode === 'weekly' ? 'weekcyc' : 'once',
|
||||
weekdata,
|
||||
actiontime: domain.time.replace(':', ''),
|
||||
id: domain.id
|
||||
};
|
||||
}
|
||||
|
||||
clearTask(input: { id: string; taskNum: number }) {
|
||||
clearTask(input: { id: string; slotNum?: number; taskNum: number }) {
|
||||
return z.object({
|
||||
action: z.literal('clearTask'), id: commandId,
|
||||
slotNum: z.number().int().min(1).max(20).default(1),
|
||||
taskNum: z.number().int().min(0).max(20)
|
||||
}).parse({ action: 'clearTask', ...input });
|
||||
}
|
||||
|
||||
reboot(id: string) {
|
||||
return z.object({ action: z.literal('reboot'), id: commandId })
|
||||
.parse({ action: 'reboot', id });
|
||||
}
|
||||
|
||||
emptyPower(id: string) {
|
||||
return z.object({ action: z.literal('emptyPower'), id: commandId })
|
||||
.parse({ action: 'emptyPower', id });
|
||||
}
|
||||
|
||||
returnFactory(input: { id: string; password: string }) {
|
||||
return z.object({
|
||||
action: z.literal('returnFactory'), id: commandId,
|
||||
[vendorPasswordField]: z.string().min(1).max(32)
|
||||
}).parse({ action: 'returnFactory', ...input });
|
||||
}
|
||||
|
||||
setReturnKey(input: { id: string; old: string; new: string }) {
|
||||
return z.object({
|
||||
action: z.literal('setReturnKey'), id: commandId,
|
||||
old: z.string().min(1).max(32), new: z.string().min(1).max(32)
|
||||
}).parse({ action: 'setReturnKey', ...input });
|
||||
}
|
||||
|
||||
protectOff(input: {
|
||||
id: string; ratedAmps: 10 | 16 | 63;
|
||||
maxPower: number; maxCurrent: number; maxTemperature: number;
|
||||
pullOutStop: 0 | 1; pullOutPower: number; pullOutSec: number;
|
||||
chargeFullStop: 0 | 1; chargeFullPower: number; chargeFullSec: number;
|
||||
}) {
|
||||
const domain = z.object({
|
||||
id: commandId, ratedAmps: z.union([z.literal(10), z.literal(16), z.literal(63)]),
|
||||
maxPower: z.number().positive(), maxCurrent: z.number().positive(),
|
||||
maxTemperature: z.number().min(1).max(125),
|
||||
pullOutStop: z.union([z.literal(0), z.literal(1)]),
|
||||
pullOutPower: z.number().nonnegative(),
|
||||
pullOutSec: z.number().int().min(1).max(86400),
|
||||
chargeFullStop: z.union([z.literal(0), z.literal(1)]),
|
||||
chargeFullPower: z.number().nonnegative(),
|
||||
chargeFullSec: z.number().int().min(1).max(86400)
|
||||
}).superRefine((value, context) => {
|
||||
const powerCeiling = value.ratedAmps * 250;
|
||||
if (value.maxCurrent > value.ratedAmps) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxCurrent'],
|
||||
message: `maxCurrent exceeds ${value.ratedAmps}A capability` });
|
||||
}
|
||||
if (value.maxPower > powerCeiling) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['maxPower'],
|
||||
message: `maxPower exceeds ${powerCeiling}W capability` });
|
||||
}
|
||||
if (value.pullOutPower > value.maxPower || value.chargeFullPower > value.maxPower) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom,
|
||||
message: 'auto-stop power cannot exceed maxPower' });
|
||||
}
|
||||
}).parse(input);
|
||||
const { ratedAmps: _, ...wire } = domain;
|
||||
return { setting: 'protectOff' as const, ...wire };
|
||||
}
|
||||
|
||||
parameter(input: {
|
||||
id: string; resetHold: 0 | 1; keyLock: 0 | 1; keyOff: 0 | 1;
|
||||
}) {
|
||||
const domain = z.object({
|
||||
id: commandId,
|
||||
resetHold: z.union([z.literal(0), z.literal(1)]),
|
||||
keyLock: z.union([z.literal(0), z.literal(1)]),
|
||||
keyOff: z.union([z.literal(0), z.literal(1)])
|
||||
}).parse(input);
|
||||
return {
|
||||
setting: 'parameter' as const,
|
||||
resetHold: domain.resetHold,
|
||||
keyLock: domain.keyLock,
|
||||
KeyOff: domain.keyOff,
|
||||
id: domain.id
|
||||
};
|
||||
}
|
||||
|
||||
parseUplink(payload: unknown) {
|
||||
return normalizeVendorMessage(payload);
|
||||
}
|
||||
@@ -196,19 +368,145 @@ export class JilianSmartSocketAdapter implements ProtocolAdapter {
|
||||
|
||||
function normalizeVendorMessage(payload: unknown): NormalizedVendorMessage {
|
||||
const parsed = vendorMessage.parse(payload);
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const eventName = parsed.event ?? parsed.action ?? parsed.read ?? 'snapshot';
|
||||
const protocolIssues: string[] = [];
|
||||
const variants = normalizeReceiveVariants(
|
||||
parsed as Record<string, unknown>, protocolIssues
|
||||
);
|
||||
const rawEventName = parsed.event ?? parsed.action ?? parsed.read ?? parsed.setting ?? 'snapshot';
|
||||
const eventName = canonicalMessageName(rawEventName);
|
||||
const record = normalizeSocketStatus(variants, eventName, protocolIssues);
|
||||
const rawResult = parsed.result ?? null;
|
||||
const result = normalizeResult(eventName, rawResult);
|
||||
return {
|
||||
kind: parsed.result ? 'ACK' : parsed.event ? 'EVENT' : 'SNAPSHOT',
|
||||
kind: parsed.event ? 'EVENT' : parsed.result ? 'ACK' : 'SNAPSHOT',
|
||||
commandId: parsed.id ?? null,
|
||||
eventType: eventName,
|
||||
result: parsed.result ?? null,
|
||||
result,
|
||||
rawResult,
|
||||
deviceId: parsed.DeviceID ?? parsed.deviceID ?? null,
|
||||
eventAt: parseEventAt(parsed.timestamp),
|
||||
payload: record
|
||||
payload: record,
|
||||
protocolIssues
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResult(
|
||||
eventName: string, result: string | null
|
||||
): KnownVendorResult | 'UNKNOWN_VENDOR_RESULT' | null {
|
||||
if (result === null) return null;
|
||||
const parsed = knownResultCode.safeParse(result);
|
||||
if (!parsed.success) return 'UNKNOWN_VENDOR_RESULT';
|
||||
const allowedByCommand: Record<string, readonly KnownVendorResult[]> = {
|
||||
PlayTTS: ['ok', 'paraerror'],
|
||||
stopTTS: ['ok'],
|
||||
task: ['ok', 'busy', 'fail', 'unconfirm'],
|
||||
addtask: ['ok', 'fail'],
|
||||
canceltask: ['ok'],
|
||||
AddDevice: ['ok', 'fail', 'timeout'],
|
||||
CtrlDevice: ['ok', 'fail', 'timeout', 'full'],
|
||||
on: ['ok'],
|
||||
off: ['ok', 'update', 'paraerror'],
|
||||
localtask: ['ok', 'paraerror', 'error', 'update'],
|
||||
clearTask: ['ok', 'paraerror', 'error'],
|
||||
reboot: ['ok', 'error'],
|
||||
emptyPower: ['ok', 'paraerror', 'error'],
|
||||
returnFactory: ['ok', 'paraerror', 'error'],
|
||||
setReturnKey: ['ok', 'paraerror', 'error'],
|
||||
protectOff: ['ok', 'paraerror', 'error', 'update'],
|
||||
parameter: ['ok', 'paraerror', 'error', 'update'],
|
||||
mqttConfig: ['ok', 'paraerror', 'error', 'update']
|
||||
};
|
||||
const allowed = allowedByCommand[eventName];
|
||||
return allowed && !allowed.includes(parsed.data) ? 'UNKNOWN_VENDOR_RESULT' : parsed.data;
|
||||
}
|
||||
|
||||
function canonicalMessageName(value: string): string {
|
||||
return value.toLowerCase() === 'mqttconfig' ? 'mqttConfig' : value;
|
||||
}
|
||||
|
||||
function normalizeReceiveVariants(
|
||||
record: Record<string, unknown>, protocolIssues: string[]
|
||||
): Record<string, unknown> {
|
||||
const normalized = { ...record };
|
||||
if (normalized.welcomevoice === undefined && normalized.welvoice !== undefined) {
|
||||
normalized.welcomevoice = normalized.welvoice;
|
||||
}
|
||||
if (normalized.setting === 'mqttconfig') normalized.setting = 'mqttConfig';
|
||||
if (normalized.read === 'mqttconfig') normalized.read = 'mqttConfig';
|
||||
if (normalized.keyOff === undefined && normalized.KeyOff !== undefined) {
|
||||
normalized.keyOff = normalized.KeyOff;
|
||||
}
|
||||
if (normalized.keyOff !== undefined && normalized.KeyOff !== undefined
|
||||
&& String(normalized.keyOff) !== String(normalized.KeyOff)) {
|
||||
protocolIssues.push('SOCKET_KEY_OFF_CONFLICT');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeSocketStatus(
|
||||
record: Record<string, unknown>, eventName: string, protocolIssues: string[]
|
||||
): Record<string, unknown> {
|
||||
const isWorkInfo = eventName === 'workInfo'
|
||||
|| ['onOff', 'vol', 'cur', 'pow', 'temp', 'temperature', 'energy']
|
||||
.some((field) => record[field] !== undefined);
|
||||
if (!isWorkInfo) return record;
|
||||
const normalized = { ...record };
|
||||
const temperature = normalizeDecimalField(record.temp, 'temp', protocolIssues);
|
||||
const temperatureVariant = normalizeDecimalField(
|
||||
record.temperature, 'temperature', protocolIssues
|
||||
);
|
||||
if (temperature !== null && temperatureVariant !== null
|
||||
&& temperature !== temperatureVariant) {
|
||||
protocolIssues.push('SOCKET_TEMPERATURE_CONFLICT');
|
||||
}
|
||||
const switchOn = normalizeOnOff(record.onOff);
|
||||
if (record.onOff !== undefined && switchOn === null) {
|
||||
protocolIssues.push('SOCKET_ON_OFF_INVALID');
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
...(switchOn === null ? {} : { switchOn }),
|
||||
...decimalProperty('voltage', record.vol, 'vol', protocolIssues),
|
||||
...decimalProperty('current', record.cur, 'cur', protocolIssues),
|
||||
...decimalProperty('power', record.pow, 'pow', protocolIssues),
|
||||
...((temperature ?? temperatureVariant) === null
|
||||
? {}
|
||||
: { temperature: temperature ?? temperatureVariant }),
|
||||
...decimalProperty('energy', record.energy, 'energy', protocolIssues)
|
||||
};
|
||||
}
|
||||
|
||||
function decimalProperty(
|
||||
name: string, value: unknown, source: string, protocolIssues: string[]
|
||||
): Record<string, string> {
|
||||
const normalized = normalizeDecimalField(value, source, protocolIssues);
|
||||
return normalized === null ? {} : { [name]: normalized };
|
||||
}
|
||||
|
||||
function normalizeDecimalField(
|
||||
value: unknown, source: string, protocolIssues: string[]
|
||||
): string | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const text = typeof value === 'number' && Number.isFinite(value)
|
||||
? String(value)
|
||||
: typeof value === 'string' ? value.trim() : '';
|
||||
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
|
||||
if (!match) {
|
||||
protocolIssues.push(`SOCKET_${source.toUpperCase()}_INVALID`);
|
||||
return null;
|
||||
}
|
||||
const integer = match[2].replace(/^0+(?=\d)/, '');
|
||||
const fraction = (match[3] ?? '').replace(/0+$/, '');
|
||||
const zero = integer === '0' && !fraction;
|
||||
return `${zero ? '' : match[1]}${integer}${fraction ? `.${fraction}` : ''}`;
|
||||
}
|
||||
|
||||
function normalizeOnOff(value: unknown): boolean | null {
|
||||
if (value === true || value === 1 || value === '1' || value === 'on') return true;
|
||||
if (value === false || value === 0 || value === '0' || value === 'off') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseEventAt(value: string | number | undefined): Date | null {
|
||||
if (value === undefined) return null;
|
||||
const date = typeof value === 'number'
|
||||
|
||||
@@ -60,12 +60,17 @@ export class OrderDeviceAutomationService {
|
||||
}
|
||||
if (payload.event === 'ORDER_ROOM_CHANGED') {
|
||||
if (payload.previousRoomId && payload.previousRoomId !== order.roomId) {
|
||||
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
|
||||
if (!await this.hasActiveRoomOrder(order.tenantId, payload.previousRoomId, order.id)) {
|
||||
await this.cancelRoomDevices(order, payload.traceId, payload.previousRoomId);
|
||||
}
|
||||
}
|
||||
await this.startOrderDevices(order, payload.traceId);
|
||||
return { orderId: order.id, action: 'ROOM_CHANGED' };
|
||||
}
|
||||
|
||||
if (await this.hasActiveRoomOrder(order.tenantId, order.roomId, order.id)) {
|
||||
return { orderId: order.id, skipped: true, reason: 'ROOM_HAS_ACTIVE_ORDER' };
|
||||
}
|
||||
await this.cancelRoomDevices(order, payload.traceId, order.roomId);
|
||||
return { orderId: order.id, action: 'CANCELLED' };
|
||||
}
|
||||
@@ -107,10 +112,22 @@ export class OrderDeviceAutomationService {
|
||||
roomId,
|
||||
orderId: order.id,
|
||||
traceId,
|
||||
actorType: 'SYSTEM' as const,
|
||||
access: systemDeviceAccess()
|
||||
};
|
||||
}
|
||||
|
||||
private async hasActiveRoomOrder(tenantId: string, roomId: string, excludedOrderId: string) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_orders
|
||||
WHERE tenant_id = ? AND room_id = ? AND id <> ?
|
||||
AND status IN ('PAID', 'RESERVED', 'IN_PROGRESS') AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[tenantId, roomId, excludedOrderId]
|
||||
);
|
||||
return Boolean(rows[0]);
|
||||
}
|
||||
|
||||
private async loadOrder(tenantId: string, orderId: string) {
|
||||
const [rows] = await this.pool.execute<OrderRow[]>(
|
||||
`SELECT id, tenant_id AS tenantId, store_id AS storeId, room_id AS roomId,
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export type FranchiseStatus = 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'REJECTED' | 'CONVERTED';
|
||||
export type FollowUpType = 'CALL' | 'WECHAT' | 'MEETING' | 'NOTE' | 'STATUS' | 'ASSIGNMENT';
|
||||
|
||||
export interface FranchiseApplicationInput {
|
||||
tenantId: string;
|
||||
submittedUserId?: string | null;
|
||||
city: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
message: string;
|
||||
source: 'MINIAPP' | 'ADMIN' | 'IMPORT';
|
||||
clientRequestId: string;
|
||||
traceId: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
}
|
||||
|
||||
interface ApplicationRow extends RowDataPacket {
|
||||
id: string; tenantId: string; applicationNo: string; city: string; contactName: string;
|
||||
contactPhone: string; message: string; source: string; status: FranchiseStatus;
|
||||
assigneeUserId: string | null; assigneeName: string | null; submittedUserId: string | null;
|
||||
nextFollowUpAt: Date | null; closedAt: Date | null; createdAt: Date; updatedAt: Date;
|
||||
}
|
||||
interface FollowUpRow extends RowDataPacket {
|
||||
id: string; actorUserId: string; actorName: string; followUpType: FollowUpType;
|
||||
fromStatus: FranchiseStatus | null; toStatus: FranchiseStatus | null;
|
||||
note: string; nextFollowUpAt: Date | null; createdAt: Date;
|
||||
}
|
||||
interface StatusRow extends RowDataPacket { status: FranchiseStatus }
|
||||
|
||||
export class FranchiseError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class FranchiseRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async submitApplication(input: FranchiseApplicationInput) {
|
||||
return this.transaction(async (connection) => {
|
||||
const applicationNo = `FR${Date.now().toString(36).toUpperCase()}${randomUUID().slice(0, 6).toUpperCase()}`;
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_franchise_applications
|
||||
(tenant_id, application_no, client_request_id, city, contact_name,
|
||||
contact_phone, message, source, submitted_user_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
||||
[input.tenantId, applicationNo, input.clientRequestId, input.city, input.contactName,
|
||||
input.contactPhone, input.message, input.source, input.submittedUserId ?? null]
|
||||
);
|
||||
const applicationId = String(result.insertId);
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { applicationNo: string }>>(
|
||||
`SELECT application_no AS applicationNo FROM qipai_franchise_applications
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, applicationId]
|
||||
);
|
||||
const created = String(rows[0]?.applicationNo ?? '') === applicationNo;
|
||||
if (created) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, ?, ?, 'FRANCHISE_APPLICATION_SUBMITTED', 'FRANCHISE_APPLICATION',
|
||||
?, ?, ?, ?, JSON_OBJECT('source', ?, 'city', ?))`,
|
||||
[input.tenantId, input.submittedUserId ? 'USER' : 'ANONYMOUS',
|
||||
input.submittedUserId ?? null, applicationId, input.traceId, input.ip,
|
||||
input.userAgent.slice(0, 255), input.source, input.city]
|
||||
);
|
||||
}
|
||||
return { applicationId, applicationNo: String(rows[0]?.applicationNo ?? applicationNo), idempotent: !created };
|
||||
});
|
||||
}
|
||||
|
||||
async listApplications(input: {
|
||||
tenantId: string; page: number; pageSize: number; status?: FranchiseStatus;
|
||||
assigneeUserId?: string; search?: string;
|
||||
}) {
|
||||
const where = ['a.tenant_id = ?', 'a.deleted_at IS NULL'];
|
||||
const params: Array<string> = [input.tenantId];
|
||||
if (input.status) { where.push('a.status = ?'); params.push(input.status); }
|
||||
if (input.assigneeUserId) { where.push('a.assignee_user_id = ?'); params.push(input.assigneeUserId); }
|
||||
if (input.search) {
|
||||
where.push('(a.application_no LIKE ? OR a.city LIKE ? OR a.contact_name LIKE ? OR a.contact_phone LIKE ?)');
|
||||
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||
}
|
||||
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_franchise_applications a WHERE ${where.join(' AND ')}`,
|
||||
params
|
||||
);
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const [rows] = await this.pool.execute<ApplicationRow[]>(
|
||||
`${this.applicationSelect()} WHERE ${where.join(' AND ')}
|
||||
ORDER BY FIELD(a.status, 'NEW', 'CONTACTED', 'QUALIFIED', 'CONVERTED', 'REJECTED'),
|
||||
a.next_follow_up_at IS NULL, a.next_follow_up_at, a.id DESC
|
||||
LIMIT ${input.pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
return { items: rows.map(normalizeApplication), total: Number(counts[0]?.total ?? 0), page: input.page, pageSize: input.pageSize };
|
||||
}
|
||||
|
||||
async getApplication(tenantId: string, applicationId: string) {
|
||||
const [rows] = await this.pool.execute<ApplicationRow[]>(
|
||||
`${this.applicationSelect()} WHERE a.tenant_id = ? AND a.id = ? AND a.deleted_at IS NULL`,
|
||||
[tenantId, applicationId]
|
||||
);
|
||||
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
|
||||
const [followUps] = await this.pool.execute<FollowUpRow[]>(
|
||||
`SELECT f.id, f.actor_user_id AS actorUserId, u.nickname AS actorName,
|
||||
f.follow_up_type AS followUpType, f.from_status AS fromStatus,
|
||||
f.to_status AS toStatus, f.note, f.next_follow_up_at AS nextFollowUpAt,
|
||||
f.created_at AS createdAt
|
||||
FROM qipai_franchise_follow_ups f
|
||||
INNER JOIN qipai_users u ON u.id = f.actor_user_id
|
||||
WHERE f.tenant_id = ? AND f.application_id = ? ORDER BY f.id DESC`,
|
||||
[tenantId, applicationId]
|
||||
);
|
||||
return { application: normalizeApplication(rows[0]), followUps: followUps.map((row) => ({ ...row, id: String(row.id), actorUserId: String(row.actorUserId) })) };
|
||||
}
|
||||
|
||||
async assignApplication(actor: ManagementActor, tenantId: string, applicationId: string, assigneeUserId: string | null) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockApplication(connection, tenantId, applicationId);
|
||||
if (assigneeUserId) {
|
||||
const [users] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_users WHERE tenant_id = ? AND id = ? AND user_type = 'STAFF'
|
||||
AND status = 'ACTIVE' AND deleted_at IS NULL`,
|
||||
[tenantId, assigneeUserId]
|
||||
);
|
||||
if (!users[0]) throw new FranchiseError('FRANCHISE_ASSIGNEE_INVALID');
|
||||
}
|
||||
await connection.execute(
|
||||
'UPDATE qipai_franchise_applications SET assignee_user_id = ? WHERE tenant_id = ? AND id = ?',
|
||||
[assigneeUserId, tenantId, applicationId]
|
||||
);
|
||||
await this.addEvent(connection, actor, tenantId, applicationId, 'ASSIGNMENT', null, null,
|
||||
assigneeUserId ? '分派加盟线索负责人' : '取消加盟线索分派', null);
|
||||
await this.audit(connection, actor, tenantId, 'FRANCHISE_APPLICATION_ASSIGNED', applicationId,
|
||||
{ assigneeUserId });
|
||||
return { applicationId, assigneeUserId };
|
||||
});
|
||||
}
|
||||
|
||||
async addFollowUp(actor: ManagementActor, tenantId: string, applicationId: string, input: {
|
||||
followUpType: Exclude<FollowUpType, 'ASSIGNMENT' | 'STATUS'>;
|
||||
note: string; nextFollowUpAt?: Date | null; status?: FranchiseStatus;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const current = await this.lockApplication(connection, tenantId, applicationId);
|
||||
const nextStatus = input.status ?? current.status;
|
||||
if (nextStatus !== current.status && !allowedTransitions[current.status].includes(nextStatus)) {
|
||||
throw new FranchiseError('FRANCHISE_STATUS_TRANSITION_INVALID');
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_franchise_applications SET status = ?, next_follow_up_at = ?,
|
||||
closed_at = CASE WHEN ? IN ('REJECTED', 'CONVERTED') THEN UTC_TIMESTAMP(3) ELSE NULL END
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[nextStatus, input.nextFollowUpAt ?? null, nextStatus, tenantId, applicationId]
|
||||
);
|
||||
const followUpId = await this.addEvent(connection, actor, tenantId, applicationId,
|
||||
input.followUpType, current.status, nextStatus, input.note, input.nextFollowUpAt ?? null);
|
||||
await this.audit(connection, actor, tenantId, 'FRANCHISE_FOLLOW_UP_ADDED', applicationId,
|
||||
{ followUpId, followUpType: input.followUpType, fromStatus: current.status, toStatus: nextStatus });
|
||||
return { applicationId, followUpId, status: nextStatus };
|
||||
});
|
||||
}
|
||||
|
||||
private applicationSelect() {
|
||||
return `SELECT a.id, a.tenant_id AS tenantId, a.application_no AS applicationNo,
|
||||
a.city, a.contact_name AS contactName, a.contact_phone AS contactPhone,
|
||||
a.message, a.source, a.status, a.assignee_user_id AS assigneeUserId,
|
||||
assignee.nickname AS assigneeName, a.submitted_user_id AS submittedUserId,
|
||||
a.next_follow_up_at AS nextFollowUpAt, a.closed_at AS closedAt,
|
||||
a.created_at AS createdAt, a.updated_at AS updatedAt
|
||||
FROM qipai_franchise_applications a
|
||||
LEFT JOIN qipai_users assignee ON assignee.id = a.assignee_user_id AND assignee.tenant_id = a.tenant_id`;
|
||||
}
|
||||
|
||||
private async lockApplication(connection: PoolConnection, tenantId: string, id: string) {
|
||||
const [rows] = await connection.execute<StatusRow[]>(
|
||||
'SELECT status FROM qipai_franchise_applications WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE',
|
||||
[tenantId, id]
|
||||
);
|
||||
if (!rows[0]) throw new FranchiseError('FRANCHISE_APPLICATION_NOT_FOUND');
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
private async addEvent(connection: PoolConnection, actor: ManagementActor, tenantId: string,
|
||||
applicationId: string, type: FollowUpType, fromStatus: FranchiseStatus | null,
|
||||
toStatus: FranchiseStatus | null, note: string, nextFollowUpAt: Date | null) {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_franchise_follow_ups
|
||||
(tenant_id, application_id, actor_user_id, follow_up_type, from_status,
|
||||
to_status, note, next_follow_up_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[tenantId, applicationId, actor.userId, type, fromStatus, toStatus, note, nextFollowUpAt]
|
||||
);
|
||||
return String(result.insertId);
|
||||
}
|
||||
|
||||
private async audit(connection: PoolConnection, actor: ManagementActor, tenantId: string,
|
||||
action: string, resourceId: string, metadata: object) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, ?,
|
||||
'FRANCHISE_APPLICATION', ?, ?, ?, ?, ?)`,
|
||||
[tenantId, actor.userId, action, resourceId, actor.traceId, actor.ip,
|
||||
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||
catch (error) { await connection.rollback(); throw error; }
|
||||
finally { connection.release(); }
|
||||
}
|
||||
}
|
||||
|
||||
const allowedTransitions: Record<FranchiseStatus, FranchiseStatus[]> = {
|
||||
NEW: ['CONTACTED', 'REJECTED'], CONTACTED: ['QUALIFIED', 'REJECTED'],
|
||||
QUALIFIED: ['CONTACTED', 'CONVERTED', 'REJECTED'], REJECTED: ['CONTACTED'], CONVERTED: []
|
||||
};
|
||||
|
||||
function normalizeApplication(row: ApplicationRow) {
|
||||
return { ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||
assigneeUserId: row.assigneeUserId === null ? null : String(row.assigneeUserId),
|
||||
submittedUserId: row.submittedUserId === null ? null : String(row.submittedUserId) };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
import type { RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
interface OrderSummaryRow extends RowDataPacket {
|
||||
orderTotal: number;
|
||||
activeOrderTotal: number;
|
||||
finishedOrderTotal: number;
|
||||
bookedAmountCents: number;
|
||||
payingMemberTotal: number;
|
||||
}
|
||||
|
||||
interface OrderStatusRow extends RowDataPacket {
|
||||
status: string;
|
||||
total: number;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
interface PaymentChannelRow extends RowDataPacket {
|
||||
channel: string;
|
||||
total: number;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
interface DailyRevenueRow extends RowDataPacket {
|
||||
date: string | Date;
|
||||
total: number;
|
||||
amountCents: number;
|
||||
}
|
||||
|
||||
interface RoomSummaryRow extends RowDataPacket {
|
||||
roomTotal: number;
|
||||
availableRoomTotal: number;
|
||||
attentionRoomTotal: number;
|
||||
}
|
||||
|
||||
interface VoucherSummaryRow extends RowDataPacket {
|
||||
voucherTotal: number;
|
||||
voucherSucceeded: number;
|
||||
voucherFailed: number;
|
||||
}
|
||||
|
||||
export class BusinessStatisticsError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class BusinessStatisticsRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async overview(
|
||||
actor: ManagementActor,
|
||||
input: { storeId: string; from: Date; to: Date }
|
||||
) {
|
||||
this.assertStoreScope(actor, input.storeId);
|
||||
const rangeParams = [actor.tenantId, input.storeId, input.from, input.to];
|
||||
const [
|
||||
orderSummaryResult,
|
||||
orderStatusResult,
|
||||
paymentChannelResult,
|
||||
dailyRevenueResult,
|
||||
roomSummaryResult,
|
||||
voucherSummaryResult
|
||||
] = await Promise.all([
|
||||
this.pool.execute<OrderSummaryRow[]>(
|
||||
`SELECT COUNT(*) AS orderTotal,
|
||||
COALESCE(SUM(o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS')), 0) AS activeOrderTotal,
|
||||
COALESCE(SUM(o.status = 'FINISHED'), 0) AS finishedOrderTotal,
|
||||
COALESCE(SUM(o.total_amount_cents), 0) AS bookedAmountCents,
|
||||
COUNT(DISTINCT owner.user_id) AS payingMemberTotal
|
||||
FROM qipai_orders o
|
||||
LEFT JOIN (
|
||||
SELECT tenant_id, order_id, MIN(user_id) AS user_id
|
||||
FROM qipai_order_user_access
|
||||
WHERE access_type = 'OWNER'
|
||||
GROUP BY tenant_id, order_id
|
||||
) owner ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id
|
||||
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
|
||||
AND o.created_at >= ? AND o.created_at < ?`,
|
||||
rangeParams
|
||||
),
|
||||
this.pool.execute<OrderStatusRow[]>(
|
||||
`SELECT o.status, COUNT(*) AS total,
|
||||
COALESCE(SUM(o.total_amount_cents), 0) AS amountCents
|
||||
FROM qipai_orders o
|
||||
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
|
||||
AND o.created_at >= ? AND o.created_at < ?
|
||||
GROUP BY o.status ORDER BY total DESC, o.status`,
|
||||
rangeParams
|
||||
),
|
||||
this.pool.execute<PaymentChannelRow[]>(
|
||||
`SELECT p.channel, COUNT(*) AS total,
|
||||
COALESCE(SUM(p.amount_cents), 0) AS amountCents
|
||||
FROM qipai_payments p
|
||||
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
|
||||
AND p.paid_at >= ? AND p.paid_at < ?
|
||||
GROUP BY p.channel ORDER BY amountCents DESC, p.channel`,
|
||||
rangeParams
|
||||
),
|
||||
this.pool.execute<DailyRevenueRow[]>(
|
||||
`SELECT DATE_FORMAT(p.paid_at, '%Y-%m-%d') AS date, COUNT(*) AS total,
|
||||
COALESCE(SUM(p.amount_cents), 0) AS amountCents
|
||||
FROM qipai_payments p
|
||||
WHERE p.tenant_id = ? AND p.store_id = ? AND p.status = 'SUCCEEDED'
|
||||
AND p.paid_at >= ? AND p.paid_at < ?
|
||||
GROUP BY DATE_FORMAT(p.paid_at, '%Y-%m-%d') ORDER BY date`,
|
||||
rangeParams
|
||||
),
|
||||
this.pool.execute<RoomSummaryRow[]>(
|
||||
`SELECT COUNT(*) AS roomTotal,
|
||||
COALESCE(SUM(configuration_status = 'ENABLED'
|
||||
AND operational_status = 'AVAILABLE'), 0) AS availableRoomTotal,
|
||||
COALESCE(SUM(configuration_status = 'DISABLED'
|
||||
OR operational_status IN ('MAINTENANCE', 'CLEANING_REQUIRED')), 0) AS attentionRoomTotal
|
||||
FROM qipai_rooms
|
||||
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`,
|
||||
[actor.tenantId, input.storeId]
|
||||
),
|
||||
this.pool.execute<VoucherSummaryRow[]>(
|
||||
`SELECT COUNT(*) AS voucherTotal,
|
||||
COALESCE(SUM(status = 'SUCCEEDED'), 0) AS voucherSucceeded,
|
||||
COALESCE(SUM(status = 'FAILED'), 0) AS voucherFailed
|
||||
FROM qipai_group_redemptions
|
||||
WHERE tenant_id = ? AND store_id = ? AND created_at >= ? AND created_at < ?`,
|
||||
rangeParams
|
||||
)
|
||||
]);
|
||||
const order = orderSummaryResult[0][0];
|
||||
const room = roomSummaryResult[0][0];
|
||||
const voucher = voucherSummaryResult[0][0];
|
||||
const paymentChannels = paymentChannelResult[0].map((row) => ({
|
||||
channel: row.channel,
|
||||
total: Number(row.total),
|
||||
amountCents: Number(row.amountCents)
|
||||
}));
|
||||
return {
|
||||
storeId: input.storeId,
|
||||
from: input.from.toISOString(),
|
||||
to: input.to.toISOString(),
|
||||
summary: {
|
||||
orderTotal: Number(order?.orderTotal ?? 0),
|
||||
activeOrderTotal: Number(order?.activeOrderTotal ?? 0),
|
||||
finishedOrderTotal: Number(order?.finishedOrderTotal ?? 0),
|
||||
bookedAmountCents: Number(order?.bookedAmountCents ?? 0),
|
||||
collectedAmountCents: paymentChannels.reduce((sum, row) => sum + row.amountCents, 0),
|
||||
payingMemberTotal: Number(order?.payingMemberTotal ?? 0),
|
||||
voucherTotal: Number(voucher?.voucherTotal ?? 0),
|
||||
voucherSucceeded: Number(voucher?.voucherSucceeded ?? 0),
|
||||
voucherFailed: Number(voucher?.voucherFailed ?? 0),
|
||||
roomTotal: Number(room?.roomTotal ?? 0),
|
||||
availableRoomTotal: Number(room?.availableRoomTotal ?? 0),
|
||||
attentionRoomTotal: Number(room?.attentionRoomTotal ?? 0)
|
||||
},
|
||||
orderStatuses: orderStatusResult[0].map((row) => ({
|
||||
status: row.status,
|
||||
total: Number(row.total),
|
||||
amountCents: Number(row.amountCents)
|
||||
})),
|
||||
paymentChannels,
|
||||
dailyRevenue: dailyRevenueResult[0].map((row) => ({
|
||||
date: formatDate(row.date),
|
||||
total: Number(row.total),
|
||||
amountCents: Number(row.amountCents)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
if (!actor.access.capabilities.includes('store.operation.read')
|
||||
|| !actor.access.storeIds.includes(storeId)) {
|
||||
throw new BusinessStatisticsError('BUSINESS_STATISTICS_FORBIDDEN');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string | Date) {
|
||||
if (typeof value === 'string') return value.slice(0, 10);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export interface AuditLogQuery {
|
||||
tenantId: string; page: number; pageSize: number; action?: string; resourceType?: string;
|
||||
actorId?: string; search?: string; from?: Date; to?: Date;
|
||||
}
|
||||
|
||||
interface AuditRow extends RowDataPacket {
|
||||
id: string; tenantId: string; actorType: string; actorId: string | null; actorName: string | null;
|
||||
action: string; resourceType: string; resourceId: string | null; traceId: string;
|
||||
ip: string; userAgent: string; metadata: unknown; createdAt: Date;
|
||||
}
|
||||
|
||||
export class SystemOperationsError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class SystemOperationsRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async listAuditLogs(input: AuditLogQuery) {
|
||||
const page = Number.isSafeInteger(input.page) && input.page > 0 ? input.page : 1;
|
||||
const pageSize = Number.isSafeInteger(input.pageSize)
|
||||
? Math.min(100, Math.max(1, input.pageSize)) : 20;
|
||||
const where = ['l.tenant_id = ?']; const params: Array<string | Date> = [input.tenantId];
|
||||
if (input.action) { where.push('l.action = ?'); params.push(input.action); }
|
||||
if (input.resourceType) { where.push('l.resource_type = ?'); params.push(input.resourceType); }
|
||||
if (input.actorId) { where.push('l.actor_id = ?'); params.push(input.actorId); }
|
||||
if (input.from) { where.push('l.created_at >= ?'); params.push(input.from); }
|
||||
if (input.to) { where.push('l.created_at < ?'); params.push(input.to); }
|
||||
if (input.search) {
|
||||
where.push('(l.action LIKE ? OR l.resource_type LIKE ? OR l.trace_id LIKE ? OR u.nickname LIKE ?)');
|
||||
const term = `%${input.search}%`; params.push(term, term, term, term);
|
||||
}
|
||||
const [counts] = await this.pool.execute<Array<RowDataPacket & { total: number }>>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_audit_logs l
|
||||
LEFT JOIN qipai_users u ON u.id = l.actor_id WHERE ${where.join(' AND ')}`, params
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [rows] = await this.pool.execute<AuditRow[]>(
|
||||
`SELECT l.id, l.tenant_id AS tenantId, l.actor_type AS actorType,
|
||||
l.actor_id AS actorId, u.nickname AS actorName, l.action,
|
||||
l.resource_type AS resourceType, l.resource_id AS resourceId,
|
||||
l.trace_id AS traceId, l.ip, l.user_agent AS userAgent,
|
||||
l.metadata, l.created_at AS createdAt
|
||||
FROM qipai_audit_logs l LEFT JOIN qipai_users u ON u.id = l.actor_id
|
||||
WHERE ${where.join(' AND ')} ORDER BY l.id DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`, params
|
||||
);
|
||||
return { items: rows.map((row) => ({ ...row, id: String(row.id), tenantId: String(row.tenantId),
|
||||
actorId: row.actorId === null ? null : String(row.actorId),
|
||||
resourceId: row.resourceId === null ? null : String(row.resourceId),
|
||||
ip: maskIp(row.ip), metadata: sanitizeMetadata(parseJson(row.metadata)) })),
|
||||
total: Number(counts[0]?.total ?? 0), page, pageSize };
|
||||
}
|
||||
|
||||
async getSystemOverview(tenantId: string) {
|
||||
const [tenants] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, code, name, status, timezone, created_at AS createdAt, updated_at AS updatedAt
|
||||
FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL`, [tenantId]
|
||||
);
|
||||
if (!tenants[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||
const [counts] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM qipai_stores WHERE tenant_id = ? AND deleted_at IS NULL) AS storeCount,
|
||||
(SELECT COUNT(*) FROM qipai_users WHERE tenant_id = ? AND deleted_at IS NULL) AS userCount,
|
||||
(SELECT COUNT(*) FROM qipai_auth_sessions WHERE tenant_id = ? AND revoked_at IS NULL AND expires_at > UTC_TIMESTAMP(3)) AS activeSessionCount,
|
||||
(SELECT COUNT(*) FROM qipai_tenant_apps WHERE tenant_id = ? AND deleted_at IS NULL) AS appBindingCount,
|
||||
(SELECT COUNT(*) FROM qipai_audit_logs WHERE tenant_id = ? AND created_at >= UTC_DATE()) AS auditTodayCount`,
|
||||
[tenantId, tenantId, tenantId, tenantId, tenantId]
|
||||
);
|
||||
const [migrations] = await this.pool.execute<RowDataPacket[]>(
|
||||
'SELECT version, name, applied_at AS appliedAt FROM qipai_schema_migrations ORDER BY version DESC LIMIT 1'
|
||||
);
|
||||
return { tenant: { ...tenants[0], id: String(tenants[0].id) },
|
||||
counts: Object.fromEntries(Object.entries(counts[0] ?? {}).map(([key, value]) => [key, Number(value)])),
|
||||
latestMigration: migrations[0] ?? null };
|
||||
}
|
||||
|
||||
async updateTenant(actor: ManagementActor, tenantId: string, input: {
|
||||
name: string; timezone: string; status?: 'ACTIVE' | 'DISABLED';
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { name: string; timezone: string; status: string }>>(
|
||||
'SELECT name, timezone, status FROM qipai_tenants WHERE id = ? AND deleted_at IS NULL FOR UPDATE', [tenantId]
|
||||
);
|
||||
if (!rows[0]) throw new SystemOperationsError('SYSTEM_TENANT_NOT_FOUND');
|
||||
await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_tenants SET name = ?, timezone = ?, status = COALESCE(?, status)
|
||||
WHERE id = ? AND deleted_at IS NULL`, [input.name, input.timezone, input.status ?? null, tenantId]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata) VALUES (?, 'USER', ?, 'TENANT_SYSTEM_CONFIG_UPDATED',
|
||||
'TENANT', ?, ?, ?, ?, ?)`,
|
||||
[tenantId, actor.userId, tenantId, actor.traceId, actor.ip, actor.userAgent.slice(0, 255),
|
||||
JSON.stringify({ before: rows[0], after: input })]
|
||||
);
|
||||
return { tenantId, updated: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try { await connection.beginTransaction(); const result = await work(connection); await connection.commit(); return result; }
|
||||
catch (error) { await connection.rollback(); throw error; } finally { connection.release(); }
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
try { return JSON.parse(value); } catch { return {}; }
|
||||
}
|
||||
function sanitizeMetadata(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(sanitizeMetadata);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) =>
|
||||
[key, /(secret|token|password|private|credential|certificate|api.?key|phone|openid|receiver|voucher)/i.test(key)
|
||||
? '[REDACTED]' : sanitizeMetadata(item)]));
|
||||
}
|
||||
function maskIp(ip: string) {
|
||||
if (!ip) return '';
|
||||
if (ip.includes(':')) return `${ip.split(':').slice(0, 2).join(':')}::****`;
|
||||
const parts = ip.split('.'); return parts.length === 4 ? `${parts[0]}.${parts[1]}.***.***` : '***';
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export class OrderQueryRepository {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
status?: OrderStatus;
|
||||
storeId?: string;
|
||||
}) {
|
||||
const where = [
|
||||
'o.tenant_id = ?',
|
||||
@@ -52,8 +53,13 @@ export class OrderQueryRepository {
|
||||
where.push('o.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.storeId) {
|
||||
where.push('o.store_id = ?');
|
||||
params.push(input.storeId);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const offset = (input.page - 1) * input.pageSize;
|
||||
const pageSize = Math.max(1, Math.min(50, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(DISTINCT o.id) AS total
|
||||
FROM qipai_orders o
|
||||
@@ -84,14 +90,14 @@ export class OrderQueryRepository {
|
||||
)
|
||||
WHERE ${whereSql}
|
||||
ORDER BY o.created_at DESC, o.id DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, offset]
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
return {
|
||||
items: rows.map(publicOrder),
|
||||
total: Number(counts[0]?.total ?? 0),
|
||||
page: input.page,
|
||||
pageSize: input.pageSize
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,13 @@ export class OrderStateError extends Error {
|
||||
export class OrderStateRepository {
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>
|
||||
private readonly benefits?: Pick<MarketingBenefitService, 'releaseReservedInTransaction'>,
|
||||
private readonly cleaningTasks?: {
|
||||
createForFinishedOrder(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; orderId: string; actorId: string; traceId: string }
|
||||
): Promise<void>;
|
||||
}
|
||||
) {}
|
||||
|
||||
async transition(actor: OrderActor, orderId: string, action: OrderAction, reason = '') {
|
||||
@@ -103,6 +109,14 @@ export class OrderStateRepository {
|
||||
[targetStatus, nextVersion, actor.tenantId, orderId]
|
||||
);
|
||||
await this.applyReservationState(connection, actor.tenantId, orderId, targetStatus);
|
||||
if (targetStatus === 'FINISHED' && this.cleaningTasks) {
|
||||
await this.cleaningTasks.createForFinishedOrder(connection, {
|
||||
tenantId: actor.tenantId,
|
||||
orderId,
|
||||
actorId: actor.userId,
|
||||
traceId: actor.traceId
|
||||
});
|
||||
}
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_order_status_history
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
|
||||
@@ -45,6 +45,7 @@ interface RoomPricingRow extends RowDataPacket {
|
||||
roomCategoryId: string | null;
|
||||
}
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface UserRow extends RowDataPacket { id: string }
|
||||
|
||||
export class PricingError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
@@ -69,8 +70,16 @@ export class PricingRepository {
|
||||
holdMinutes?: number;
|
||||
allowedStoreIds?: string[] | null;
|
||||
benefits?: OrderBenefitInput | null;
|
||||
actor?: {
|
||||
userId: string;
|
||||
source: 'APP' | 'ADMIN';
|
||||
traceId: string;
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
};
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.assertActiveTenantUser(connection, input.tenantId, input.userId);
|
||||
const room = await this.loadRoom(connection, input.tenantId, input.roomId, true);
|
||||
if (input.allowedStoreIds && !input.allowedStoreIds.includes(String(room.storeId))) {
|
||||
throw new PricingError('STORE_SCOPE_FORBIDDEN');
|
||||
@@ -158,9 +167,24 @@ export class PricingRepository {
|
||||
(tenant_id, order_id, from_status, to_status, action, actor_type,
|
||||
actor_id, source, reason, trace_id, metadata)
|
||||
VALUES (?, ?, NULL, 'PENDING_PAYMENT', 'CREATED', 'USER', ?,
|
||||
'APP', 'Room hold created', ?, JSON_OBJECT('statusVersion', 1))`,
|
||||
[input.tenantId, orderId, input.userId, `order-created-${orderId}`]
|
||||
?, ?, ?, JSON_OBJECT('statusVersion', 1, 'targetUserId', ?))`,
|
||||
[input.tenantId, orderId, input.actor?.userId ?? input.userId,
|
||||
input.actor?.source ?? 'APP',
|
||||
input.actor?.source === 'ADMIN' ? 'Order created on behalf' : 'Room hold created',
|
||||
input.actor?.traceId ?? `order-created-${orderId}`, input.userId]
|
||||
);
|
||||
if (input.actor?.source === 'ADMIN') {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, 'ORDER_CREATED_ON_BEHALF', 'ORDER', ?, ?, ?, ?,
|
||||
JSON_OBJECT('targetUserId', ?, 'storeId', ?, 'roomId', ?))`,
|
||||
[input.tenantId, input.actor.userId, orderId, input.actor.traceId,
|
||||
input.actor.ip, input.actor.userAgent.slice(0, 255), input.userId,
|
||||
String(room.storeId), input.roomId]
|
||||
);
|
||||
}
|
||||
return {
|
||||
orderId,
|
||||
orderNo,
|
||||
@@ -173,6 +197,17 @@ export class PricingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private async assertActiveTenantUser(
|
||||
connection: PoolConnection, tenantId: string, userId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<UserRow[]>(
|
||||
`SELECT id FROM qipai_users
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'ACTIVE' LIMIT 1`,
|
||||
[tenantId, userId]
|
||||
);
|
||||
if (!rows[0]) throw new PricingError('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
async releaseExpired(
|
||||
tenantId?: string,
|
||||
roomId?: string,
|
||||
@@ -304,7 +339,7 @@ export class PricingRepository {
|
||||
r.deposit_cents AS depositCents,
|
||||
r.minimum_minutes AS minimumMinutes,
|
||||
r.max_advance_days AS maxAdvanceDays,
|
||||
r.room_category_id AS roomCategoryId
|
||||
r.category_id AS roomCategoryId
|
||||
FROM qipai_rooms r
|
||||
INNER JOIN qipai_stores s
|
||||
ON s.id = r.store_id AND s.tenant_id = r.tenant_id AND s.deleted_at IS NULL
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface WechatPayCredential {
|
||||
apiV3Key: string;
|
||||
platformCertificates: Record<string, string>;
|
||||
profitShareReceivers?: Record<string, string>;
|
||||
transferSceneId?: string;
|
||||
transferSceneReportInfos?: Array<{ infoType: string; infoContent: string }>;
|
||||
transferNotifyUrl?: string;
|
||||
}
|
||||
|
||||
export interface WechatPayTransport {
|
||||
@@ -173,6 +176,56 @@ export class WechatPayClient {
|
||||
});
|
||||
}
|
||||
|
||||
async createMerchantTransfer(
|
||||
credential: WechatPayCredential,
|
||||
input: {
|
||||
outBillNo: string;
|
||||
openid: string;
|
||||
amountCents: number;
|
||||
remark: string;
|
||||
sceneId: string;
|
||||
notifyUrl?: string;
|
||||
reportInfos: Array<{ infoType: string; infoContent: string }>;
|
||||
}
|
||||
) {
|
||||
const result = await this.apiRequest(credential, 'POST', '/v3/fund-app/mch-transfer/transfer-bills', {
|
||||
appid: credential.appId,
|
||||
out_bill_no: input.outBillNo,
|
||||
transfer_scene_id: input.sceneId,
|
||||
openid: input.openid,
|
||||
transfer_amount: input.amountCents,
|
||||
transfer_remark: input.remark.slice(0, 32),
|
||||
notify_url: input.notifyUrl,
|
||||
transfer_scene_report_infos: input.reportInfos.length > 0 ? input.reportInfos.map((item) => ({
|
||||
info_type: item.infoType.slice(0, 15),
|
||||
info_content: item.infoContent.slice(0, 32)
|
||||
})) : undefined
|
||||
});
|
||||
return {
|
||||
outBillNo: stringField(result, 'out_bill_no'),
|
||||
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
|
||||
state: stringField(result, 'state'),
|
||||
failReason: optionalStringField(result, 'fail_reason'),
|
||||
packageInfo: optionalStringField(result, 'package_info')
|
||||
};
|
||||
}
|
||||
|
||||
async queryMerchantTransferByOutBillNo(credential: WechatPayCredential, outBillNo: string) {
|
||||
const result = await this.apiRequest(
|
||||
credential,
|
||||
'GET',
|
||||
`/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`
|
||||
);
|
||||
return {
|
||||
merchantId: stringField(result, 'mch_id'),
|
||||
outBillNo: stringField(result, 'out_bill_no'),
|
||||
transferBillNo: optionalStringField(result, 'transfer_bill_no'),
|
||||
state: stringField(result, 'state'),
|
||||
failReason: optionalStringField(result, 'fail_reason'),
|
||||
amountCents: optionalNumberField(result, 'transfer_amount')
|
||||
};
|
||||
}
|
||||
|
||||
verifyAndDecrypt(
|
||||
credential: WechatPayCredential,
|
||||
headers: WechatNotificationHeaders,
|
||||
@@ -279,7 +332,10 @@ export function parseWechatPayCredentials(value: string) {
|
||||
}
|
||||
return [reference, account];
|
||||
})
|
||||
) : {}
|
||||
) : {},
|
||||
transferSceneId: optionalStringField(item, 'transferSceneId'),
|
||||
transferSceneReportInfos: parseTransferReportInfos(item.transferSceneReportInfos),
|
||||
transferNotifyUrl: optionalStringField(item, 'transferNotifyUrl')
|
||||
});
|
||||
}
|
||||
return credentials;
|
||||
@@ -327,3 +383,22 @@ function optionalStringField(value: Record<string, unknown>, key: string) {
|
||||
const field = value[key];
|
||||
return typeof field === 'string' ? field : '';
|
||||
}
|
||||
|
||||
function optionalNumberField(value: Record<string, unknown>, key: string) {
|
||||
const field = value[key];
|
||||
return typeof field === 'number' ? field : 0;
|
||||
}
|
||||
|
||||
function parseTransferReportInfos(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new WechatPayError('WECHAT_TRANSFER_REPORT_INVALID');
|
||||
}
|
||||
const raw = item as Record<string, unknown>;
|
||||
return {
|
||||
infoType: stringField(raw, 'infoType'),
|
||||
infoContent: stringField(raw, 'infoContent')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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,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
|
||||
});
|
||||
}
|
||||
@@ -1,28 +1,147 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
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' | 'claim' | 'start' | 'submit' | 'stats'>;
|
||||
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;
|
||||
@@ -32,6 +151,36 @@ 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;
|
||||
@@ -66,6 +215,91 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -78,6 +312,97 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -90,6 +415,54 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -107,6 +480,290 @@ export async function registerCleaningRoutes(
|
||||
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(
|
||||
@@ -150,16 +807,30 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof CleaningTaskError)) throw error;
|
||||
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
|
||||
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: error.code,
|
||||
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',
|
||||
@@ -167,3 +838,33 @@ function invalid(reply: FastifyReply, traceId: string) {
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,9 @@ import { MediaStorage, MediaValidationError } from '../content/media-storage.js'
|
||||
|
||||
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const storeQuerySchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const optionalStoreQuerySchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
const componentSchema = z.object({
|
||||
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
|
||||
props: z.record(z.unknown())
|
||||
@@ -34,8 +37,9 @@ const adSchema = z.object({
|
||||
|
||||
export interface ContentRouteOptions {
|
||||
repository: Pick<ContentRepository,
|
||||
'registerAsset' | 'saveDecoration' | 'publishDecoration'
|
||||
| 'listAdvertisements' | 'saveAdvertisement'>;
|
||||
'registerAsset' | 'listAssets' | 'saveDecoration' | 'listDecorations'
|
||||
| 'publishDecoration' | 'listAdvertisements' | 'saveAdvertisement'
|
||||
| 'updateAdvertisement'>;
|
||||
mediaStorage: MediaStorage;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
@@ -50,6 +54,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
|
||||
(_request, body, done) => done(null, body)
|
||||
);
|
||||
}
|
||||
app.get('/admin-api/media/images', async (request, reply) => {
|
||||
const actor = await requireContentManager(request, reply, options);
|
||||
const query = optionalStoreQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/media/images', async (request, reply) => {
|
||||
const actor = await requireContentManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
@@ -72,6 +86,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
|
||||
});
|
||||
});
|
||||
});
|
||||
app.get('/admin-api/decorations', async (request, reply) => {
|
||||
const actor = await requireContentManager(request, reply, options);
|
||||
const query = storeQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listDecorations(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/decorations', async (request, reply) => {
|
||||
const actor = await requireContentManager(request, reply, options);
|
||||
const body = decorationSchema.safeParse(request.body);
|
||||
@@ -109,6 +133,19 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.put('/admin-api/advertisements/:id', async (request, reply) => {
|
||||
const actor = await requireContentManager(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = adSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateAdvertisement(actor, params.data.id, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireContentManager(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from '../devices/device-control-service.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const vendorPasswordField = 'password' as const;
|
||||
const contextSchema = z.object({
|
||||
storeId: id,
|
||||
roomId: id,
|
||||
@@ -32,9 +33,13 @@ const doorSchema = contextSchema.extend({
|
||||
});
|
||||
const ttsSchema = contextSchema.extend({
|
||||
content: z.string().trim().min(1).max(500),
|
||||
volume: z.number().int().min(0).max(100).default(80),
|
||||
playCount: z.number().int().min(1).max(10).default(1),
|
||||
priority: z.number().int().min(0).max(10).default(0)
|
||||
volume: z.number().int().min(0).max(10).default(8),
|
||||
firstPlay: z.union([z.literal(0), z.literal(1)]).default(0),
|
||||
loop: z.number().int().positive().max(100).default(1),
|
||||
speaker: z.number().int().min(0).max(5).default(0),
|
||||
style: z.number().int().min(0).max(2).default(0),
|
||||
speed: z.number().int().min(0).max(10).default(5),
|
||||
intonation: z.number().int().min(0).max(10).default(5)
|
||||
});
|
||||
const minuteSchema = contextSchema.extend({
|
||||
minute: z.number().int().min(0).max(10080)
|
||||
@@ -53,31 +58,78 @@ const pairSchema = contextSchema.extend({
|
||||
timeout: z.number().int().min(10).max(300).default(60)
|
||||
});
|
||||
const subLockSchema = contextSchema.extend({
|
||||
subtype: z.enum(['14', '15']),
|
||||
subID: z.string().min(1).max(64),
|
||||
order: z.enum([
|
||||
'open', 'close', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||
'open', 'close', 'none', 'setkey', 'delkey', 'setcard', 'delcard', 'factoryreset'
|
||||
]),
|
||||
holdopen: z.union([z.literal(0), z.literal(1)]).optional(),
|
||||
delayTime: z.number().int().min(1).max(14).optional(),
|
||||
content: z.string().min(1).max(128).optional(),
|
||||
value: z.string().min(1).max(128).optional(),
|
||||
dangerConfirmation: z.string().max(64).optional()
|
||||
});
|
||||
const socketReadSchema = contextSchema.extend({
|
||||
target: z.enum(['basicInfo', 'workInfo']).default('workInfo')
|
||||
target: z.enum(['basicInfo', 'workInfo', 'localtask', 'mqttConfig']).default('workInfo'),
|
||||
slotNum: z.number().int().min(1).max(20).optional(),
|
||||
taskNum: z.number().int().min(0).max(20).optional()
|
||||
}).superRefine((value, context) => {
|
||||
if (value.target === 'localtask' && value.taskNum === undefined) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['taskNum'],
|
||||
message: 'taskNum is required for localtask reads' });
|
||||
}
|
||||
});
|
||||
const socketSwitchSchema = contextSchema.extend({
|
||||
on: z.boolean(),
|
||||
slotNum: z.number().int().min(1).max(20).default(1)
|
||||
});
|
||||
const socketTaskSchema = contextSchema.extend({
|
||||
slotNum: z.number().int().min(1).max(20).default(1),
|
||||
taskNum: z.number().int().min(1).max(20),
|
||||
action: z.enum(['on', 'off']),
|
||||
mode: z.enum(['once', 'daily', 'weekly']),
|
||||
time: z.string().regex(/^\d{2}:\d{2}$/),
|
||||
weekdays: z.array(z.number().int().min(1).max(7)).max(7).optional()
|
||||
time: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
|
||||
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7)
|
||||
.refine((items) => new Set(items).size === items.length).optional()
|
||||
}).superRefine((value, context) => {
|
||||
if (value.mode === 'weekly' && !value.weekdays) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'],
|
||||
message: 'weekdays are required weekly' });
|
||||
}
|
||||
if (value.mode !== 'weekly' && value.weekdays) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ['weekdays'],
|
||||
message: 'weekdays are only valid weekly' });
|
||||
}
|
||||
});
|
||||
const socketClearTaskSchema = contextSchema.extend({
|
||||
taskNum: z.number().int().min(0).max(20)
|
||||
slotNum: z.number().int().min(1).max(20).default(1),
|
||||
taskNum: z.number().int().min(0).max(20),
|
||||
dangerConfirmation: z.string().max(64).optional()
|
||||
});
|
||||
const dangerSchema = contextSchema.extend({
|
||||
dangerConfirmation: z.string().max(64)
|
||||
});
|
||||
const socketResetSchema = dangerSchema.extend({
|
||||
[vendorPasswordField]: z.string().min(1).max(32)
|
||||
});
|
||||
const socketReturnKeySchema = dangerSchema.extend({
|
||||
old: z.string().min(1).max(32),
|
||||
new: z.string().min(1).max(32)
|
||||
});
|
||||
const socketProtectionSchema = contextSchema.extend({
|
||||
maxPower: z.number().positive(),
|
||||
maxCurrent: z.number().positive(),
|
||||
maxTemperature: z.number().min(1).max(125),
|
||||
pullOutStop: z.union([z.literal(0), z.literal(1)]),
|
||||
pullOutPower: z.number().nonnegative(),
|
||||
pullOutSec: z.number().int().min(1).max(86400),
|
||||
chargeFullStop: z.union([z.literal(0), z.literal(1)]),
|
||||
chargeFullPower: z.number().nonnegative(),
|
||||
chargeFullSec: z.number().int().min(1).max(86400)
|
||||
});
|
||||
const socketParameterSchema = contextSchema.extend({
|
||||
resetHold: z.union([z.literal(0), z.literal(1)]),
|
||||
keyLock: z.union([z.literal(0), z.literal(1)]),
|
||||
keyOff: z.union([z.literal(0), z.literal(1)])
|
||||
});
|
||||
const orderParams = z.object({ orderId: id });
|
||||
const customerOpenDoorSchema = z.object({
|
||||
@@ -89,7 +141,9 @@ export interface DeviceControlRouteOptions {
|
||||
'controlPower' | 'controlDoor' | 'playTts' | 'stopTts' | 'controlLed'
|
||||
| 'startTask' | 'extendTask' | 'cancelTask' | 'pairSubLock' | 'controlSubLock'
|
||||
| 'readSmartSocket' | 'switchSmartSocket' | 'scheduleSmartSocket'
|
||||
| 'clearSmartSocketTask'>;
|
||||
| 'clearSmartSocketTask' | 'rebootSmartSocket' | 'emptySmartSocketEnergy'
|
||||
| 'resetSmartSocket' | 'changeSmartSocketReturnKey'
|
||||
| 'configureSmartSocketProtection' | 'configureSmartSocketParameter'>;
|
||||
customerAccess?: Pick<CustomerDeviceAccessRepository, 'getDoorContext'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
@@ -99,34 +153,48 @@ export interface DeviceControlRouteOptions {
|
||||
export async function registerDeviceControlRoutes(
|
||||
app: FastifyInstance, options: DeviceControlRouteOptions
|
||||
) {
|
||||
register('/admin-api/device-control/power', powerSchema,
|
||||
registerManagement('/power', powerSchema,
|
||||
(context, body) => options.service.controlPower(context, body));
|
||||
register('/admin-api/device-control/door', doorSchema,
|
||||
registerManagement('/door', doorSchema,
|
||||
(context, body) => options.service.controlDoor(context, body));
|
||||
register('/admin-api/device-control/tts', ttsSchema,
|
||||
registerManagement('/tts', ttsSchema,
|
||||
(context, body) => options.service.playTts(context, body));
|
||||
register('/admin-api/device-control/tts/stop', contextSchema,
|
||||
registerManagement('/tts/stop', contextSchema,
|
||||
(context) => options.service.stopTts(context));
|
||||
register('/admin-api/device-control/led', minuteSchema,
|
||||
registerManagement('/led', minuteSchema,
|
||||
(context, body) => options.service.controlLed(context, body.minute));
|
||||
register('/admin-api/device-control/task/start', taskSchema,
|
||||
registerManagement('/task/start', taskSchema,
|
||||
(context, body) => options.service.startTask(context, body));
|
||||
register('/admin-api/device-control/task/extend', extendSchema,
|
||||
registerManagement('/task/extend', extendSchema,
|
||||
(context, body) => options.service.extendTask(context, body.addminute));
|
||||
register('/admin-api/device-control/task/cancel', contextSchema,
|
||||
registerManagement('/task/cancel', contextSchema,
|
||||
(context) => options.service.cancelTask(context));
|
||||
register('/admin-api/device-control/sub-lock/pair', pairSchema,
|
||||
registerManagement('/sub-lock/pair', pairSchema,
|
||||
(context, body) => options.service.pairSubLock(context, body.timeout));
|
||||
register('/admin-api/device-control/sub-lock/action', subLockSchema,
|
||||
registerManagement('/sub-lock/action', subLockSchema,
|
||||
(context, body) => options.service.controlSubLock(context, body));
|
||||
register('/admin-api/device-control/socket/read', socketReadSchema,
|
||||
(context, body) => options.service.readSmartSocket(context, body.target));
|
||||
register('/admin-api/device-control/socket/switch', socketSwitchSchema,
|
||||
registerManagement('/socket/read', socketReadSchema,
|
||||
(context, body) => options.service.readSmartSocket(context, body));
|
||||
registerManagement('/socket/switch', socketSwitchSchema,
|
||||
(context, body) => options.service.switchSmartSocket(context, body));
|
||||
register('/admin-api/device-control/socket/task', socketTaskSchema,
|
||||
registerManagement('/socket/task', socketTaskSchema,
|
||||
(context, body) => options.service.scheduleSmartSocket(context, body));
|
||||
register('/admin-api/device-control/socket/task/clear', socketClearTaskSchema,
|
||||
(context, body) => options.service.clearSmartSocketTask(context, body.taskNum));
|
||||
registerManagement('/socket/task/clear', socketClearTaskSchema,
|
||||
(context, body) => options.service.clearSmartSocketTask(context, body));
|
||||
registerManagement('/socket/reboot', dangerSchema,
|
||||
(context, body) => options.service.rebootSmartSocket(context, body.dangerConfirmation));
|
||||
registerManagement('/socket/energy/clear', dangerSchema,
|
||||
(context, body) => options.service.emptySmartSocketEnergy(
|
||||
context, body.dangerConfirmation
|
||||
));
|
||||
registerManagement('/socket/factory-reset', socketResetSchema,
|
||||
(context, body) => options.service.resetSmartSocket(context, body));
|
||||
registerManagement('/socket/return-key', socketReturnKeySchema,
|
||||
(context, body) => options.service.changeSmartSocketReturnKey(context, body));
|
||||
registerManagement('/socket/protection', socketProtectionSchema,
|
||||
(context, body) => options.service.configureSmartSocketProtection(context, body));
|
||||
registerManagement('/socket/parameter', socketParameterSchema,
|
||||
(context, body) => options.service.configureSmartSocketParameter(context, body));
|
||||
|
||||
app.post('/app-api/orders/:orderId/open-door', async (request, reply) => {
|
||||
if (!options.customerAccess) {
|
||||
@@ -160,6 +228,10 @@ export async function registerDeviceControlRoutes(
|
||||
roomId: order.roomId,
|
||||
orderId: order.orderId,
|
||||
traceId: request.traceId,
|
||||
actorType: 'CUSTOMER',
|
||||
actorId: auth.session.user.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '',
|
||||
access: {
|
||||
roles: ['CUSTOMER'],
|
||||
capabilities: ['device.write'],
|
||||
@@ -216,6 +288,15 @@ export async function registerDeviceControlRoutes(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function registerManagement<T extends z.ZodTypeAny>(
|
||||
suffix: string,
|
||||
schema: T,
|
||||
handler: (context: CommandContext, body: z.infer<T>) => Promise<unknown>
|
||||
) {
|
||||
register(`/admin-api/device-control${suffix}`, schema, handler);
|
||||
register(`/app-api/management/device-control${suffix}`, schema, handler);
|
||||
}
|
||||
}
|
||||
|
||||
async function requireContext(
|
||||
@@ -235,12 +316,19 @@ async function requireContext(
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
const actorType: CommandContext['actorType'] = access.roles.some((role) =>
|
||||
['STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN'].includes(role)
|
||||
) ? 'ADMIN' : 'STAFF';
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
storeId: input.storeId,
|
||||
roomId: input.roomId,
|
||||
orderId: input.orderId,
|
||||
traceId: request.traceId,
|
||||
actorType,
|
||||
actorId: auth.session.user.id,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '',
|
||||
access
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,16 +64,18 @@ export interface DeviceRouteOptions {
|
||||
}
|
||||
|
||||
export async function registerDeviceRoutes(app: FastifyInstance, options: DeviceRouteOptions) {
|
||||
app.get('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/devices', '/app-api/management/devices']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id.optional() }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAssets(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
app.post('/admin-api/devices', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = assetSchema.safeParse(request.body);
|
||||
@@ -83,16 +85,18 @@ export async function registerDeviceRoutes(app: FastifyInstance, options: Device
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/admin-api/device-topology', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/device-topology', '/app-api/management/device-topology']) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const query = z.object({ storeId: id }).safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getTopology(actor, query.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
app.post('/admin-api/device-channels', async (request, reply) => {
|
||||
const actor = await requireDeviceOperator(request, reply, options);
|
||||
const body = channelSchema.safeParse(request.body);
|
||||
|
||||
@@ -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 }); }
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
@@ -54,29 +54,33 @@ export async function registerMemberRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/members', async (request, reply) => {
|
||||
const actor = await requireReader(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listMembers({ actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
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
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/admin-api/members/:id', async (request, reply) => {
|
||||
const actor = await requireReader(request, reply, options);
|
||||
if (!actor) return;
|
||||
const params = idSchema.safeParse(request.params);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.getMember({ actor, memberId: params.data.id }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
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(
|
||||
|
||||
@@ -91,23 +91,25 @@ export async function registerOrderManagementRoutes(
|
||||
] as const;
|
||||
|
||||
for (const [path, schema, execute] of adminActions) {
|
||||
app.post(`/admin-api/orders/:orderId/${path}`, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = schema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
|
||||
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
};
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await execute(actor, params.data.orderId, body.data as never),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const prefix of ['/admin-api/orders', '/app-api/management/orders']) {
|
||||
app.post(`${prefix}/:orderId/${path}`, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = schema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
const actor = {
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER' as const,
|
||||
source: 'ADMIN' as const, traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
};
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await execute(actor, params.data.orderId, body.data as never),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ const orderStatuses = [
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(50).default(20),
|
||||
status: z.enum(orderStatuses).optional()
|
||||
status: z.enum(orderStatuses).optional(),
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
const paramsSchema = z.object({ orderId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
|
||||
@@ -31,29 +32,31 @@ export async function registerOrderQueryRoutes(
|
||||
app: FastifyInstance,
|
||||
options: OrderQueryRouteOptions
|
||||
) {
|
||||
app.get('/app-api/orders', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return {
|
||||
code: 0,
|
||||
data: await options.repository.listMine({ ...auth, ...query.data }),
|
||||
traceId: request.traceId
|
||||
};
|
||||
});
|
||||
for (const prefix of ['/app-api/orders', '/admin-api/orders', '/app-api/management/orders']) {
|
||||
app.get(prefix, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return {
|
||||
code: 0,
|
||||
data: await options.repository.listMine({ ...auth, ...query.data }),
|
||||
traceId: request.traceId
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/app-api/orders/:orderId', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getMine({ ...auth, orderId: params.data.orderId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get(`${prefix}/:orderId`, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getMine({ ...auth, orderId: params.data.orderId }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
|
||||
@@ -27,19 +27,25 @@ export interface OrderStateRouteOptions {
|
||||
export async function registerOrderStateRoutes(
|
||||
app: FastifyInstance, options: OrderStateRouteOptions
|
||||
) {
|
||||
app.get('/app-api/orders/:orderId/history', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.history(
|
||||
auth.tenantId, auth.userId, params.data.orderId, auth.access
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/app-api/orders/:orderId/history',
|
||||
'/admin-api/orders/:orderId/history',
|
||||
'/app-api/management/orders/:orderId/history'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.history(
|
||||
auth.tenantId, auth.userId, params.data.orderId, auth.access
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/app-api/orders/:orderId/cancel', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
@@ -62,22 +68,27 @@ export async function registerOrderStateRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin-api/orders/:orderId/actions', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = transitionSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.transition({
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'ADMIN', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
}, params.data.orderId, body.data.action, body.data.reason),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/orders/:orderId/actions',
|
||||
'/app-api/management/orders/:orderId/actions'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const params = paramsSchema.safeParse(request.params);
|
||||
const body = transitionSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.transition({
|
||||
tenantId: auth.tenantId, userId: auth.userId, actorType: 'USER',
|
||||
source: 'ADMIN', traceId: request.traceId, ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? '', access: auth.access
|
||||
}, params.data.orderId, body.data.action, body.data.reason),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import { PlatformAdminError, type PlatformAdminRepository } from '../tenancy/platform-admin-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const configSchema = z.object({
|
||||
brandName: z.string().trim().min(1).max(128),
|
||||
logoUrl: z.string().trim().max(512).default(''),
|
||||
themeColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
|
||||
servicePhone: z.string().trim().max(32).default(''),
|
||||
franchisePhone: z.string().trim().max(32).default(''),
|
||||
shareTitle: z.string().trim().max(128).default(''),
|
||||
shareImageUrl: z.string().trim().max(512).default(''),
|
||||
defaultStoreId: id.nullable().default(null),
|
||||
extraConfig: z.record(z.unknown()).default({}),
|
||||
bindingStatus: z.enum(['ACTIVE', 'DISABLED']),
|
||||
isDefault: z.boolean()
|
||||
});
|
||||
const bindSchema = z.object({
|
||||
appId: z.string().regex(/^[A-Za-z0-9_-]{6,64}$/),
|
||||
appName: z.string().trim().min(1).max(128),
|
||||
appStatus: z.enum(['ACTIVE', 'DISABLED']),
|
||||
config: configSchema
|
||||
});
|
||||
|
||||
export interface PlatformManagementRouteOptions {
|
||||
repository: Pick<PlatformAdminRepository, 'listTenantApps' | 'updateTenantConfig' | 'bindApplication'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerPlatformManagementRoutes(app: FastifyInstance, options: PlatformManagementRouteOptions) {
|
||||
app.get('/admin-api/platform-apps', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, false);
|
||||
if (!actor) return;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0, data: await options.repository.listTenantApps(actor.tenantId), traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.put('/admin-api/platform-apps/:id/config', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, false);
|
||||
const params = z.object({ id }).safeParse(request.params);
|
||||
const body = configSchema.safeParse(request.body);
|
||||
if (!actor) return;
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateTenantConfig(actor, params.data.id, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/platform-apps/bind', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, true);
|
||||
const body = bindSchema.safeParse(request.body);
|
||||
if (!actor) return;
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0, data: await options.repository.bindApplication(actor, body.data), traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(request: FastifyRequest, reply: FastifyReply, options: PlatformManagementRouteOptions, platformOnly: boolean): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(request.headers.authorization, options.authRepository, options.jwtSecret);
|
||||
if (!auth) { reply.status(401).send({ code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId }); return null; }
|
||||
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
|
||||
const platform = access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
|
||||
if ((platformOnly && !platform) || (!platformOnly && !platform && !access.capabilities.includes('tenant.manage'))) {
|
||||
reply.status(403).send({ code: platformOnly ? 'PLATFORM_MANAGEMENT_FORBIDDEN' : 'TENANT_MANAGEMENT_FORBIDDEN', message: 'Management permission is required.', traceId: request.traceId });
|
||||
return null;
|
||||
}
|
||||
return { tenantId: auth.session.tenantId, userId: auth.session.user.id, access, traceId: request.traceId, ip: request.ip, userAgent: request.headers['user-agent'] ?? '' };
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try { return await work(); }
|
||||
catch (error) {
|
||||
if (!(error instanceof PlatformAdminError)) throw error;
|
||||
const missing = error.code.endsWith('_NOT_FOUND');
|
||||
return reply.status(missing ? 404 : 400).send({ code: error.code, message: 'The platform application operation is invalid.', traceId });
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({ code: 'INVALID_PLATFORM_APP_REQUEST', message: 'The platform application request is invalid.', traceId });
|
||||
}
|
||||
@@ -80,35 +80,47 @@ export async function registerPricingRoutes(app: FastifyInstance, options: Prici
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/admin-api/orders/reserve-on-behalf', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const body = adminReserveSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
||||
const unrestricted = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
|
||||
return reply.status(403).send({
|
||||
code: 'ORDER_MANAGEMENT_FORBIDDEN',
|
||||
message: 'Order management permission is required.',
|
||||
for (const path of [
|
||||
'/admin-api/orders/reserve-on-behalf',
|
||||
'/app-api/management/orders/reserve-on-behalf'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options);
|
||||
const body = adminReserveSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
const access = await options.accessControl.getAccessProfile(auth.tenantId, auth.userId);
|
||||
const unrestricted = access.capabilities.includes('tenant.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!unrestricted && !access.capabilities.includes('store.operation.write')) {
|
||||
return reply.status(403).send({
|
||||
code: 'ORDER_MANAGEMENT_FORBIDDEN',
|
||||
message: 'Order management permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.reserve({
|
||||
tenantId: auth.tenantId,
|
||||
userId: body.data.userId,
|
||||
roomId: body.data.roomId,
|
||||
startAt: body.data.startAt,
|
||||
endAt: body.data.endAt,
|
||||
pricingMode: body.data.pricingMode,
|
||||
allowedStoreIds: unrestricted ? null : access.storeIds,
|
||||
actor: {
|
||||
userId: auth.userId,
|
||||
source: 'ADMIN',
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: String(request.headers['user-agent'] ?? '')
|
||||
}
|
||||
}),
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.reserve({
|
||||
tenantId: auth.tenantId,
|
||||
userId: body.data.userId,
|
||||
roomId: body.data.roomId,
|
||||
startAt: body.data.startAt,
|
||||
endAt: body.data.endAt,
|
||||
pricingMode: body.data.pricingMode,
|
||||
allowedStoreIds: unrestricted ? null : access.storeIds
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
@@ -131,8 +143,9 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
|
||||
} catch (error) {
|
||||
if (!(error instanceof PricingError)) throw error;
|
||||
const conflict = error.code === 'TIME_SLOT_CONFLICT';
|
||||
const notFound = error.code === 'ROOM_NOT_FOUND';
|
||||
return reply.status(conflict ? 409 : notFound ? 404 : 400).send({
|
||||
const forbidden = error.code === 'STORE_SCOPE_FORBIDDEN';
|
||||
const notFound = error.code === 'ROOM_NOT_FOUND' || error.code === 'USER_NOT_FOUND';
|
||||
return reply.status(conflict ? 409 : forbidden ? 403 : notFound ? 404 : 400).send({
|
||||
code: error.code,
|
||||
message: 'The requested price or time slot is not available.',
|
||||
traceId
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import {
|
||||
ProductCatalogError,
|
||||
type ProductCatalogRepository
|
||||
} from '../products/product-catalog-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const version = z.coerce.number().int().min(0).max(0xffffffff);
|
||||
const code = z.string().trim().min(1).max(64)
|
||||
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
||||
const storeParamsSchema = z.object({ storeId: id }).strict();
|
||||
const categoryParamsSchema = z.object({ storeId: id, categoryId: id }).strict();
|
||||
const productParamsSchema = z.object({ productId: id }).strict();
|
||||
const skuParamsSchema = z.object({ productId: id, skuId: id }).strict();
|
||||
const listingParamsSchema = z.object({ storeId: id, productId: id }).strict();
|
||||
const expectedVersionQuerySchema = z.object({ expectedVersion: version.min(1) }).strict();
|
||||
const includeInactiveQuerySchema = z.object({
|
||||
includeInactive: z.enum(['true', 'false']).transform((value) => value === 'true').default('true')
|
||||
}).strict();
|
||||
|
||||
const categoryFields = {
|
||||
parentId: id.nullable().optional(),
|
||||
categoryCode: code,
|
||||
name: z.string().trim().min(1).max(128),
|
||||
description: z.string().trim().max(1024).default(''),
|
||||
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE'),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
};
|
||||
const createCategorySchema = z.object(categoryFields).strict();
|
||||
const updateCategorySchema = z.object({
|
||||
...categoryFields,
|
||||
expectedVersion: version.min(1)
|
||||
}).strict();
|
||||
|
||||
const productFields = {
|
||||
productCode: code,
|
||||
name: z.string().trim().min(1).max(128),
|
||||
unitName: z.string().trim().min(1).max(32),
|
||||
description: z.string().trim().max(10000).default(''),
|
||||
coverUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||
images: z.array(z.string().url().max(512)).max(20).default([]),
|
||||
deliveryEnabled: z.boolean().default(true),
|
||||
storageEnabled: z.boolean().default(false),
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).default('DRAFT'),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
};
|
||||
const createProductSchema = z.object(productFields).strict();
|
||||
const updateProductSchema = z.object({ ...productFields, expectedVersion: version.min(1) }).strict();
|
||||
const productListQuerySchema = z.object({
|
||||
status: z.enum(['DRAFT', 'ACTIVE', 'INACTIVE']).optional(),
|
||||
search: z.string().trim().min(1).max(128).optional()
|
||||
}).strict();
|
||||
|
||||
const attributesSchema = z.record(
|
||||
z.string().trim().min(1).max(64),
|
||||
z.string().trim().max(128)
|
||||
).refine((value) => Object.keys(value).length <= 20, 'at most 20 SKU attributes are allowed');
|
||||
const skuFields = {
|
||||
skuCode: code,
|
||||
name: z.string().trim().min(1).max(128),
|
||||
attributes: attributesSchema.default({}),
|
||||
barcode: z.string().trim().max(64).default(''),
|
||||
imageUrl: z.union([z.string().url().max(512), z.literal('')]).default(''),
|
||||
salePriceCents: z.number().int().min(0).max(100000000),
|
||||
marketPriceCents: z.number().int().min(0).max(100000000).default(0),
|
||||
costPriceCents: z.number().int().min(0).max(100000000).default(0),
|
||||
defaultInventoryPolicy: z.enum(['TRACKED', 'UNLIMITED']).default('TRACKED'),
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']).default('ACTIVE')
|
||||
};
|
||||
const createSkuSchema = z.object(skuFields).strict();
|
||||
const updateSkuSchema = z.object({ ...skuFields, expectedVersion: version.min(1) }).strict();
|
||||
|
||||
const listingSchema = z.object({
|
||||
expectedVersion: version,
|
||||
categoryId: id,
|
||||
status: z.enum(['ACTIVE', 'INACTIVE']).default('INACTIVE'),
|
||||
fulfillmentMode: z.enum(['DELIVERY', 'SELF_SERVICE', 'BOTH']).default('DELIVERY'),
|
||||
salesStartAt: z.coerce.date().nullable().optional(),
|
||||
salesEndAt: z.coerce.date().nullable().optional(),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0)
|
||||
}).strict().refine((value) => !value.salesStartAt || !value.salesEndAt
|
||||
|| value.salesEndAt > value.salesStartAt, {
|
||||
message: 'salesEndAt must be later than salesStartAt'
|
||||
});
|
||||
|
||||
const hourSchema = z.object({
|
||||
weekday: z.number().int().min(1).max(7),
|
||||
slotNo: z.number().int().min(1).max(8),
|
||||
openMinute: z.number().int().min(0).max(1439),
|
||||
closeMinute: z.number().int().min(0).max(1439),
|
||||
crossesMidnight: z.boolean()
|
||||
}).strict();
|
||||
const settingsSchema = z.object({
|
||||
expectedVersion: version,
|
||||
salesStatus: z.enum(['OPEN', 'CLOSED']),
|
||||
manualPaused: z.boolean(),
|
||||
manualPausedUntil: z.coerce.date().nullable().optional(),
|
||||
manualPauseReason: z.string().trim().max(512).default(''),
|
||||
hours: z.array(hourSchema).max(56).default([])
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.manualPaused && value.manualPauseReason.length === 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['manualPauseReason'],
|
||||
message: 'manualPauseReason is required while manually paused'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export interface ProductRouteOptions {
|
||||
repository: Pick<ProductCatalogRepository,
|
||||
'listCategories' | 'createCategory' | 'updateCategory' | 'archiveCategory'
|
||||
| 'listProducts' | 'createProduct' | 'updateProduct' | 'archiveProduct'
|
||||
| 'listSkus' | 'createSku' | 'updateSku' | 'archiveSku'
|
||||
| 'listListings' | 'putListing' | 'archiveListing'
|
||||
| 'getStoreSettings' | 'putStoreSettings'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerProductRoutes(app: FastifyInstance, options: ProductRouteOptions) {
|
||||
for (const path of [
|
||||
'/admin-api/stores/:storeId/product-categories',
|
||||
'/app-api/management/stores/:storeId/product-categories'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listCategories(
|
||||
actor, params.data.storeId, query.data.includeInactive
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/stores/:storeId/product-categories', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const body = createCategorySchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.createCategory(actor, params.data.storeId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/stores/:storeId/product-categories/:categoryId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = categoryParamsSchema.safeParse(request.params);
|
||||
const body = updateCategorySchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateCategory(
|
||||
actor, params.data.storeId, params.data.categoryId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/stores/:storeId/product-categories/:categoryId',
|
||||
async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = categoryParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveCategory(
|
||||
actor, params.data.storeId, params.data.categoryId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/products', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
|
||||
const query = productListQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listProducts(actor, query.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/products', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const body = createProductSchema.safeParse(request.body);
|
||||
if (!actor || !body.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.createProduct(actor, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/products/:productId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const body = updateProductSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateProduct(
|
||||
actor, params.data.productId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/products/:productId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveProduct(
|
||||
actor, params.data.productId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/products/:productId/skus', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read', 'global-catalog');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listSkus(
|
||||
actor, params.data.productId, query.data.includeInactive
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/products/:productId/skus', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = productParamsSchema.safeParse(request.params);
|
||||
const body = createSkuSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => reply.status(201).send({
|
||||
code: 0,
|
||||
data: await options.repository.createSku(actor, params.data.productId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = skuParamsSchema.safeParse(request.params);
|
||||
const body = updateSkuSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateSku(
|
||||
actor, params.data.productId, params.data.skuId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/products/:productId/skus/:skuId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = skuParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveSku(
|
||||
actor, params.data.productId, params.data.skuId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
for (const path of [
|
||||
'/admin-api/stores/:storeId/product-listings',
|
||||
'/app-api/management/stores/:storeId/product-listings'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const query = includeInactiveQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listListings(
|
||||
actor, params.data.storeId, query.data.includeInactive
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.put('/admin-api/stores/:storeId/product-listings/:productId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = listingParamsSchema.safeParse(request.params);
|
||||
const body = listingSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.putListing(
|
||||
actor, params.data.storeId, params.data.productId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.delete('/admin-api/stores/:storeId/product-listings/:productId',
|
||||
async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = listingParamsSchema.safeParse(request.params);
|
||||
const query = expectedVersionQuerySchema.safeParse(request.query);
|
||||
if (!actor || !params.success || !query.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.archiveListing(
|
||||
actor, params.data.storeId, params.data.productId, query.data.expectedVersion
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
for (const path of [
|
||||
'/admin-api/stores/:storeId/product-sales-settings',
|
||||
'/app-api/management/stores/:storeId/product-sales-settings'
|
||||
]) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getStoreSettings(actor, params.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.put('/admin-api/stores/:storeId/product-sales-settings', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
const params = storeParamsSchema.safeParse(request.params);
|
||||
const body = settingsSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const { expectedVersion, ...input } = body.data;
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.putStoreSettings(
|
||||
actor, params.data.storeId, expectedVersion, input
|
||||
),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireActor(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: ProductRouteOptions,
|
||||
mode: 'read' | 'write',
|
||||
scope: 'store' | 'global-catalog' = 'store'
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization, options.authRepository, options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId, auth.session.user.id
|
||||
);
|
||||
const capability = mode === 'read' ? 'product.catalog.read' : 'product.catalog.write';
|
||||
const allowed = access.capabilities.includes(capability)
|
||||
|| (mode === 'read' && access.capabilities.includes('product.catalog.write'))
|
||||
|| access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
if (!allowed) {
|
||||
reply.status(403).send({
|
||||
code: mode === 'read' ? 'PRODUCT_READ_FORBIDDEN' : 'PRODUCT_WRITE_FORBIDDEN',
|
||||
message: 'Product catalog permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (scope === 'global-catalog') {
|
||||
const tenantManager = access.capabilities.includes('tenant.manage')
|
||||
|| access.capabilities.includes('platform.manage')
|
||||
|| access.roles.includes('PLATFORM_ADMIN');
|
||||
const authorizedStoreAdmin = access.roles.includes('STORE_ADMIN')
|
||||
&& access.storeIds.length > 0
|
||||
&& (access.capabilities.includes('product.catalog.read')
|
||||
|| access.capabilities.includes('product.catalog.write'));
|
||||
if (!tenantManager && !authorizedStoreAdmin) {
|
||||
reply.status(403).send({
|
||||
code: 'PRODUCT_GLOBAL_CATALOG_READ_FORBIDDEN',
|
||||
message: 'Tenant management or an authorized store administrator is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof ProductCatalogError)) throw error;
|
||||
const code = error.code;
|
||||
const status = code.endsWith('_FORBIDDEN') ? 403
|
||||
: code.endsWith('_NOT_FOUND') ? 404
|
||||
: code.includes('INVALID') ? 400
|
||||
: 409;
|
||||
return reply.status(status).send({
|
||||
code,
|
||||
message: 'The product catalog request cannot be completed.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_PRODUCT_REQUEST', message: 'The product request is invalid.', traceId
|
||||
});
|
||||
}
|
||||
@@ -29,7 +29,7 @@ const storeSchema = z.object({
|
||||
timezone: z.string().trim().min(1).max(64).default('Asia/Shanghai'),
|
||||
businessStatus: z.enum(['OPEN', 'CLOSED', 'SUSPENDED']).default('OPEN'),
|
||||
wifiSsid: z.string().trim().max(128).default(''),
|
||||
wifiPassword: z.string().max(255).default(''),
|
||||
wifiPassword: z.string().max(255).optional(),
|
||||
notificationUrl: z.union([z.string().url(), z.literal('')]).default(''),
|
||||
sortOrder: z.number().int().min(-100000).max(100000).default(0),
|
||||
businessHours: hoursSchema.default([])
|
||||
@@ -64,11 +64,21 @@ const disabledPeriodSchema = z.object({
|
||||
endsAt: z.coerce.date(),
|
||||
reason: z.string().trim().max(255).default('')
|
||||
}).refine((value) => value.endsAt > value.startsAt);
|
||||
const roomStatusSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
configurationStatus: z.enum(['ENABLED', 'DISABLED']).optional(),
|
||||
operationalStatus: z.enum([
|
||||
'AVAILABLE', 'MAINTENANCE', 'RESERVED', 'IN_USE', 'CLEANING_REQUIRED'
|
||||
]).optional(),
|
||||
reason: z.string().trim().min(1).max(255)
|
||||
}).strict().refine((value) => value.configurationStatus || value.operationalStatus, {
|
||||
message: 'at least one room status is required'
|
||||
});
|
||||
|
||||
export interface StoreRoomRouteOptions {
|
||||
repository: Pick<StoreRoomRepository,
|
||||
'listStores' | 'createStore' | 'updateStore' | 'archiveStore' | 'listRooms'
|
||||
| 'createRoom' | 'updateRoom' | 'archiveRoom' | 'addDisabledPeriod'>;
|
||||
| 'createRoom' | 'updateRoom' | 'updateRoomStatus' | 'archiveRoom' | 'addDisabledPeriod'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
@@ -80,6 +90,11 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
||||
if (!actor) return;
|
||||
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
|
||||
});
|
||||
app.get('/app-api/management/stores', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
if (!actor) return;
|
||||
return { code: 0, data: await options.repository.listStores(actor), traceId: request.traceId };
|
||||
});
|
||||
app.post('/admin-api/stores', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const body = storeSchema.safeParse(request.body);
|
||||
@@ -119,6 +134,15 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.get('/app-api/management/stores/:storeId/rooms', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = storeIdSchema.safeParse(request.params);
|
||||
if (!actor || !params.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0, data: await options.repository.listRooms(actor, params.data.storeId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
app.post('/admin-api/rooms', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const body = roomSchema.safeParse(request.body);
|
||||
@@ -140,6 +164,21 @@ export async function registerStoreRoomRoutes(app: FastifyInstance, options: Sto
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of ['/admin-api/rooms/:id/status', '/app-api/management/rooms/:id/status']) {
|
||||
app.patch(path, async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
const body = roomStatusSchema.safeParse(request.body);
|
||||
if (!actor || !params.success || !body.success) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
return mutate(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateRoomStatus(actor, params.data.id, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
app.delete('/admin-api/rooms/:id', async (request, reply) => {
|
||||
const actor = await requireOperator(request, reply, options);
|
||||
const params = idSchema.safeParse(request.params);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { AuthRepository } from '../auth/auth-repository.js';
|
||||
import { authenticateAccessToken } from '../auth/authenticate.js';
|
||||
import type { AccessProfile } from '../auth/rbac-repository.js';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import {
|
||||
SystemOperationsError,
|
||||
type SystemOperationsRepository
|
||||
} from '../operations/system-operations-repository.js';
|
||||
|
||||
const id = z.string().regex(/^[1-9]\d{0,19}$/);
|
||||
const auditQuerySchema = z.object({
|
||||
tenantId: id.optional(),
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
action: z.string().trim().min(1).max(128).optional(),
|
||||
resourceType: z.string().trim().min(1).max(64).optional(),
|
||||
actorId: id.optional(),
|
||||
search: z.string().trim().min(1).max(128).optional(),
|
||||
from: z.coerce.date().optional(),
|
||||
to: z.coerce.date().optional()
|
||||
}).strict();
|
||||
const tenantQuerySchema = z.object({ tenantId: id.optional() }).strict();
|
||||
const updateTenantSchema = z.object({
|
||||
tenantId: id.optional(),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
timezone: z.string().trim().min(1).max(64),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).optional()
|
||||
}).strict();
|
||||
|
||||
export interface SystemOperationsRouteOptions {
|
||||
repository: Pick<SystemOperationsRepository, 'listAuditLogs' | 'getSystemOverview' | 'updateTenant'>;
|
||||
authRepository: Pick<AuthRepository, 'validateSession'>;
|
||||
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
|
||||
jwtSecret: string;
|
||||
}
|
||||
|
||||
export async function registerSystemOperationsRoutes(
|
||||
app: FastifyInstance,
|
||||
options: SystemOperationsRouteOptions
|
||||
) {
|
||||
app.get('/admin-api/audit-logs', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const query = auditQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const tenantId = resolveTenant(actor, query.data.tenantId);
|
||||
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||
const to = query.data.to ?? new Date();
|
||||
const from = query.data.from ?? new Date(to.getTime() - 30 * 86400000);
|
||||
if (to.getTime() <= from.getTime() || to.getTime() - from.getTime() > 366 * 86400000) {
|
||||
return invalid(reply, request.traceId);
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.listAuditLogs({ ...query.data, tenantId, from, to }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/system/overview', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const query = tenantQuerySchema.safeParse(request.query);
|
||||
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
|
||||
const tenantId = resolveTenant(actor, query.data.tenantId);
|
||||
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.getSystemOverview(tenantId),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.put('/admin-api/system/tenant', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
const body = updateTenantSchema.safeParse(request.body);
|
||||
if (!actor || !body.success || !isValidTimeZone(body.data.timezone)) {
|
||||
return actor ? invalid(reply, request.traceId) : undefined;
|
||||
}
|
||||
const tenantId = resolveTenant(actor, body.data.tenantId);
|
||||
if (!tenantId) return tenantForbidden(reply, request.traceId);
|
||||
if (body.data.status && !isPlatform(actor.access)) {
|
||||
return reply.status(403).send({
|
||||
code: 'SYSTEM_STATUS_FORBIDDEN',
|
||||
message: 'Platform management permission is required to change tenant status.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
}
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.updateTenant(actor, tenantId, body.data),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
async function requireManager(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
options: SystemOperationsRouteOptions
|
||||
): Promise<ManagementActor | null> {
|
||||
const auth = await authenticateAccessToken(
|
||||
request.headers.authorization,
|
||||
options.authRepository,
|
||||
options.jwtSecret
|
||||
);
|
||||
if (!auth) {
|
||||
reply.status(401).send({
|
||||
code: 'AUTH_SESSION_INVALID',
|
||||
message: 'Authentication required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const access = await options.accessControl.getAccessProfile(
|
||||
auth.session.tenantId,
|
||||
auth.session.user.id
|
||||
);
|
||||
if (!access.capabilities.includes('tenant.manage') && !isPlatform(access)) {
|
||||
reply.status(403).send({
|
||||
code: 'SYSTEM_OPERATIONS_FORBIDDEN',
|
||||
message: 'Tenant management permission is required.',
|
||||
traceId: request.traceId
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
tenantId: auth.session.tenantId,
|
||||
userId: auth.session.user.id,
|
||||
access,
|
||||
traceId: request.traceId,
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'] ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTenant(actor: ManagementActor, requested?: string) {
|
||||
if (!requested || requested === actor.tenantId) return actor.tenantId;
|
||||
return isPlatform(actor.access) ? requested : null;
|
||||
}
|
||||
|
||||
function isPlatform(access: AccessProfile) {
|
||||
return access.roles.includes('PLATFORM_ADMIN') || access.capabilities.includes('platform.manage');
|
||||
}
|
||||
|
||||
function isValidTimeZone(value: string) {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: value }).format();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
|
||||
try {
|
||||
return await work();
|
||||
} catch (error) {
|
||||
if (!(error instanceof SystemOperationsError)) throw error;
|
||||
const statusCode = error.code.endsWith('_NOT_FOUND') ? 404 : 400;
|
||||
return reply.status(statusCode).send({
|
||||
code: error.code,
|
||||
message: 'The system operations request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(400).send({
|
||||
code: 'INVALID_SYSTEM_OPERATIONS_REQUEST',
|
||||
message: 'The system operations request is invalid.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
|
||||
function tenantForbidden(reply: FastifyReply, traceId: string) {
|
||||
return reply.status(403).send({
|
||||
code: 'SYSTEM_TENANT_FORBIDDEN',
|
||||
message: 'The tenant is outside the allowed scope.',
|
||||
traceId
|
||||
});
|
||||
}
|
||||
@@ -36,8 +36,10 @@ const bookingSchema = z.object({
|
||||
const bookingParams = z.object({ bookingId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const recordsQuery = z.object({
|
||||
provider: providerSchema.optional(),
|
||||
status: z.string().min(1).max(32).optional()
|
||||
status: z.string().min(1).max(32).optional(),
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
});
|
||||
const setupQuery = z.object({ provider: providerSchema.optional() });
|
||||
const configSchema = z.object({
|
||||
provider: providerSchema,
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).nullable().default(null),
|
||||
@@ -82,22 +84,49 @@ export async function registerThirdPartyRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/group-vouchers/redeem-manual', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const body = manualSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.redeemVoucherManually({
|
||||
tenantId: auth.tenantId,
|
||||
actorId: auth.userId,
|
||||
access: auth.access!,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
for (const path of [
|
||||
'/admin-api/group-vouchers/redeem',
|
||||
'/app-api/management/group-vouchers/redeem'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const body = redeemSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.redeemVoucherAsManager({
|
||||
tenantId: auth.tenantId,
|
||||
actorId: auth.userId,
|
||||
access: auth.access!,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
'/admin-api/group-vouchers/redeem-manual',
|
||||
'/app-api/management/group-vouchers/redeem-manual'
|
||||
]) {
|
||||
app.post(path, async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const body = manualSchema.safeParse(request.body);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.redeemVoucherManually({
|
||||
tenantId: auth.tenantId,
|
||||
actorId: auth.userId,
|
||||
access: auth.access!,
|
||||
...body.data
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
app.post(
|
||||
'/app-api/third-party/:provider/tenants/:tenantId/bookings/notify',
|
||||
@@ -150,7 +179,42 @@ export async function registerThirdPartyRoutes(
|
||||
tenantId: auth.tenantId,
|
||||
access: auth.access!,
|
||||
provider: query.data.provider as ThirdPartyProvider | undefined,
|
||||
status: query.data.status
|
||||
status: query.data.status,
|
||||
storeId: query.data.storeId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/third-party/setup', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const query = setupQuery.safeParse(request.query);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listSetup({
|
||||
tenantId: auth.tenantId,
|
||||
access: auth.access!,
|
||||
provider: query.data.provider as ThirdPartyProvider | undefined
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/app-api/management/third-party/records', async (request, reply) => {
|
||||
const auth = await authenticate(request.headers.authorization, options, true);
|
||||
const query = recordsQuery.safeParse(request.query);
|
||||
if (!auth) return unauthorized(reply, request.traceId);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.service.listRecords({
|
||||
tenantId: auth.tenantId,
|
||||
access: auth.access!,
|
||||
provider: query.data.provider as ThirdPartyProvider | undefined,
|
||||
status: query.data.status,
|
||||
storeId: query.data.storeId
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
|
||||
@@ -31,7 +31,9 @@ const updateSchema = z.object({
|
||||
const listSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
userType: z.enum(['STAFF']).optional(),
|
||||
status: z.enum(['ACTIVE', 'DISABLED']).optional(),
|
||||
role: z.enum(['CLEANER', 'STAFF', 'STORE_ADMIN', 'TENANT_ADMIN']).optional(),
|
||||
search: z.string().trim().max(128).optional()
|
||||
});
|
||||
|
||||
@@ -46,14 +48,23 @@ export async function registerUserManagementRoutes(
|
||||
app: FastifyInstance,
|
||||
options: UserManagementRouteOptions
|
||||
): Promise<void> {
|
||||
app.get('/admin-api/users', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const data = await options.repository.listUsers({ actor, ...query.data });
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
for (const [path, staffOnly] of [
|
||||
['/admin-api/users', false],
|
||||
['/app-api/management/staff', true]
|
||||
] as const) {
|
||||
app.get(path, async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
if (!actor) return;
|
||||
const query = listSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
const data = await options.repository.listUsers({
|
||||
actor,
|
||||
...query.data,
|
||||
...(staffOnly ? { userType: 'STAFF' as const } : {})
|
||||
});
|
||||
return { code: 0, data, traceId: request.traceId };
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/admin-api/staff', async (request, reply) => {
|
||||
const actor = await requireManager(request, reply, options);
|
||||
|
||||
+69
-3
@@ -2,6 +2,7 @@ import { buildApp } from './app.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { closeMySqlPool, createMySqlPool } from './db/mysql.js';
|
||||
import { PlatformConfigRepository } from './tenancy/platform-config-repository.js';
|
||||
import { PlatformAdminRepository } from './tenancy/platform-admin-repository.js';
|
||||
import { AuthRepository } from './auth/auth-repository.js';
|
||||
import { RbacRepository } from './auth/rbac-repository.js';
|
||||
import { parseWechatAppSecrets, WechatHttpClient } from './auth/wechat-client.js';
|
||||
@@ -38,17 +39,35 @@ import { RechargeService } from './wallets/recharge-service.js';
|
||||
import { WalletLedgerService } from './wallets/wallet-ledger-service.js';
|
||||
import { MarketingBenefitService } from './wallets/marketing-benefit-service.js';
|
||||
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
|
||||
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
|
||||
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
|
||||
import { FranchiseRepository } from './franchise/franchise-repository.js';
|
||||
import { SystemOperationsRepository } from './operations/system-operations-repository.js';
|
||||
import { AdminAuthRepository } from './auth/admin-auth-repository.js';
|
||||
import { ProductCatalogRepository } from './products/product-catalog-repository.js';
|
||||
import { InventoryService } from './inventory/inventory-service.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const pool = createMySqlPool(config);
|
||||
const authRepository = new AuthRepository(pool);
|
||||
const accessControl = new RbacRepository(pool);
|
||||
const platformConfigRepository = new PlatformConfigRepository(pool);
|
||||
const orderManagementRepository = new OrderManagementRepository(pool);
|
||||
const walletLedgerService = new WalletLedgerService(pool);
|
||||
const marketingBenefits = new MarketingBenefitService(pool);
|
||||
const cleaningTaskRepository = new CleaningTaskRepository(pool);
|
||||
const productCatalogRepository = new ProductCatalogRepository(pool);
|
||||
const inventoryService = new InventoryService(pool);
|
||||
const paymentRepository = new PaymentRepository(pool, walletLedgerService, marketingBenefits);
|
||||
const wechatCredentials = parseWechatPayCredentials(config.payment.wechatCredentialsJson);
|
||||
const wechatPayClient = new WechatPayClient(new FetchWechatPayTransport());
|
||||
const cleaningPayoutService = new CleaningPayoutService(
|
||||
pool,
|
||||
cleaningTaskRepository,
|
||||
wechatPayClient,
|
||||
wechatCredentials,
|
||||
config.payment.cleaningPayoutMockEnabled
|
||||
);
|
||||
const thirdPartyCredentials = parseThirdPartyCredentials(config.thirdParty.credentialsJson);
|
||||
const iotMessages = new IotMessageService(pool);
|
||||
const mqtt = new MqttService(config.mqtt, undefined, (topic, payload) =>
|
||||
@@ -58,7 +77,13 @@ const deviceCommands = new DeviceCommandService(iotMessages, mqtt);
|
||||
const app = await buildApp({
|
||||
config,
|
||||
mqtt,
|
||||
platformConfigRepository: new PlatformConfigRepository(pool),
|
||||
platformConfigRepository,
|
||||
platformManagement: {
|
||||
repository: new PlatformAdminRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
auth: {
|
||||
repository: authRepository,
|
||||
wechat: new WechatHttpClient(parseWechatAppSecrets(config.auth.wechatAppSecretsJson)),
|
||||
@@ -103,7 +128,7 @@ const app = await buildApp({
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
orderState: {
|
||||
repository: new OrderStateRepository(pool, marketingBenefits),
|
||||
repository: new OrderStateRepository(pool, marketingBenefits, cleaningTaskRepository),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
@@ -187,7 +212,48 @@ const app = await buildApp({
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
cleaning: {
|
||||
repository: new CleaningTaskRepository(pool),
|
||||
repository: cleaningTaskRepository,
|
||||
payoutService: cleaningPayoutService,
|
||||
mediaStorage: new MediaStorage(resolve(process.cwd(), 'shared', 'uploads')),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
businessStatistics: {
|
||||
repository: new BusinessStatisticsRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
franchise: {
|
||||
repository: new FranchiseRepository(pool),
|
||||
platformConfig: platformConfigRepository,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
systemOperations: {
|
||||
repository: new SystemOperationsRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
adminAuth: {
|
||||
repository: new AdminAuthRepository(pool),
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret,
|
||||
accessTokenTtlSeconds: config.auth.accessTokenTtlSeconds,
|
||||
sessionTtlSeconds: config.auth.sessionTtlSeconds
|
||||
},
|
||||
products: {
|
||||
repository: productCatalogRepository,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
},
|
||||
inventory: {
|
||||
service: inventoryService,
|
||||
authRepository,
|
||||
accessControl,
|
||||
jwtSecret: config.auth.jwtSecret
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface StoreInput {
|
||||
timezone: string;
|
||||
businessStatus: 'OPEN' | 'CLOSED' | 'SUSPENDED';
|
||||
wifiSsid: string;
|
||||
wifiPassword: string;
|
||||
wifiPassword?: string;
|
||||
notificationUrl: string;
|
||||
sortOrder: number;
|
||||
businessHours: Array<{
|
||||
@@ -47,13 +47,23 @@ export interface RoomInput {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface RoomStatusInput {
|
||||
storeId: string;
|
||||
configurationStatus?: 'ENABLED' | 'DISABLED';
|
||||
operationalStatus?: 'AVAILABLE' | 'MAINTENANCE' | 'RESERVED' | 'IN_USE' | 'CLEANING_REQUIRED';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface IdRow extends RowDataPacket { id: string }
|
||||
interface CountRow extends RowDataPacket { total: number }
|
||||
interface StoreRow extends RowDataPacket {
|
||||
id: string; name: string; address: string; city: string; district: string;
|
||||
longitude: string | null; latitude: string | null;
|
||||
contactPhone: string; timezone: string; businessStatus: string; wifiSsid: string;
|
||||
notificationUrl: string; sortOrder: number;
|
||||
wifiConfigured: number; notificationUrl: string; sortOrder: number;
|
||||
}
|
||||
interface StoreBusinessHourRow extends RowDataPacket {
|
||||
storeId: string; weekday: number; openMinute: number; closeMinute: number; isClosed: number;
|
||||
}
|
||||
interface RoomRow extends RowDataPacket {
|
||||
id: string; storeId: string; categoryName: string; name: string; roomNo: string; capacity: number;
|
||||
@@ -78,17 +88,42 @@ export class StoreRoomRepository {
|
||||
`SELECT s.id, s.name, s.address, s.city, s.district, s.longitude, s.latitude,
|
||||
s.contact_phone AS contactPhone, s.timezone,
|
||||
s.business_status AS businessStatus, s.wifi_ssid AS wifiSsid,
|
||||
CASE WHEN s.wifi_password <> '' THEN 1 ELSE 0 END AS wifiConfigured,
|
||||
s.notification_url AS notificationUrl, s.sort_order AS sortOrder
|
||||
FROM qipai_stores s
|
||||
WHERE s.tenant_id = ? AND s.deleted_at IS NULL AND ${scope.sql}
|
||||
ORDER BY s.sort_order, s.id`,
|
||||
[actor.tenantId, ...scope.params]
|
||||
);
|
||||
if (rows.length === 0) return [];
|
||||
const storeIds = rows.map((row) => String(row.id));
|
||||
const [hourRows] = await this.pool.execute<StoreBusinessHourRow[]>(
|
||||
`SELECT store_id AS storeId, weekday, open_minute AS openMinute,
|
||||
close_minute AS closeMinute, is_closed AS isClosed
|
||||
FROM qipai_store_business_hours
|
||||
WHERE tenant_id = ? AND store_id IN (${storeIds.map(() => '?').join(',')})
|
||||
ORDER BY store_id, weekday`,
|
||||
[actor.tenantId, ...storeIds]
|
||||
);
|
||||
const hoursByStore = new Map<string, StoreInput['businessHours']>();
|
||||
for (const hour of hourRows) {
|
||||
const storeId = String(hour.storeId);
|
||||
const items = hoursByStore.get(storeId) ?? [];
|
||||
items.push({
|
||||
weekday: Number(hour.weekday),
|
||||
openMinute: Number(hour.openMinute),
|
||||
closeMinute: Number(hour.closeMinute),
|
||||
isClosed: Boolean(hour.isClosed)
|
||||
});
|
||||
hoursByStore.set(storeId, items);
|
||||
}
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
id: String(row.id),
|
||||
longitude: row.longitude === null ? null : Number(row.longitude),
|
||||
latitude: row.latitude === null ? null : Number(row.latitude)
|
||||
latitude: row.latitude === null ? null : Number(row.latitude),
|
||||
wifiConfigured: Boolean(row.wifiConfigured),
|
||||
businessHours: hoursByStore.get(String(row.id)) ?? []
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -102,7 +137,7 @@ export class StoreRoomRepository {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, input.name, input.address, input.city, input.district, input.longitude ?? null,
|
||||
input.latitude ?? null, input.contactPhone, input.timezone, input.businessStatus,
|
||||
input.wifiSsid, input.wifiPassword, input.notificationUrl, input.sortOrder]
|
||||
input.wifiSsid, input.wifiPassword ?? '', input.notificationUrl, input.sortOrder]
|
||||
);
|
||||
const storeId = String(result.insertId);
|
||||
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
||||
@@ -118,12 +153,12 @@ export class StoreRoomRepository {
|
||||
`UPDATE qipai_stores SET name = ?, address = ?, city = ?, district = ?,
|
||||
longitude = ?, latitude = ?,
|
||||
contact_phone = ?, timezone = ?, business_status = ?, wifi_ssid = ?,
|
||||
wifi_password = ?, notification_url = ?, sort_order = ?
|
||||
wifi_password = COALESCE(?, wifi_password), notification_url = ?, sort_order = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.name, input.address, input.city, input.district,
|
||||
input.longitude ?? null, input.latitude ?? null,
|
||||
input.contactPhone, input.timezone, input.businessStatus, input.wifiSsid,
|
||||
input.wifiPassword, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
|
||||
input.wifiPassword ?? null, input.notificationUrl, input.sortOrder, actor.tenantId, storeId]
|
||||
);
|
||||
await this.replaceHours(connection, actor.tenantId, storeId, input.businessHours);
|
||||
await this.audit(connection, actor, 'STORE_UPDATED', 'STORE', storeId);
|
||||
@@ -152,7 +187,7 @@ export class StoreRoomRepository {
|
||||
}
|
||||
|
||||
async listRooms(actor: ManagementActor, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
this.assertStoreScope(actor, storeId, false);
|
||||
const [rows] = await this.pool.execute<RoomRow[]>(
|
||||
`SELECT r.id, r.store_id AS storeId, COALESCE(c.name, '') AS categoryName,
|
||||
r.name, r.room_no AS roomNo, r.capacity,
|
||||
@@ -226,6 +261,33 @@ export class StoreRoomRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async updateRoomStatus(actor: ManagementActor, roomId: string, input: RoomStatusInput) {
|
||||
this.assertStoreScope(actor, input.storeId, true);
|
||||
return this.transaction(async (connection) => {
|
||||
await this.lockRoom(connection, actor, roomId, input.storeId);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_rooms
|
||||
SET configuration_status = COALESCE(?, configuration_status),
|
||||
operational_status = COALESCE(?, operational_status),
|
||||
status = CASE
|
||||
WHEN COALESCE(?, configuration_status) = 'DISABLED' THEN 'DISABLED'
|
||||
ELSE COALESCE(?, operational_status)
|
||||
END
|
||||
WHERE tenant_id = ? AND store_id = ? AND id = ?`,
|
||||
[input.configurationStatus ?? null, input.operationalStatus ?? null,
|
||||
input.configurationStatus ?? null, input.operationalStatus ?? null,
|
||||
actor.tenantId, input.storeId, roomId]
|
||||
);
|
||||
await this.audit(connection, actor, 'ROOM_STATUS_UPDATED', 'ROOM', roomId, {
|
||||
storeId: input.storeId,
|
||||
configurationStatus: input.configurationStatus ?? null,
|
||||
operationalStatus: input.operationalStatus ?? null,
|
||||
reason: input.reason
|
||||
});
|
||||
return { roomId, ...input };
|
||||
});
|
||||
}
|
||||
|
||||
async archiveRoom(actor: ManagementActor, roomId: string, storeId: string) {
|
||||
this.assertStoreScope(actor, storeId);
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -265,10 +327,11 @@ export class StoreRoomRepository {
|
||||
}
|
||||
}
|
||||
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string) {
|
||||
private assertStoreScope(actor: ManagementActor, storeId: string, write = true) {
|
||||
if (actor.access.capabilities.includes('tenant.manage')
|
||||
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
|
||||
if (!actor.access.capabilities.includes('store.operation.write')
|
||||
const capability = write ? 'store.operation.write' : 'store.operation.read';
|
||||
if (!actor.access.capabilities.includes(capability)
|
||||
|| !actor.access.storeIds.includes(storeId)) {
|
||||
throw new StoreRoomError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
@@ -343,15 +406,16 @@ export class StoreRoomRepository {
|
||||
|
||||
private async audit(
|
||||
connection: PoolConnection, actor: ManagementActor,
|
||||
action: string, resourceType: string, resourceId: string
|
||||
action: string, resourceType: string, resourceId: string,
|
||||
metadata: Record<string, unknown> = {}
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
||||
VALUES (?, 'USER', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, actor.userId, action, resourceType, resourceId,
|
||||
actor.traceId, actor.ip, actor.userAgent.slice(0, 255)]
|
||||
actor.traceId, actor.ip, actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { ManagementActor } from '../auth/user-management-repository.js';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
export interface TenantAppConfigInput {
|
||||
brandName: string;
|
||||
logoUrl: string;
|
||||
themeColor: string;
|
||||
servicePhone: string;
|
||||
franchisePhone: string;
|
||||
shareTitle: string;
|
||||
shareImageUrl: string;
|
||||
defaultStoreId: string | null;
|
||||
extraConfig: Record<string, unknown>;
|
||||
bindingStatus: 'ACTIVE' | 'DISABLED';
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export class PlatformAdminError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class PlatformAdminRepository {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async listTenantApps(tenantId: string) {
|
||||
const [rows] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT pa.id AS platformAppId, pa.appid AS appId, pa.name AS appName,
|
||||
pa.status AS appStatus, ta.status AS bindingStatus, ta.is_default AS isDefault,
|
||||
tc.brand_name AS brandName, tc.logo_url AS logoUrl,
|
||||
tc.theme_color AS themeColor, tc.service_phone AS servicePhone,
|
||||
tc.franchise_phone AS franchisePhone, tc.share_title AS shareTitle,
|
||||
tc.share_image_url AS shareImageUrl, tc.default_store_id AS defaultStoreId,
|
||||
tc.extra_config AS extraConfig, tc.updated_at AS updatedAt
|
||||
FROM qipai_tenant_apps ta
|
||||
INNER JOIN qipai_platform_apps pa
|
||||
ON pa.id = ta.platform_app_id AND pa.deleted_at IS NULL
|
||||
INNER JOIN qipai_tenant_configs tc
|
||||
ON tc.tenant_id = ta.tenant_id AND tc.platform_app_id = ta.platform_app_id
|
||||
AND tc.deleted_at IS NULL
|
||||
WHERE ta.tenant_id = ? AND ta.deleted_at IS NULL
|
||||
ORDER BY ta.is_default DESC, pa.id`,
|
||||
[tenantId]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
platformAppId: String(row.platformAppId),
|
||||
appId: row.appId,
|
||||
appName: row.appName,
|
||||
appStatus: row.appStatus,
|
||||
bindingStatus: row.bindingStatus,
|
||||
isDefault: Boolean(row.isDefault),
|
||||
brandName: row.brandName,
|
||||
logoUrl: row.logoUrl,
|
||||
themeColor: row.themeColor,
|
||||
servicePhone: row.servicePhone,
|
||||
franchisePhone: row.franchisePhone,
|
||||
shareTitle: row.shareTitle,
|
||||
shareImageUrl: row.shareImageUrl,
|
||||
defaultStoreId: row.defaultStoreId === null ? null : String(row.defaultStoreId),
|
||||
extraConfig: parseJson(row.extraConfig),
|
||||
updatedAt: row.updatedAt
|
||||
}));
|
||||
}
|
||||
|
||||
async updateTenantConfig(actor: ManagementActor, platformAppId: string, input: TenantAppConfigInput) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.assertBinding(connection, actor.tenantId, platformAppId);
|
||||
await this.assertDefaultStore(connection, actor.tenantId, input.defaultStoreId);
|
||||
if (input.isDefault) {
|
||||
await connection.execute(
|
||||
'UPDATE qipai_tenant_apps SET is_default = 0 WHERE tenant_id = ? AND deleted_at IS NULL',
|
||||
[actor.tenantId]
|
||||
);
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_tenant_apps
|
||||
SET status = ?, is_default = ?
|
||||
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL`,
|
||||
[input.bindingStatus, input.isDefault ? 1 : 0, actor.tenantId, platformAppId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_tenant_configs SET brand_name = ?, logo_url = ?, theme_color = ?,
|
||||
service_phone = ?, franchise_phone = ?, share_title = ?, share_image_url = ?,
|
||||
default_store_id = ?, extra_config = ?
|
||||
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL`,
|
||||
[input.brandName, input.logoUrl, input.themeColor, input.servicePhone,
|
||||
input.franchisePhone, input.shareTitle, input.shareImageUrl, input.defaultStoreId,
|
||||
JSON.stringify(input.extraConfig), actor.tenantId, platformAppId]
|
||||
);
|
||||
await this.audit(connection, actor, 'TENANT_APP_CONFIG_UPDATED', platformAppId, {
|
||||
bindingStatus: input.bindingStatus, isDefault: input.isDefault,
|
||||
defaultStoreId: input.defaultStoreId
|
||||
});
|
||||
return { platformAppId, updated: true };
|
||||
});
|
||||
}
|
||||
|
||||
async bindApplication(actor: ManagementActor, input: {
|
||||
appId: string; appName: string; appStatus: 'ACTIVE' | 'DISABLED'; config: TenantAppConfigInput;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
await this.assertDefaultStore(connection, actor.tenantId, input.config.defaultStoreId);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_platform_apps (appid, name, status)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), status = VALUES(status), deleted_at = NULL`,
|
||||
[input.appId, input.appName, input.appStatus]
|
||||
);
|
||||
const [apps] = await connection.execute<Array<RowDataPacket & { id: string }>>(
|
||||
'SELECT id FROM qipai_platform_apps WHERE appid = ? AND deleted_at IS NULL FOR UPDATE',
|
||||
[input.appId]
|
||||
);
|
||||
if (!apps[0]) throw new PlatformAdminError('PLATFORM_APP_NOT_FOUND');
|
||||
const platformAppId = String(apps[0].id);
|
||||
if (input.config.isDefault) {
|
||||
await connection.execute(
|
||||
'UPDATE qipai_tenant_apps SET is_default = 0 WHERE tenant_id = ? AND deleted_at IS NULL',
|
||||
[actor.tenantId]
|
||||
);
|
||||
}
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_tenant_apps (tenant_id, platform_app_id, status, is_default)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE status = VALUES(status), is_default = VALUES(is_default), deleted_at = NULL`,
|
||||
[actor.tenantId, platformAppId, input.config.bindingStatus, input.config.isDefault ? 1 : 0]
|
||||
);
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_tenant_configs
|
||||
(tenant_id, platform_app_id, brand_name, logo_url, theme_color, service_phone,
|
||||
franchise_phone, share_title, share_image_url, default_store_id, extra_config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE brand_name = VALUES(brand_name), logo_url = VALUES(logo_url),
|
||||
theme_color = VALUES(theme_color), service_phone = VALUES(service_phone),
|
||||
franchise_phone = VALUES(franchise_phone), share_title = VALUES(share_title),
|
||||
share_image_url = VALUES(share_image_url), default_store_id = VALUES(default_store_id),
|
||||
extra_config = VALUES(extra_config), deleted_at = NULL`,
|
||||
[actor.tenantId, platformAppId, input.config.brandName, input.config.logoUrl,
|
||||
input.config.themeColor, input.config.servicePhone, input.config.franchisePhone,
|
||||
input.config.shareTitle, input.config.shareImageUrl, input.config.defaultStoreId,
|
||||
JSON.stringify(input.config.extraConfig)]
|
||||
);
|
||||
await this.audit(connection, actor, 'TENANT_APP_BOUND', platformAppId, {
|
||||
appId: input.appId, bindingStatus: input.config.bindingStatus,
|
||||
isDefault: input.config.isDefault
|
||||
});
|
||||
return { platformAppId, bound: true };
|
||||
});
|
||||
}
|
||||
|
||||
private async assertBinding(connection: PoolConnection, tenantId: string, platformAppId: string) {
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
`SELECT id FROM qipai_tenant_apps
|
||||
WHERE tenant_id = ? AND platform_app_id = ? AND deleted_at IS NULL FOR UPDATE`,
|
||||
[tenantId, platformAppId]
|
||||
);
|
||||
if (!rows[0]) throw new PlatformAdminError('TENANT_APP_BINDING_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async assertDefaultStore(connection: PoolConnection, tenantId: string, storeId: string | null) {
|
||||
if (!storeId) return;
|
||||
const [rows] = await connection.execute<RowDataPacket[]>(
|
||||
'SELECT id FROM qipai_stores WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL',
|
||||
[tenantId, storeId]
|
||||
);
|
||||
if (!rows[0]) throw new PlatformAdminError('TENANT_APP_DEFAULT_STORE_INVALID');
|
||||
}
|
||||
|
||||
private async audit(connection: PoolConnection, actor: ManagementActor, action: string, id: string, metadata: object) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_audit_logs
|
||||
(tenant_id, actor_type, actor_id, action, resource_type, resource_id,
|
||||
trace_id, ip, user_agent, metadata)
|
||||
VALUES (?, 'USER', ?, ?, 'PLATFORM_APP', ?, ?, ?, ?, ?)`,
|
||||
[actor.tenantId, actor.userId, action, id, actor.traceId, actor.ip,
|
||||
actor.userAgent.slice(0, 255), JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const result = await work(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === 'object') return value as Record<string, unknown>;
|
||||
if (typeof value !== 'string') return {};
|
||||
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : {}; }
|
||||
catch { return {}; }
|
||||
}
|
||||
+135
-17
@@ -119,6 +119,45 @@ export class ThirdPartyService {
|
||||
});
|
||||
}
|
||||
|
||||
async redeemVoucherAsManager(input: {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
access: AccessProfile;
|
||||
provider: ThirdPartyProvider;
|
||||
voucherCode: string;
|
||||
orderId: string;
|
||||
clientRequestId: string;
|
||||
}) {
|
||||
const existing = await this.findRedemptionByRequest(
|
||||
input.tenantId, input.clientRequestId, input.orderId
|
||||
);
|
||||
if (existing) return { ...existing, idempotent: true };
|
||||
const order = await this.loadOrder(input.tenantId, input.orderId);
|
||||
assertStoreAccess(input.access, order.storeId);
|
||||
const config = await this.resolveConfig(input.tenantId, order.storeId, input.provider);
|
||||
const expectedAmountCents = Number(order.totalAmountCents) - Number(order.paidAmountCents);
|
||||
if (expectedAmountCents <= 0) throw new ThirdPartyError('PAYMENT_NOT_REQUIRED');
|
||||
const result = await this.client.redeemVoucher({
|
||||
mode: config.mode,
|
||||
provider: input.provider,
|
||||
voucherCode: input.voucherCode,
|
||||
orderNo: order.orderNo,
|
||||
expectedAmountCents,
|
||||
settings: config.settings,
|
||||
credential: this.resolveCredential(config.credentialRef)
|
||||
});
|
||||
return this.recordRedemption({
|
||||
...input,
|
||||
mode: config.mode,
|
||||
amountCents: result.amountCents,
|
||||
expectedAmountCents,
|
||||
externalProductId: result.externalProductId ?? '',
|
||||
status: result.status,
|
||||
failureCode: result.failureCode ?? '',
|
||||
response: result.response ?? {}
|
||||
});
|
||||
}
|
||||
|
||||
async receiveDirectBooking(input: {
|
||||
tenantId: string;
|
||||
provider: ThirdPartyProvider;
|
||||
@@ -242,26 +281,31 @@ export class ThirdPartyService {
|
||||
access: AccessProfile;
|
||||
provider?: ThirdPartyProvider;
|
||||
status?: string;
|
||||
storeId?: string;
|
||||
}) {
|
||||
const storeFilter = input.access.capabilities.includes('tenant.manage')
|
||||
|| input.access.roles.includes('PLATFORM_ADMIN')
|
||||
? null : input.access.storeIds;
|
||||
const unrestricted = input.access.capabilities.includes('tenant.manage')
|
||||
|| input.access.roles.includes('PLATFORM_ADMIN');
|
||||
if (input.storeId && !unrestricted && !input.access.storeIds.includes(input.storeId)) {
|
||||
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
const storeFilter = input.storeId ? [input.storeId]
|
||||
: unrestricted ? null : input.access.storeIds;
|
||||
if (storeFilter && storeFilter.length === 0) {
|
||||
throw new ThirdPartyError('STORE_SCOPE_FORBIDDEN');
|
||||
}
|
||||
const params: string[] = [input.tenantId];
|
||||
const filters = ['tenant_id = ?'];
|
||||
const bookingParams: string[] = [input.tenantId];
|
||||
const bookingFilters = ['tenant_id = ?'];
|
||||
if (input.provider) {
|
||||
filters.push('provider = ?');
|
||||
params.push(input.provider);
|
||||
bookingFilters.push('provider = ?');
|
||||
bookingParams.push(input.provider);
|
||||
}
|
||||
if (input.status) {
|
||||
filters.push('status = ?');
|
||||
params.push(input.status);
|
||||
bookingFilters.push('status = ?');
|
||||
bookingParams.push(input.status);
|
||||
}
|
||||
if (storeFilter) {
|
||||
filters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
|
||||
params.push(...storeFilter);
|
||||
bookingFilters.push(`store_id IN (${storeFilter.map(() => '?').join(',')})`);
|
||||
bookingParams.push(...storeFilter);
|
||||
}
|
||||
const [bookings] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, provider, external_booking_no AS externalBookingNo,
|
||||
@@ -269,24 +313,87 @@ export class ThirdPartyService {
|
||||
ends_at AS endsAt, amount_cents AS amountCents, status,
|
||||
order_id AS orderId, failure_code AS failureCode, created_at AS createdAt
|
||||
FROM qipai_direct_bookings
|
||||
WHERE ${filters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
|
||||
params
|
||||
WHERE ${bookingFilters.join(' AND ')} ORDER BY id DESC LIMIT 100`,
|
||||
bookingParams
|
||||
);
|
||||
const redemptionParams: string[] = [input.tenantId];
|
||||
const redemptionFilters = ['r.tenant_id = ?'];
|
||||
if (input.provider) {
|
||||
redemptionFilters.push('v.provider = ?');
|
||||
redemptionParams.push(input.provider);
|
||||
}
|
||||
if (input.status) {
|
||||
redemptionFilters.push('r.status = ?');
|
||||
redemptionParams.push(input.status);
|
||||
}
|
||||
if (storeFilter) {
|
||||
redemptionFilters.push(`r.store_id IN (${storeFilter.map(() => '?').join(',')})`);
|
||||
redemptionParams.push(...storeFilter);
|
||||
}
|
||||
const [redemptions] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT r.id, v.provider, v.voucher_masked AS voucherMasked,
|
||||
r.order_id AS orderId, r.store_id AS storeId, r.redemption_mode AS mode,
|
||||
r.status, r.failure_code AS failureCode, r.created_at AS createdAt
|
||||
r.actor_id AS actorId, r.status, r.failure_code AS failureCode,
|
||||
r.created_at AS createdAt, r.completed_at AS completedAt
|
||||
FROM qipai_group_redemptions r
|
||||
INNER JOIN qipai_group_vouchers v
|
||||
ON v.tenant_id = r.tenant_id AND v.id = r.voucher_id
|
||||
WHERE r.tenant_id = ?
|
||||
${storeFilter ? `AND r.store_id IN (${storeFilter.map(() => '?').join(',')})` : ''}
|
||||
WHERE ${redemptionFilters.join(' AND ')}
|
||||
ORDER BY r.id DESC LIMIT 100`,
|
||||
storeFilter ? [input.tenantId, ...storeFilter] : [input.tenantId]
|
||||
redemptionParams
|
||||
);
|
||||
return { bookings, redemptions };
|
||||
}
|
||||
|
||||
async listSetup(input: {
|
||||
tenantId: string;
|
||||
access: AccessProfile;
|
||||
provider?: ThirdPartyProvider;
|
||||
}) {
|
||||
if (!input.access.capabilities.includes('tenant.manage')
|
||||
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
throw new ThirdPartyError('THIRD_PARTY_CONFIG_FORBIDDEN');
|
||||
}
|
||||
const params = input.provider ? [input.tenantId, input.provider] : [input.tenantId];
|
||||
const providerFilter = input.provider ? 'AND provider = ?' : '';
|
||||
const [configs] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, provider, store_id AS storeId, mode, enabled,
|
||||
credential_ref AS credentialRef, settings, updated_at AS updatedAt
|
||||
FROM qipai_third_party_configs
|
||||
WHERE tenant_id = ? ${providerFilter}
|
||||
ORDER BY provider, store_id IS NULL DESC, store_id, id`,
|
||||
params
|
||||
);
|
||||
const [mappings] = await this.pool.execute<RowDataPacket[]>(
|
||||
`SELECT id, provider, resource_type AS resourceType,
|
||||
external_ref AS externalRef, local_resource_id AS localResourceId
|
||||
FROM qipai_third_party_mappings
|
||||
WHERE tenant_id = ? ${providerFilter}
|
||||
ORDER BY provider, resource_type, id`,
|
||||
params
|
||||
);
|
||||
return {
|
||||
configs: configs.map((row) => ({
|
||||
id: String(row.id),
|
||||
provider: row.provider,
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
mode: row.mode,
|
||||
enabled: Boolean(row.enabled),
|
||||
credentialRef: String(row.credentialRef || ''),
|
||||
credentialConfigured: Boolean(row.credentialRef),
|
||||
settings: sanitizeSettings(row.settings),
|
||||
updatedAt: row.updatedAt
|
||||
})),
|
||||
mappings: mappings.map((row) => ({
|
||||
id: String(row.id),
|
||||
provider: row.provider,
|
||||
resourceType: row.resourceType,
|
||||
externalRef: row.externalRef,
|
||||
localResourceId: String(row.localResourceId)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async saveConfig(input: {
|
||||
tenantId: string;
|
||||
actorId: string;
|
||||
@@ -666,6 +773,17 @@ function sanitizePayload(value: Record<string, unknown>) {
|
||||
return copy;
|
||||
}
|
||||
|
||||
function sanitizeSettings(value: unknown): Record<string, unknown> {
|
||||
let source: unknown = value;
|
||||
if (typeof source === 'string') {
|
||||
try { source = JSON.parse(source); } catch { return {}; }
|
||||
}
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
|
||||
return Object.fromEntries(Object.entries(source as Record<string, unknown>)
|
||||
.filter(([key]) => !/(secret|token|password|private|api.?key|credential)/i.test(key))
|
||||
.map(([key, item]) => [key, item]));
|
||||
}
|
||||
|
||||
function normalizeBooking(row: BookingRow) {
|
||||
return {
|
||||
bookingId: String(row.id),
|
||||
|
||||
@@ -138,13 +138,15 @@ export class MemberProfileService {
|
||||
WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [rows] = await this.pool.execute<MemberRow[]>(
|
||||
`SELECT u.id, u.status, u.nickname, u.phone,
|
||||
u.created_at AS createdAt, u.last_login_at AS lastLoginAt
|
||||
FROM qipai_users u
|
||||
WHERE ${where}
|
||||
ORDER BY u.id DESC LIMIT ? OFFSET ?`,
|
||||
[...params, input.pageSize, (input.page - 1) * input.pageSize]
|
||||
ORDER BY u.id DESC LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
const items = await Promise.all(rows.map((row) => this.memberCard(input.actor, row)));
|
||||
return { items, total: Number(counts[0]?.total ?? 0) };
|
||||
@@ -341,9 +343,9 @@ export class MemberProfileService {
|
||||
);
|
||||
const [orderRows] = await this.pool.execute<OrderSummaryRow[]>(
|
||||
`SELECT COUNT(*) AS orderCount,
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
|
||||
THEN 1 ELSE 0 END), 0) AS paidOrderCount,
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'IN_USE', 'COMPLETED')
|
||||
COALESCE(SUM(CASE WHEN o.status IN ('PAID', 'RESERVED', 'IN_PROGRESS', 'FINISHED')
|
||||
THEN o.paid_amount_cents ELSE 0 END), 0) AS paidAmountCents,
|
||||
MAX(o.created_at) AS lastOrderAt
|
||||
FROM qipai_orders o
|
||||
@@ -433,12 +435,19 @@ function memberScopeClause(actor: MemberActor, userExpression: string) {
|
||||
}
|
||||
const placeholders = actor.access.storeIds.map(() => '?').join(',');
|
||||
return {
|
||||
sql: `EXISTS (
|
||||
sql: `(EXISTS (
|
||||
SELECT 1 FROM qipai_wallet_accounts wa
|
||||
WHERE wa.tenant_id = u.tenant_id AND wa.user_id = ${userExpression}
|
||||
AND wa.store_id IN (${placeholders})
|
||||
)`,
|
||||
params: actor.access.storeIds
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM qipai_order_user_access oa
|
||||
INNER JOIN qipai_orders mo
|
||||
ON mo.tenant_id = oa.tenant_id AND mo.id = oa.order_id
|
||||
AND mo.deleted_at IS NULL
|
||||
WHERE oa.tenant_id = u.tenant_id AND oa.user_id = ${userExpression}
|
||||
AND oa.access_type = 'OWNER' AND mo.store_id IN (${placeholders})
|
||||
))`,
|
||||
params: [...actor.access.storeIds, ...actor.access.storeIds]
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { AdminAuthError } from '../dist/auth/admin-auth-repository.js';
|
||||
import { hashPassword, verifyPassword } from '../dist/auth/password.js';
|
||||
|
||||
const secret = 'test-only-admin-password-auth-secret-32';
|
||||
const user = { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
|
||||
nickname: '租户管理员', avatarUrl: '', phone: '13800138000' };
|
||||
let loginInput; let refreshInput; let credentialInput; let revokedSessionId;
|
||||
const repository = {
|
||||
async loginWithPassword(input) {
|
||||
loginInput = input;
|
||||
if (input.password === 'wrong-password') throw new AdminAuthError('ADMIN_LOGIN_INVALID');
|
||||
if (input.password === 'locked-password') throw new AdminAuthError('ADMIN_LOGIN_LOCKED');
|
||||
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user, expiresAt: input.expiresAt };
|
||||
},
|
||||
async rotateRefreshToken(input) {
|
||||
refreshInput = input;
|
||||
return { id: input.sessionId, tenantId: '7', platformAppId: '9', user,
|
||||
expiresAt: new Date(Date.now() + 60000) };
|
||||
},
|
||||
async setCredential(actor, tenantId, input) {
|
||||
credentialInput = { actor, tenantId, input };
|
||||
return { userId: input.userId, configured: true };
|
||||
}
|
||||
};
|
||||
const authRepository = {
|
||||
async validateSession(sessionId) {
|
||||
return { id: sessionId, tenantId: '7', platformAppId: '9', user,
|
||||
expiresAt: new Date(Date.now() + 60000) };
|
||||
},
|
||||
async revokeSession(sessionId) { revokedSessionId = sessionId; return true; }
|
||||
};
|
||||
const access = { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] };
|
||||
const app = await buildApp({ adminAuth: { repository, authRepository,
|
||||
accessControl: { async getAccessProfile() { return access; } }, jwtSecret: secret,
|
||||
accessTokenTtlSeconds: 900, sessionTtlSeconds: 604800 } });
|
||||
|
||||
const login = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
|
||||
payload: { tenantCode: 'demo', loginName: 'Admin.User', ['password']: 'ValidPassword!123' } });
|
||||
assert.equal(login.statusCode, 200);
|
||||
assert.equal(loginInput.tenantCode, 'demo');
|
||||
assert.equal(login.json().data.user.phone, undefined);
|
||||
assert.deepEqual(login.json().data.access.roles, ['TENANT_ADMIN']);
|
||||
assert.ok(login.json().data.access.menus.includes('system'));
|
||||
assert.ok(login.json().data.access.menus.includes('products'));
|
||||
assert.match(login.json().data.accessToken, /^[^.]+\.[^.]+\.[^.]+$/);
|
||||
assert.match(login.json().data.refreshToken, /^[0-9a-f-]{36}\.[A-Za-z0-9_-]{43}$/);
|
||||
const successfulSessionId = loginInput.sessionId;
|
||||
|
||||
const bad = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
|
||||
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'wrong-password' } });
|
||||
assert.equal(bad.statusCode, 401);
|
||||
const locked = await app.inject({ method: 'POST', url: '/admin-api/auth/login',
|
||||
payload: { tenantCode: 'demo', loginName: 'admin', ['password']: 'locked-password' } });
|
||||
assert.equal(locked.statusCode, 423);
|
||||
|
||||
const refreshed = await app.inject({ method: 'POST', url: '/admin-api/auth/refresh',
|
||||
payload: { refreshToken: login.json().data.refreshToken } });
|
||||
assert.equal(refreshed.statusCode, 200);
|
||||
assert.equal(refreshInput.sessionId, successfulSessionId);
|
||||
assert.notEqual(refreshed.json().data.refreshToken, login.json().data.refreshToken);
|
||||
|
||||
const auth = { authorization: `Bearer ${login.json().data.accessToken}` };
|
||||
const me = await app.inject({ method: 'GET', url: '/admin-api/auth/me', headers: auth });
|
||||
assert.equal(me.statusCode, 200);
|
||||
assert.equal(me.json().data.user.nickname, '租户管理员');
|
||||
const weakCredential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
|
||||
headers: auth, payload: { loginName: 'operator', ['password']: 'too-weak' } });
|
||||
assert.equal(weakCredential.statusCode, 400);
|
||||
const credential = await app.inject({ method: 'PUT', url: '/admin-api/auth/credentials/22',
|
||||
headers: auth, payload: { loginName: 'Operator.22', ['password']: 'StrongPassword!2026' } });
|
||||
assert.equal(credential.statusCode, 200);
|
||||
assert.equal(credentialInput.tenantId, '7');
|
||||
assert.equal(credentialInput.input.loginName, 'Operator.22');
|
||||
assert.match(credentialInput.input.passwordHash, /^scrypt\$16384\$8\$1\$/);
|
||||
|
||||
const logout = await app.inject({ method: 'POST', url: '/admin-api/auth/logout', headers: auth });
|
||||
assert.equal(logout.statusCode, 200);
|
||||
assert.equal(revokedSessionId, successfulSessionId);
|
||||
await app.close();
|
||||
|
||||
const passwordHash = await hashPassword('StrongPassword!2026');
|
||||
assert.equal(await verifyPassword('StrongPassword!2026', passwordHash), true);
|
||||
assert.equal(await verifyPassword('WrongPassword!2026', passwordHash), false);
|
||||
assert.equal(await verifyPassword('anything', 'invalid-hash'), false);
|
||||
|
||||
console.log('PASS: M08-D admin password login, rotating refresh, session cleanup and credential setup are present.');
|
||||
@@ -0,0 +1,129 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import {
|
||||
BusinessStatisticsError,
|
||||
BusinessStatisticsRepository
|
||||
} from '../dist/operations/business-statistics-repository.js';
|
||||
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '22',
|
||||
access: {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['store.operation.read'],
|
||||
storeIds: ['11']
|
||||
},
|
||||
traceId: 'business-stats-test',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'test'
|
||||
};
|
||||
|
||||
const repository = new BusinessStatisticsRepository({
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_orders o') && sql.includes('COUNT(DISTINCT owner.user_id)')) {
|
||||
return [[{
|
||||
orderTotal: 12,
|
||||
activeOrderTotal: 3,
|
||||
finishedOrderTotal: 7,
|
||||
bookedAmountCents: 36000,
|
||||
payingMemberTotal: 9
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_orders o') && sql.includes('GROUP BY o.status')) {
|
||||
return [[{ status: 'FINISHED', total: 7, amountCents: 21000 }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_payments p') && sql.includes('GROUP BY p.channel')) {
|
||||
return [[
|
||||
{ channel: 'WECHAT', total: 5, amountCents: 15000 },
|
||||
{ channel: 'GROUP_BUY', total: 2, amountCents: 6000 }
|
||||
], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_payments p') && sql.includes('DATE_FORMAT')) {
|
||||
return [[{ date: '2026-08-10', total: 7, amountCents: 21000 }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_rooms')) {
|
||||
return [[{ roomTotal: 8, availableRoomTotal: 4, attentionRoomTotal: 1 }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_group_redemptions')) {
|
||||
return [[{ voucherTotal: 3, voucherSucceeded: 2, voucherFailed: 1 }], []];
|
||||
}
|
||||
throw new Error(`Unexpected statistics SQL: ${sql}`);
|
||||
}
|
||||
});
|
||||
|
||||
const overview = await repository.overview(actor, {
|
||||
storeId: '11',
|
||||
from: new Date('2026-08-01T00:00:00.000Z'),
|
||||
to: new Date('2026-09-01T00:00:00.000Z')
|
||||
});
|
||||
assert.equal(overview.summary.orderTotal, 12);
|
||||
assert.equal(overview.summary.collectedAmountCents, 21000);
|
||||
assert.equal(overview.summary.voucherSucceeded, 2);
|
||||
assert.equal(overview.summary.availableRoomTotal, 4);
|
||||
assert.equal(overview.dailyRevenue[0].date, '2026-08-10');
|
||||
await assert.rejects(
|
||||
() => repository.overview(actor, {
|
||||
storeId: '12',
|
||||
from: new Date('2026-08-01T00:00:00.000Z'),
|
||||
to: new Date('2026-09-01T00:00:00.000Z')
|
||||
}),
|
||||
(error) => error instanceof BusinessStatisticsError
|
||||
&& error.code === 'BUSINESS_STATISTICS_FORBIDDEN'
|
||||
);
|
||||
|
||||
const secret = 'business-statistics-secret-with-32-characters';
|
||||
const token = signAccessToken({
|
||||
sub: '22', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let routed;
|
||||
const app = await buildApp({
|
||||
businessStatistics: {
|
||||
jwtSecret: secret,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e',
|
||||
tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000),
|
||||
user: {
|
||||
id: '22', tenantId: '7', userType: 'STAFF', status: 'ACTIVE',
|
||||
roleVersion: 1, nickname: '', avatarUrl: '', phone: ''
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return actor.access; } },
|
||||
repository: {
|
||||
async overview(routedActor, input) {
|
||||
routed = { actor: routedActor, input };
|
||||
return overview;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/statistics?storeId=11&from=2026-08-01T00%3A00%3A00.000Z&to=2026-09-01T00%3A00%3A00.000Z',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().data.summary.collectedAmountCents, 21000);
|
||||
assert.equal(routed.actor.userId, '22');
|
||||
assert.equal(routed.input.storeId, '11');
|
||||
const adminResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/statistics?storeId=11&from=2026-08-01T00%3A00%3A00.000Z&to=2026-09-01T00%3A00%3A00.000Z',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(adminResponse.statusCode, 200);
|
||||
assert.equal(adminResponse.json().data.summary.orderTotal, 12);
|
||||
const invalidRange = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/statistics?storeId=11&from=2026-01-01&to=2026-09-01',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(invalidRange.statusCode, 400);
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: M08-C business statistics enforce store scope and aggregate stable metrics.');
|
||||
@@ -0,0 +1,327 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadConfig } from '../dist/config.js';
|
||||
import {
|
||||
CleaningPayoutError,
|
||||
CleaningPayoutService
|
||||
} from '../dist/cleaning/cleaning-payout-service.js';
|
||||
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'production',
|
||||
QIPAI_MQTT_USERNAME: 'backend-test',
|
||||
QIPAI_MQTT_PASSWORD: 'not-a-real-secret',
|
||||
QIPAI_JWT_SECRET: 'production-cleaning-payout-secret-long-enough',
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
|
||||
}).payment.cleaningPayoutMockEnabled, false);
|
||||
assert.equal(loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
QIPAI_CLEANING_PAYOUT_MOCK_ENABLED: 'true'
|
||||
}).payment.cleaningPayoutMockEnabled, true);
|
||||
|
||||
const actor = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId: 'cleaning-payout-test'
|
||||
};
|
||||
|
||||
{
|
||||
const harness = createHarness({});
|
||||
const result = await harness.service.preflightWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.account.merchantIdMasked, '19***0109');
|
||||
assert.equal(result.credential.reportInfoCount, 1);
|
||||
assert.equal(result.cleaner.openidConfigured, true);
|
||||
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'PASS');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({
|
||||
credentialPatch: { transferSceneId: '', transferSceneReportInfos: [] },
|
||||
openid: ''
|
||||
});
|
||||
const result = await harness.service.preflightWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.ready, false);
|
||||
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_id').status, 'FAIL');
|
||||
assert.equal(result.checks.find((item) => item.key === 'transfer_scene_report_infos').status, 'WARN');
|
||||
assert.equal(result.checks.find((item) => item.key === 'cleaner_openid').status, 'FAIL');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'SUCCESS' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.begun.length, 1);
|
||||
assert.equal(harness.repository.begun[0].payoutReference, 'CLP501');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
|
||||
assert.equal(harness.state.transferInput.amountCents, 1200);
|
||||
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'WAIT_USER_CONFIRM', packageInfo: 'package-info' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API',
|
||||
note: 'need user confirm'
|
||||
});
|
||||
assert.equal(result.transferState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(harness.repository.pending[0].payoutState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(harness.repository.pending[0].payoutPackageInfo, 'package-info');
|
||||
assert.equal(harness.repository.paid.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ payoutState: 'PROCESSING' });
|
||||
await assert.rejects(
|
||||
() => harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
}),
|
||||
(error) => error instanceof CleaningPayoutError
|
||||
&& error.code === 'CLEANING_PAYOUT_ALREADY_STARTED'
|
||||
);
|
||||
assert.equal(harness.repository.begun.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'SUCCESS' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
note: 'poll success'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.state.queryOutBillNo, 'CLP501');
|
||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'FAIL', queryFailReason: 'ACCOUNT_ABNORMAL' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.transferState, 'FAIL');
|
||||
assert.equal(harness.repository.failures[0].error, 'ACCOUNT_ABNORMAL');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'PROCESSING' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501'
|
||||
});
|
||||
assert.equal(result.transferState, 'PROCESSING');
|
||||
assert.equal(harness.repository.pending[0].payoutState, 'PROCESSING');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({
|
||||
notificationPayload: {
|
||||
mch_id: '1900000109',
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-notify-501',
|
||||
state: 'SUCCESS'
|
||||
}
|
||||
});
|
||||
const result = await harness.service.processWechatTransferNotification({
|
||||
timestamp: '1782700000',
|
||||
nonce: 'notify-nonce',
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature: 'signature'
|
||||
}, '{"resource":"encrypted"}', 'notify-trace');
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.state.verifiedNotification.rawBody, '{"resource":"encrypted"}');
|
||||
assert.equal(harness.repository.paid[0].tenantId, '7');
|
||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({
|
||||
status: 'PAID',
|
||||
payoutState: 'SUCCESS',
|
||||
notificationPayload: {
|
||||
mch_id: '1900000109',
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-notify-501',
|
||||
state: 'SUCCESS'
|
||||
}
|
||||
});
|
||||
const result = await harness.service.processWechatTransferNotification({
|
||||
timestamp: '1782700000',
|
||||
nonce: 'notify-nonce',
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature: 'signature'
|
||||
}, '{"resource":"encrypted"}', 'notify-replay-trace');
|
||||
assert.equal(result.idempotent, true);
|
||||
assert.equal(harness.repository.paid.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'FAIL');
|
||||
assert.equal(harness.repository.failures[0].error, 'REAL_NAME_CHECK_FAILED');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ mockEnabled: true });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'MOCK'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER_MOCK');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ mockEnabled: false });
|
||||
await assert.rejects(
|
||||
() => harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'MOCK'
|
||||
}),
|
||||
(error) => error instanceof CleaningPayoutError
|
||||
&& error.code === 'CLEANING_PAYOUT_MOCK_DISABLED'
|
||||
);
|
||||
}
|
||||
|
||||
function createHarness(options = {}) {
|
||||
const state = {
|
||||
transferInput: null,
|
||||
queryOutBillNo: null,
|
||||
settlement: {
|
||||
id: '501',
|
||||
settlementNo: 'CLS-20260627-501',
|
||||
cleanerUserId: '31',
|
||||
generatedBy: '21',
|
||||
storeId: '11',
|
||||
status: options.status ?? 'CONFIRMED',
|
||||
totalRewardCents: 1200,
|
||||
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
|
||||
payoutRequestNo: options.payoutRequestNo ?? 'CLP501',
|
||||
payoutReference: options.payoutReference ?? 'CLP501',
|
||||
payoutState: options.payoutState ?? '',
|
||||
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
|
||||
},
|
||||
account: {
|
||||
id: '41',
|
||||
platformAppId: '9',
|
||||
storeId: '11',
|
||||
merchantId: '1900000109',
|
||||
credentialRef: 'wechat-cleaning',
|
||||
authorizationStatus: options.authorizationStatus ?? 'AUTHORIZED'
|
||||
}
|
||||
};
|
||||
const pool = {
|
||||
async execute(sql) {
|
||||
if (sql.includes("payout_channel = 'WECHAT_TRANSFER'")) {
|
||||
return [[{ ...state.settlement, tenantId: '7' }], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_cleaning_settlements')) {
|
||||
return [[state.settlement], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_collection_accounts')) {
|
||||
return [[state.account], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_user_identities')) {
|
||||
return [options.openid === '' ? [] : [{ openid: options.openid ?? 'openid-cleaner' }], []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
const repository = {
|
||||
begun: [],
|
||||
paid: [],
|
||||
failures: [],
|
||||
pending: [],
|
||||
async beginSettlementPayout(input) {
|
||||
this.begun.push(input);
|
||||
state.settlement.payoutChannel = input.payoutChannel;
|
||||
state.settlement.payoutReference = input.payoutReference;
|
||||
state.settlement.payoutState = 'PROCESSING';
|
||||
return { ...state.settlement };
|
||||
},
|
||||
async markSettlementPaid(input) {
|
||||
this.paid.push(input);
|
||||
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
|
||||
},
|
||||
async recordSettlementPayoutFailure(input) {
|
||||
this.failures.push(input);
|
||||
return { ...state.settlement, payoutError: input.error };
|
||||
},
|
||||
async recordSettlementPayoutPending(input) {
|
||||
this.pending.push(input);
|
||||
return { ...state.settlement, payoutState: input.payoutState };
|
||||
}
|
||||
};
|
||||
const client = {
|
||||
async createMerchantTransfer(_credential, input) {
|
||||
state.transferInput = input;
|
||||
return {
|
||||
outBillNo: input.outBillNo,
|
||||
transferBillNo: 'wx-transfer-501',
|
||||
state: options.transferState ?? 'SUCCESS',
|
||||
failReason: options.failReason ?? '',
|
||||
packageInfo: options.packageInfo ?? ''
|
||||
};
|
||||
},
|
||||
async queryMerchantTransferByOutBillNo(_credential, outBillNo) {
|
||||
state.queryOutBillNo = outBillNo;
|
||||
return {
|
||||
merchantId: options.queryMerchantId ?? '1900000109',
|
||||
outBillNo,
|
||||
transferBillNo: 'wx-transfer-query-501',
|
||||
state: options.queryState ?? 'SUCCESS',
|
||||
failReason: options.queryFailReason ?? '',
|
||||
amountCents: options.queryAmountCents ?? 1200
|
||||
};
|
||||
},
|
||||
verifyAndDecrypt(_credential, headers, rawBody) {
|
||||
state.verifiedNotification = { headers, rawBody };
|
||||
return options.notificationPayload ?? {};
|
||||
}
|
||||
};
|
||||
const credential = {
|
||||
appId: 'wx-test',
|
||||
merchantId: '1900000109',
|
||||
serialNo: 'serial',
|
||||
privateKeyPem: 'private-key',
|
||||
apiV3Key: '0123456789abcdef0123456789abcdef',
|
||||
platformCertificates: { 'PLATFORM-SERIAL': 'certificate' },
|
||||
transferSceneId: '1000',
|
||||
transferSceneReportInfos: [{ infoType: '岗位类型', infoContent: '保洁员' }]
|
||||
};
|
||||
Object.assign(credential, options.credentialPatch ?? {});
|
||||
return {
|
||||
state,
|
||||
repository,
|
||||
service: new CleaningPayoutService(
|
||||
pool,
|
||||
repository,
|
||||
client,
|
||||
new Map([['wechat-cleaning', credential]]),
|
||||
options.mockEnabled ?? false
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
console.log('PASS: M08-B cleaning payout service handles WeChat transfer states and mock gate.');
|
||||
@@ -41,7 +41,10 @@ const app = await buildApp({
|
||||
return userId === '31'
|
||||
? {
|
||||
roles: ['CLEANER'],
|
||||
capabilities: ['cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'],
|
||||
capabilities: [
|
||||
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read',
|
||||
'cleaning.settlement.read', 'cleaning.settlement.write'
|
||||
],
|
||||
storeIds: ['11']
|
||||
}
|
||||
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
|
||||
@@ -56,6 +59,10 @@ const app = await buildApp({
|
||||
calls.push(['listMine', input]);
|
||||
return { items: [task('CLAIMED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async listManage(input) {
|
||||
calls.push(['listManage', input]);
|
||||
return { items: [task(input.status || 'SUBMITTED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async claim(input) {
|
||||
calls.push(['claim', input]);
|
||||
return task('CLAIMED');
|
||||
@@ -64,18 +71,309 @@ const app = await buildApp({
|
||||
calls.push(['start', input]);
|
||||
return task('STARTED');
|
||||
},
|
||||
async rework(input) {
|
||||
calls.push(['rework', input]);
|
||||
return task('STARTED');
|
||||
},
|
||||
async assertCanUploadPhoto(input) {
|
||||
calls.push(['assertCanUploadPhoto', input]);
|
||||
return { storeId: '11', maxPhotoCount: 3 };
|
||||
},
|
||||
async recordPhotoUpload(input) {
|
||||
calls.push(['recordPhotoUpload', input]);
|
||||
return { id: '701', ...input.image };
|
||||
},
|
||||
async listTemplates(input) {
|
||||
calls.push(['listTemplates', input]);
|
||||
return [{
|
||||
id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null,
|
||||
name: '门店清洁', requirement: '桌面与地面', photoRequired: true,
|
||||
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START',
|
||||
version: 2, status: 'ACTIVE', appliesToExistingTasks: false
|
||||
}];
|
||||
},
|
||||
async upsertTemplate(input) {
|
||||
calls.push(['upsertTemplate', input]);
|
||||
return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input,
|
||||
storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false };
|
||||
},
|
||||
async submit(input) {
|
||||
calls.push(['submit', input]);
|
||||
return { ...task('SUBMITTED'), photoUrls: input.photoUrls };
|
||||
},
|
||||
async assign(input) {
|
||||
calls.push(['assign', input]);
|
||||
return { ...task('CLAIMED'), cleanerUserId: input.cleanerUserId };
|
||||
},
|
||||
async complete(input) {
|
||||
calls.push(['complete', input]);
|
||||
return task('COMPLETED');
|
||||
},
|
||||
async reject(input) {
|
||||
calls.push(['reject', input]);
|
||||
return { ...task('REJECTED'), rejectReason: input.reason };
|
||||
},
|
||||
async exempt(input) {
|
||||
calls.push(['exempt', input]);
|
||||
return { ...task('EXEMPT'), rewardCents: 0 };
|
||||
},
|
||||
async listMembers(input) {
|
||||
calls.push(['listMembers', input]);
|
||||
return [member('LEAD', '31', 500), member('ASSIST', '33', 100)];
|
||||
},
|
||||
async listEvents(input) {
|
||||
calls.push(['listEvents', input]);
|
||||
return [{
|
||||
id: '9001',
|
||||
taskId: input.taskId,
|
||||
fromStatus: 'STARTED',
|
||||
toStatus: 'SUBMITTED',
|
||||
action: 'SUBMIT',
|
||||
actorId: input.userId,
|
||||
traceId: input.traceId,
|
||||
note: 'done',
|
||||
createdAt: new Date()
|
||||
}];
|
||||
},
|
||||
async listSubmissions(input) {
|
||||
calls.push(['listSubmissions', input]);
|
||||
return [{
|
||||
id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED',
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first',
|
||||
rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31',
|
||||
submittedAt: new Date(), reviewedAt: new Date()
|
||||
}];
|
||||
},
|
||||
async addMember(input) {
|
||||
calls.push(['addMember', input]);
|
||||
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
|
||||
},
|
||||
async removeMember(input) {
|
||||
calls.push(['removeMember', input]);
|
||||
return [member('LEAD', '31', 600)];
|
||||
},
|
||||
async settlementCandidates(input) {
|
||||
calls.push(['settlementCandidates', input]);
|
||||
return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async listSettlements(input) {
|
||||
calls.push(['listSettlements', input]);
|
||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async exportSettlements(input) {
|
||||
calls.push(['exportSettlements', input]);
|
||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, exported: 1, truncated: false };
|
||||
},
|
||||
async getSettlementDetail(input) {
|
||||
calls.push(['getSettlementDetail', input]);
|
||||
return {
|
||||
settlement: settlementResponse('DRAFT'),
|
||||
items: [settlementItemResponse()],
|
||||
events: [{
|
||||
id: '701', settlementId: input.settlementId, fromStatus: null, toStatus: 'DRAFT',
|
||||
action: 'GENERATE', actorId: input.userId, traceId: input.traceId,
|
||||
note: '', createdAt: new Date()
|
||||
}],
|
||||
reversals: []
|
||||
};
|
||||
},
|
||||
async generateSettlement(input) {
|
||||
calls.push(['generateSettlement', input]);
|
||||
return settlementResponse('DRAFT');
|
||||
},
|
||||
async confirmSettlement(input) {
|
||||
calls.push(['confirmSettlement', input]);
|
||||
return settlementResponse('CONFIRMED');
|
||||
},
|
||||
async cancelSettlement(input) {
|
||||
calls.push(['cancelSettlement', input]);
|
||||
return {
|
||||
...settlementResponse('CANCELLED'),
|
||||
cancelledBy: input.userId,
|
||||
cancelledAt: new Date(),
|
||||
note: input.reason
|
||||
};
|
||||
},
|
||||
async markSettlementPaid(input) {
|
||||
calls.push(['markSettlementPaid', input]);
|
||||
return {
|
||||
...settlementResponse('PAID'),
|
||||
paidBy: input.userId,
|
||||
paidAt: new Date(),
|
||||
payoutChannel: input.payoutChannel,
|
||||
payoutReference: input.payoutReference,
|
||||
payoutError: ''
|
||||
};
|
||||
},
|
||||
async recordSettlementPayoutFailure(input) {
|
||||
calls.push(['recordSettlementPayoutFailure', input]);
|
||||
return {
|
||||
...settlementResponse('CONFIRMED'),
|
||||
payoutChannel: input.payoutChannel || '',
|
||||
payoutReference: input.payoutReference || '',
|
||||
payoutError: input.error
|
||||
};
|
||||
},
|
||||
async reclaimTimeouts(input) {
|
||||
calls.push(['reclaimTimeouts', input]);
|
||||
return { reclaimed: 2, taskIds: ['101', '102'] };
|
||||
},
|
||||
async claimExpiredPhotoUploads(input) {
|
||||
calls.push(['claimExpiredPhotoUploads', input]);
|
||||
return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }];
|
||||
},
|
||||
async markPhotoUploadsDeleted(input) {
|
||||
calls.push(['markPhotoUploadsDeleted', input]);
|
||||
return { deleted: input.photoIds.length };
|
||||
},
|
||||
async stats(input) {
|
||||
calls.push(['stats', input]);
|
||||
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
|
||||
return {
|
||||
byStatus: { SUBMITTED: 2, COMPLETED: 1, REJECTED: 1 },
|
||||
taskTotal: 4,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
completionRate: 25,
|
||||
pendingSettlementCents: 1200,
|
||||
settledRewardCents: 600
|
||||
};
|
||||
},
|
||||
async managerStatistics(input) {
|
||||
calls.push(['managerStatistics', input]);
|
||||
return {
|
||||
summary: {
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
active: 1,
|
||||
rejected: 1,
|
||||
exempted: 1,
|
||||
completed: 1,
|
||||
rewardCents: 3000,
|
||||
pendingSettlementCents: 1200,
|
||||
confirmedSettlementCents: 800,
|
||||
paidSettlementCents: 600
|
||||
},
|
||||
byStatus: [{ status: 'SUBMITTED', total: 2, rewardCents: 1200 }],
|
||||
byStore: [{
|
||||
storeId: '11',
|
||||
storeName: 'Test Store',
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
exempted: 1,
|
||||
rewardCents: 3000
|
||||
}],
|
||||
settlements: [{ status: 'CONFIRMED', total: 1, rewardCents: 800 }],
|
||||
members: [{
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
taskCount: 3,
|
||||
completedTaskCount: 2,
|
||||
rejectedTaskCount: 1,
|
||||
pendingSettlementCents: 1200,
|
||||
settledRewardCents: 600
|
||||
}],
|
||||
trend: [{
|
||||
date: '2026-06-30',
|
||||
taskTotal: 5,
|
||||
pendingReview: 2,
|
||||
completed: 1,
|
||||
rejected: 1,
|
||||
exempted: 1,
|
||||
rewardCents: 3000,
|
||||
paidSettlementCents: 600
|
||||
}]
|
||||
};
|
||||
}
|
||||
},
|
||||
payoutService: {
|
||||
async preflightWechatTransfer(input) {
|
||||
calls.push(['preflightWechatTransfer', input]);
|
||||
return {
|
||||
ready: true,
|
||||
settlement: { id: input.settlementId, settlementNo: 'CLS-501', status: 'CONFIRMED' },
|
||||
account: { configured: true, merchantIdMasked: '19***0109' },
|
||||
credential: { configured: true, transferSceneId: '1000', reportInfoCount: 1 },
|
||||
cleaner: { openidConfigured: true },
|
||||
checks: [{ key: 'transfer_scene_id', status: 'PASS', message: 'ok' }]
|
||||
};
|
||||
},
|
||||
async executeWechatTransfer(input) {
|
||||
calls.push(['executeWechatTransfer', input]);
|
||||
return {
|
||||
settlement: settlementResponse(input.mode === 'MOCK' ? 'PAID' : 'CONFIRMED'),
|
||||
transferState: input.mode === 'MOCK' ? 'SUCCESS' : 'WAIT_USER_CONFIRM',
|
||||
idempotent: false
|
||||
};
|
||||
},
|
||||
async syncWechatTransfer(input) {
|
||||
calls.push(['syncWechatTransfer', input]);
|
||||
return {
|
||||
settlement: {
|
||||
...settlementResponse('PAID'),
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-transfer-sync-501'
|
||||
},
|
||||
transferState: 'SUCCESS',
|
||||
idempotent: false
|
||||
};
|
||||
},
|
||||
async processWechatTransferNotification(headers, rawBody, traceId) {
|
||||
calls.push(['processWechatTransferNotification', { headers, rawBody, traceId }]);
|
||||
return {
|
||||
settlement: {
|
||||
...settlementResponse('PAID'),
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-transfer-notify-501'
|
||||
},
|
||||
transferState: 'SUCCESS',
|
||||
idempotent: false
|
||||
};
|
||||
}
|
||||
},
|
||||
mediaStorage: {
|
||||
async storeImage(input) {
|
||||
calls.push(['storeImage', input]);
|
||||
return {
|
||||
storagePath: 'tenants/7/stores/11/cleaning.webp',
|
||||
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
|
||||
mimeType: 'image/webp',
|
||||
byteSize: input.body.length,
|
||||
width: 640,
|
||||
height: 480,
|
||||
checksumSha256: 'abc'
|
||||
};
|
||||
},
|
||||
async deleteImage(storagePath) {
|
||||
calls.push(['deleteImage', storagePath]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const templates = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/templates',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(templates.statusCode, 200);
|
||||
assert.equal(templates.json().data[0].scopeKey, 'STORE:11');
|
||||
|
||||
const updatedTemplate = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/admin-api/cleaning/templates',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面',
|
||||
photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3,
|
||||
exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
||||
}
|
||||
});
|
||||
assert.equal(updatedTemplate.statusCode, 200);
|
||||
assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false);
|
||||
assert.equal(calls.at(-1)[0], 'upsertTemplate');
|
||||
|
||||
const hall = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
|
||||
@@ -95,6 +393,62 @@ assert.equal(mine.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listMine');
|
||||
assert.equal(calls.at(-1)[1].status, 'CLAIMED');
|
||||
|
||||
const manageList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks?status=SUBMITTED&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(manageList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listManage');
|
||||
assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const settlementExport = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/export?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementExport.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'exportSettlements');
|
||||
assert.equal(calls.at(-1)[1].status, 'DRAFT');
|
||||
assert.equal(settlementExport.json().data.exported, 1);
|
||||
|
||||
const appManageList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appManageList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listManage');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
|
||||
const managerStats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/statistics?from=2026-06-01&to=2026-07-01&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(managerStats.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'managerStatistics');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
assert.equal(managerStats.json().data.summary.pendingSettlementCents, 1200);
|
||||
assert.equal(managerStats.json().data.summary.exempted, 1);
|
||||
assert.equal(managerStats.json().data.byStore[0].exempted, 1);
|
||||
assert.equal(managerStats.json().data.members[0].completedTaskCount, 2);
|
||||
assert.equal(managerStats.json().data.members[0].rejectedTaskCount, 1);
|
||||
assert.equal(managerStats.json().data.trend[0].exempted, 1);
|
||||
assert.equal(managerStats.json().data.trend[0].paidSettlementCents, 600);
|
||||
|
||||
const appManagerStats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/cleaning/statistics?storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appManagerStats.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'managerStatistics');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
|
||||
const claim = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/claim',
|
||||
@@ -112,6 +466,32 @@ const start = await app.inject({
|
||||
assert.equal(start.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'start');
|
||||
|
||||
const rework = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/rework',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(rework.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'rework');
|
||||
|
||||
const photo = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/photos',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/octet-stream',
|
||||
'x-file-name': 'cleaning.jpg',
|
||||
'x-image-content-type': 'image/jpeg'
|
||||
},
|
||||
payload: Buffer.from('fake-image')
|
||||
});
|
||||
assert.equal(photo.statusCode, 201);
|
||||
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
|
||||
assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
|
||||
assert.equal(calls.at(-2)[0], 'storeImage');
|
||||
assert.equal(calls.at(-2)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[0], 'recordPhotoUpload');
|
||||
|
||||
const submit = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/tasks/101/submit',
|
||||
@@ -121,6 +501,295 @@ const submit = await app.inject({
|
||||
assert.equal(submit.statusCode, 200);
|
||||
assert.deepEqual(calls.at(-1)[1].photoUrls, ['https://api.txyundm.cn/uploads/cleaning/101.jpg']);
|
||||
|
||||
const assign = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/assign',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '33', note: 'manual dispatch' }
|
||||
});
|
||||
assert.equal(assign.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'assign');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
|
||||
const complete = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/complete',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'ok' }
|
||||
});
|
||||
assert.equal(complete.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'complete');
|
||||
|
||||
const appComplete = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/cleaning/tasks/101/complete',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'miniapp approval' }
|
||||
});
|
||||
assert.equal(appComplete.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'complete');
|
||||
assert.equal(calls.at(-1)[1].note, 'miniapp approval');
|
||||
|
||||
const reject = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/reject',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'photo is unclear' }
|
||||
});
|
||||
assert.equal(reject.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reject');
|
||||
assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
|
||||
|
||||
const appReject = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/cleaning/tasks/101/reject',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'miniapp asks for rework' }
|
||||
});
|
||||
assert.equal(appReject.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reject');
|
||||
assert.equal(calls.at(-1)[1].reason, 'miniapp asks for rework');
|
||||
|
||||
const exempt = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/exempt',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'no cleaning required' }
|
||||
});
|
||||
assert.equal(exempt.statusCode, 200);
|
||||
assert.equal(exempt.json().data.status, 'EXEMPT');
|
||||
assert.equal(calls.at(-1)[0], 'exempt');
|
||||
assert.equal(calls.at(-1)[1].note, 'no cleaning required');
|
||||
|
||||
const appExempt = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/cleaning/tasks/101/exempt',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'miniapp exemption' }
|
||||
});
|
||||
assert.equal(appExempt.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'exempt');
|
||||
assert.equal(calls.at(-1)[1].note, 'miniapp exemption');
|
||||
|
||||
const members = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(members.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listMembers');
|
||||
assert.equal(members.json().data.length, 2);
|
||||
|
||||
const events = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/events',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(events.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listEvents');
|
||||
assert.equal(calls.at(-1)[1].taskId, '101');
|
||||
assert.equal(events.json().data[0].action, 'SUBMIT');
|
||||
|
||||
const submissions = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/tasks/101/submissions',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(submissions.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listSubmissions');
|
||||
assert.equal(submissions.json().data[0].revision, 2);
|
||||
assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp');
|
||||
|
||||
const addedMember = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/members',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '33', rewardCents: 100, note: 'support' }
|
||||
});
|
||||
assert.equal(addedMember.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'addMember');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
assert.equal(calls.at(-1)[1].rewardCents, 100);
|
||||
|
||||
const removedMember = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/tasks/101/members/33/remove',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'done' }
|
||||
});
|
||||
assert.equal(removedMember.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'removeMember');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
|
||||
|
||||
const settlement = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'settlementCandidates');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const settlementList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementList.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'listSettlements');
|
||||
assert.equal(calls.at(-1)[1].status, 'DRAFT');
|
||||
assert.equal(calls.at(-1)[1].payoutState, 'FAIL');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const settlementDetail = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/501',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementDetail.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'getSettlementDetail');
|
||||
assert.equal(settlementDetail.json().data.items[0].taskNo, 'CLN-20260625-0001');
|
||||
|
||||
const generatedSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { cleanerUserId: '31', storeId: '11', note: 'weekly settlement' }
|
||||
});
|
||||
assert.equal(generatedSettlement.statusCode, 201);
|
||||
assert.equal(calls.at(-1)[0], 'generateSettlement');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const confirmedSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/confirm',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'confirmed' }
|
||||
});
|
||||
assert.equal(confirmedSettlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'confirmSettlement');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const cancelledSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/cancel',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'duplicate weekly batch' }
|
||||
});
|
||||
assert.equal(cancelledSettlement.statusCode, 200);
|
||||
assert.equal(cancelledSettlement.json().data.status, 'CANCELLED');
|
||||
assert.equal(calls.at(-1)[0], 'cancelSettlement');
|
||||
assert.equal(calls.at(-1)[1].reason, 'duplicate weekly batch');
|
||||
|
||||
const failedPayout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/payout-failure',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-failed-001',
|
||||
error: 'insufficient merchant balance',
|
||||
note: 'retry later'
|
||||
}
|
||||
});
|
||||
assert.equal(failedPayout.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'recordSettlementPayoutFailure');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
assert.equal(calls.at(-1)[1].error, 'insufficient merchant balance');
|
||||
|
||||
const paidSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/paid',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: 'wx-paid-001',
|
||||
note: 'paid'
|
||||
}
|
||||
});
|
||||
assert.equal(paidSettlement.statusCode, 200);
|
||||
assert.equal(paidSettlement.json().data.status, 'PAID');
|
||||
assert.equal(paidSettlement.json().data.payoutReference, 'wx-paid-001');
|
||||
assert.equal(calls.at(-1)[0], 'markSettlementPaid');
|
||||
|
||||
const wechatTransfer = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { mode: 'API', note: 'wechat transfer' }
|
||||
});
|
||||
assert.equal(wechatTransfer.statusCode, 200);
|
||||
assert.equal(wechatTransfer.json().data.transferState, 'WAIT_USER_CONFIRM');
|
||||
assert.equal(calls.at(-1)[0], 'executeWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
assert.equal(calls.at(-1)[1].mode, 'API');
|
||||
|
||||
const wechatTransferPreflight = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer/preflight',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(wechatTransferPreflight.statusCode, 200);
|
||||
assert.equal(wechatTransferPreflight.json().data.ready, true);
|
||||
assert.equal(wechatTransferPreflight.json().data.account.merchantIdMasked, '19***0109');
|
||||
assert.equal(calls.at(-1)[0], 'preflightWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const syncedWechatTransfer = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/wechat-transfer/sync',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { note: 'poll wechat transfer' }
|
||||
});
|
||||
assert.equal(syncedWechatTransfer.statusCode, 200);
|
||||
assert.equal(syncedWechatTransfer.json().data.transferState, 'SUCCESS');
|
||||
assert.equal(syncedWechatTransfer.json().data.settlement.payoutReference, 'wx-transfer-sync-501');
|
||||
assert.equal(calls.at(-1)[0], 'syncWechatTransfer');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const wechatTransferNotify = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/cleaning/wechat-transfer/notify',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'wechatpay-timestamp': '1782700001',
|
||||
'wechatpay-nonce': 'notify-nonce',
|
||||
'wechatpay-serial': 'PLATFORM-SERIAL',
|
||||
'wechatpay-signature': 'signature'
|
||||
},
|
||||
payload: '{"resource":"encrypted"}'
|
||||
});
|
||||
assert.equal(wechatTransferNotify.statusCode, 200);
|
||||
assert.equal(wechatTransferNotify.json().code, 'SUCCESS');
|
||||
assert.equal(calls.at(-1)[0], 'processWechatTransferNotification');
|
||||
assert.equal(calls.at(-1)[1].headers.serial, 'PLATFORM-SERIAL');
|
||||
assert.equal(calls.at(-1)[1].rawBody, '{"resource":"encrypted"}');
|
||||
|
||||
const reclaimed = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/reclaim-timeouts',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { olderThanMinutes: 30, limit: 10 }
|
||||
});
|
||||
assert.equal(reclaimed.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
|
||||
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
|
||||
|
||||
const cleanedPhotos = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/photos/cleanup',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { limit: 10 }
|
||||
});
|
||||
assert.equal(cleanedPhotos.statusCode, 200);
|
||||
assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] });
|
||||
assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads');
|
||||
assert.equal(calls.at(-2)[0], 'deleteImage');
|
||||
assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted');
|
||||
assert.deepEqual(calls.at(-1)[1].photoIds, ['701']);
|
||||
|
||||
const stats = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/cleaning/stats',
|
||||
@@ -128,6 +797,8 @@ const stats = await app.inject({
|
||||
});
|
||||
assert.equal(stats.statusCode, 200);
|
||||
assert.equal(stats.json().data.pendingSettlementCents, 1200);
|
||||
assert.equal(stats.json().data.settledRewardCents, 600);
|
||||
assert.equal(stats.json().data.completionRate, 25);
|
||||
|
||||
const unauthorized = await app.inject({ method: 'GET', url: '/app-api/cleaning/tasks/hall' });
|
||||
assert.equal(unauthorized.statusCode, 401);
|
||||
@@ -158,3 +829,58 @@ function task(status) {
|
||||
photoUrls: []
|
||||
};
|
||||
}
|
||||
|
||||
function settlementResponse(status) {
|
||||
return {
|
||||
id: '501',
|
||||
settlementNo: 'CLS-20260626-0001',
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
storeId: '11',
|
||||
storeName: 'Test Store',
|
||||
status,
|
||||
taskCount: 2,
|
||||
totalRewardCents: 1200,
|
||||
periodStart: new Date(),
|
||||
periodEnd: new Date(),
|
||||
paidBy: status === 'PAID' ? '31' : null,
|
||||
confirmedAt: status === 'CONFIRMED' ? new Date() : null,
|
||||
paidAt: status === 'PAID' ? new Date() : null,
|
||||
payoutChannel: '',
|
||||
payoutReference: '',
|
||||
payoutError: '',
|
||||
note: ''
|
||||
};
|
||||
}
|
||||
|
||||
function settlementItemResponse() {
|
||||
return {
|
||||
id: '601',
|
||||
settlementId: '501',
|
||||
taskId: '101',
|
||||
taskNo: 'CLN-20260625-0001',
|
||||
orderNo: 'O301',
|
||||
storeName: 'Test Store',
|
||||
roomName: 'A Room',
|
||||
roomNo: 'A01',
|
||||
cleanerUserId: '31',
|
||||
cleanerName: 'Cleaner',
|
||||
rewardCents: 600,
|
||||
completedAt: new Date(),
|
||||
createdAt: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
function member(memberRole, userId, rewardCents) {
|
||||
return {
|
||||
id: `${userId}01`,
|
||||
taskId: '101',
|
||||
userId,
|
||||
nickname: `Cleaner ${userId}`,
|
||||
memberRole,
|
||||
rewardCents,
|
||||
joinedAt: new Date(),
|
||||
removedAt: null,
|
||||
settledAt: null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
import { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js';
|
||||
import { ContentError, ContentRepository } from '../dist/content/content-repository.js';
|
||||
|
||||
@@ -19,7 +21,18 @@ try {
|
||||
assert.equal(image.mimeType, 'image/webp');
|
||||
assert.ok(image.width <= 1920);
|
||||
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
|
||||
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
|
||||
const storedPath = join(root, ...image.storagePath.split('/'));
|
||||
const storedBytes = await readFile(storedPath);
|
||||
assert.ok(storedBytes.length > 0);
|
||||
const storedMetadata = await sharp(storedBytes).metadata();
|
||||
assert.equal(storedMetadata.exif, undefined);
|
||||
assert.equal(storedMetadata.icc, undefined);
|
||||
await assert.rejects(
|
||||
() => storage.storeImage({
|
||||
tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png
|
||||
}),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED'
|
||||
);
|
||||
await assert.rejects(
|
||||
() => storage.storeImage({
|
||||
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
|
||||
@@ -27,6 +40,12 @@ try {
|
||||
}),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
|
||||
);
|
||||
await storage.deleteImage(image.storagePath);
|
||||
await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT');
|
||||
await assert.rejects(
|
||||
() => storage.deleteImage('../outside.webp'),
|
||||
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID'
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -47,4 +66,65 @@ await assert.rejects(
|
||||
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
|
||||
);
|
||||
|
||||
console.log('PASS: M03-B image compression, tenant paths and advertisement scope validation are present.');
|
||||
await assert.rejects(
|
||||
() => repository.registerAsset(storeActor, undefined, {
|
||||
storagePath: 'asset.webp', publicUrl: '/asset.webp', mimeType: 'image/webp',
|
||||
byteSize: 100, width: 10, height: 10, checksumSha256: '0'.repeat(64)
|
||||
}),
|
||||
(error) => error instanceof ContentError && error.code === 'GLOBAL_ASSET_FORBIDDEN'
|
||||
);
|
||||
|
||||
const secret = 'test-only-content-management-secret-32';
|
||||
const token = signAccessToken({
|
||||
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1
|
||||
}, secret, 900);
|
||||
let listedAssetStoreId;
|
||||
let listedDecorationStoreId;
|
||||
let updatedAdvertisement;
|
||||
const app = await buildApp({
|
||||
content: {
|
||||
jwtSecret: secret,
|
||||
authRepository: {
|
||||
async validateSession() {
|
||||
return {
|
||||
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9',
|
||||
expiresAt: new Date(Date.now() + 60000),
|
||||
user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
|
||||
nickname: '', avatarUrl: '', phone: '' }
|
||||
};
|
||||
}
|
||||
},
|
||||
accessControl: { async getAccessProfile() { return storeActor.access; } },
|
||||
mediaStorage: { async storeImage() { throw new Error('not used'); } },
|
||||
repository: {
|
||||
async listAssets(_actor, storeId) { listedAssetStoreId = storeId; return [{ id: '31' }]; },
|
||||
async listDecorations(_actor, storeId) { listedDecorationStoreId = storeId; return [{ id: '41' }]; },
|
||||
async updateAdvertisement(_actor, id, input) { updatedAdvertisement = { id, input }; return { advertisementId: id }; },
|
||||
async listAdvertisements() { return []; },
|
||||
async registerAsset() {}, async saveDecoration() {}, async publishDecoration() {},
|
||||
async saveAdvertisement() {}
|
||||
}
|
||||
}
|
||||
});
|
||||
const authorization = { authorization: `Bearer ${token}` };
|
||||
const assets = await app.inject({ method: 'GET', url: '/admin-api/media/images?storeId=11', headers: authorization });
|
||||
assert.equal(assets.statusCode, 200);
|
||||
assert.equal(listedAssetStoreId, '11');
|
||||
const decorations = await app.inject({ method: 'GET', url: '/admin-api/decorations?storeId=11', headers: authorization });
|
||||
assert.equal(decorations.statusCode, 200);
|
||||
assert.equal(listedDecorationStoreId, '11');
|
||||
const updated = await app.inject({
|
||||
method: 'PUT', url: '/admin-api/advertisements/51', headers: authorization,
|
||||
payload: {
|
||||
scopeType: 'STORE', storeId: '11', title: '门店轮播', imageAssetId: '31',
|
||||
targetType: 'PAGE', targetValue: '/pages/booking/index', status: 'ACTIVE', sortOrder: 10
|
||||
}
|
||||
});
|
||||
assert.equal(updated.statusCode, 200);
|
||||
assert.equal(updatedAdvertisement.id, '51');
|
||||
assert.equal(updatedAdvertisement.input.storeId, '11');
|
||||
const missingStore = await app.inject({ method: 'GET', url: '/admin-api/decorations', headers: authorization });
|
||||
assert.equal(missingStore.statusCode, 400);
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: content media, decoration and advertisement management contracts are scoped and editable.');
|
||||
|
||||
@@ -12,13 +12,15 @@ const managerAccess = {
|
||||
storeIds: ['11']
|
||||
};
|
||||
const issued = [];
|
||||
const audits = [];
|
||||
const service = new DeviceControlService({
|
||||
async execute(sql) {
|
||||
if (sql.includes('FROM qipai_devices')) {
|
||||
if (sql.includes("device_type = 'SMART_SOCKET'")) {
|
||||
return [[{
|
||||
id: 52, deviceId: 'SOCKET_001', storeId: 11, roomId: 31,
|
||||
deviceType: 'SMART_SOCKET', status: 'ONLINE'
|
||||
deviceType: 'SMART_SOCKET', status: 'ONLINE', model: 'JL-SOCKET-16A',
|
||||
capabilities: ['POWER', 'METERING', 'RATED_16A']
|
||||
}], []];
|
||||
}
|
||||
return [[{
|
||||
@@ -26,6 +28,7 @@ const service = new DeviceControlService({
|
||||
deviceType: 'CONTROL_BOX', status: 'ONLINE'
|
||||
}], []];
|
||||
}
|
||||
if (sql.includes('DEVICE_COMMAND_REQUESTED')) audits.push(sql);
|
||||
return [[], []];
|
||||
}
|
||||
}, {
|
||||
@@ -37,6 +40,7 @@ const service = new DeviceControlService({
|
||||
});
|
||||
const context = {
|
||||
tenantId: '7', storeId: '11', roomId: '31', traceId: 'trace',
|
||||
actorType: 'ADMIN', actorId: '22', ip: '127.0.0.1', userAgent: 'test',
|
||||
access: managerAccess
|
||||
};
|
||||
|
||||
@@ -45,7 +49,11 @@ assert.equal(issued.at(-1).payload.action, 'ConctolPower');
|
||||
await service.controlDoor(context, { order: 'open', holdopen: 1, delayTime: 4 });
|
||||
assert.equal(issued.at(-1).payload.action, 'Crldoor');
|
||||
await service.playTts(context, { content: '欢迎光临' });
|
||||
assert.equal(issued.at(-1).payload.action, 'PlayTTS');
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'PlayTTS', content: '欢迎光临', vol: 8, firstPlay: 0,
|
||||
loop: 1, speaker: 0, style: 0, speed: 5, intona: 5,
|
||||
id: '1782120000001'
|
||||
});
|
||||
await service.stopTts(context);
|
||||
assert.equal(issued.at(-1).payload.action, 'stopTTS');
|
||||
await service.controlLed(context, 30);
|
||||
@@ -57,13 +65,31 @@ assert.equal(issued.at(-1).payload.action, 'addtask');
|
||||
await service.cancelTask(context);
|
||||
assert.equal(issued.at(-1).payload.action, 'canceltask');
|
||||
await service.pairSubLock(context, 60);
|
||||
assert.equal(issued.at(-1).payload.action, 'AddDevice');
|
||||
await service.controlSubLock(context, {
|
||||
subID: 'SUB001', order: 'open', delayTime: 4
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'AddDevice', time: 60, id: '1782120000001'
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.action, 'CtrlDevice');
|
||||
await service.controlSubLock(context, {
|
||||
subtype: '14', subID: 'SUB001', order: 'open', delayTime: 4
|
||||
});
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'CtrlDevice', subtype: '14', subID: 'SUB001',
|
||||
order: 'open', delayTime: 4, id: '1782120000001'
|
||||
});
|
||||
await service.controlSubLock(context, {
|
||||
subtype: '14', subID: 'SUB001', order: 'setkey', value: '123456654321'
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.value, '123456654321');
|
||||
assert.equal('content' in issued.at(-1).payload, false);
|
||||
await service.readSmartSocket(context, 'workInfo');
|
||||
assert.deepEqual(issued.at(-1).payload, { read: 'workInfo' });
|
||||
assert.deepEqual(issued.at(-1).payload, { read: 'workInfo', slotNum: 1 });
|
||||
await service.readSmartSocket(context, {
|
||||
target: 'localtask', slotNum: 2, taskNum: 0
|
||||
});
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
read: 'localtask', slotNum: 2, taskNum: 0
|
||||
});
|
||||
await service.readSmartSocket(context, { target: 'mqttConfig' });
|
||||
assert.deepEqual(issued.at(-1).payload, { read: 'mqttConfig' });
|
||||
await service.switchSmartSocket(context, { on: false, slotNum: 2 });
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'off', id: '1782120000001', slotNum: 2
|
||||
@@ -72,13 +98,44 @@ await service.scheduleSmartSocket(context, {
|
||||
taskNum: 1, action: 'on', mode: 'weekly', time: '18:30', weekdays: [5, 6]
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.action, 'localtask');
|
||||
assert.equal(issued.at(-1).payload.switch, 'on');
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'localtask', slotNum: 1, taskNum: 1, tasktype: 'on',
|
||||
cyctype: 'weekcyc', weekdata: '0000110', actiontime: '1830',
|
||||
id: '1782120000001'
|
||||
});
|
||||
await service.clearSmartSocketTask(context, 1);
|
||||
assert.equal(issued.at(-1).payload.action, 'clearTask');
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'clearTask', slotNum: 1, taskNum: 1, id: '1782120000001'
|
||||
});
|
||||
await service.configureSmartSocketProtection(context, {
|
||||
maxPower: 3500, maxCurrent: 15, maxTemperature: 80,
|
||||
pullOutStop: 1, pullOutPower: 5, pullOutSec: 10,
|
||||
chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.setting, 'protectOff');
|
||||
assert.equal('ratedAmps' in issued.at(-1).payload, false);
|
||||
await assert.rejects(
|
||||
() => service.configureSmartSocketProtection(context, {
|
||||
maxPower: 3500, maxCurrent: 17, maxTemperature: 80,
|
||||
pullOutStop: 1, pullOutPower: 5, pullOutSec: 10,
|
||||
chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30
|
||||
}),
|
||||
/16A capability/
|
||||
);
|
||||
await service.configureSmartSocketParameter(context, {
|
||||
resetHold: 1, keyLock: 0, keyOff: 1
|
||||
});
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
setting: 'parameter', resetHold: 1, keyLock: 0, KeyOff: 1,
|
||||
id: '1782120000001'
|
||||
});
|
||||
assert.equal(audits.length, 20);
|
||||
assert.match(audits[0], /DEVICE_COMMAND_REQUESTED/);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.controlSubLock(context, {
|
||||
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET'
|
||||
subtype: '14', subID: 'SUB001', order: 'factoryreset',
|
||||
dangerConfirmation: 'CONFIRM_FACTORY_RESET'
|
||||
}),
|
||||
(error) => error instanceof DeviceControlError
|
||||
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
@@ -88,11 +145,70 @@ const platformContext = {
|
||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: [], storeIds: [] }
|
||||
};
|
||||
await service.controlSubLock(platformContext, {
|
||||
subID: 'SUB001', order: 'factoryreset', dangerConfirmation: 'CONFIRM_FACTORY_RESET'
|
||||
subtype: '14', subID: 'SUB001', order: 'factoryreset',
|
||||
dangerConfirmation: 'CONFIRM_FACTORY_RESET'
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.order, 'factoryreset');
|
||||
assert.equal('dangerConfirmation' in issued.at(-1).payload, false);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.controlSubLock(context, {
|
||||
subtype: '14', subID: 'SUB001', order: 'delkey', value: 'all'
|
||||
}),
|
||||
(error) => error instanceof DeviceControlError
|
||||
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
);
|
||||
await service.controlSubLock(platformContext, {
|
||||
subtype: '15', subID: 'SUB002', order: 'delcard', value: 'all',
|
||||
dangerConfirmation: 'CONFIRM_CLEAR_CREDENTIALS'
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.value, 'all');
|
||||
assert.equal(issued.at(-1).payload.subtype, '15');
|
||||
|
||||
await assert.rejects(
|
||||
() => service.emptySmartSocketEnergy(context, 'CONFIRM_EMPTY_SOCKET_ENERGY'),
|
||||
(error) => error instanceof DeviceControlError
|
||||
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
);
|
||||
await service.emptySmartSocketEnergy(platformContext, 'CONFIRM_EMPTY_SOCKET_ENERGY');
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'emptyPower', id: '1782120000001'
|
||||
});
|
||||
await assert.rejects(
|
||||
() => service.resetSmartSocket(platformContext, {
|
||||
['password']: 'return-secret', dangerConfirmation: 'WRONG'
|
||||
}),
|
||||
(error) => error instanceof DeviceControlError
|
||||
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
);
|
||||
await service.resetSmartSocket(platformContext, {
|
||||
['password']: 'return-secret', dangerConfirmation: 'CONFIRM_SOCKET_FACTORY_RESET'
|
||||
});
|
||||
assert.equal(issued.at(-1).payload.action, 'returnFactory');
|
||||
assert.equal(issued.at(-1).payload.password, 'return-secret');
|
||||
assert.equal('dangerConfirmation' in issued.at(-1).payload, false);
|
||||
await service.changeSmartSocketReturnKey(platformContext, {
|
||||
old: 'old-secret', new: 'new-secret',
|
||||
dangerConfirmation: 'CONFIRM_CHANGE_SOCKET_RETURN_KEY'
|
||||
});
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'setReturnKey', old: 'old-secret', new: 'new-secret',
|
||||
id: '1782120000001'
|
||||
});
|
||||
await assert.rejects(
|
||||
() => service.clearSmartSocketTask(context, {
|
||||
taskNum: 0, dangerConfirmation: 'CONFIRM_CLEAR_ALL_SOCKET_TASKS'
|
||||
}),
|
||||
(error) => error instanceof DeviceControlError
|
||||
&& error.code === 'DEVICE_DANGEROUS_ACTION_FORBIDDEN'
|
||||
);
|
||||
await service.clearSmartSocketTask(platformContext, {
|
||||
slotNum: 2, taskNum: 0, dangerConfirmation: 'CONFIRM_CLEAR_ALL_SOCKET_TASKS'
|
||||
});
|
||||
assert.deepEqual(issued.at(-1).payload, {
|
||||
action: 'clearTask', slotNum: 2, taskNum: 0, id: '1782120000001'
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => service.controlPower({
|
||||
...context,
|
||||
@@ -145,10 +261,16 @@ const app = await buildApp({
|
||||
async cancelTask() { return {}; },
|
||||
async pairSubLock() { return {}; },
|
||||
async controlSubLock() { return {}; },
|
||||
async readSmartSocket() { return {}; },
|
||||
async readSmartSocket(_context, input) { routed = input; return {}; },
|
||||
async switchSmartSocket(_context, input) { routed = input; return { status: 'PUBLISHED' }; },
|
||||
async scheduleSmartSocket() { return {}; },
|
||||
async clearSmartSocketTask() { return {}; }
|
||||
async clearSmartSocketTask() { return {}; },
|
||||
async rebootSmartSocket() { return {}; },
|
||||
async emptySmartSocketEnergy() { return {}; },
|
||||
async resetSmartSocket(_context, input) { routed = input; return {}; },
|
||||
async changeSmartSocketReturnKey() { return {}; },
|
||||
async configureSmartSocketProtection(_context, input) { routed = input; return {}; },
|
||||
async configureSmartSocketParameter() { return {}; }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -160,6 +282,25 @@ const response = await app.inject({
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(routed.slot1, 'on');
|
||||
const appPowerResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/device-control/power',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', slotall: 'off' }
|
||||
});
|
||||
assert.equal(appPowerResponse.statusCode, 200);
|
||||
assert.equal(routed.slotall, 'off');
|
||||
const appDoorResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/app-api/management/device-control/door',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', order: 'open', holdopen: 0, delayTime: 4 }
|
||||
});
|
||||
assert.equal(appDoorResponse.statusCode, 200);
|
||||
assert.equal(routed.order, 'open');
|
||||
assert.equal(routedContext.storeId, '11');
|
||||
assert.equal(routedContext.actorType, 'ADMIN');
|
||||
assert.equal(routedContext.actorId, '22');
|
||||
const socketResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/socket/switch',
|
||||
@@ -168,6 +309,68 @@ const socketResponse = await app.inject({
|
||||
});
|
||||
assert.equal(socketResponse.statusCode, 200);
|
||||
assert.equal(routed.on, true);
|
||||
const socketTaskRead = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/socket/read',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', target: 'localtask', slotNum: 1, taskNum: 0 }
|
||||
});
|
||||
assert.equal(socketTaskRead.statusCode, 200);
|
||||
assert.equal(routed.target, 'localtask');
|
||||
assert.equal(routed.taskNum, 0);
|
||||
const invalidSocketTaskRead = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/socket/read',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', target: 'localtask' }
|
||||
});
|
||||
assert.equal(invalidSocketTaskRead.statusCode, 400);
|
||||
const invalidSocketTask = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/socket/task',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
storeId: '11', roomId: '31', taskNum: 1, action: 'on',
|
||||
mode: 'weekly', time: '24:00', weekdays: [1]
|
||||
}
|
||||
});
|
||||
assert.equal(invalidSocketTask.statusCode, 400);
|
||||
const socketProtection = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/socket/protection',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
storeId: '11', roomId: '31', maxPower: 3500, maxCurrent: 15,
|
||||
maxTemperature: 80, pullOutStop: 1, pullOutPower: 5, pullOutSec: 10,
|
||||
chargeFullStop: 1, chargeFullPower: 8, chargeFullSec: 30
|
||||
}
|
||||
});
|
||||
assert.equal(socketProtection.statusCode, 200);
|
||||
assert.equal(routed.maxCurrent, 15);
|
||||
const subLockResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/sub-lock/action',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
storeId: '11', roomId: '31', subtype: '14', subID: 'SUB001',
|
||||
order: 'open', delayTime: 4
|
||||
}
|
||||
});
|
||||
assert.equal(subLockResponse.statusCode, 200);
|
||||
const invalidSubLock = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/sub-lock/action',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', subID: 'SUB001', order: 'open' }
|
||||
});
|
||||
assert.equal(invalidSubLock.statusCode, 400);
|
||||
const invalidTts = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/tts',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { storeId: '11', roomId: '31', content: 'test', volume: 11 }
|
||||
});
|
||||
assert.equal(invalidTts.statusCode, 400);
|
||||
const invalid = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/device-control/door',
|
||||
@@ -196,4 +399,4 @@ assert.equal(routedContext.traceId, 'm08a-open-door');
|
||||
assert.deepEqual(routedContext.access.capabilities, ['device.write']);
|
||||
await app.close();
|
||||
|
||||
console.log('PASS: M06-E device control commands include control-box, Sub-1G and smart socket.');
|
||||
console.log('PASS: M06-E-R1 socket controls, capability limits and dangerous gates work.');
|
||||
|
||||
@@ -69,6 +69,20 @@ const app = await buildApp({
|
||||
}
|
||||
}
|
||||
});
|
||||
const appAssets = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/devices?storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appAssets.statusCode, 200);
|
||||
assert.deepEqual(appAssets.json().data, []);
|
||||
|
||||
const appTopology = await app.inject({
|
||||
method: 'GET', url: '/app-api/management/device-topology?storeId=11',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(appTopology.statusCode, 200);
|
||||
assert.deepEqual(appTopology.json().data.openAlerts, []);
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/admin-api/devices',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp } from '../dist/app.js';
|
||||
import { signAccessToken } from '../dist/auth/jwt.js';
|
||||
|
||||
const secret = 'test-only-franchise-management-secret-32';
|
||||
const token = signAccessToken({ sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900);
|
||||
let submitted;
|
||||
let listInput;
|
||||
let assignment;
|
||||
let followUp;
|
||||
const repository = {
|
||||
async submitApplication(input) { submitted = input; return { applicationId: '101', applicationNo: 'FR101', idempotent: false }; },
|
||||
async listApplications(input) { listInput = input; return { items: [], total: 0, page: input.page, pageSize: input.pageSize }; },
|
||||
async getApplication(tenantId, id) { return { application: { id, tenantId }, followUps: [] }; },
|
||||
async assignApplication(_actor, tenantId, id, assigneeUserId) { assignment = { tenantId, id, assigneeUserId }; return { applicationId: id, assigneeUserId }; },
|
||||
async addFollowUp(_actor, tenantId, id, input) { followUp = { tenantId, id, input }; return { applicationId: id, followUpId: '301', status: input.status || 'NEW' }; }
|
||||
};
|
||||
const app = await buildApp({ franchise: {
|
||||
repository,
|
||||
platformConfig: { async resolveBootstrap(appId, tenantId) { return appId === 'wx-franchise' ? { tenantId: tenantId || '7' } : null; } },
|
||||
authRepository: { async validateSession() { return { id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9', expiresAt: new Date(Date.now() + 60000), user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' } }; } },
|
||||
accessControl: { async getAccessProfile() { return { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }; } },
|
||||
jwtSecret: secret
|
||||
} });
|
||||
|
||||
const created = await app.inject({ method: 'POST', url: '/app-api/franchise-applications', headers: { 'x-wechat-appid': 'wx-franchise' }, payload: { city: '上海市', contactName: '张先生', contactPhone: '13800138000', message: '计划开店', clientRequestId: 'franchise-request-001' } });
|
||||
assert.equal(created.statusCode, 201);
|
||||
assert.equal(submitted.tenantId, '7');
|
||||
assert.equal(submitted.contactPhone, '13800138000');
|
||||
assert.equal('contactPhone' in (submitted.audit || {}), false);
|
||||
|
||||
const auth = { authorization: `Bearer ${token}` };
|
||||
const listed = await app.inject({ method: 'GET', url: '/admin-api/franchise-applications?status=NEW&page=2&pageSize=10', headers: auth });
|
||||
assert.equal(listed.statusCode, 200);
|
||||
assert.deepEqual({ tenantId: listInput.tenantId, status: listInput.status, page: listInput.page }, { tenantId: '7', status: 'NEW', page: 2 });
|
||||
const crossTenant = await app.inject({ method: 'GET', url: '/admin-api/franchise-applications?tenantId=8', headers: auth });
|
||||
assert.equal(crossTenant.statusCode, 403);
|
||||
const assigned = await app.inject({ method: 'PATCH', url: '/admin-api/franchise-applications/101/assignee', headers: auth, payload: { assigneeUserId: '22' } });
|
||||
assert.equal(assigned.statusCode, 200);
|
||||
assert.deepEqual(assignment, { tenantId: '7', id: '101', assigneeUserId: '22' });
|
||||
const followed = await app.inject({ method: 'POST', url: '/admin-api/franchise-applications/101/follow-ups', headers: auth, payload: { followUpType: 'CALL', note: '已电话沟通', status: 'CONTACTED' } });
|
||||
assert.equal(followed.statusCode, 201);
|
||||
assert.equal(followUp.input.status, 'CONTACTED');
|
||||
await app.close();
|
||||
console.log('PASS: M08-D franchise submission, tenant scope, assignment and follow-up routes are present.');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user