chore: 文档与 IDE 配置整理

- .kiro/specs/ → docs/specs/(41 个历史需求 spec 迁移,移除 .config.kiro)
- CLAUDE.md 三层拆分:根文件精简 + apps/backend/CLAUDE.md + .claude/commands/
- 新增 /spec-close、/pre-change 两个工作流命令
- DDL 基线刷新(从测试库重新导出 11 个文件,dws 35→38 表,biz 18→21 表)
- BD_Manual → BD_manual 命名统一(48 个文件)
- 修复 3 处文档与数据库不一致(auth.users.status 默认值、scheduled_tasks 字段、RLS 视图数)
- 新增 BD_manual_public_rbac_tables.md(public schema 8 张 RBAC/工作流表)
- 合并 biz.trigger_jobs 文档(10→12 字段,归档独立文档)
- docs/database/README.md 索引更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:02:37 +08:00
parent 8228b3fa37
commit 70324d8542
185 changed files with 13595 additions and 1219 deletions

View File

@@ -0,0 +1,65 @@
# /pre-change — 逻辑改动前置调研
对即将修改的模块进行全面调研,输出上下文摘要供用户确认后再动手。
## 适用场景
任何逻辑改动ETL/业务规则/API/数据模型/前端交互),写代码前执行。
## 执行步骤
### 第 1 步:识别改动范围
从用户需求中提取:
- 要修改的模块和文件
- 涉及的数据表/API/页面
- 预期的行为变化
### 第 2 步:委托 Explore 子代理调研
启动 Explore 子代理thoroughness: very thorough调研以下内容
1. **目标模块文件**:读取要修改的文件及其直接依赖
2. **历史审计**:搜索 `docs/audit/changes/` 中相关模块的历史变更记录
3. **相关文档**README、PRD`docs/prd/`、BD 手册(`docs/database/`、API 参考
4. **调用关系**:要修改文件的调用方和被调用方
5. **数据流向**:上游(数据从哪来)→ 当前模块 → 下游(数据到哪去)
6. **影响范围**:哪些模块/页面/任务可能受影响
### 第 3 步:输出「改动前上下文摘要」
格式:
```
## 改动前上下文摘要
### 模块职责
<模块做什么,在系统中的角色>
### 历史变更
<近期审计记录中的相关改动,特别是踩坑记录>
### 数据流向
上游: <数据来源>
当前: <本模块处理>
下游: <消费方>
### 影响范围
- <受影响的模块/页面/任务列表>
### 风险点
- <可能的副作用、边界条件、兼容性问题>
### 建议方案
<基于调研结果的实施建议>
```
### 第 4 步:等待用户确认
输出摘要后,等待用户确认或调整方向,确认后再进入编码实施。
## 例外(无需执行此流程)
- 纯格式调整、注释/文档纯文字修改
- 用户明确说"直接改/跳过调研"
- 新建文件且不涉及已有逻辑

View File

@@ -0,0 +1,63 @@
# /spec-close — Spec 收尾通用流程
当一个功能 spec 开发完成时,执行此收尾检查清单确保质量闭环。
## 执行步骤
### 步骤 1最终测试检查点必选
- 运行 Monorepo 属性测试:`cd /c/NeoZQYY && pytest tests/ -v`
- 运行模块单元测试:`cd <模块路径> && pytest tests/ -v`
- 确保所有测试通过,有问题询问用户
### 步骤 2前后端联调验证涉及 API + 前端时必选)
- 启动后端服务,使用测试库验证各端点完整请求-响应链路
- 验证 JSON 响应结构与 Schema 定义一致camelCase 序列化)
- 验证权限校验和数据隔离(`SET LOCAL app.current_site_id`)在真实请求中生效
- 前端联调验证:确认前端页面能正确调用 API 并渲染数据
- 验证空数据/降级场景下前端不崩溃
### 步骤 3数据库变更审计与 DDL 合并(涉及 DB 改动时必选)
- 审计本次实现中对数据库的所有改动新建表、新增字段、新增索引、FDW 映射变更等)
- **必须通过 pg MCP 工具实际执行迁移 SQL**(禁止仅标记完成而不执行)
- 执行后用查询验证表/字段/索引已正确创建
- RLS 视图双 schema后端查询 `app.v_*` 视图,新建 DWS RLS 视图时必须同时在原 schema 和 `app` schema 下创建
- 合并到主 DDL 基线文件ETL → `docs/database/ddl/etl_feiqiu__<schema>.sql`,业务 → `docs/database/ddl/zqyy_app__<schema>.sql`
- 编写回滚脚本(逆序 DROP/ALTER
### 步骤 4BD 手册更新(涉及 DB 改动时必选)
- 业务库 → `docs/database/BD_manual_*.md`
- ETL 库 → `apps/etl/connectors/feiqiu/docs/database/<层级>/main/BD_manual_*.md`
- FDW → `docs/database/BD_manual_fdw*.md`
- 每份手册必须包含:字段明细、约束与索引、验证 SQL≥3 条)、兼容性影响、回滚策略
### 步骤 5项目文档同步更新按涉及范围裁剪
根据改动类型选择需要更新的文档:
| 文档 | 更新条件 |
|------|----------|
| 模块 README | 模块内部结构变更时 |
| `apps/backend/docs/API-REFERENCE.md` | 新增/修改后端路由时 |
| `docs/contracts/openapi/backend-api.json` | 新增/修改 API 端点时 |
| `docs/DOCUMENTATION-MAP.md` | 新增任何文档条目时 |
### 步骤 6变更审计收口涉及高风险路径时必选
执行 `/audit` 命令完成审计流程。
### 步骤 7服务清理启动了运行时服务时必选
- 关闭浏览器实例、停止后端和前端服务、清理资源
## 按 Spec 类型裁剪
| 类型 | 必选步骤 |
|------|---------|
| ETL 类ODS/DWD/DWS | 1, 3, 4, 5, 6 |
| 后端 API 类 | 1, 2, 5, 6 |
| 全栈类(前后端 + DB | 1, 2, 3, 4, 5, 6 |
| 重构类 | 1, 5, 6 |

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "27029642-a405-4932-8c22-5bc54fad5173", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "cf5c24d6-ec72-4c49-8650-264ef414e10e", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "98a585de-82d9-4bbd-bed8-179208c12f8b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "7e1dc63d-3dbd-4462-a43c-9ecaa9b1dd07", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "cd79656c-9c23-4470-a147-d402b5f4b50b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "a277a91a-b35c-4d48-b4a2-09df0e47b71b", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "4b6736e7-40fc-40a9-82f7-809f80253fe2", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "cd30e87b-ce7a-4ff5-8587-f5ae75013e58", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "cd30e87b-ce7a-4ff5-8587-f5ae75013e58", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "a7c3e1f2-9b84-4d6e-b5a1-3f8c2d7e9a04", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "a3f7c2d1-8e4b-4f6a-9c5d-2b1e8f3a7d9c", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "b2f4e8a1-3c7d-4f9b-a6e2-8d5c1b3f7a9e", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "13cfd0bc-b6d6-408e-b943-aa11fb515478", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"specId": "a7e3c1d4-8f2b-4e6a-b5d9-3c1f7a2e8b4d", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1 +0,0 @@
{"generationMode": "requirements-first"}

View File

@@ -1 +0,0 @@
{"specId": "9eccc890-b6c3-41a3-8ba1-bb2f0e09f653", "workflowType": "requirements-first", "specType": "feature"}

View File

@@ -1,6 +1,21 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 本文件为 Claude Code (claude.ai/code) 在本仓库工作时的指导规范。
## 语言(强制)
始终使用中文,覆盖所有场景:
- 对话回复、解释、提问、状态更新 → 中文
- 代码注释 → 中文
- Git commit message → 中文描述 + 英文 Co-Authored-By 签名行
- PR 标题与正文 → 中文
- 审计记录、文档、变更说明 → 中文
- 错误提示、日志说明 → 中文
- 变量名/函数名/类名 → 保持英文(编程惯例)
- 第三方 API 字段名、CLI 命令 → 保持原文
禁止在回复中使用英文段落或英文标题(技术术语、代码片段、专有名词内嵌除外)。
## 项目概览 ## 项目概览
@@ -18,6 +33,8 @@ NeoZQYY Monorepo — 面向台球门店业务的全栈数据平台。多门店
| `apps/mcp-server/` | MCP ServerPostgreSQL 只读AI 工具集成) | | `apps/mcp-server/` | MCP ServerPostgreSQL 只读AI 工具集成) |
| `packages/shared/` | 跨项目共享包enums, money, datetime_utils | | `packages/shared/` | 跨项目共享包enums, money, datetime_utils |
| `db/` | DDL / 迁移 / 种子数据 | | `db/` | DDL / 迁移 / 种子数据 |
| `tools/` | 通用工具db/reporting/health/h5-to-mp-checker |
| `scripts/ops/` | 日常运维脚本ETL 监控、数据回填、导出等) |
### 两个管理后台的区别 ### 两个管理后台的区别
@@ -74,6 +91,8 @@ cd apps/miniprogram && npm test # 小程序 Jest
### ETL ### ETL
> 完整规则见 `apps/etl/connectors/feiqiu/CLAUDE.md`
- 任务模式:继承 `BaseTask`Extract → Transform → Load`orchestration/task_registry.py` 注册 - 任务模式:继承 `BaseTask`Extract → Transform → Load`orchestration/task_registry.py` 注册
- 加载器模式:每张目标表一个 Loader`upsert()` + 冲突处理 - 加载器模式:每张目标表一个 Loader`upsert()` + 冲突处理
- SCD2 处理:`scd/` 模块 - SCD2 处理:`scd/` 模块
@@ -81,11 +100,12 @@ cd apps/miniprogram && npm test # 小程序 Jest
### 后端 ### 后端
- 全局响应包装:`ResponseWrapperMiddleware` 把所有 2xx 响应包为 `{ "code": 0, "data": <payload> }` > 完整规则见 `apps/backend/CLAUDE.md`
- `CamelModel` 基类snake_case → camelCase 自动转换(小程序 API 用)
- JWT 双认证用户名密码admin+ 微信 code小程序待审核用户有 limited token - 全局响应包装:`ResponseWrapperMiddleware``{ "code": 0, "data": <payload> }`
- AI 集成8 个千问应用通过 DashScope SDKchat/finance/clue/analysis/tactics/note/customer/consolidate带熔断、限流、预算追踪 - JWT 双认证admin用户名密码+ miniapp微信 code+ tenant-admin用户名密码
- 后台服务lifespan`TaskQueue`(按 site_id 消费)、`Scheduler`(读 scheduled_tasks 自动入队、4 个触发器 - AI 集成8 个千问应用DashScope SDK带熔断、限流、预算追踪
- 后台服务TaskQueue + Scheduler + 4 个触发器
## 文件归属规则 ## 文件归属规则
@@ -96,9 +116,11 @@ cd apps/miniprogram && npm test # 小程序 Jest
| 只验证本模块逻辑的测试 | 模块内 `tests/` | | 只验证本模块逻辑的测试 | 模块内 `tests/` |
| 守护 monorepo 结构/约定的测试 | 根 `tests/` | | 守护 monorepo 结构/约定的测试 | 根 `tests/` |
| 只操作本模块数据的脚本 | 模块内 `scripts/` | | 只操作本模块数据的脚本 | 模块内 `scripts/` |
| 运维/全局工具脚本 | `scripts/` | | 日常运维脚本(回填/导出/种子/初始化) | `scripts/ops/` |
| 可复用通用工具(健康检查/数据库/报表/分析) | `tools/`(按类型分子目录) |
| 审计记录(任何模块的变更) | 根 `docs/audit/` — 禁止写入子模块 | | 审计记录(任何模块的变更) | 根 `docs/audit/` — 禁止写入子模块 |
| 数据库文档(全局 schema 视角) | 根 `docs/database/` | | 数据库文档(全局 schema 视角) | 根 `docs/database/` |
| 归档/待删除内容 | `_DEL/`(保持原路径结构,用户定期手动清理) |
审计产物路径: 审计产物路径:
- 变更记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md` - 变更记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md`
@@ -106,38 +128,12 @@ cd apps/miniprogram && npm test # 小程序 Jest
## 飞球数据规范 ## 飞球数据规范
权威文档:`docs/reports/DWD-DOC/`DWD 12 条)+ 同目录 DWS 权威规范。与 BD 手册、ETL 文档、DDL 注释冲突时以 DWD-DOC 为准 > 完整规则见 `apps/etl/connectors/feiqiu/CLAUDE.md`(进入 ETL 目录时自动加载)
### 硬规则速查 **核心速查**
- `consume_money` 禁止直接计算 → 用 `items_sum` 拆分字段
1. `consume_money` 禁止直接计算 → 用 `items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money` - 取数优先级DWS > DWD > 禁止 ODS
2. 助教费用拆分:`assistant_pd_money`(陪打)+ `assistant_cx_money`(超休),禁止用 `service_fee` / `ASSISTANT_BASE` / `ASSISTANT_BONUS` - 参考优先级DWD-DOC > DWS 权威规范 > BD 手册 > ETL 任务文档 > DDL 注释
3. 支付恒等式:`balance_amount = recharge_card_amount + gift_card_amount`,三者不可重复计算
4. `settle_type` 过滤:正向交易 `IN (1, 3)`,本表无 `is_delete` 字段
5. 会员信息通过 `member_id` JOIN 维度表(`scd2_is_current=1`结算单冗余字段不可靠DQ-6/DQ-7
6. 支付方式拆分来源是 `dwd_payment`DQ-8`payment_method=2` 现金,`payment_method=4` 扫码。`dwd_settlement_head_ex.cash_amount/online_amount` 不可靠
7. 散客:`member_id ≤ 0`,全链路过滤入口加 `member_id > 0`
8. 课程类型/定价/绩效档位/奖金/指数权重 → 配置表读取,禁止硬编码
9. DWS 汇总表 delete-before-insert库存表 upsert
10. 折扣互斥:`discount_manual` + `discount_other` = `adjust_amount`
11. 现金流互斥:`platform_settlement_amount``groupbuy_pay_amount` 互斥
12. 废单判断:`dwd_assistant_service_log_ex.is_trash`
### 取数优先级
DWS > DWD明细+维度表)> 禁止 ODSAPI 快照表,同一 id 有 100+ 行重复JOIN 会行膨胀)
### 参考优先级
DWD-DOC > DWS 权威规范 > BD 手册 > ETL 任务文档 > 业务规则文档 > DDL 注释
## 废弃对象黑名单(禁止引用)
- `dwd_assistant_trash_event` / `_ex`2026-02-22 DROP→ 用 `dwd_assistant_service_log_ex.is_trash`
- `ods.assistant_cancellation_records`2026-02-22→ 无需独立链路
- `ODS_ASSISTANT_ABOLISH` / `ASSISTANT_ABOLISH`2026-02-22→ 无
- `BILLIARD_VIP`2026-03-07→ V1-V4 归 `BILLIARD`V5 归 `SNOOKER`
- `dws_member_recall_index` / `v_dws_member_recall_index`2026-03-20→ WBI + NCI
- 所有 `_archived/` 目录禁止读取或参考 - 所有 `_archived/` 目录禁止读取或参考
## 编码前需求审问(强制) ## 编码前需求审问(强制)

52
apps/backend/CLAUDE.md Normal file
View File

@@ -0,0 +1,52 @@
# CLAUDE.md — Backend (FastAPI)
进入本目录时自动加载。
## 架构模式
### 全局响应包装
`ResponseWrapperMiddleware` 把所有 2xx 响应包为 `{ "code": 0, "data": <payload> }`
非 2xx 响应保持原样。前端统一通过 `response.data` 解包。
### 序列化
`CamelModel` 基类snake_case → camelCase 自动转换(小程序 API 用)。
后端代码始终用 snake_caseJSON 输出自动转驼峰。
### JWT 双认证
| 认证方式 | 用途 | 表 | JWT aud |
|---------|------|-----|---------|
| 用户名+密码 | admin-web 登录 | `auth.admin_users` | `admin` |
| 微信 code | 小程序登录 | `auth.users` | `miniapp` |
| 用户名+密码 | tenant-admin 登录 | `auth.tenant_admins` | `tenant-admin` |
待审核用户有 limited token仅可访问审核状态接口
### AI 集成
8 个千问应用通过 DashScope SDK
chat / finance / clue / analysis / tactics / note / customer / consolidate
特性:熔断(连续失败自动断路)、限流(每分钟/每日)、预算追踪、对话缓存。
### 后台服务lifespan
- `TaskQueue`:按 site_id 消费FIFO 队列
- `Scheduler`:读 `meta.scheduled_tasks` 自动入队
- 4 个触发器:日结/月结/工资/关系指数
### 数据库访问
- 业务库通过 `APP_DB_DSN` 直连 `zqyy_app`
- ETL 数据通过 FDW 映射的 `app.v_*` RLS 视图访问
- 查询前必须 `SET LOCAL app.current_site_id = :site_id`
## 测试
```bash
cd apps/backend && pytest tests/ -v
```
使用测试库(`TEST_APP_DB_DSN`),禁止连正式库。

View File

@@ -0,0 +1,60 @@
# cfg_skill_type 课程类型配置表
> 生成时间2026-03-24
## 表信息
| 属性 | 值 |
|------|-----|
| Schema | dws |
| 表名 | cfg_skill_type |
| 主键 | skill_type_id自增 |
| 唯一键 | skill_id |
| 数据来源 | 手工维护,对应飞球系统的课程/技能类型 |
| 更新频率 | 按需(新增课程类型时) |
| 说明 | 将飞球系统的 skill_id 映射到业务课程分类BASE/BONUS供 ETL 到店判定、工资计算、绩效统计使用 |
## 字段说明
| 序号 | 字段名 | 类型 | 可空 | 说明 |
|------|--------|------|------|------|
| 1 | skill_type_id | SERIAL | NO | 自增主键 |
| 2 | skill_id | BIGINT | NO | 飞球系统技能 ID唯一 |
| 3 | skill_name | VARCHAR | YES | 技能名称(来自飞球系统) |
| 4 | course_type_code | VARCHAR | NO | 课程分类代码BASE基础课/ BONUS附加课/超休/激励课) |
| 5 | course_type_name | VARCHAR | NO | 课程分类中文名 |
| 6 | is_active | BOOLEAN | NO | 是否启用(默认 true |
| 7 | description | TEXT | YES | 备注说明 |
| 8 | created_at | TIMESTAMPTZ | NO | 创建时间 |
| 9 | updated_at | TIMESTAMPTZ | NO | 更新时间 |
## 当前数据(截至 2026-03-24
| skill_id | skill_name | course_type_code | 来源说明 |
|----------|-----------|-----------------|---------|
| 2790683529513797 | 基础课 | BASE | 飞球系统原始课程类型2026-03-24 补录) |
| 2790683529513798 | 附加课 | BONUS | 飞球系统原始课程类型2026-03-24 补录) |
| 2791903611396869 | 台球基础陪打 | BASE | 初始种子数据 |
| 2807440316432197 | 台球超休服务 | BONUS | 初始种子数据 |
| 2807440316432198 | 包厢服务 | BASE | 初始种子数据 |
| 3039912271463941 | 包厢课 | BASE | 飞球系统原始课程类型2026-03-24 补录) |
## 业务口径
- `course_type_code = 'BONUS'` 用于 WBI/NCI 到店判定settle_type=3 的商城订单,仅当关联了 BONUS 类型的助教服务记录时才算"到店"
- `course_type_code = 'BASE'` 用于基础课工资计算(按助教等级计价)
- `course_type_code = 'BONUS'` 用于附加课工资计算(固定 190 元/小时)
## 下游依赖
| 消费方 | 用途 |
|--------|------|
| `member_index_base._build_visit_condition_sql()` | WBI/NCI 到店判定 |
| `index_verifier.visit_members` CTE | 指数验证器到店范围 |
| 助教工资计算任务 | 区分基础课/附加课计价 |
| 助教绩效统计 | 按课程类型分类统计服务时长 |
## 维护注意事项
- 飞球系统新增课程类型时,必须同步在此表补录,否则相关订单会被 WBI 到店判定漏掉
- 2026-03-24 发现 3 条缺失记录导致 113 名会员、3766 条服务记录的到店判定失效

View File

@@ -1,6 +1,6 @@
# cfg_area_category 台区分类映射表 # cfg_area_category 台区分类映射表
> 生成时间2026-02-03 | 更新时间2026-03-09 > 生成时间2026-02-03 | 更新时间2026-03-20
## 表信息 ## 表信息
@@ -30,6 +30,7 @@
| 11 | description | TEXT | YES | | 说明 | | 11 | description | TEXT | YES | | 说明 |
| 12 | created_at | TIMESTAMPTZ | NO | | 创建时间 | | 12 | created_at | TIMESTAMPTZ | NO | | 创建时间 |
| 13 | updated_at | TIMESTAMPTZ | NO | | 更新时间 | | 13 | updated_at | TIMESTAMPTZ | NO | | 更新时间 |
| 14 | sort_order | INTEGER | NO | | 前端筛选器显示排序值越小越靠前DEFAULT 100 |
## 变更说明2026-03-09 ## 变更说明2026-03-09
@@ -108,6 +109,7 @@
| 2026-02-03 | 初始创建,区域级精确 + LIKE 模糊匹配 | | 2026-02-03 | 初始创建,区域级精确 + LIKE 模糊匹配 |
| 2026-03-07 | 新增 source_table_name 支持台桌级细分;废弃 BILLIARD_VIP | | 2026-03-07 | 新增 source_table_name 支持台桌级细分;废弃 BILLIARD_VIP |
| 2026-03-09 | 改为纯台桌级精确映射,删除所有 LIKE 和区域级映射 | | 2026-03-09 | 改为纯台桌级精确映射,删除所有 LIKE 和区域级映射 |
| 2026-03-20 | 新增 sort_order 字段,控制前端筛选器分类显示排序 |
## 验证 SQL ## 验证 SQL

View File

@@ -0,0 +1,159 @@
# dws_finance_area_daily 区域日粒度财务原子层表
> 生成时间2026-03-28
> 关联 SPECboard-finance-dws-area-refactor
## 表信息
| 属性 | 值 |
|------|-----|
| Schema | dws |
| 表名 | dws_finance_area_daily |
| 主键 | id |
| 唯一键 | (site_id, stat_date, area_code) |
| 数据来源 | dwd_settlement_head + dim_table + dws_finance_daily_summary |
| 更新频率 | 每小时更新当日数据 |
| 幂等策略 | delete-before-insert按 site_id + stat_date 删除后插入 9 行) |
| 说明 | 按 (site_id, stat_date, area_code) 粒度存储 9 个区域的收入/优惠/现金流预计算数据 |
## 字段说明
| 序号 | 字段名 | 类型 | 可空 | 说明 |
|------|--------|------|------|------|
| 1 | id | BIGSERIAL | NO | 自增主键 |
| 2 | site_id | BIGINT | NO | 门店ID |
| 3 | tenant_id | BIGINT | NO | 租户ID |
| 4 | stat_date | DATE | NO | 统计日期(营业日,按 BUSINESS_DAY_START_HOUR=8 切点) |
| 5 | area_code | VARCHAR(20) | NO | 区域编码all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv |
| 6 | table_fee_amount | NUMERIC(14,2) | NO | 台费正价 |
| 7 | goods_amount | NUMERIC(14,2) | NO | 商品正价 |
| 8 | assistant_pd_amount | NUMERIC(14,2) | NO | 助教基础课正价(陪打) |
| 9 | assistant_cx_amount | NUMERIC(14,2) | NO | 助教激励课正价(超休) |
| 10 | gross_amount | NUMERIC(14,2) | NO | 毛收入 = 四项之和 |
| 11 | discount_groupbuy | NUMERIC(14,2) | NO | 团购优惠 |
| 12 | discount_vip | NUMERIC(14,2) | NO | 会员折扣 |
| 13 | discount_manual | NUMERIC(14,2) | NO | 手动调整adjust_amount |
| 14 | discount_gift_card | NUMERIC(14,2) | NO | 赠送卡消费金额口径 |
| 15 | discount_rounding | NUMERIC(14,2) | NO | 抹零 |
| 16 | discount_other | NUMERIC(14,2) | NO | 其他优惠 |
| 17 | discount_total | NUMERIC(14,2) | NO | 优惠合计 = 六项之和 |
| 18 | confirmed_income | NUMERIC(14,2) | NO | 确认收入 = gross_amount - discount_total |
| 19 | cash_pay_amount | NUMERIC(14,2) | NO | 收银实付(仅 all 行有效) |
| 20 | cash_paper_amount | NUMERIC(14,2) | NO | 纸币支付(仅 all 行有效) |
| 21 | scan_pay_amount | NUMERIC(14,2) | NO | 扫码支付(仅 all 行有效) |
| 22 | groupbuy_pay_amount | NUMERIC(14,2) | NO | 团购支付金额(仅 all 行有效) |
| 23 | recharge_cash_inflow | NUMERIC(14,2) | NO | 充值现金流入(仅 all 行有效) |
| 24 | cash_inflow_total | NUMERIC(14,2) | NO | 现金流入合计(仅 all 行有效) |
| 25 | cash_outflow_total | NUMERIC(14,2) | NO | 现金流出合计(仅 all 行有效) |
| 26 | cash_balance_change | NUMERIC(14,2) | NO | 现金余额变动(仅 all 行有效) |
| 27 | card_consume_total | NUMERIC(14,2) | NO | 卡消费合计(仅 all 行有效) |
| 28 | recharge_card_consume | NUMERIC(14,2) | NO | 充值卡消费(仅 all 行有效) |
| 29 | gift_card_consume | NUMERIC(14,2) | NO | 赠送卡消费(仅 all 行有效) |
| 30 | recharge_cash | NUMERIC(14,2) | NO | 充值现金(仅 all 行有效) |
| 31 | first_recharge_cash | NUMERIC(14,2) | NO | 首充现金(仅 all 行有效) |
| 32 | renewal_cash | NUMERIC(14,2) | NO | 续充现金(仅 all 行有效) |
| 33 | order_count | INTEGER | NO | 结账单数 |
| 34 | created_at | TIMESTAMPTZ | NO | 创建时间 |
| 35 | updated_at | TIMESTAMPTZ | NO | 更新时间 |
## 约束与索引
| 约束/索引 | 类型 | 列 |
|-----------|------|-----|
| dws_finance_area_daily_pkey | PRIMARY KEY | id |
| dws_finance_area_daily_site_id_stat_date_area_code_key | UNIQUE | (site_id, stat_date, area_code) |
## RLS 视图
```sql
CREATE OR REPLACE VIEW dws.v_dws_finance_area_daily AS
SELECT * FROM dws.dws_finance_area_daily
WHERE site_id = (current_setting('app.current_site_id'::text))::bigint;
```
## 数学恒等式
```
gross_amount = table_fee_amount + goods_amount + assistant_pd_amount + assistant_cx_amount
discount_total = discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other
confirmed_income = gross_amount - discount_total
```
- `area_code ≠ 'all'` 时:现金流/卡消费/充值字段 = 0
- `all` 行收入/优惠 = hallA~ktv 各行对应字段之和
- `hall` 行 = hallA~ktv 各行之和(历史兼容)
- 每个 (site_id, stat_date) 恰好 9 行
## 变更原因
解决财务看板在 `area≠all` 时优惠数据从全局 DWS 表取数导致区域级优惠占比严重失真的 bug如 B区优惠占比 417.9%)。
## 兼容性影响
| 组件 | 影响 |
|------|------|
| ETL 任务 | 新增 DWS_FINANCE_AREA_DAILY 任务,依赖 DWD_LOAD_FROM_ODS |
| 后端 API | board_service.py 改为从本表查询 overview/revenue 板块数据 |
| 小程序 | 无直接影响API 签名不变) |
| dws_finance_daily_summary | 不改动,本表的 all 行现金流/充值/卡消费复用其数据 |
## 回滚策略
```sql
DROP VIEW IF EXISTS dws.v_dws_finance_area_daily;
DROP TABLE IF EXISTS dws.dws_finance_area_daily;
```
回滚后后端需恢复到从 `dws_finance_daily_summary` 取数的旧逻辑。
## 验证 SQL
```sql
-- 1. 验证表存在且有数据
SELECT COUNT(*), COUNT(DISTINCT area_code), MIN(stat_date), MAX(stat_date)
FROM dws.dws_finance_area_daily
WHERE site_id = 1;
-- 2. 验证每天恰好 9 行
SELECT stat_date, COUNT(*) AS row_count
FROM dws.dws_finance_area_daily
WHERE site_id = 1
GROUP BY stat_date
HAVING COUNT(*) != 9
ORDER BY stat_date;
-- 3. 验证收入恒等式
SELECT stat_date, area_code,
gross_amount,
(table_fee_amount + goods_amount + assistant_pd_amount + assistant_cx_amount) AS calc_gross,
gross_amount - (table_fee_amount + goods_amount + assistant_pd_amount + assistant_cx_amount) AS diff
FROM dws.dws_finance_area_daily
WHERE site_id = 1
AND gross_amount != (table_fee_amount + goods_amount + assistant_pd_amount + assistant_cx_amount);
-- 4. 验证优惠恒等式
SELECT stat_date, area_code,
discount_total,
(discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other) AS calc_disc,
discount_total - (discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other) AS diff
FROM dws.dws_finance_area_daily
WHERE site_id = 1
AND discount_total != (discount_groupbuy + discount_vip + discount_manual + discount_gift_card + discount_rounding + discount_other);
-- 5. 验证非 all 行现金流为零
SELECT stat_date, area_code, cash_inflow_total, cash_outflow_total
FROM dws.dws_finance_area_daily
WHERE site_id = 1
AND area_code != 'all'
AND (cash_inflow_total != 0 OR cash_outflow_total != 0);
```
## 可回溯性
| 项目 | 说明 |
|------|------|
| 可回溯 | ✅ 完全可回溯delete-before-insert |
| 数据范围 | 2025-07-16 ~ 至今 |
| 依赖表 | dwd_settlement_head, dim_table, dws_finance_daily_summary |
| 回填脚本 | `scripts/ops/backfill_finance_area_daily.py` |

View File

@@ -0,0 +1,121 @@
# dws_finance_board_cache 看板缓存层表
> 生成时间2026-03-28
> 关联 SPECboard-finance-dws-area-refactor
## 表信息
| 属性 | 值 |
|------|-----|
| Schema | dws |
| 表名 | dws_finance_board_cache |
| 主键 | id |
| 唯一键 | (site_id, time_range, area_code) |
| 数据来源 | dws_finance_area_daily日粒度原子层 |
| 更新频率 | 每天一次(营业日切点后) |
| 幂等策略 | ON CONFLICT (site_id, time_range, area_code) DO UPDATE |
| 说明 | 缓存已完成周期的 overview 聚合结果,避免重复 SUM 计算 |
## 字段说明
| 序号 | 字段名 | 类型 | 可空 | 说明 |
|------|--------|------|------|------|
| 1 | id | BIGSERIAL | NO | 自增主键 |
| 2 | site_id | BIGINT | NO | 门店ID |
| 3 | time_range | VARCHAR(20) | NO | 时间范围lastMonth/lastWeek/lastQuarter/quarter3/half6 |
| 4 | area_code | VARCHAR(20) | NO | 区域编码all/hall/hallA/hallB/hallC/vip/snooker/mahjong/ktv |
| 5 | start_date | DATE | NO | 当期起始日期 |
| 6 | end_date | DATE | NO | 当期结束日期 |
| 7 | prev_start_date | DATE | YES | 上期起始日期(环比用) |
| 8 | prev_end_date | DATE | YES | 上期结束日期(环比用) |
| 9 | occurrence | NUMERIC(14,2) | NO | 发生额gross_amount 周期汇总) |
| 10 | discount | NUMERIC(14,2) | NO | 优惠合计discount_total 周期汇总) |
| 11 | discount_rate | NUMERIC(8,4) | NO | 优惠占比 = discount / occurrence |
| 12 | confirmed_revenue | NUMERIC(14,2) | NO | 确认收入 = occurrence - discount |
| 13 | cash_in | NUMERIC(14,2) | NO | 现金流入合计(仅 area_code=all 有效) |
| 14 | cash_out | NUMERIC(14,2) | NO | 现金流出合计(仅 area_code=all 有效) |
| 15 | cash_balance | NUMERIC(14,2) | NO | 现金余额变动 = cash_in - cash_out |
| 16 | balance_rate | NUMERIC(8,4) | NO | 余额变动率 = cash_balance / cash_in |
| 17 | data_fingerprint | VARCHAR(64) | YES | 源数据 MD5 指纹,用于检测补录导致的数据变化 |
| 18 | computed_at | TIMESTAMPTZ | NO | 缓存计算时间 |
| 19 | created_at | TIMESTAMPTZ | NO | 创建时间 |
| 20 | updated_at | TIMESTAMPTZ | NO | 更新时间 |
## 约束与索引
| 约束/索引 | 类型 | 列 |
|-----------|------|-----|
| dws_finance_board_cache_pkey | PRIMARY KEY | id |
| dws_finance_board_cache_site_id_time_range_area_code_key | UNIQUE | (site_id, time_range, area_code) |
## RLS 视图
```sql
CREATE OR REPLACE VIEW dws.v_dws_finance_board_cache AS
SELECT * FROM dws.dws_finance_board_cache
WHERE site_id = (current_setting('app.current_site_id'::text))::bigint;
```
## 缓存策略
- 已完成周期缓存lastMonth, lastWeek, lastQuarter, quarter3, half6
- 当期周期不缓存month, week, quarter
- 失效条件:`data_fingerprint` 变化(补录导致源数据变化)
- 指纹算法:`MD5(sorted [(stat_date, gross_amount, discount_total), ...])`
## 变更原因
为已完成周期的聚合结果提供缓存,避免每次查询都从日粒度表 SUM 计算。通过数据指纹机制自动检测补录导致的数据变化并重算。
## 兼容性影响
| 组件 | 影响 |
|------|------|
| ETL 任务 | 新增 DWS_FINANCE_BOARD_CACHE 任务,依赖 DWS_FINANCE_AREA_DAILY |
| 后端 API | board_service.py 已完成周期先查缓存,未命中从日粒度表 SUM |
| 小程序 | 无直接影响API 签名不变) |
## 回滚策略
```sql
DROP VIEW IF EXISTS dws.v_dws_finance_board_cache;
DROP TABLE IF EXISTS dws.dws_finance_board_cache;
```
回滚后后端需移除缓存查询逻辑,改为每次从日粒度表 SUM。
## 验证 SQL
```sql
-- 1. 验证表存在且有数据
SELECT COUNT(*), COUNT(DISTINCT time_range), COUNT(DISTINCT area_code)
FROM dws.dws_finance_board_cache
WHERE site_id = 1;
-- 2. 验证唯一约束生效(应返回 0 行)
SELECT site_id, time_range, area_code, COUNT(*)
FROM dws.dws_finance_board_cache
GROUP BY site_id, time_range, area_code
HAVING COUNT(*) > 1;
-- 3. 验证 confirmed_revenue = occurrence - discount
SELECT time_range, area_code,
occurrence, discount, confirmed_revenue,
(occurrence - discount) AS expected
FROM dws.dws_finance_board_cache
WHERE site_id = 1
AND ABS(confirmed_revenue - (occurrence - discount)) > 0.01;
-- 4. 验证当期周期不在缓存中
SELECT * FROM dws.dws_finance_board_cache
WHERE time_range IN ('month', 'week', 'quarter');
```
## 可回溯性
| 项目 | 说明 |
|------|------|
| 可回溯 | ✅ 完全可回溯upsert 幂等) |
| 数据范围 | 取决于 dws_finance_area_daily 的数据范围 |
| 依赖表 | dws_finance_area_daily |
| 回填脚本 | `scripts/ops/backfill_finance_area_daily.py`(阶段 2 自动重算缓存) |

View File

@@ -1,11 +1,11 @@
# db/ — 数据库资产目录 # db/ — 数据库资产目录
## 当前状态2026-03-15 更新) ## 当前状态2026-04-05 更新)
完整 DDL 基线已迁移至 `docs/database/ddl/`(按 schema 分文件,从测试库自动导出)。 完整 DDL 基线 `docs/database/ddl/`(按 schema 分文件,从测试库自动导出)。
本目录保留运行时资产迁移脚本、种子数据、FDW 配置、建库脚本 本目录保留运行时资产迁移脚本、FDW 配置、建库脚本。
2026-02-22 基线重置前的旧迁移已归档至 `_archived/`;之后的新迁移仍在 `migrations/` 中。
DDL 基线最近一次刷新2026-03-15(合并了截至 2026-03-09 的全部迁移)。 DDL 基线最近一次刷新:**2026-04-05**(合并了截至 2026-03-31 的全部迁移)。
## 目录结构 ## 目录结构
@@ -13,44 +13,57 @@ DDL 基线最近一次刷新2026-03-15合并了截至 2026-03-09 的全部
db/ db/
├── etl_feiqiu/ ├── etl_feiqiu/
│ ├── schemas/ — 已清空DDL 基线见 docs/database/ddl/etl_feiqiu__*.sql │ ├── schemas/ — 已清空DDL 基线见 docs/database/ddl/etl_feiqiu__*.sql
│ ├── migrations/ — 已清空(全部合并进 DDL 基线,2026-03-15 │ ├── migrations/ — 11 个活跃迁移2026-03-19 ~ 2026-03-31
│ ├── seeds/ — 已清空(合并进对应 DDL 文件末尾) │ ├── seeds/ — 已清空(合并进对应 DDL 文件末尾)
│ └── scripts/ │ └── scripts/
│ └── create_test_db.sql │ └── create_test_db.sql
├── zqyy_app/ ├── zqyy_app/
│ ├── schemas/ — 已清空 │ ├── schemas/ — 已清空
│ ├── migrations/ — 已清空(全部合并进 DDL 基线2026-03-15 │ ├── migrations/ — 8 个活跃迁移2026-03-20 ~ 2026-03-31+ _archived/
│ ├── seeds/ — 已清空(合并进对应 DDL 文件末尾) │ ├── seeds/ — 已清空
│ └── scripts/ │ └── scripts/
│ └── create_test_db.sql │ └── create_test_db.sql
├── fdw/ — FDW 跨库映射配置(正式 + 测试 + 反向) ├── fdw/ — FDW 跨库映射配置
│ ├── setup_fdw.sql │ ├── setup_fdw.sql — 正向zqyy_app → etl_feiqiu.app
│ ├── setup_fdw_test.sql │ ├── setup_fdw_test.sql — 正向(测试环境)
│ ├── setup_fdw_reverse.sql │ ├── setup_fdw_reverse.sql — 反向etl_feiqiu → zqyy_app.member_retention_clue
│ └── setup_fdw_reverse_test.sql │ └── setup_fdw_reverse_test.sql — 反向(测试环境)
└── _archived/ — 归档(旧 DDL + 基线重置前的迁移脚本,仅供历史参考) └── _archived/ — 归档(2026-02-22 基线重置前的迁移,仅供历史参考)
└── ddl_baseline_2026-02-22/ └── ddl_baseline_2026-02-22/
``` ```
## DDL 基线 ## DDL 基线
新建库或 schema diff 请使用: 新建库或 schema diff 请使用 `docs/database/ddl/` 下的文件
- `docs/database/ddl/etl_feiqiu__meta.sql`
- `docs/database/ddl/etl_feiqiu__ods.sql`
- `docs/database/ddl/etl_feiqiu__dwd.sql`
- `docs/database/ddl/etl_feiqiu__core.sql`
- `docs/database/ddl/etl_feiqiu__dws.sql`
- `docs/database/ddl/etl_feiqiu__app.sql`(仅视图)
- `docs/database/ddl/zqyy_app__public.sql`
- `docs/database/ddl/zqyy_app__auth.sql`
- `docs/database/ddl/zqyy_app__biz.sql`
- `docs/database/ddl/fdw.sql`(仅正向映射;反向映射见 `db/fdw/setup_fdw_reverse*.sql`
重新生成:`python scripts/ops/gen_consolidated_ddl.py` | 文件 | 数据库 | Schema | 对象数 |
|------|--------|--------|--------|
| `etl_feiqiu__meta.sql` | etl_feiqiu | meta | 3 表 |
| `etl_feiqiu__ods.sql` | etl_feiqiu | ods | 23 表 |
| `etl_feiqiu__dwd.sql` | etl_feiqiu | dwd | 42 表 |
| `etl_feiqiu__core.sql` | etl_feiqiu | core | 7 表 |
| `etl_feiqiu__dws.sql` | etl_feiqiu | dws | 38 表 |
| `etl_feiqiu__app.sql` | etl_feiqiu | app | 仅视图 |
| `zqyy_app__public.sql` | zqyy_app | public | 12 表 |
| `zqyy_app__auth.sql` | zqyy_app | auth | 9 表 |
| `zqyy_app__biz.sql` | zqyy_app | biz | 21 表 |
| `fdw.sql` | — | — | 正向 FDW 映射 |
| `fdw_reverse.sql` | — | — | 反向 FDW 映射 |
重新生成:`PYTHONUTF8=1 python tools/db/gen_consolidated_ddl.py`
## 迁移管理 ## 迁移管理
项目 1.0 尚未上线DDL 基线已统一到 `docs/database/ddl/`(含种子数据)。 项目 1.0 尚未上线DDL 基线已统一到 `docs/database/ddl/`(含种子数据)。
后续新增迁移脚本放 `migrations/`,文件名格式 `YYYY-MM-DD__描述.sql` 后续新增迁移脚本放 `migrations/`,文件名格式 `YYYY-MM-DD__描述.sql`
每次迁移执行后,重新运行 DDL 生成脚本刷新基线`python scripts/ops/gen_consolidated_ddl.py` 每次迁移执行后,重新运行 DDL 生成脚本刷新基线
种子数据已合并进对应 DDL 文件末尾,不再单独维护 `seeds/` 目录。 种子数据已合并进对应 DDL 文件末尾,不再单独维护 `seeds/` 目录。
## 文档分工
| 位置 | 管辖范围 |
|------|---------|
| `docs/database/` | 业务库(zqyy_app) BD_Manual + 跨模块(FDW/RLS) + DDL 基线 |
| `apps/etl/connectors/feiqiu/docs/database/` | ETL 专属(ODS/DWD/DWS 表级文档) |
| `db/`(本目录) | 运行时资产(迁移脚本/FDW 配置/建库脚本) |
| `tools/db/` | 数据库工具(DDL 生成/验证/一致性检查) |

View File

@@ -1,22 +1,26 @@
# BD_Manualbiz Schema AI 表(对话记录 + 消息 + 缓存) # BD_Manualbiz Schema AI 表(对话记录 + 消息 + 缓存 + 运行日志 + 调度记录
> 目标库:`test_zqyy_app`(通过 `APP_DB_DSN` 连接) > 目标库:`test_zqyy_app`(通过 `APP_DB_DSN` 连接)
> 迁移脚本: > 迁移脚本:
> - `db/zqyy_app/migrations/2026-03-08__create_ai_tables.sql`(初始建表) > - `db/zqyy_app/migrations/2026-03-08__create_ai_tables.sql`(初始建表)
> - `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`RNS1.4 CHAT 扩展) > - `db/zqyy_app/migrations/2026-03-20__rns14_chat_module_extend.sql`RNS1.4 CHAT 扩展)
> 关联 SPEC`05-miniapp-ai-integration`P5 AI 集成层)、`rns1-chat-integration`RNS1.4 CHAT 对齐与联调收尾 > - `db/zqyy_app/migrations/2026-03-22__p14_ai_module.sql`P14 DashScope 迁移 + 调度器完善
> - `db/zqyy_app/migrations/2026-03-23__p15_ai_monitoring.sql`P15 AI 监控后台 — alert_status + BRIN 索引)
> 关联 SPEC`05-miniapp-ai-integration`P5 AI 集成层)、`rns1-chat-integration`RNS1.4 CHAT 对齐与联调收尾)、`P14-ai-dashscope-migration`P14 DashScope 迁移)、`ai-monitoring-testing`P15 AI 监控后台)
--- ---
## 1. 变更说明 ## 1. 变更说明
### 新增表(3 张P5 初始建表 ### 新增表(5 张
| # | 表名 | 用途 | 字段数(初始→当前) | | # | 表名 | 用途 | 字段数(初始→当前) | 来源 |
|---|------|------|---------------------| |---|------|------|---------------------|------|
| 1 | `biz.ai_conversations` | AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条 | 8 → 13 | | 1 | `biz.ai_conversations` | AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条 | 8 → 14 | P5 |
| 2 | `biz.ai_messages` | AI 消息记录:对话中的每条消息(输入/输出/系统) | 6 → 7 | | 2 | `biz.ai_messages` | AI 消息记录:对话中的每条消息(输入/输出/系统) | 6 → 7 | P5 |
| 3 | `biz.ai_cache` | AI 应用缓存:各应用的结构化输出结果 | 9 | | 3 | `biz.ai_cache` | AI 应用缓存:各应用的结构化输出结果 | 9 → 10 | P5 |
| 4 | `biz.ai_run_logs` | **P14 新增** — AI 运行记录:每次 DashScope API 调用的详细日志 | 14 → 15 | P14, P15 |
| 5 | `biz.ai_trigger_jobs` | **P14 新增** — 调度运行记录:每次 AI 事件触发的编排执行记录 | 13 | P14 |
### RNS1.4 CHAT 模块扩展字段2026-03-20 ### RNS1.4 CHAT 模块扩展字段2026-03-20
@@ -25,9 +29,26 @@
| 1 | `biz.ai_conversations` | `context_type`, `context_id`, `title`, `last_message`, `last_message_at` | 多入口对话复用 + 历史列表展示与排序 | | 1 | `biz.ai_conversations` | `context_type`, `context_id`, `title`, `last_message`, `last_message_at` | 多入口对话复用 + 历史列表展示与排序 |
| 2 | `biz.ai_messages` | `reference_card` | 引用卡片 JSON客户概览等结构化上下文数据 | | 2 | `biz.ai_messages` | `reference_card` | 引用卡片 JSON客户概览等结构化上下文数据 |
### P14 DashScope 迁移扩展字段2026-03-22
| # | 表名 | 新增字段 | 用途 |
|---|------|---------|------|
| 1 | `biz.ai_conversations` | `session_id` | 百炼 session_id格式 `conv_{id}_{ts}`),仅 App1 使用 |
| 2 | `biz.ai_cache` | `status` | 缓存状态valid / expired / invalidated / generating |
### P15 AI 监控后台扩展2026-03-23
| # | 表名 | 变更类型 | 说明 |
|---|------|---------|------|
| 1 | `biz.ai_run_logs` | 新增字段 `alert_status` | 告警处理状态NULL / pending / acknowledged / ignored |
| 2 | `biz.ai_run_logs` | 新增约束 `chk_ai_run_logs_alert_status` | CHECK (alert_status IS NULL OR alert_status IN ('pending', 'acknowledged', 'ignored')) |
| 3 | `biz.ai_run_logs` | 新增部分索引 `idx_ai_run_logs_alert` | (alert_status, created_at DESC) WHERE status IN ('failed', 'timeout', 'circuit_open') — 告警列表查询 |
| 4 | `biz.ai_run_logs` | 新增 BRIN 索引 `idx_ai_run_logs_created_brin` | BRIN (created_at) WITH (pages_per_range = 32) — Dashboard 聚合优化 |
| 5 | `biz.ai_run_logs` | 回填 | 已有 status IN ('failed','timeout','circuit_open') 的记录 alert_status 设为 'pending' |
### 表字段明细 ### 表字段明细
#### biz.ai_conversations13 字段) #### biz.ai_conversations14 字段)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -39,11 +60,12 @@
| `source_page` | VARCHAR(100) | 可空 | 来源页面标识 | | `source_page` | VARCHAR(100) | 可空 | 来源页面标识 |
| `source_context` | JSONB | 可空 | 页面上下文 JSON | | `source_context` | JSONB | 可空 | 页面上下文 JSON |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| `context_type` | VARCHAR(20) | 可空 | **RNS1.4 新增** — 对话关联上下文类型task(任务)/ customer(客户)/ coach(助教)/ general(通用) | | `context_type` | VARCHAR(20) | 可空 | **RNS1.4 新增** — 对话关联上下文类型task / customer / coach / general |
| `context_id` | VARCHAR(50) | 可空 | **RNS1.4 新增** — 关联上下文 IDtask 入口为 taskIdcustomer 入口为 customerIdcoach 入口为 coachIdgeneral 为 NULL | | `context_id` | VARCHAR(50) | 可空 | **RNS1.4 新增** — 关联上下文 ID |
| `title` | VARCHAR(200) | 可空 | **RNS1.4 新增** — 对话标题:自定义 > 上下文名称 > 首条消息前20字 | | `title` | VARCHAR(200) | 可空 | **RNS1.4 新增** — 对话标题 |
| `last_message` | TEXT | 可空 | **RNS1.4 新增** — 最后一条消息内容摘要截断至100字 | | `last_message` | TEXT | 可空 | **RNS1.4 新增** — 最后一条消息内容摘要截断至100字 |
| `last_message_at` | TIMESTAMPTZ | 可空 | **RNS1.4 新增** — 最后消息时间,用于历史列表排序和对话复用时限判断 | | `last_message_at` | TIMESTAMPTZ | 可空 | **RNS1.4 新增** — 最后消息时间 |
| `session_id` | VARCHAR(100) | 可空 | **P14 新增** — 百炼 session_id格式 `conv_{conversation_id}_{created_timestamp}`,仅 App1 使用 |
#### biz.ai_messages7 字段) #### biz.ai_messages7 字段)
@@ -57,7 +79,7 @@
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| `reference_card` | JSONB | 可空 | **RNS1.4 新增** — 引用卡片 JSON`{type, title, summary, data}`,用于展示客户概览等结构化上下文数据 | | `reference_card` | JSONB | 可空 | **RNS1.4 新增** — 引用卡片 JSON`{type, title, summary, data}`,用于展示客户概览等结构化上下文数据 |
#### biz.ai_cache9 字段) #### biz.ai_cache10 字段)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -70,6 +92,7 @@
| `triggered_by` | VARCHAR(100) | 可空 | 触发来源标识 | | `triggered_by` | VARCHAR(100) | 可空 | 触发来源标识 |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| `expires_at` | TIMESTAMPTZ | 可空 | 可选过期时间 | | `expires_at` | TIMESTAMPTZ | 可空 | 可选过期时间 |
| `status` | VARCHAR(20) | DEFAULT 'valid', CHECK | **P14 新增** — 缓存状态valid有效/ expired已过期/ invalidated手动失效/ generating生成中 |
### cache_type 枚举值与 target_id 约定 ### cache_type 枚举值与 target_id 约定
@@ -89,14 +112,62 @@
|----|-----------|------|------| |----|-----------|------|------|
| `ai_conversations` | `idx_ai_conv_user_site` | INDEX | `(user_id, site_id, created_at DESC)` — 用户历史对话列表查询 | | `ai_conversations` | `idx_ai_conv_user_site` | INDEX | `(user_id, site_id, created_at DESC)` — 用户历史对话列表查询 |
| `ai_conversations` | `idx_ai_conv_app_site` | INDEX | `(app_id, site_id, created_at DESC)` — 按应用查询对话 | | `ai_conversations` | `idx_ai_conv_app_site` | INDEX | `(app_id, site_id, created_at DESC)` — 按应用查询对话 |
| `ai_conversations` | `idx_ai_conv_context` | INDEX条件 | **RNS1.4**`(user_id, site_id, context_type, context_id, last_message_at DESC) WHERE context_type IS NOT NULL` |
| `ai_conversations` | `idx_ai_conv_last_msg` | INDEX | **RNS1.4**`(user_id, site_id, last_message_at DESC NULLS LAST)` |
| `ai_messages` | FK `conversation_id` | FK | → `biz.ai_conversations(id)` ON DELETE CASCADE | | `ai_messages` | FK `conversation_id` | FK | → `biz.ai_conversations(id)` ON DELETE CASCADE |
| `ai_messages` | `chk_ai_msg_role` | CHECK | `role IN ('user', 'assistant', 'system')` | | `ai_messages` | `chk_ai_msg_role` | CHECK | `role IN ('user', 'assistant', 'system')` |
| `ai_messages` | `idx_ai_msg_conv` | INDEX | `(conversation_id, created_at)` — 对话消息列表 | | `ai_messages` | `idx_ai_msg_conv` | INDEX | `(conversation_id, created_at)` — 对话消息列表 |
| `ai_cache` | `chk_ai_cache_type` | CHECK | 7 个枚举值 | | `ai_cache` | `chk_ai_cache_type` | CHECK | 7 个枚举值 |
| `ai_cache` | `chk_ai_cache_status` | CHECK | **P14**`status IN ('valid', 'expired', 'invalidated', 'generating')` |
| `ai_cache` | `idx_ai_cache_lookup` | INDEX | `(cache_type, site_id, target_id, created_at DESC)` — 查询最新缓存 | | `ai_cache` | `idx_ai_cache_lookup` | INDEX | `(cache_type, site_id, target_id, created_at DESC)` — 查询最新缓存 |
| `ai_cache` | `idx_ai_cache_cleanup` | INDEX | `(cache_type, site_id, target_id, created_at)` — 清理超限记录ASC 排序便于删除最旧) | | `ai_cache` | `idx_ai_cache_cleanup` | INDEX | `(cache_type, site_id, target_id, created_at)` — 清理超限记录 |
| `ai_conversations` | `idx_ai_conv_context` | INDEX(条件) | **RNS1.4 新增**`(user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE context_type IS NOT NULL` — 上下文对话查找(多入口复用) | | `ai_run_logs` | `idx_ai_run_logs_site_app` | INDEX | **P14**`(site_id, app_type)` |
| `ai_conversations` | `idx_ai_conv_last_msg` | INDEX | **RNS1.4 新增**`(user_id, site_id, last_message_at DESC NULLS LAST)` — 历史列表排序优化CHAT-1 倒序) | | `ai_run_logs` | `idx_ai_run_logs_created` | INDEX | **P14**`(created_at)` — Token 预算聚合 |
| `ai_run_logs` | `idx_ai_run_logs_status` | INDEX | **P14**`(status)` |
| `ai_run_logs` | `chk_ai_run_logs_alert_status` | CHECK | **P15**`alert_status IS NULL OR alert_status IN ('pending', 'acknowledged', 'ignored')` |
| `ai_run_logs` | `idx_ai_run_logs_alert` | INDEX部分 | **P15**`(alert_status, created_at DESC) WHERE status IN ('failed', 'timeout', 'circuit_open')` — 告警列表查询优化 |
| `ai_run_logs` | `idx_ai_run_logs_created_brin` | BRIN INDEX | **P15**`BRIN (created_at) WITH (pages_per_range = 32)` — Dashboard 聚合查询优化,适合按时间顺序插入的表 |
| `ai_trigger_jobs` | `idx_ai_trigger_jobs_site` | INDEX | **P14**`(site_id, event_type)` |
| `ai_trigger_jobs` | `idx_ai_trigger_jobs_dedup` | INDEX条件 | **P14**`(event_type, member_id, site_id, created_at) WHERE status NOT IN ('skipped_duplicate')` — 去重 |
| `ai_trigger_jobs` | `idx_ai_trigger_jobs_status` | INDEX | **P14**`(status)` |
#### biz.ai_run_logs15 字段P14 新增 + P15 扩展)
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
| `app_type` | VARCHAR(30) | NOT NULL | 应用类型app1_chat / app2_finance / ... / app8_consolidate |
| `trigger_type` | VARCHAR(20) | NOT NULL | 触发类型user / scheduled / event / forced |
| `member_id` | BIGINT | 可空 | 会员 ID |
| `request_prompt` | TEXT | 可空 | 请求 prompt截断前 2000 字符) |
| `response_text` | TEXT | 可空 | 响应文本 |
| `tokens_used` | INTEGER | DEFAULT 0 | 消耗 token 数 |
| `latency_ms` | INTEGER | 可空 | 调用延迟(毫秒) |
| `status` | VARCHAR(20) | NOT NULL DEFAULT 'pending' | 状态pending / running / success / failed / timeout / budget_exceeded |
| `error_message` | TEXT | 可空 | 错误信息 |
| `session_id` | VARCHAR(100) | 可空 | 百炼 session_id仅 App1 |
| `alert_status` | VARCHAR(20) | DEFAULT NULL, CHECK | **P15 新增** — 告警处理状态NULL非告警记录/ pending待处理/ acknowledged已确认/ ignored已忽略。仅 status IN ('failed','timeout','circuit_open') 的记录才设置此字段 |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT now() | 创建时间 |
| `finished_at` | TIMESTAMPTZ | 可空 | 完成时间 |
#### biz.ai_trigger_jobs13 字段P14 新增)
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
| `event_type` | VARCHAR(30) | NOT NULL | 事件类型consumption / dws_completed / note_created / task_assigned |
| `connector_type` | VARCHAR(30) | DEFAULT 'feiqiu' | 连接器类型 |
| `member_id` | BIGINT | 可空 | 会员 ID |
| `payload` | JSONB | 可空 | 附加数据 |
| `status` | VARCHAR(20) | NOT NULL DEFAULT 'pending' | 状态pending / running / completed / failed / skipped_duplicate / budget_exceeded |
| `is_forced` | BOOLEAN | DEFAULT false | 是否强制执行(跳过去重检查) |
| `app_chain` | VARCHAR(100) | 可空 | 调用链描述,如 `app3→app8→app7` |
| `started_at` | TIMESTAMPTZ | 可空 | 开始时间 |
| `finished_at` | TIMESTAMPTZ | 可空 | 完成时间 |
| `error_message` | TEXT | 可空 | 错误信息 |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT now() | 创建时间 |
--- ---
@@ -104,19 +175,56 @@
| 组件 | 影响 | | 组件 | 影响 |
|------|------| |------|------|
| ETL 任务 | 无影响。AI 表属于 `biz` Schema不参与 ETL 流程 | | ETL 任务 | **P14 新增**DWS 任务完成后通过 `utils/ai_trigger.py` 发送 HTTP 触发事件到后端 `ai_trigger_jobs`,失败不中断 ETL 流程 |
| 后端 API | 直接依赖。P5 AI 模块(`apps/backend/app/ai/`基于这三张表实现对话持久化、缓存读写、SSE 流式对话等功能 | | 后端 API | 直接依赖。P5 AI 模块(`apps/backend/app/ai/`)基于这表实现对话持久化、缓存读写、SSE 流式对话等功能 |
| 后端 APIRNS1.4 | **直接依赖**。CHAT 模块`apps/backend/app/routers/xcx_chat.py``apps/backend/app/services/chat_service.py`)依赖 `ai_conversations` 的 5 个新字段(`context_type`/`context_id`/`title`/`last_message`/`last_message_at`)实现多入口对话复用、历史列表展示与排序;依赖 `ai_messages.reference_card` 存储引用卡片 JSON | | 后端 APIRNS1.4 | **直接依赖**。CHAT 模块依赖 `ai_conversations` 的 5 个 RNS1.4 字段实现多入口对话复用 |
| 后端 APIP14 | **直接依赖**`ai_run_logs` 用于 Token 预算聚合BudgetTracker`ai_trigger_jobs` 用于事件去重和调度记录;`ai_conversations.session_id` 用于百炼会话管理;`ai_cache.status` 用于缓存状态控制 |
| 小程序 | 间接依赖。小程序通过后端 AI API 间接使用对话和缓存数据 | | 小程序 | 间接依赖。小程序通过后端 AI API 间接使用对话和缓存数据 |
| 小程序RNS1.4 | **接依赖**`pages/chat/chat.ts``pages/chat-history/chat-history.ts` 通过 CHAT-1/2/3/4 端点间接依赖新字段(`title`→对话标题、`lastMessage`→摘要、`timestamp`→排序、`referenceCard`→引用卡片渲染) | | 管理后台 | **P15 直接依赖**admin-web AI 监控后台4 个页面)依赖 `ai_run_logs.alert_status` 实现告警管理(确认/忽略),依赖 BRIN 索引优化 Dashboard 聚合查询性能 |
| 管理后台 | 暂无影响。后续可能增加 AI 调用统计和缓存管理界面 | | `member_retention_clue` | 间接关联。App8 结果同时写入 `ai_cache``member_retention_clue`P14 实现幂等 DELETE+INSERT |
| `member_retention_clue` | 间接关联。App8维客线索整理的结果同时写入 `ai_cache``member_retention_clue` 表 |
| 现有 `biz` Schema | 兼容。P5 新增 3 张表RNS1.4 仅在已有表上 ADD COLUMN / CREATE INDEX不修改已有字段或约束 |
--- ---
## 3. 回滚策略 ## 3. 回滚策略
### 3d. 回滚 P15 AI 监控后台2026-03-23 迁移)
按逆序 DROP 新增索引、约束和字段:
```sql
BEGIN;
DROP INDEX IF EXISTS biz.idx_ai_run_logs_created_brin;
DROP INDEX IF EXISTS biz.idx_ai_run_logs_alert;
ALTER TABLE biz.ai_run_logs DROP CONSTRAINT IF EXISTS chk_ai_run_logs_alert_status;
ALTER TABLE biz.ai_run_logs DROP COLUMN IF EXISTS alert_status;
COMMIT;
```
注意:
- 回滚后 admin-web AI 监控后台的告警管理功能将不可用
- Dashboard 聚合查询性能可能下降(失去 BRIN 索引)
- 回滚不影响 P14 的核心功能(调度器、预算追踪等)
### 3c. 回滚 P14 DashScope 迁移2026-03-22 迁移)
按逆序 DROP 新增表和字段:
```sql
-- 删除 P14 新增约束和字段
ALTER TABLE biz.ai_cache DROP CONSTRAINT IF EXISTS chk_ai_cache_status;
ALTER TABLE biz.ai_cache DROP COLUMN IF EXISTS status;
ALTER TABLE biz.ai_conversations DROP COLUMN IF EXISTS session_id;
-- 删除 P14 新增表
DROP TABLE IF EXISTS biz.ai_trigger_jobs;
DROP TABLE IF EXISTS biz.ai_run_logs;
```
注意:
- 回滚后 P14 AI 调度器dispatcher、Token 预算追踪BudgetTracker将无法正常工作
- `ai_run_logs``ai_trigger_jobs` 中的数据将丢失,需先备份
- 回滚不影响 P5 和 RNS1.4 的功能
### 3a. 回滚 RNS1.4 CHAT 扩展2026-03-20 迁移) ### 3a. 回滚 RNS1.4 CHAT 扩展2026-03-20 迁移)
按逆序 DROP 新增索引和字段: 按逆序 DROP 新增索引和字段:
@@ -185,7 +293,7 @@ SELECT column_name, data_type, is_nullable
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_conversations' WHERE table_schema = 'biz' AND table_name = 'ai_conversations'
ORDER BY ordinal_position; ORDER BY ordinal_position;
-- 预期:返回 13P5 原始 8 字段 + RNS1.4 新增 5 字段 -- 预期:返回 14P5 原始 8 + RNS1.4 新增 5 + P14 session_id = 14
-- 3. 验证 ai_messages 的外键和 CHECK 约束 -- 3. 验证 ai_messages 的外键和 CHECK 约束
SELECT conname, contype, pg_get_constraintdef(oid) AS constraint_def SELECT conname, contype, pg_get_constraintdef(oid) AS constraint_def
@@ -198,7 +306,7 @@ ORDER BY conname;
SELECT conname, pg_get_constraintdef(oid) AS constraint_def SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint FROM pg_constraint
WHERE conrelid = 'biz.ai_cache'::regclass AND contype = 'c'; WHERE conrelid = 'biz.ai_cache'::regclass AND contype = 'c';
-- 预期:返回 1 chk_ai_cache_type,包含 7 个枚举值 -- 预期:返回 2chk_ai_cache_type 含 7 个枚举值chk_ai_cache_status 含 4 个状态值)
-- 5. 验证 P5 初始索引全部存在5 个) -- 5. 验证 P5 初始索引全部存在5 个)
SELECT indexname SELECT indexname
@@ -244,3 +352,101 @@ WHERE schemaname = 'biz' AND tablename = 'ai_conversations'
-- idx_ai_conv_context — 含 WHERE context_type IS NOT NULL 条件 -- idx_ai_conv_context — 含 WHERE context_type IS NOT NULL 条件
-- idx_ai_conv_last_msg — (user_id, site_id, last_message_at DESC NULLS LAST) -- idx_ai_conv_last_msg — (user_id, site_id, last_message_at DESC NULLS LAST)
``` ```
### 4c. P14 DashScope 迁移验证
```sql
-- 9. 验证 ai_run_logs 表存在且字段正确
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_run_logs'
ORDER BY ordinal_position;
-- 预期:返回 15 行
-- 10. 验证 ai_trigger_jobs 表存在且字段正确
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_trigger_jobs'
ORDER BY ordinal_position;
-- 预期:返回 13 行
-- 11. 验证 ai_run_logs 索引(含 PK
SELECT indexname FROM pg_indexes
WHERE schemaname = 'biz' AND tablename = 'ai_run_logs';
-- 预期6 行PK + 3 P14 索引 + 2 P15 索引)
-- 12. 验证 ai_trigger_jobs 索引(含去重部分索引)
SELECT indexname FROM pg_indexes
WHERE schemaname = 'biz' AND tablename = 'ai_trigger_jobs';
-- 预期4 行PK + 3 个索引)
-- 13. 验证 ai_conversations.session_id 字段
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_conversations'
AND column_name = 'session_id';
-- 预期1 行varchar(100)
-- 14. 验证 ai_cache.status 字段 + CHECK 约束
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_cache'
AND column_name = 'status';
-- 预期1 行varchar(20)default 'valid'
SELECT conname FROM pg_constraint
WHERE conrelid = 'biz.ai_cache'::regclass AND conname = 'chk_ai_cache_status';
-- 预期1 行
```
### 4d. P15 AI 监控后台验证
```sql
-- 15. 验证 ai_run_logs.alert_status 字段存在
SELECT column_name, data_type, character_maximum_length, column_default
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'ai_run_logs'
AND column_name = 'alert_status';
-- 预期1 行varchar(20)default NULL
-- 16. 验证 alert_status CHECK 约束
SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid = 'biz.ai_run_logs'::regclass
AND conname = 'chk_ai_run_logs_alert_status';
-- 预期1 行CHECK (alert_status IS NULL OR alert_status IN ('pending', 'acknowledged', 'ignored'))
-- 17. 验证 P15 新增索引(部分索引 + BRIN 索引)
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'biz' AND tablename = 'ai_run_logs'
AND indexname IN ('idx_ai_run_logs_alert', 'idx_ai_run_logs_created_brin');
-- 预期2 行
-- idx_ai_run_logs_alert — 含 WHERE status IN ('failed', 'timeout', 'circuit_open')
-- idx_ai_run_logs_created_brin — USING brin
-- 18. 验证 ai_run_logs 总索引数P14 3个 + P15 2个 + PK = 6
SELECT indexname FROM pg_indexes
WHERE schemaname = 'biz' AND tablename = 'ai_run_logs';
-- 预期6 行
-- 19. 验证回填结果:失败/超时/熔断记录的 alert_status 应为 'pending'
SELECT COUNT(*) AS unset_alerts
FROM biz.ai_run_logs
WHERE status IN ('failed', 'timeout', 'circuit_open')
AND alert_status IS NULL;
-- 预期0所有失败记录已回填为 'pending'
```
### P15 admin API 查询模式说明
P15 admin-web AI 监控后台引入以下典型查询模式:
| 查询场景 | SQL 模式 | 使用索引 |
|---------|---------|---------|
| Dashboard 今日统计 | `SELECT COUNT(*), AVG(latency_ms) FROM ai_run_logs WHERE created_at >= 今日零点` | `idx_ai_run_logs_created_brin` |
| Dashboard 7天趋势 | `SELECT date_trunc('day', created_at), COUNT(*) FROM ai_run_logs WHERE created_at >= 7天前 GROUP BY 1` | `idx_ai_run_logs_created_brin` |
| 告警列表 | `SELECT * FROM ai_run_logs WHERE status IN ('failed','timeout','circuit_open') AND alert_status = 'pending' ORDER BY created_at DESC` | `idx_ai_run_logs_alert` |
| 告警确认/忽略 | `UPDATE ai_run_logs SET alert_status = 'acknowledged' WHERE id = ?` | PK |
| 调用记录分页 | `SELECT * FROM ai_run_logs WHERE site_id = ? AND app_type = ? ORDER BY created_at DESC LIMIT ? OFFSET ?` | `idx_ai_run_logs_site_app` |
| Token 预算聚合 | `SELECT SUM(tokens_used) FROM ai_run_logs WHERE created_at >= 今日零点 AND status = 'success'` | `idx_ai_run_logs_created` (B-tree) |

View File

@@ -15,7 +15,7 @@
### 新增角色 ### 新增角色
- `app_reader`:只读角色(`LOGIN`),拥有 `app` Schema 的 `USAGE` + `SELECT` 权限 - `app_reader`:只读角色(`LOGIN`),拥有 `app` Schema 的 `USAGE` + `SELECT` 权限
### 新增视图(39 张) ### 新增视图(49 张)
**DWD 层11 张,全部含 `site_id` 过滤):** **DWD 层11 张,全部含 `site_id` 过滤):**
@@ -48,6 +48,7 @@
| `app.v_dws_assistant_salary_calc` | `dws.dws_assistant_salary_calc` | | `app.v_dws_assistant_salary_calc` | `dws.dws_assistant_salary_calc` |
| `app.v_dws_assistant_customer_stats` | `dws.dws_assistant_customer_stats` | | `app.v_dws_assistant_customer_stats` | `dws.dws_assistant_customer_stats` |
| `app.v_dws_assistant_finance_analysis` | `dws.dws_assistant_finance_analysis` | | `app.v_dws_assistant_finance_analysis` | `dws.dws_assistant_finance_analysis` |
| `app.v_dws_assistant_order_contribution` | `dws.dws_assistant_order_contribution` |
| `app.v_dws_finance_daily_summary` | `dws.dws_finance_daily_summary` | | `app.v_dws_finance_daily_summary` | `dws.dws_finance_daily_summary` |
| `app.v_dws_finance_income_structure` | `dws.dws_finance_income_structure` | | `app.v_dws_finance_income_structure` | `dws.dws_finance_income_structure` |
| `app.v_dws_finance_recharge_summary` | `dws.dws_finance_recharge_summary` | | `app.v_dws_finance_recharge_summary` | `dws.dws_finance_recharge_summary` |
@@ -59,6 +60,9 @@
| `app.v_dws_assistant_project_tag` | `dws.dws_assistant_project_tag` | | `app.v_dws_assistant_project_tag` | `dws.dws_assistant_project_tag` |
| `app.v_dws_member_project_tag` | `dws.dws_member_project_tag` | | `app.v_dws_member_project_tag` | `dws.dws_member_project_tag` |
| `app.v_dws_member_spending_power_index` | `dws.dws_member_spending_power_index` | | `app.v_dws_member_spending_power_index` | `dws.dws_member_spending_power_index` |
| `app.v_dws_coach_area_hours` | `dws.dws_coach_area_hours` |
| `app.v_dws_finance_area_daily` | `dws.dws_finance_area_daily` |
| `app.v_dws_finance_board_cache` | `dws.dws_finance_board_cache` |
**DWS 层 — cfg_* 配置表5 张,无 `site_id`** **DWS 层 — cfg_* 配置表5 张,无 `site_id`**
@@ -70,15 +74,24 @@
| `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` | 同上 | | `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` | 同上 |
| `app.v_cfg_area_category` | `dws.cfg_area_category` | DISTINCT 去重到 category 级别,排除 SPECIAL/OTHER按 sort_order 排序。用于项目类型筛选器CONFIG-1。2026-03-20 新增。 | | `app.v_cfg_area_category` | `dws.cfg_area_category` | DISTINCT 去重到 category 级别,排除 SPECIAL/OTHER按 sort_order 排序。用于项目类型筛选器CONFIG-1。2026-03-20 新增。 |
**快捷别名视图7 张,简化常用查询路径):**
| 视图 | 源视图 | 说明 |
|------|--------|------|
| `app.v_assistant` | `app.v_dim_assistant` | 助教维度快捷别名 |
| `app.v_assistant_daily` | `app.v_dws_assistant_daily_detail` | 助教日明细快捷别名 |
| `app.v_finance_daily` | `app.v_dws_finance_daily_summary` | 财务日汇总快捷别名 |
| `app.v_member` | `app.v_dim_member` | 会员维度快捷别名 |
| `app.v_member_consumption` | `app.v_dws_member_consumption_summary` | 会员消费汇总快捷别名 |
| `app.v_order_summary` | `app.v_dws_order_summary` | 订单汇总快捷别名 |
| `app.v_site` | `dwd.dim_site` | 门店维度快捷别名 |
### 权限配置 ### 权限配置
| 角色 | Schema | 权限 | | 角色 | Schema | 权限 |
|------|--------|------| |------|--------|------|
| `app_reader` | `app` | `USAGE` + `SELECT ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES` | | `app_reader` | `app` | `USAGE` + `SELECT ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES` |
### P2 预留(注释形式,暂不创建)
- `dws.dws_assistant_order_contribution``app.v_dws_assistant_order_contribution`
> `v_dws_member_spending_power_index`、`v_dws_assistant_project_tag`、`v_dws_member_project_tag` 已于 2026-03-19 正式创建(迁移脚本 `2026-03-19_add_board_rls_views.sql`)。 > `v_dws_member_spending_power_index`、`v_dws_assistant_project_tag`、`v_dws_member_project_tag` 已于 2026-03-19 正式创建(迁移脚本 `2026-03-19_add_board_rls_views.sql`)。
> `v_dws_finance_recharge_summary` 已于 2026-03-20 重建,新增 6 个赠送卡细分字段(`gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`、`gift_liquor_recharge`、`gift_table_fee_recharge`、`gift_voucher_recharge`)。迁移脚本:`db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql`。关联 SPEC`gift-card-breakdown`。 > `v_dws_finance_recharge_summary` 已于 2026-03-20 重建,新增 6 个赠送卡细分字段(`gift_liquor_balance`、`gift_table_fee_balance`、`gift_voucher_balance`、`gift_liquor_recharge`、`gift_table_fee_recharge`、`gift_voucher_recharge`)。迁移脚本:`db/zqyy_app/migrations/2026-03-20_rebuild_rls_view_gift_breakdown.sql`。关联 SPEC`gift-card-breakdown`。
@@ -119,7 +132,7 @@ DROP ROLE IF EXISTS app_reader;
-- 1. 验证 app Schema 存在 -- 1. 验证 app Schema 存在
SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'app'; SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'app';
-- 2. 验证视图数量(应为 39 张:原 35 + 2026-03-19 新增 3 + 2026-03-20 新增 1 -- 2. 验证视图数量(应为 49 张:11 DWD + 26 DWS + 5 cfg + 7 快捷别名
SELECT count(*) FROM information_schema.views WHERE table_schema = 'app'; SELECT count(*) FROM information_schema.views WHERE table_schema = 'app';
-- 3. 验证 app_reader 角色存在且有 app Schema 权限 -- 3. 验证 app_reader 角色存在且有 app Schema 权限

View File

@@ -4,24 +4,27 @@
> 迁移脚本: > 迁移脚本:
> - `db/zqyy_app/migrations/2026-02-25__p3_create_auth_tables.sql`(建表) > - `db/zqyy_app/migrations/2026-02-25__p3_create_auth_tables.sql`(建表)
> - `db/zqyy_app/migrations/2026-02-25__p3_seed_roles_permissions.sql`(种子数据) > - `db/zqyy_app/migrations/2026-02-25__p3_seed_roles_permissions.sql`(种子数据)
> - `db/zqyy_app/migrations/2026-03-23__add_head_coach_manager_roles.sql`(新增 head_coach、manager 角色及权限映射)
> - `db/zqyy_app/migrations/2026-03-23__add_rejection_count_and_cancelled_status.sql`申请审核流程增强rejection_count + cancelled 状态)
> 关联 SPEC`miniapp-auth-system`P3 小程序用户认证系统) > 关联 SPEC`miniapp-auth-system`P3 小程序用户认证系统)
--- ---
## 1. 变更说明 ## 1. 变更说明
### 新增表(8 张) ### 新增表(9 张)
| # | 表名 | 用途 | 主要字段 | | # | 表名 | 用途 | 主要字段 |
|---|------|------|---------| |---|------|------|---------|
| 1 | `auth.users` | 微信用户主表 | `id`(PK), `wx_openid`(UK), `wx_union_id`, `wx_avatar_url`, `nickname`, `phone`, `status`(默认 `pending`), `created_at`, `updated_at` | | 1 | `auth.users` | 微信用户主表 | `id`(PK), `wx_openid`(UK), `wx_union_id`, `wx_avatar_url`, `nickname`, `phone`, `avatar_url`, `status`(默认 `new`), `rejection_count`(默认 0累计被拒次数), `created_at`, `updated_at` |
| 2 | `auth.user_applications` | 用户入驻申请表 | `id`(PK), `user_id`(FK→users), `site_code`, `site_id`, `applied_role_text`, `employee_number`, `phone`, `status`(默认 `pending`), `reviewer_id`, `review_note`, `created_at`, `reviewed_at` | | 2 | `auth.user_applications` | 用户入驻申请表 | `id`(PK), `user_id`(FK→users), `site_code`, `site_id`, `applied_role_text`, `employee_number`, `phone`, `status`(默认 `pending`,可选值: pending/approved/rejected/cancelled), `reviewer_id`, `review_note`, `created_at`, `reviewed_at` |
| 3 | `auth.site_code_mapping` | 球房ID与门店映射表 | `id`(PK), `site_code`(UK), `site_id`(UK), `site_name`, `tenant_id`, `created_at` | | 3 | `auth._archived_site_code_mapping` | [已废弃] 球房ID与门店映射表NS4.1 重命名,替代方案:`biz.sites` + `biz.site_code_history` | `id`(PK), `site_code`(UK), `site_id`(UK), `site_name`, `tenant_id`, `created_at` |
| 4 | `auth.roles` | 角色定义表 | `id`(PK), `code`(UK), `name`, `description`, `created_at` | | 4 | `auth.roles` | 角色定义表 | `id`(PK), `code`(UK), `name`, `description`, `created_at` |
| 5 | `auth.permissions` | 权限定义表 | `id`(PK), `code`(UK), `name`, `description`, `created_at` | | 5 | `auth.permissions` | 权限定义表 | `id`(PK), `code`(UK), `name`, `description`, `created_at` |
| 6 | `auth.role_permissions` | 角色-权限关联表 | `role_id`(FK→roles), `permission_id`(FK→permissions),联合主键 | | 6 | `auth.role_permissions` | 角色-权限关联表 | `role_id`(FK→roles), `permission_id`(FK→permissions),联合主键 |
| 7 | `auth.user_site_roles` | 用户-门店-角色关联表 | `id`(PK), `user_id`(FK→users), `site_id`, `role_id`(FK→roles), `created_at``(user_id, site_id, role_id)` 唯一约束 | | 7 | `auth.user_site_roles` | 用户-门店-角色关联表 | `id`(PK), `user_id`(FK→users), `site_id`, `role_id`(FK→roles), `created_at``(user_id, site_id, role_id)` 唯一约束 |
| 8 | `auth.user_assistant_binding` | 用户-人员绑定表 | `id`(PK), `user_id`(FK→users), `site_id`, `assistant_id`(可空), `staff_id`(可空), `binding_type`, `created_at` | | 8 | `auth.user_assistant_binding` | 用户-人员绑定表 | `id`(PK), `user_id`(FK→users), `site_id`, `assistant_id`(可空), `staff_id`(可空), `binding_type`, `created_at` |
| 9 | `auth.tenant_admins` | 租户管理员表(详见 `BD_Manual_tenant_admin_tables.md` | `id`(PK), `username`(UK), `password_hash`, `display_name`, `tenant_id`, `managed_site_ids`, `is_active`, `deleted_at`, `created_by`, `created_at`, `last_login_at` |
### 约束与索引 ### 约束与索引
@@ -29,14 +32,15 @@
|----|-----------|------|------| |----|-----------|------|------|
| `users` | `uq_users_wx_openid` | UNIQUE | 微信 openid 唯一 | | `users` | `uq_users_wx_openid` | UNIQUE | 微信 openid 唯一 |
| `users` | `ix_users_wx_openid` | INDEX | openid 查询加速 | | `users` | `ix_users_wx_openid` | INDEX | openid 查询加速 |
| `site_code_mapping` | `uq_site_code_mapping_site_code` | UNIQUE | 球房ID 唯一 | | `site_code_mapping` | `uq_site_code_mapping_site_code` | UNIQUE | 球房ID 唯一(已废弃,表已重命名为 `_archived_site_code_mapping` |
| `site_code_mapping` | `uq_site_code_mapping_site_id` | UNIQUE | site_id 唯一映射 | | `site_code_mapping` | `uq_site_code_mapping_site_id` | UNIQUE | site_id 唯一映射(已废弃) |
| `site_code_mapping` | `ix_site_code_mapping_site_code` | INDEX | site_code 查询加速 | | `site_code_mapping` | `ix_site_code_mapping_site_code` | INDEX | site_code 查询加速(已废弃) |
| `roles` | `uq_roles_code` | UNIQUE | 角色 code 唯一 | | `roles` | `uq_roles_code` | UNIQUE | 角色 code 唯一 |
| `permissions` | `uq_permissions_code` | UNIQUE | 权限 code 唯一 | | `permissions` | `uq_permissions_code` | UNIQUE | 权限 code 唯一 |
| `role_permissions` | PK `(role_id, permission_id)` | PRIMARY KEY | 联合主键 | | `role_permissions` | PK `(role_id, permission_id)` | PRIMARY KEY | 联合主键 |
| `role_permissions` | `fk_role_permissions_role_id` | FK | → `auth.roles(id)` CASCADE | | `role_permissions` | `fk_role_permissions_role_id` | FK | → `auth.roles(id)` CASCADE |
| `role_permissions` | `fk_role_permissions_permission_id` | FK | → `auth.permissions(id)` CASCADE | | `role_permissions` | `fk_role_permissions_permission_id` | FK | → `auth.permissions(id)` CASCADE |
| `user_applications` | `user_applications_status_check` | CHECK | status IN ('pending', 'approved', 'rejected', 'cancelled') |
| `user_applications` | `fk_user_applications_user_id` | FK | → `auth.users(id)` CASCADE | | `user_applications` | `fk_user_applications_user_id` | FK | → `auth.users(id)` CASCADE |
| `user_applications` | `ix_user_applications_user_id` | INDEX | user_id 查询加速 | | `user_applications` | `ix_user_applications_user_id` | INDEX | user_id 查询加速 |
| `user_applications` | `ix_user_applications_status` | INDEX | status 过滤加速 | | `user_applications` | `ix_user_applications_status` | INDEX | status 过滤加速 |
@@ -65,17 +69,21 @@
|------|------|-------------| |------|------|-------------|
| `coach` | 助教 | 球房助教,可查看任务和助教看板 | | `coach` | 助教 | 球房助教,可查看任务和助教看板 |
| `staff` | 员工 | 球房员工,可查看任务和数据看板 | | `staff` | 员工 | 球房员工,可查看任务和数据看板 |
| `site_admin` | 店铺管理员 | 单店管理员,可查看所有看板 | | `head_coach` | 教练 | 主教练,负责训练助教,可查看任务和全部看板 |
| `tenant_admin` | 租户管理员 | 租户级管理员,拥有全部权限 | | `manager` | 管理员 | 球房管理员,可查看任务和全部看板,未来将与 staff 进一步区分权限 |
#### 角色-权限映射14 条) > 注:`site_admin` 和 `tenant_admin` 已于 2026-03-23 从小程序 RBAC 体系中清理。租户/店铺管理员的区分通过 `auth.tenant_admins.admin_type` 列实现,不依赖 `auth.roles` 表。
#### 角色-权限映射11 条)
| 角色 | 权限列表 | 权限数 | | 角色 | 权限列表 | 权限数 |
|------|---------|--------| |------|---------|--------|
| `coach` | `view_tasks`, `view_board_coach` | 2 | | `coach` | `view_tasks`, `view_board_coach` | 2 |
| `staff` | `view_tasks`, `view_board` | 2 | | `staff` | `view_tasks`, `view_board` | 2 |
| `site_admin` | `view_tasks`, `view_board`, `view_board_finance`, `view_board_customer`, `view_board_coach` | 5 | | `head_coach` | `view_tasks`, `view_board` | 2 |
| `tenant_admin` | `view_tasks`, `view_board`, `view_board_finance`, `view_board_customer`, `view_board_coach` | 5 | | `manager` | `view_tasks`, `view_board`, `view_board_finance`, `view_board_customer`, `view_board_coach` | 5 |
> 注:`site_admin`5 条)和 `tenant_admin`5 条)的权限映射已于 2026-03-23 删除,角色体系隔离后总映射从 24 条减为 11 条head_coach 仅分配 view_tasks + view_board非全部 5 个权限)。详见迁移脚本 `2026-03-23__cleanup_roles_add_admin_type.sql`。
--- ---
@@ -100,10 +108,10 @@
```sql ```sql
-- 先删除种子数据(如需保留表结构) -- 先删除种子数据(如需保留表结构)
DELETE FROM auth.role_permissions DELETE FROM auth.role_permissions
WHERE role_id IN (SELECT id FROM auth.roles WHERE code IN ('coach', 'staff', 'site_admin', 'tenant_admin')) WHERE role_id IN (SELECT id FROM auth.roles WHERE code IN ('coach', 'staff', 'head_coach', 'manager'))
AND permission_id IN (SELECT id FROM auth.permissions WHERE code IN ('view_tasks', 'view_board', 'view_board_finance', 'view_board_customer', 'view_board_coach')); AND permission_id IN (SELECT id FROM auth.permissions WHERE code IN ('view_tasks', 'view_board', 'view_board_finance', 'view_board_customer', 'view_board_coach'));
DELETE FROM auth.roles WHERE code IN ('coach', 'staff', 'site_admin', 'tenant_admin'); DELETE FROM auth.roles WHERE code IN ('coach', 'staff', 'head_coach', 'manager');
DELETE FROM auth.permissions WHERE code IN ('view_tasks', 'view_board', 'view_board_finance', 'view_board_customer', 'view_board_coach'); DELETE FROM auth.permissions WHERE code IN ('view_tasks', 'view_board', 'view_board_finance', 'view_board_customer', 'view_board_coach');
-- 删除表按逆序CASCADE 处理外键依赖) -- 删除表按逆序CASCADE 处理外键依赖)
@@ -113,7 +121,7 @@ DROP TABLE IF EXISTS auth.user_applications CASCADE;
DROP TABLE IF EXISTS auth.role_permissions CASCADE; DROP TABLE IF EXISTS auth.role_permissions CASCADE;
DROP TABLE IF EXISTS auth.permissions CASCADE; DROP TABLE IF EXISTS auth.permissions CASCADE;
DROP TABLE IF EXISTS auth.roles CASCADE; DROP TABLE IF EXISTS auth.roles CASCADE;
DROP TABLE IF EXISTS auth.site_code_mapping CASCADE; DROP TABLE IF EXISTS auth._archived_site_code_mapping CASCADE;
DROP TABLE IF EXISTS auth.users CASCADE; DROP TABLE IF EXISTS auth.users CASCADE;
``` ```
@@ -124,17 +132,17 @@ DROP TABLE IF EXISTS auth.users CASCADE;
## 4. 验证 SQL ## 4. 验证 SQL
```sql ```sql
-- 1. 验证 auth Schema 下 8 张认证表全部存在 -- 1. 验证 auth Schema 下 9 张认证表全部存在
SELECT table_name SELECT table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = 'auth' WHERE table_schema = 'auth'
AND table_name IN ( AND table_name IN (
'users', 'user_applications', 'site_code_mapping', 'users', 'user_applications', '_archived_site_code_mapping',
'roles', 'permissions', 'role_permissions', 'roles', 'permissions', 'role_permissions',
'user_site_roles', 'user_assistant_binding' 'user_site_roles', 'user_assistant_binding', 'tenant_admins'
) )
ORDER BY table_name; ORDER BY table_name;
-- 预期:返回 8 -- 预期:返回 9
-- 2. 验证种子数据5 条权限 -- 2. 验证种子数据5 条权限
SELECT COUNT(*) AS perm_count SELECT COUNT(*) AS perm_count
@@ -142,28 +150,111 @@ FROM auth.permissions
WHERE code IN ('view_tasks', 'view_board', 'view_board_finance', 'view_board_customer', 'view_board_coach'); WHERE code IN ('view_tasks', 'view_board', 'view_board_finance', 'view_board_customer', 'view_board_coach');
-- 预期5 -- 预期5
-- 3. 验证种子数据4 条角色 -- 3. 验证种子数据4 条角色site_admin/tenant_admin 已于 2026-03-23 删除)
SELECT COUNT(*) AS role_count SELECT COUNT(*) AS role_count
FROM auth.roles FROM auth.roles
WHERE code IN ('coach', 'staff', 'site_admin', 'tenant_admin'); WHERE code IN ('coach', 'staff', 'head_coach', 'manager');
-- 预期4 -- 预期4
-- 4. 验证角色-权限映射数量 -- 4. 验证角色-权限映射数量11 条)
SELECT r.code AS role_code, COUNT(rp.permission_id) AS perm_count SELECT r.code AS role_code, COUNT(rp.permission_id) AS perm_count
FROM auth.roles r FROM auth.roles r
JOIN auth.role_permissions rp ON r.id = rp.role_id JOIN auth.role_permissions rp ON r.id = rp.role_id
GROUP BY r.code GROUP BY r.code
ORDER BY r.code; ORDER BY r.code;
-- 预期coach=2, site_admin=5, staff=2, tenant_admin=5共 14 条映射 -- 预期coach=2, head_coach=2, manager=5, staff=2共 11 条映射,无 site_admin/tenant_admin
-- 5. 验证关键约束存在 -- 5. 验证关键约束存在
SELECT conname, contype SELECT conname, contype
FROM pg_constraint FROM pg_constraint
WHERE conrelid IN ( WHERE conrelid IN (
'auth.users'::regclass, 'auth.users'::regclass,
'auth.site_code_mapping'::regclass, 'auth._archived_site_code_mapping'::regclass,
'auth.user_site_roles'::regclass 'auth.user_site_roles'::regclass
) )
ORDER BY conrelid::regclass::text, conname; ORDER BY conrelid::regclass::text, conname;
-- 预期:包含 uq_users_wx_openid、uq_site_code_mapping_site_code、uq_site_code_mapping_site_id、uq_user_site_roles_user_site_role 等 -- 预期:包含 uq_users_wx_openid、uq_site_code_mapping_site_code(已废弃表 _archived_site_code_mapping、uq_site_code_mapping_site_id、uq_user_site_roles_user_site_role 等
``` ```
---
---
## 5. 变更记录2026-03-23 申请审核流程增强
### 5.1 变更说明
| 对象 | 变更类型 | 说明 |
|------|---------|------|
| `auth.users.rejection_count` | 新增字段 | `integer NOT NULL DEFAULT 0`,累计被管理员拒绝的申请次数,达到 3 次自动将 `status` 设为 `disabled` |
| `auth.user_applications.status` CHECK 约束 | 修改 | 新增 `'cancelled'` 可选值(用户主动取消申请),约束名 `user_applications_status_check` |
### 5.2 业务规则
- 管理员拒绝申请时:`rejection_count += 1`,第 3 次自动将 `users.status` 设为 `disabled`
- 用户主动取消(`cancelled`)不计入 `rejection_count`
- `cancelled` 状态的申请不在管理端申请列表中显示
- `disabled` 用户不允许重新申请,需管理员手动解除(功能待开发)
### 5.3 兼容性影响
| 组件 | 影响 |
|------|------|
| ETL | 无影响,不涉及 ETL 库 |
| 后端 API | 直接依赖。`reject_application` 增加 rejection_count 累加逻辑;新增 `cancel_application` 服务和 `/api/xcx/cancel-application` 端点;`/api/xcx/me` 返回 `latestApplication` 详情 |
| 小程序 | 间接依赖。reviewing 页展示申请详情+重新申请按钮no-permission 页区分 rejected/disabled 状态 |
| 管理后台 | 间接依赖。拒绝端点自动累加 rejection_count 并触发禁用;申请列表排除 cancelled |
### 5.4 回滚策略
```sql
-- 回滚 rejection_count 字段
ALTER TABLE auth.users DROP COLUMN IF EXISTS rejection_count;
-- 回滚 status CHECK 约束(恢复为不含 cancelled
ALTER TABLE auth.user_applications DROP CONSTRAINT IF EXISTS user_applications_status_check;
ALTER TABLE auth.user_applications
ADD CONSTRAINT user_applications_status_check
CHECK (status IN ('pending', 'approved', 'rejected'));
-- 注意:回滚前需确认无 status='cancelled' 的记录,否则约束添加会失败
-- UPDATE auth.user_applications SET status = 'pending' WHERE status = 'cancelled';
```
### 5.5 验证 SQL
```sql
-- 1. 验证 rejection_count 字段存在且默认值正确
SELECT column_name, data_type, column_default, is_nullable
FROM information_schema.columns
WHERE table_schema = 'auth' AND table_name = 'users' AND column_name = 'rejection_count';
-- 预期integer, 0, NO
-- 2. 验证 CHECK 约束包含 cancelled
SELECT conname, pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE conrelid = 'auth.user_applications'::regclass
AND conname = 'user_applications_status_check';
-- 预期:包含 'cancelled'
-- 3. 验证现有数据 rejection_count 默认值
SELECT COUNT(*) AS users_with_zero_rejection
FROM auth.users
WHERE rejection_count = 0;
-- 预期:等于 users 表总行数(所有现有用户默认 0
-- 4. 验证 cancelled 状态可正常写入dry-run 验证)
-- INSERT INTO auth.user_applications (user_id, site_code, site_id, applied_role_text, phone, status)
-- VALUES (1, 'TEST', 1, '测试', '13800000000', 'cancelled');
-- 预期:不报 CHECK 约束错误
```
---
<!-- AI_CHANGELOG
| 日期 | Prompt | 变更 |
|------|--------|------|
| 2026-03-23 | 角色路由+页面权限守卫 | 更新角色表从 4 条到 6 条,新增 head_coach/manager 角色及权限映射,更新验证 SQL 和回滚策略 |
| 2026-03-23 | 申请审核流程增强 | users 新增 rejection_count 字段user_applications.status CHECK 约束增加 cancelled新增第 5 节变更记录 |
| 2026-03-23 | 角色体系隔离+店铺管理员 | auth.roles 删除 site_admin/tenant_admin小程序 RBAC 不需要);角色从 6 条减为 4 条role_permissions 从 24 条减为 11 条head_coach 仅 view_tasks+view_board |
-->

View File

@@ -4,24 +4,28 @@
> 迁移脚本: > 迁移脚本:
> - `db/zqyy_app/migrations/2026-02-27__p4_create_biz_tables.sql`(建表) > - `db/zqyy_app/migrations/2026-02-27__p4_create_biz_tables.sql`(建表)
> - `db/zqyy_app/migrations/2026-02-27__p4_seed_trigger_jobs.sql`(种子数据) > - `db/zqyy_app/migrations/2026-02-27__p4_seed_trigger_jobs.sql`(种子数据)
> 关联 SPEC`04-miniapp-core-business`P4 小程序核心业务模块 > - `db/zqyy_app/migrations/2026-03-24__p17_task_engine_ownership.sql`P17 客户归属与转移
> - `db/zqyy_app/migrations/2026-03-24__p18_task_engine_dashboard.sql`P18 运营看板字段扩展)
> 关联 SPEC`04-miniapp-core-business`P4、`P17-assistant-ownership-task-engine`P17、`P18-admin-task-engine-dashboard`P18
--- ---
## 1. 变更说明 ## 1. 变更说明
### 新增表(4 张) ### 新增表(6 张)
| # | 表名 | 用途 | 字段数 | | # | 表名 | 用途 | 字段数 | 来源 |
|---|------|------|--------| |---|------|------|--------|------|
| 1 | `biz.coach_tasks` | 助教任务表:存储任务分配、状态、有效期、置顶、放弃原因等 | 15 | | 1 | `biz.coach_tasks` | 助教任务表:存储任务分配、状态、有效期、置顶、放弃原因、转移追踪等 | 18 | P4+P17 |
| 2 | `biz.coach_task_history` | 任务变更历史表:记录任务关闭/新建/置顶/放弃的追溯链 | 9 | | 2 | `biz.coach_task_history` | 任务变更历史表:记录任务关闭/新建/置顶/放弃/转移的追溯链 | 9 | P4 |
| 3 | `biz.notes` | 统一备注表:通过 `type` 字段区分普通备注/回访备注/放弃原因,含星星评分 | 15 | | 3 | `biz.notes` | 统一备注表:通过 `type` 字段区分普通备注/回访备注/放弃原因,含星星评分 | 15 | P4 |
| 4 | `biz.trigger_jobs` | 触发器配置表:存储 cron/interval/event 三种触发方式的配置与执行状态 | 9 | | 4 | `biz.trigger_jobs` | 触发器配置表:存储 cron/interval/event 三种触发方式的配置与执行状态 | 12 | P4+P18+P23 |
| 5 | `biz.cfg_task_generator_params` | 任务引擎参数配置表:支持全局默认 + 门店级覆盖 | 7 | P17+P18 |
| 6 | `biz.coach_task_transfer_log` | 客户转移日志表:记录每次转移的完整上下文 | 11 | P17 |
### 表字段明细 ### 表字段明细
#### biz.coach_tasks15 字段) #### biz.coach_tasks18 字段)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -30,7 +34,7 @@
| `assistant_id` | BIGINT | NOT NULL | 助教 ID | | `assistant_id` | BIGINT | NOT NULL | 助教 ID |
| `member_id` | BIGINT | NOT NULL | 客户 ID | | `member_id` | BIGINT | NOT NULL | 客户 ID |
| `task_type` | VARCHAR(50) | NOT NULL | 任务类型:`high_priority_recall` / `priority_recall` / `follow_up_visit` / `relationship_building` | | `task_type` | VARCHAR(50) | NOT NULL | 任务类型:`high_priority_recall` / `priority_recall` / `follow_up_visit` / `relationship_building` |
| `status` | VARCHAR(20) | NOT NULL DEFAULT 'active' | 状态:`active` / `inactive` / `completed` / `abandoned` | | `status` | VARCHAR(20) | NOT NULL DEFAULT 'active' | 状态:`active` / `inactive` / `completed` / `abandoned` / `transferred`P17/ `pending_review`P17 |
| `priority_score` | NUMERIC(5,2) | 可空 | 优先级分数,取 `max(WBI, NCI)` 快照 | | `priority_score` | NUMERIC(5,2) | 可空 | 优先级分数,取 `max(WBI, NCI)` 快照 |
| `expires_at` | TIMESTAMPTZ | 可空 | 有效期时间戳NULL 表示无限期 | | `expires_at` | TIMESTAMPTZ | 可空 | 有效期时间戳NULL 表示无限期 |
| `is_pinned` | BOOLEAN | DEFAULT FALSE | 是否置顶 | | `is_pinned` | BOOLEAN | DEFAULT FALSE | 是否置顶 |
@@ -38,6 +42,9 @@
| `completed_at` | TIMESTAMPTZ | 可空 | 完成时间 | | `completed_at` | TIMESTAMPTZ | 可空 | 完成时间 |
| `completed_task_type` | VARCHAR(50) | 可空 | 完成时的任务类型快照 | | `completed_task_type` | VARCHAR(50) | 可空 | 完成时的任务类型快照 |
| `parent_task_id` | BIGINT | FK → `biz.coach_tasks(id)`,可空 | 父任务 ID自引用 | | `parent_task_id` | BIGINT | FK → `biz.coach_tasks(id)`,可空 | 父任务 ID自引用 |
| `transfer_count` | INTEGER | NOT NULL DEFAULT 0 | 该客户在此任务链上的累计转移次数P17 新增) |
| `transferred_from` | BIGINT | FK → `biz.coach_tasks(id)`,可空 | 转移来源任务 IDP17 新增) |
| `transferred_at` | TIMESTAMPTZ | 可空 | 转移发生时间P17 新增) |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 | | `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
@@ -47,7 +54,7 @@
|------|------|------|------| |------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 | | `id` | BIGSERIAL | PK | 自增主键 |
| `task_id` | BIGINT | NOT NULL, FK → `biz.coach_tasks(id)` | 关联任务 | | `task_id` | BIGINT | NOT NULL, FK → `biz.coach_tasks(id)` | 关联任务 |
| `action` | VARCHAR(50) | NOT NULL | 操作类型:`created` / `type_changed` / `pinned` / `abandoned` / `cancel_abandon` / `expired` / `completed` | | `action` | VARCHAR(50) | NOT NULL | 操作类型:`created` / `type_changed` / `type_change_close` / `pinned` / `abandoned` / `cancel_abandon` / `expired` / `completed` / `expires_at_filled` / `transferred_out`P17/ `transferred_in`P17 |
| `old_status` | VARCHAR(20) | 可空 | 变更前状态 | | `old_status` | VARCHAR(20) | 可空 | 变更前状态 |
| `new_status` | VARCHAR(20) | 可空 | 变更后状态 | | `new_status` | VARCHAR(20) | 可空 | 变更后状态 |
| `old_task_type` | VARCHAR(50) | 可空 | 变更前任务类型 | | `old_task_type` | VARCHAR(50) | 可空 | 变更前任务类型 |
@@ -75,20 +82,53 @@
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 | | `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
| `score` | SMALLINT | CHECK (1-5),可空 | 备注星星评分,助教创建备注时可选填写,不参与 AI 分析RNS1.1 新增) | | `score` | SMALLINT | CHECK (1-5),可空 | 备注星星评分,助教创建备注时可选填写,不参与 AI 分析RNS1.1 新增) |
#### biz.trigger_jobs9 字段) #### biz.trigger_jobs12 字段)
| 字段 | 类型 | 约束 | 说明 | | 字段 | 类型 | 约束 | 说明 |
|------|------|------|------| |------|------|------|------|
| `id` | SERIAL | PK | 自增主键 | | `id` | SERIAL | PK | 自增主键 |
| `job_type` | VARCHAR(100) | NOT NULL | 任务类型标识,映射到 Python handler | | `job_type` | VARCHAR(100) | NOT NULL | 任务类型标识,映射到 Python handler`_JOB_REGISTRY` 注册键) |
| `job_name` | VARCHAR(100) | NOT NULL, UNIQUE | 任务名称(唯一) | | `job_name` | VARCHAR(100) | NOT NULL, UNIQUE | 任务名称(唯一标识,如 `task_generator` |
| `trigger_condition` | VARCHAR(20) | NOT NULL | 触发方式:`cron` / `interval` / `event` | | `trigger_condition` | VARCHAR(20) | NOT NULL | 触发方式:`cron` / `interval` / `event` |
| `trigger_config` | JSONB | NOT NULL | 触发配置cron 表达式 / 间隔秒数 / 事件名) | | `trigger_config` | JSONB | NOT NULL | 触发配置cron 表达式 / 间隔秒数 / 事件名) |
| `last_run_at` | TIMESTAMPTZ | 可空 | 上次运行时间 | | `last_run_at` | TIMESTAMPTZ | 可空 | 上次运行时间 |
| `next_run_at` | TIMESTAMPTZ | 可空 | 下次运行时间event 类型为 NULL | | `next_run_at` | TIMESTAMPTZ | 可空 | 下次运行时间event 类型为 NULL |
| `status` | VARCHAR(20) | NOT NULL DEFAULT 'enabled' | 状态:`enabled` / `disabled` | | `status` | VARCHAR(20) | NOT NULL DEFAULT 'enabled' | 状态:`enabled` / `disabled` |
| `description` | TEXT | 可空 | 任务中文描述管理后台页面展示P23 新增) |
| `last_error` | TEXT | 可空 | 最后一次执行异常的错误信息,成功后清空为 NULLP23 新增) |
| `last_stats` | JSONB | 可空 | 最近一次执行的统计结果 JSON`{"created":5,"replaced":2,"skipped":10,"transferred":1}`P18 新增) |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 | | `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
#### biz.cfg_task_generator_params7 字段P17+P18
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `site_id` | BIGINT | 可空 | NULL=全局默认非NULL=门店级覆盖 |
| `param_key` | VARCHAR(64) | NOT NULL | 参数键名 |
| `param_value` | NUMERIC | NOT NULL | 参数值 |
| `description` | TEXT | 可空 | 参数说明 |
| `updated_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 |
| `updated_by` | BIGINT | 可空 | 最近修改人 user_id用于审计追溯P18 新增) |
继承链:代码默认 → 全局默认site_id IS NULL→ 门店覆盖site_id = ?
#### biz.coach_task_transfer_log11 字段P17 新增)
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
| `member_id` | BIGINT | NOT NULL | 客户 ID |
| `from_assistant_id` | BIGINT | NOT NULL | 原助教 ID |
| `to_assistant_id` | BIGINT | NOT NULL | 新助教 ID |
| `from_task_id` | BIGINT | NOT NULL, FK → `biz.coach_tasks(id)` | 原任务 ID |
| `to_task_id` | BIGINT | FK → `biz.coach_tasks(id)`,可空 | 新任务 ID |
| `transfer_reason` | TEXT | 可空 | 转移原因描述 |
| `guard_checks` | JSONB | 可空 | 三重保护检查结果快照 |
| `transfer_score` | NUMERIC | 可空 | 转移候选得分 |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
### 约束与索引 ### 约束与索引
| 表 | 约束/索引名 | 类型 | 说明 | | 表 | 约束/索引名 | 类型 | 说明 |
@@ -96,6 +136,7 @@
| `coach_tasks` | `idx_coach_tasks_site_assistant_member_type` | UNIQUE INDEX (partial) | `(site_id, assistant_id, member_id, task_type) WHERE status = 'active'`,保证同一组合下活跃任务最多一条 | | `coach_tasks` | `idx_coach_tasks_site_assistant_member_type` | UNIQUE INDEX (partial) | `(site_id, assistant_id, member_id, task_type) WHERE status = 'active'`,保证同一组合下活跃任务最多一条 |
| `coach_tasks` | `idx_coach_tasks_assistant_status` | INDEX | `(site_id, assistant_id, status)`,助教任务列表查询加速 | | `coach_tasks` | `idx_coach_tasks_assistant_status` | INDEX | `(site_id, assistant_id, status)`,助教任务列表查询加速 |
| `coach_tasks` | FK `parent_task_id` | FK | → `biz.coach_tasks(id)`,自引用 | | `coach_tasks` | FK `parent_task_id` | FK | → `biz.coach_tasks(id)`,自引用 |
| `coach_tasks` | FK `fk_coach_tasks_transferred_from` | FK | → `biz.coach_tasks(id)`转移来源任务P17 新增) |
| `coach_task_history` | FK `task_id` | FK | → `biz.coach_tasks(id)` | | `coach_task_history` | FK `task_id` | FK | → `biz.coach_tasks(id)` |
| `notes` | `idx_notes_target` | INDEX | `(site_id, target_type, target_id)`,按目标查询备注加速 | | `notes` | `idx_notes_target` | INDEX | `(site_id, target_type, target_id)`,按目标查询备注加速 |
| `notes` | CHECK `rating_service_willingness` | CHECK | `BETWEEN 1 AND 5` | | `notes` | CHECK `rating_service_willingness` | CHECK | `BETWEEN 1 AND 5` |
@@ -103,6 +144,31 @@
| `notes` | CHECK `score` | CHECK | `score IS NULL OR (score >= 1 AND score <= 5)`RNS1.1 新增) | | `notes` | CHECK `score` | CHECK | `score IS NULL OR (score >= 1 AND score <= 5)`RNS1.1 新增) |
| `notes` | FK `task_id` | FK | → `biz.coach_tasks(id)` | | `notes` | FK `task_id` | FK | → `biz.coach_tasks(id)` |
| `trigger_jobs` | UNIQUE `job_name` | UNIQUE | 触发器名称唯一 | | `trigger_jobs` | UNIQUE `job_name` | UNIQUE | 触发器名称唯一 |
| `cfg_task_generator_params` | UNIQUE `(site_id, param_key)` | UNIQUE | 全局+门店级参数唯一约束P17 新增) |
| `coach_task_transfer_log` | FK `from_task_id` | FK | → `biz.coach_tasks(id)`P17 新增) |
| `coach_task_transfer_log` | FK `to_task_id` | FK | → `biz.coach_tasks(id)`P17 新增) |
| `coach_task_transfer_log` | `idx_transfer_log_site_created` | INDEX | `(site_id, created_at DESC)`P17 新增) |
| `coach_task_transfer_log` | `idx_transfer_log_member` | INDEX | `(member_id, created_at DESC)`P17 新增) |
### P17 种子数据13 条任务引擎参数)
| param_key | param_value | description |
|-----------|-------------|-------------|
| `high_priority_recall_threshold` | 7.0 | max(WBI,NCI) 超过此值生成高优先召回 |
| `priority_recall_threshold` | 5.0 | max(WBI,NCI) 超过此值生成优先召回 |
| `rs_min_for_relationship` | 1.0 | RS ≤ 此值不生成关系构建 |
| `rs_max_for_relationship` | 6.0 | RS ≥ 此值不生成关系构建 |
| `consecutive_recall_fail_cycles` | 3 | 连续失败多少轮触发客户转移 |
| `min_wbi_for_transfer` | 5.0 | WBI 低于此值不触发转移 |
| `guard_assistant_coverage_ratio` | 0.5 | 绑定率低于此值禁用转移 |
| `guard_new_assistant_days` | 10 | 新助教入驻保护天数 |
| `transfer_score_w_rs` | 0.5 | 转移候选排序RS 权重 |
| `transfer_score_w_ms` | 0.3 | 转移候选排序MS 权重 |
| `transfer_score_w_ml` | 0.2 | 转移候选排序ML 权重 |
| `max_transfer_count` | 2 | 单客户最大累计转移次数 |
| `follow_up_visit_retention_hours` | 48 | 回访任务最低保留时长(小时) |
> 所有参数 `site_id IS NULL`(全局默认),门店可通过插入 `site_id = ?` 的行覆盖。
### 种子数据4 条触发器配置) ### 种子数据4 条触发器配置)
@@ -120,13 +186,13 @@
| 组件 | 影响 | | 组件 | 影响 |
|------|------| |------|------|
| ETL 任务 | 无直接影响。`biz` Schema 表不参与 ETL 流程,但任务生成器通过 FDW 只读访问 ETL 库的 WBI/NCI/RS 指数数据 | | ETL 任务 | 无直接影响。`biz` Schema 表不参与 ETL 流程,但任务生成器通过 FDW 只读访问 ETL 库的 WBI/NCI/RS 指数数据 |
| 后端 API | 直接依赖。FastAPI 后端将基于这些表实现任务 CRUD`/api/xcx/tasks`)、备注 CRUD`/api/xcx/notes`)、触发器调度等功能 | | 后端 API | 直接依赖。FastAPI 后端将基于这些表实现任务 CRUD`/api/xcx/tasks`)、备注 CRUD`/api/xcx/notes`)、触发器调度等功能。P17 新增:`task_generator.py` 完全重写,入口改为 OS 归属对;`fdw_queries.py` 新增 4 个批量查询方法 |
| 小程序 | 间接依赖。小程序通过后端 API 间接使用任务列表、备注功能 | | 小程序 | 间接依赖。小程序通过后端 API 间接使用任务列表、备注功能 |
| 管理后台 | 暂无影响。后续可能增加任务监控和触发器管理界面 | | 管理后台 | P18 已实施。`admin_task_engine` router 提供 9 个端点(转移日志分页+历史、待审核任务分页+重新分配+关闭、参数管理 CRUD前端 3 个页面TransferLog/PendingReview/TaskEngineConfig通过 `taskEngine.ts` API 层调用 |
| FDW 配置 | 无影响。`fdw_etl` Schema 独立于 `biz`,任务生成器和召回检测器通过 FDW 只读查询 ETL 库 | | FDW 配置 | 无影响。`fdw_etl` Schema 独立于 `biz`,任务生成器和召回检测器通过 FDW 只读查询 ETL 库 |
| `auth` Schema | 间接依赖。任务生成器通过 `auth.user_assistant_binding` 确定助教与小程序用户的映射关系 | | `auth` Schema | 间接依赖。P17 仍通过 `auth.user_assistant_binding` 获取 site_ids 和助教绑定信息(转移保护检查) |
| `public` Schema | 无影响。`member_retention_clue` 表独立于本次变更 | | `public` Schema | 无影响。`member_retention_clue` 表独立于本次变更 |
| 现有 `biz` Schema | 兼容。`biz` Schema 已由 P1 迁移脚本创建,本次仅在其中新增 4 张表,不修改已有对象 | | 现有 `biz` Schema | 兼容。`coach_tasks` 新增 3 字段均有默认值(`transfer_count DEFAULT 0`,其余可空),不影响现有查询。新增 `transferred`/`pending_review` 状态值7 个下游模块均使用显式 status 过滤,不会误匹配 |
--- ---
@@ -170,6 +236,8 @@ RNS1.2(客户与助教接口)新增 3 个端点,引用了以下 biz/public
## 3. 回滚策略 ## 3. 回滚策略
### P4 回滚(原始 4 张表)
按逆序 `DROP TABLE IF EXISTS CASCADE`(迁移脚本末尾已包含注释形式的回滚语句): 按逆序 `DROP TABLE IF EXISTS CASCADE`(迁移脚本末尾已包含注释形式的回滚语句):
```sql ```sql
@@ -194,6 +262,32 @@ DROP TABLE IF EXISTS biz.coach_task_history CASCADE;
DROP TABLE IF EXISTS biz.coach_tasks CASCADE; DROP TABLE IF EXISTS biz.coach_tasks CASCADE;
``` ```
### P17 回滚(增量变更)
```sql
-- 1. 删除 P17 种子数据
DELETE FROM biz.cfg_task_generator_params WHERE site_id IS NULL;
-- 2. 删除 P17 新增表
DROP TABLE IF EXISTS biz.coach_task_transfer_log;
DROP TABLE IF EXISTS biz.cfg_task_generator_params;
-- 3. 删除 P17 新增字段
ALTER TABLE biz.coach_tasks DROP COLUMN IF EXISTS transfer_count;
ALTER TABLE biz.coach_tasks DROP COLUMN IF EXISTS transferred_from;
ALTER TABLE biz.coach_tasks DROP COLUMN IF EXISTS transferred_at;
-- 注意enum 值transferred/pending_review一旦添加无法直接删除需重建类型
```
### P18 回滚(字段扩展)
```sql
-- 删除 P18 新增字段
ALTER TABLE biz.trigger_jobs DROP COLUMN IF EXISTS last_stats;
ALTER TABLE biz.cfg_task_generator_params DROP COLUMN IF EXISTS updated_by;
```
注意: 注意:
- `CASCADE` 会级联删除依赖对象(外键引用的子表数据) - `CASCADE` 会级联删除依赖对象(外键引用的子表数据)
- 如果表中已有业务数据,需先备份再执行回滚 - 如果表中已有业务数据,需先备份再执行回滚
@@ -204,52 +298,90 @@ DROP TABLE IF EXISTS biz.coach_tasks CASCADE;
## 4. 验证 SQL ## 4. 验证 SQL
```sql ```sql
-- 1. 验证 biz Schema 下 4 张业务表全部存在 -- 1. 验证 biz Schema 下 6 张业务表全部存在
SELECT table_name SELECT table_name
FROM information_schema.tables FROM information_schema.tables
WHERE table_schema = 'biz' WHERE table_schema = 'biz'
AND table_name IN ('coach_tasks', 'coach_task_history', 'notes', 'trigger_jobs') AND table_name IN ('coach_tasks', 'coach_task_history', 'notes', 'trigger_jobs',
'cfg_task_generator_params', 'coach_task_transfer_log')
ORDER BY table_name; ORDER BY table_name;
-- 预期:返回 4coach_task_history, coach_tasks, notes, trigger_jobs -- 预期:返回 6
-- 2. 验证 coach_tasks 表字段数量和关键字段 -- 2. 验证 coach_tasks 表字段数量P4 原 15 + P17 新增 3 = 18
SELECT column_name, data_type, is_nullable, column_default SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'coach_tasks' WHERE table_schema = 'biz' AND table_name = 'coach_tasks'
ORDER BY ordinal_position; ORDER BY ordinal_position;
-- 预期:返回 15 行,包含 id/site_id/assistant_id/member_id/task_type/status 等 -- 预期:返回 18 行,包含 transfer_count/transferred_from/transferred_at
-- 3. 验证部分唯一索引存在 -- 3. 验证 P17 新增字段的默认值和约束
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'coach_tasks'
AND column_name IN ('transfer_count', 'transferred_from', 'transferred_at');
-- 预期transfer_count: integer, NOT NULL, DEFAULT 0
-- transferred_from: bigint, YES (nullable)
-- transferred_at: timestamp with time zone, YES (nullable)
-- 4. 验证 cfg_task_generator_params 表结构
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'cfg_task_generator_params'
ORDER BY ordinal_position;
-- 预期6 行id, site_id, param_key, param_value, description, updated_at
-- 5. 验证 P17 种子数据13 条全局默认参数)
SELECT param_key, param_value, description
FROM biz.cfg_task_generator_params
WHERE site_id IS NULL
ORDER BY param_key;
-- 预期:返回 13 行
-- 6. 验证 coach_task_transfer_log 表结构
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'coach_task_transfer_log'
ORDER BY ordinal_position;
-- 预期11 行id, site_id, member_id, from_assistant_id, to_assistant_id,
-- from_task_id, to_task_id, transfer_reason, guard_checks, transfer_score, created_at
-- 7. 验证 P17 新增索引
SELECT indexname
FROM pg_indexes
WHERE schemaname = 'biz'
AND indexname IN ('idx_transfer_log_site_created', 'idx_transfer_log_member')
ORDER BY indexname;
-- 预期:返回 2 行
-- 8. 验证 transferred_from 外键约束
SELECT conname
FROM pg_constraint
WHERE conrelid = 'biz.coach_tasks'::regclass
AND conname = 'fk_coach_tasks_transferred_from';
-- 预期:返回 1 行
-- 9. 验证部分唯一索引存在P4 原有)
SELECT indexname, indexdef SELECT indexname, indexdef
FROM pg_indexes FROM pg_indexes
WHERE schemaname = 'biz' AND indexname = 'idx_coach_tasks_site_assistant_member_type'; WHERE schemaname = 'biz' AND indexname = 'idx_coach_tasks_site_assistant_member_type';
-- 预期:返回 1 行indexdef 包含 "WHERE ((status)::text = 'active'::text)" -- 预期:返回 1 行indexdef 包含 "WHERE ((status)::text = 'active'::text)"
-- 4. 验证 notes 表的 CHECK 约束(评分 1-5 -- 10. 验证种子数据4 条触发器配置P4 原有
SELECT conname, pg_get_constraintdef(oid) AS constraint_def SELECT job_name, job_type, trigger_condition
FROM pg_constraint
WHERE conrelid = 'biz.notes'::regclass AND contype = 'c';
-- 预期:返回 2 行,分别约束 rating_service_willingness 和 rating_revisit_likelihood 在 1-5 范围
-- 5. 验证种子数据4 条触发器配置
SELECT job_name, job_type, trigger_condition,
trigger_config->>'cron_expression' AS cron,
trigger_config->>'interval_seconds' AS interval_sec,
trigger_config->>'event_name' AS event
FROM biz.trigger_jobs FROM biz.trigger_jobs
WHERE job_name IN ('task_generator', 'task_expiry_check', 'recall_completion_check', 'note_reclassify_backfill') WHERE job_name IN ('task_generator', 'task_expiry_check', 'recall_completion_check', 'note_reclassify_backfill')
ORDER BY job_name; ORDER BY job_name;
-- 预期:返回 4 行 -- 预期:返回 4 行
-- note_reclassify_backfill | event | recall_completed
-- recall_completion_check | event | etl_data_updated
-- task_expiry_check | interval| interval_seconds=3600
-- task_generator | cron | 0 4 * * *
-- 6. 验证查询索引存在 -- 11. 验证 P18 新增字段trigger_jobs.last_stats
SELECT indexname SELECT column_name, data_type, is_nullable
FROM pg_indexes FROM information_schema.columns
WHERE schemaname = 'biz' WHERE table_schema = 'biz' AND table_name = 'trigger_jobs' AND column_name = 'last_stats';
AND indexname IN ('idx_coach_tasks_assistant_status', 'idx_notes_target') -- 预期:返回 1 行data_type = 'jsonb'is_nullable = 'YES'
ORDER BY indexname;
-- 预期:返回 2 行 -- 12. 验证 P18 新增字段cfg_task_generator_params.updated_by
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'biz' AND table_name = 'cfg_task_generator_params' AND column_name = 'updated_by';
-- 预期:返回 1 行data_type = 'bigint'is_nullable = 'YES'
``` ```

View File

@@ -34,9 +34,11 @@
### 导入的外部表 ### 导入的外部表
通过 `IMPORT FOREIGN SCHEMA app` 批量导入,外部表与 ETL 库 `app` Schema 中的 RLS 视图一一对应(共 38 张,2026-03-19 新增 3 张 BOARD 看板视图 通过 `IMPORT FOREIGN SCHEMA app` 批量导入,外部表与 ETL 库 `app` Schema 中的 RLS 视图一一对应(共 46 张,含 11 DWD + 23 DWS + 5 cfg + 7 快捷别名
- 11 张 DWD 视图:`v_dim_member``v_dim_assistant``v_dim_member_card_account``v_dim_table``v_dwd_settlement_head``v_dwd_table_fee_log``v_dwd_assistant_service_log``v_dwd_recharge_order``v_dwd_store_goods_sale``v_dim_staff``v_dim_staff_ex` - 11 张 DWD 视图:`v_dim_member``v_dim_assistant``v_dim_member_card_account``v_dim_table``v_dwd_settlement_head``v_dwd_table_fee_log``v_dwd_assistant_service_log``v_dwd_recharge_order``v_dwd_store_goods_sale``v_dim_staff``v_dim_staff_ex`
- 27 张 DWS 视图(含 4 张 cfg_* 配置表):`v_dws_member_consumption_summary``v_dws_member_visit_detail` - 23 张 DWS 视图(含 `v_dws_assistant_project_tag``v_dws_member_project_tag``v_dws_member_spending_power_index``v_dws_assistant_order_contribution`
- 5 张 cfg_* 配置表视图(`v_cfg_performance_tier``v_cfg_assistant_level_price``v_cfg_bonus_rules``v_cfg_index_parameters``v_cfg_area_category`
- 7 张快捷别名视图(`v_assistant``v_assistant_daily``v_finance_daily``v_member``v_member_consumption``v_order_summary``v_site`
### 权限配置 ### 权限配置

View File

@@ -217,6 +217,7 @@ SELECT n.nspname AS schema_name, d.defaclacl AS default_acl
| 项目 | 状态 | 说明 | | 项目 | 状态 | 说明 |
|------|------|------| |------|------|------|
| `source` 列缺失 | ⚠️ 待同步 | 业务库侧已有 `source VARCHAR(20) NOT NULL DEFAULT 'manual'`2026-02-27FDW 外部表定义未包含。当前无 ETL 任务需要此字段,但未来如需读取线索来源需先更新外部表 | | `source` 列缺失 | ⚠️ 待同步 | 业务库侧已有 `source VARCHAR(20) NOT NULL DEFAULT 'manual'`2026-02-27FDW 外部表定义未包含。当前无 ETL 任务需要此字段,但未来如需读取线索来源需先更新外部表 |
| `is_hidden` 列缺失 | ⚠️ 待同步 | 业务库侧已有 `is_hidden BOOLEAN NOT NULL DEFAULT false`2026-03-20NS4 迁移FDW 外部表定义未包含。如 ETL 任务需要过滤隐藏线索需先更新外部表 |
| DWS 任务消费 | 📋 待规划 | 原 `member_birthday_manual` 的 DWS 消费逻辑已移除。维客线索的 DWS 聚合任务尚未规划 | | DWS 任务消费 | 📋 待规划 | 原 `member_birthday_manual` 的 DWS 消费逻辑已移除。维客线索的 DWS 聚合任务尚未规划 |
### source 列同步方法(备用) ### source 列同步方法(备用)

View File

@@ -14,6 +14,7 @@
| zqyy_app / test_zqyy_app | public | member_retention_clue | 新建 | 维客线索表 | | zqyy_app / test_zqyy_app | public | member_retention_clue | 新建 | 维客线索表 |
| zqyy_app / test_zqyy_app | public | member_retention_clue.source | 新增列 | 2026-02-27 补齐线索来源字段 | | zqyy_app / test_zqyy_app | public | member_retention_clue.source | 新增列 | 2026-02-27 补齐线索来源字段 |
| zqyy_app / test_zqyy_app | public | member_retention_clue.category | 约束变更 | 2026-03-08 枚举对齐:`客户基础信息``客户基础` | | zqyy_app / test_zqyy_app | public | member_retention_clue.category | 约束变更 | 2026-03-08 枚举对齐:`客户基础信息``客户基础` |
| zqyy_app / test_zqyy_app | public | member_retention_clue.is_hidden | 新增列 | 2026-03-20 NS4 租户管理后台:隐藏/显示控制 |
### 表结构 ### 表结构
@@ -29,6 +30,7 @@
| recorded_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录时间 | | recorded_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 记录时间 |
| site_id | BIGINT | NOT NULL | 门店 ID多门店隔离 | | site_id | BIGINT | NOT NULL | 门店 ID多门店隔离 |
| source | VARCHAR(20) | NOT NULL DEFAULT 'manual' | 线索来源2026-02-27 新增) | | source | VARCHAR(20) | NOT NULL DEFAULT 'manual' | 线索来源2026-02-27 新增) |
| is_hidden | BOOLEAN | NOT NULL DEFAULT false | 是否隐藏true=管理后台保留但小程序不展示2026-03-20 新增) |
### category 枚举值 ### category 枚举值
@@ -65,6 +67,7 @@
- **后端 API**`POST /api/member-birthday` 废弃,替换为 `POST /api/retention-clue``GET /api/retention-clue/{member_id}``DELETE /api/retention-clue/{clue_id}` - **后端 API**`POST /api/member-birthday` 废弃,替换为 `POST /api/retention-clue``GET /api/retention-clue/{member_id}``DELETE /api/retention-clue/{clue_id}`
- **source 字段**2026-02-27`POST /api/retention-clue` 接受可选 `source` 参数,默认 `manual``GET` 返回中包含 `source` 字段。已有数据自动填充 `DEFAULT 'manual'`,向后兼容 - **source 字段**2026-02-27`POST /api/retention-clue` 接受可选 `source` 参数,默认 `manual``GET` 返回中包含 `source` 字段。已有数据自动填充 `DEFAULT 'manual'`,向后兼容
- **is_hidden 字段**2026-03-20 NS4租户管理后台线索隐藏/显示控制。`DEFAULT false` 保证已有数据兼容。小程序端线索查询已追加 `WHERE is_hidden = false` 条件,隐藏线索仅在管理后台可见。租户管理后台路由 `tenant_clues.py` 提供 `PATCH /api/tenant/clues/{id}/visibility` 端点切换状态
- **ETL Connector**DWS 任务移除 FDW 读取 `member_birthday_manual` 的逻辑,生日仅从 `dim_member.birthday`API 来源)读取 - **ETL Connector**DWS 任务移除 FDW 读取 `member_birthday_manual` 的逻辑,生日仅从 `dim_member.birthday`API 来源)读取
- **FDW**`fdw_app.member_birthday_manual` 外部表需在 ETL 库侧同步更新为 `fdw_app.member_retention_clue`(含 `source` 列) - **FDW**`fdw_app.member_birthday_manual` 外部表需在 ETL 库侧同步更新为 `fdw_app.member_retention_clue`(含 `source` 列)
- **小程序**:助教端调用新 API 提交维客线索 - **小程序**:助教端调用新 API 提交维客线索
@@ -102,13 +105,13 @@ SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'member_retention_clue'; WHERE table_schema = 'public' AND table_name = 'member_retention_clue';
-- 预期1 行 -- 预期1 行
-- 3. 确认列结构完整10 列) -- 3. 确认列结构完整11 列)
SELECT column_name, data_type, is_nullable SELECT column_name, data_type, is_nullable
FROM information_schema.columns FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'member_retention_clue' WHERE table_schema = 'public' AND table_name = 'member_retention_clue'
ORDER BY ordinal_position; ORDER BY ordinal_position;
-- 预期id, member_id, category, summary, detail, -- 预期id, member_id, category, summary, detail,
-- recorded_by_assistant_id, recorded_by_name, recorded_at, site_id, source -- recorded_by_assistant_id, recorded_by_name, recorded_at, site_id, source, is_hidden
-- 4. 确认 CHECK 约束 -- 4. 确认 CHECK 约束
SELECT conname FROM pg_constraint SELECT conname FROM pg_constraint
@@ -143,6 +146,23 @@ SELECT col_description(
-- 9. 确认已有数据的 source 分布 -- 9. 确认已有数据的 source 分布
SELECT source, COUNT(*) FROM member_retention_clue GROUP BY source; SELECT source, COUNT(*) FROM member_retention_clue GROUP BY source;
-- 预期:全部为 'manual'(或空表) -- 预期:全部为 'manual'(或空表)
-- 10. 确认 is_hidden 列存在且默认值正确2026-03-20
SELECT column_name, data_type, column_default, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'member_retention_clue'
AND column_name = 'is_hidden';
-- 预期1 行boolean, false, NO
-- 11. 确认已有数据的 is_hidden 分布2026-03-20
SELECT is_hidden, COUNT(*) FROM member_retention_clue GROUP BY is_hidden;
-- 预期:全部为 falseDEFAULT false 保证兼容)
-- 12. 确认列结构完整11 列,含 is_hidden
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'member_retention_clue';
-- 预期11
``` ```
## 关联文件 ## 关联文件
@@ -150,9 +170,11 @@ SELECT source, COUNT(*) FROM member_retention_clue GROUP BY source;
- 迁移脚本(建表):`db/zqyy_app/migrations/2026-02-26__refactor_birthday_to_retention_clue.sql` - 迁移脚本(建表):`db/zqyy_app/migrations/2026-02-26__refactor_birthday_to_retention_clue.sql`
- 迁移脚本source 列):`db/zqyy_app/migrations/2026-02-27__add_source_to_retention_clue.sql` - 迁移脚本source 列):`db/zqyy_app/migrations/2026-02-27__add_source_to_retention_clue.sql`
- 迁移脚本category 枚举对齐):`db/zqyy_app/migrations/2026-03-08__align_retention_clue_category_enum.sql` - 迁移脚本category 枚举对齐):`db/zqyy_app/migrations/2026-03-08__align_retention_clue_category_enum.sql`
- 迁移脚本is_hidden 列):`db/zqyy_app/migrations/2026-03-xx__ns4_member_clue_is_hidden.sql`
- FDW 反向映射(生产):`db/fdw/setup_fdw_reverse.sql` - FDW 反向映射(生产):`db/fdw/setup_fdw_reverse.sql`
- FDW 反向映射(测试):`db/fdw/setup_fdw_reverse_test.sql` - FDW 反向映射(测试):`db/fdw/setup_fdw_reverse_test.sql`
- 后端路由:`apps/backend/app/routers/member_retention_clue.py` - 后端路由:`apps/backend/app/routers/member_retention_clue.py`
- 后端路由(租户管理后台):`apps/backend/app/routers/tenant_clues.py`
- 后端模型:`apps/backend/app/schemas/member_retention_clue.py` - 后端模型:`apps/backend/app/schemas/member_retention_clue.py`
- H5 原型:`docs/h5_ui/pages/customer-detail.html``docs/h5_ui/pages/task-detail.html` - H5 原型:`docs/h5_ui/pages/customer-detail.html``docs/h5_ui/pages/task-detail.html`
- 旧表文档(已归档):`docs/database/_archived/BD_Manual_member_birthday_manual.md` - 旧表文档(已归档):`docs/database/_archived/BD_Manual_member_birthday_manual.md`

View File

@@ -0,0 +1,53 @@
# BD 手册auth.users.avatar_url 字段
## 概述
`auth.users` 表新增 `avatar_url` 字段,存储用户头像的相对路径。
## 字段定义
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `avatar_url` | `VARCHAR(500)` | `NULL` | 头像相对路径,格式 `avatars/{user_id}.jpg` |
## 数据流
1. 小程序端通过 `<button open-type="chooseAvatar">` 获取微信头像临时路径
2. 通过 `wx.uploadFile` 上传到 `POST /api/xcx/avatar/upload`
3. 后端保存文件到 `AVATAR_EXPORT_PATH/{user_id}.jpg`(覆盖式,幂等)
4. 数据库 `avatar_url` 更新为 `avatars/{user_id}.jpg`(相对路径)
5. 小程序通过 `GET /api/xcx/avatar/{user_id}` 获取头像文件
## 关联接口
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/xcx/avatar/upload` | POST | 上传头像,更新 avatar_url |
| `/api/xcx/avatar/{user_id}` | GET | 获取头像文件FileResponse |
| `/api/xcx/me` | GET | 返回 avatar_url 字段 |
## 设计决策
- 审核表 `auth.user_applications` 不冗余 `avatar_url`,通过 JOIN `auth.users` 获取(头像可能更新)
- 文件命名按 `user_id`,覆盖式保存(幂等,无历史版本)
- 文件大小限制 2MB空文件拒绝
## 迁移脚本
`db/zqyy_app/migrations/20260324_add_avatar_url_to_users.sql`
## 环境变量
`AVATAR_EXPORT_PATH` — 头像文件存储目录,缺失时后端报 500 错误
## 回滚
```sql
ALTER TABLE auth.users DROP COLUMN IF EXISTS avatar_url;
```
## 验证
```sql
SELECT id, avatar_url FROM auth.users WHERE avatar_url IS NOT NULL LIMIT 5;
```

View File

@@ -0,0 +1,254 @@
# BD 手册biz 注册体系表NS4.1 registry
## 概述
NS4.1 注册体系新增 4 张表,全部位于 `biz` Schema建立「连接器 → 租户 → 店铺」三级注册体系。合并原 `auth.site_code_mapping`,统一管理上游 SaaS 系统、租户和店铺的关系并为简写ID 提供归属和变更历史。
所有表位于 `zqyy_app` / `test_zqyy_app` 数据库。
## 变更原因
- NS4.1 注册体系设计,建立项目级「连接器 → 租户 → 店铺」三级结构
- 合并原 `auth.site_code_mapping``biz.sites`增加租户关联和简写ID 变更历史管理
- 数据来源:种子数据从 `auth.site_code_mapping` 迁移ETL 增量同步从 `dwd.dim_site`
## 变更说明
| 库 | Schema | 表 | 变更类型 | 说明 |
|----|--------|---|---------|------|
| zqyy_app | biz | connectors | 新建 | 连接器注册表 |
| zqyy_app | biz | tenants | 新建 | 租户注册表 |
| zqyy_app | biz | sites | 新建 | 店铺注册表(合并原 auth.site_code_mapping |
| zqyy_app | biz | site_code_history | 新建 | 简写ID 变更历史表 |
| zqyy_app | auth | site_code_mapping | 废弃重命名 | → `auth._archived_site_code_mapping` |
---
## 1. biz.connectors — 连接器注册表
记录本项目接入的上游 SaaS 系统。当前仅有飞球feiqiu一个连接器预留多连接器扩展能力。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | SERIAL | PRIMARY KEY | 自增主键 |
| connector_key | VARCHAR(50) | UNIQUE NOT NULL | 连接器标识(如 `'feiqiu'` |
| display_name | VARCHAR(100) | NOT NULL | 显示名称 |
| is_active | BOOLEAN | NOT NULL DEFAULT true | 是否启用 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| connectors_pkey | PRIMARY KEY | id | 主键 |
| connectors_connector_key_key | UNIQUE | connector_key | 连接器标识唯一 |
### 种子数据
```sql
INSERT INTO biz.connectors (connector_key, display_name) VALUES ('feiqiu', '飞球');
```
---
## 2. biz.tenants — 租户注册表
连接器下的租户,`tenant_id` 来自上游系统。同一连接器下 `tenant_id` 唯一。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | SERIAL | PRIMARY KEY | 自增主键 |
| connector_id | INTEGER | NOT NULL FK → biz.connectors(id) | 所属连接器 |
| tenant_id | BIGINT | NOT NULL | 上游系统租户 ID |
| tenant_name | VARCHAR(200) | — | 租户名称 |
| is_active | BOOLEAN | NOT NULL DEFAULT true | 是否启用 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| tenants_pkey | PRIMARY KEY | id | 主键 |
| tenants_connector_id_tenant_id_key | UNIQUE | (connector_id, tenant_id) | 同一连接器下租户唯一 |
| tenants_connector_id_fkey | FOREIGN KEY | connector_id → biz.connectors(id) | 外键关联连接器 |
### 种子数据
```sql
INSERT INTO biz.tenants (connector_id, tenant_id, tenant_name)
VALUES (1, 2790683160709957, '朗朗桌球');
```
---
## 3. biz.sites — 店铺注册表
合并原 `auth.site_code_mapping`增加租户关联和简写ID 管理。`site_id` 来自上游系统,全局唯一。`site_code` 为当前生效的简写ID6 位字符3+3 格式),全局唯一。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | SERIAL | PRIMARY KEY | 自增主键 |
| tenant_id | INTEGER | NOT NULL FK → biz.tenants(id) | 所属租户 |
| site_id | BIGINT | NOT NULL UNIQUE | 上游系统门店 ID |
| site_name | VARCHAR(200) | — | 门店名称 |
| site_code | VARCHAR(6) | UNIQUE | 当前生效的简写ID3+3 格式,如 `LLQ001` |
| site_label | VARCHAR(50) | — | 门店标签 |
| is_active | BOOLEAN | NOT NULL DEFAULT true | 是否启用 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| updated_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 更新时间 |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| sites_pkey | PRIMARY KEY | id | 主键 |
| sites_site_id_key | UNIQUE | site_id | 上游门店 ID 唯一 |
| sites_site_code_key | UNIQUE | site_code | 简写ID 全局唯一 |
| sites_tenant_id_fkey | FOREIGN KEY | tenant_id → biz.tenants(id) | 外键关联租户 |
### 数据迁移
```sql
-- 从 auth.site_code_mapping 迁移真实数据
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_code)
SELECT t.id, scm.site_id, scm.site_name, scm.site_code
FROM auth.site_code_mapping scm
JOIN biz.tenants t ON t.tenant_id = scm.tenant_id
WHERE scm.tenant_id IS NOT NULL;
-- ETL 增量同步补充:通过 FDW 读取 dwd.dim_sitescd2_is_current=1
-- 补充 auth.site_code_mapping 中没有但 dwd.dim_site 中有的店铺
```
---
## 4. biz.site_code_history — 简写ID 变更历史表
增量记录所有使用过的简写ID。`site_code` 全局唯一(含历史),确保已退役的 code 不会被重新分配。每个 `site_id` 最多一条 `is_current=true` 记录。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | SERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 关联 biz.sites.site_id |
| site_code | VARCHAR(6) | NOT NULL UNIQUE | 简写ID全局唯一含历史 |
| is_current | BOOLEAN | NOT NULL DEFAULT false | true=当前生效,每个 site_id 最多一条 |
| created_at | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | 创建时间 |
| retired_at | TIMESTAMPTZ | — | 退役时间is_current=false 时设置) |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| site_code_history_pkey | PRIMARY KEY | id | 主键 |
| site_code_history_site_code_key | UNIQUE | site_code | 简写ID 全局唯一(含历史) |
### 初始数据
```sql
-- 为已有 site_code 的店铺创建历史记录
INSERT INTO biz.site_code_history (site_id, site_code, is_current)
SELECT site_id, site_code, true
FROM biz.sites
WHERE site_code IS NOT NULL;
```
---
## 兼容性
| 组件 | 影响 |
|------|------|
| 后端 API | 全部切换到 `biz.sites` + `biz.site_code_history`。新增 `admin_registry` 路由模块租户列表、店铺列表、简写ID 管理)。`admin_tenant_admins` 路由中 `tenant_id``biz.tenants` 选择。`tenant_users` 路由中 site_code 查询从 `auth.site_code_mapping` 切换到 `biz.sites` |
| ETL | 无直接影响。店铺同步通过 FDW 只读访问 ETL 库 `dwd.dim_site`,写入 `biz.sites`。ETL 流程本身不变 |
| 小程序 | 无需改动。用户申请时的 site_code 验证由后端 API 透明切换到 `biz.sites` + `biz.site_code_history` |
| 管理后台admin-web | 新增注册体系 API 调用(`src/api/registry.ts`),租户管理员创建流程从 `biz.tenants`/`biz.sites` 选择 |
| 原 auth.site_code_mapping | 迁移完成后重命名为 `auth._archived_site_code_mapping`,保留供回滚 |
## 回滚策略
### 完整回滚(逆序 DROP + 恢复原表)
```sql
BEGIN;
-- 1. 逆序删除注册体系表
DROP TABLE IF EXISTS biz.site_code_history CASCADE;
DROP TABLE IF EXISTS biz.sites CASCADE;
DROP TABLE IF EXISTS biz.tenants CASCADE;
DROP TABLE IF EXISTS biz.connectors CASCADE;
-- 2. 恢复原表(如已重命名)
ALTER TABLE IF EXISTS auth._archived_site_code_mapping
RENAME TO site_code_mapping;
COMMENT ON TABLE auth.site_code_mapping IS '店铺简写ID 映射表(已恢复)';
COMMIT;
```
注意:
- 回滚前需确认后端代码已切换回 `auth.site_code_mapping` 查询
- `CASCADE` 会级联删除依赖对象
- 如果 `biz.sites` 中已有新增店铺ETL 同步补充的),回滚后这些数据将丢失
## 验证 SQL
```sql
-- 1. 验证 biz.sites 中已有 site_code 的店铺数量
SELECT COUNT(*) FROM biz.sites WHERE site_code IS NOT NULL;
-- 预期:与原 auth.site_code_mapping 中有 site_code 的行数一致
-- 2. 验证 sites 与 site_code_history 的一致性
SELECT s.site_id, s.site_code, h.is_current
FROM biz.sites s
LEFT JOIN biz.site_code_history h
ON h.site_id = s.site_id AND h.site_code = s.site_code
WHERE s.site_code IS NOT NULL;
-- 预期:所有行的 h.is_current = true每个有 code 的店铺在历史表中有对应的当前记录)
-- 3. 验证三级注册体系关联完整性
SELECT c.connector_key, t.tenant_name, COUNT(s.id) AS site_count
FROM biz.connectors c
JOIN biz.tenants t ON t.connector_id = c.id
LEFT JOIN biz.sites s ON s.tenant_id = t.id
GROUP BY c.connector_key, t.tenant_name;
-- 预期:至少 1 行feiqiu / 朗朗桌球 / Nsite_count > 0
-- 4. 验证 4 张注册体系表全部存在
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'biz'
AND table_name IN ('connectors', 'tenants', 'sites', 'site_code_history')
ORDER BY table_name;
-- 预期:返回 4 行
-- 5. 验证 site_code 全局唯一性sites + history 无冲突)
SELECT site_code, COUNT(*) AS cnt
FROM biz.site_code_history
GROUP BY site_code
HAVING COUNT(*) > 1;
-- 预期:返回 0 行(每个 site_code 在历史表中最多出现一次)
-- 6. 验证种子数据
SELECT connector_key, display_name FROM biz.connectors WHERE connector_key = 'feiqiu';
-- 预期1 行feiqiu / 飞球)
SELECT tenant_id, tenant_name FROM biz.tenants WHERE tenant_id = 2790683160709957;
-- 预期1 行2790683160709957 / 朗朗桌球)
```
## 关联文件
- DDL 基线biz`docs/database/ddl/zqyy_app__biz.sql`
- 迁移脚本:`db/zqyy_app/migrations/2026-03-22__ns41_registry_tables.sql`
- 后端路由:`apps/backend/app/routers/admin_registry.py`
- 后端 Schema`apps/backend/app/schemas/admin_registry.py`
- 管理员路由:`apps/backend/app/routers/admin_tenant_admins.py`
- 前端 API`apps/admin-web/src/api/registry.ts`
- Spec`.kiro/specs/admin-web-enhancement/`
- PRD`docs/prd/Neo_Specs/NS4.1-tenant-admin-redesign.md`

View File

@@ -0,0 +1,76 @@
# BD 手册biz.dws_assistant_task_monthly
> 助教任务月度统计汇总表
## 基本信息
| 属性 | 值 |
|------|-----|
| Schema | biz |
| 表名 | dws_assistant_task_monthly |
| 所属库 | zqyy_app |
| 创建日期 | 2026-03-31 |
| 写入方 | `task_generator._update_task_stats()` |
| 读取方 | 看板 / 绩效报表(待接入) |
| 幂等策略 | UPSERT`ON CONFLICT (site_id, assistant_id, stat_month) DO UPDATE` |
## 用途
按助教 + 自然月维度汇总任务引擎的创建/完成/放弃/转移数据。每次 `task_generator.run()` 执行时,对当前门店的所有助教重新计算当月统计并 upsert。
## 字段定义
| 字段名 | 类型 | 可空 | 默认值 | 说明 |
|--------|------|------|--------|------|
| id | BIGSERIAL | NOT NULL | 自增 | 主键 |
| site_id | BIGINT | NOT NULL | — | 门店 ID |
| assistant_id | BIGINT | NOT NULL | — | 助教 ID |
| stat_month | DATE | NOT NULL | — | 统计月份(月初日期,如 2026-03-01 |
| recall_created | INT | NOT NULL | 0 | 当月创建的召回任务数high_priority + priority |
| follow_up_created | INT | NOT NULL | 0 | 当月创建的回访任务数 |
| relationship_created | INT | NOT NULL | 0 | 当月创建的关系构建任务数 |
| total_created | INT | NOT NULL | 0 | 当月创建的任务总数 |
| recall_completed | INT | NOT NULL | 0 | 当月完成的召回任务数 |
| follow_up_completed | INT | NOT NULL | 0 | 当月完成的回访任务数 |
| total_completed | INT | NOT NULL | 0 | 当月完成的任务总数 |
| abandoned_count | INT | NOT NULL | 0 | 当月放弃的任务数 |
| transferred_count | INT | NOT NULL | 0 | 当月转移的任务数 |
| updated_at | TIMESTAMPTZ | NOT NULL | NOW() | 最后更新时间 |
## 约束与索引
| 名称 | 类型 | 字段 |
|------|------|------|
| PK | PRIMARY KEY | id |
| UQ | UNIQUE | (site_id, assistant_id, stat_month) |
| idx_task_monthly_site_month | INDEX | (site_id, stat_month DESC) |
| idx_task_monthly_assistant | INDEX | (assistant_id, stat_month DESC) |
## 数据来源
统计数据从 `biz.coach_tasks` 表聚合:
-`assigned_assistant_id` + `DATE_TRUNC('month', created_at)` 分组
- `recall_created` = COUNT WHERE task_type IN ('high_priority_recall', 'priority_recall')
- `follow_up_created` = COUNT WHERE task_type = 'follow_up_visit'
- `relationship_created` = COUNT WHERE task_type = 'relationship_building'
- `*_completed` = COUNT WHERE status = 'completed' AND 对应 task_type
- `abandoned_count` = COUNT WHERE status = 'abandoned'
- `transferred_count` = COUNT WHERE status = 'transferred'
## 兼容性
- ETL无直接依赖表由后端 task_generator 写入)
- 后端 API待接入看板/绩效接口
- 小程序:无直接依赖
## 回滚策略
```sql
DROP TABLE IF EXISTS biz.dws_assistant_task_monthly;
```
## 关联变更
- 迁移脚本:`db/zqyy_app/migrations/2026-03-31__task_stats_tables.sql`
- 审计记录:`docs/audit/changes/2026-03-31__task-engine-overhaul.md`
- 关联表:`biz.coach_tasks`(数据源)、`dws.dws_member_assistant_relation_index`C 层历史总计字段)

View File

@@ -0,0 +1,83 @@
# BD_ManualFDW 财务区域查询映射fdw_finance_area
> 目标库:后端通过 `get_etl_readonly_connection(site_id)` 直连 ETL 库
> 关联 SPECboard-finance-dws-area-refactor
> 日期2026-03-28
---
## 1. 变更说明
### 新增 RLS 视图ETL 库 app Schema 自动导出)
本次新增的两张 DWS 表各有一个 RLS 视图,后端通过直连 ETL 库的 `app.v_*` 视图访问(与 RNS1.2 以来的模式一致,不使用 `fdw_etl.*` 外部表)。
| 视图 | 源表 | 用途 |
|------|------|------|
| `dws.v_dws_finance_area_daily` | `dws.dws_finance_area_daily` | 区域日粒度财务数据overview/revenue 板块) |
| `dws.v_dws_finance_board_cache` | `dws.dws_finance_board_cache` | 已完成周期缓存overview 8 项指标) |
### 后端查询函数
| 函数 | 视图 | 用途 |
|------|------|------|
| `get_finance_overview_area()` | `v_dws_finance_area_daily` | 按 area_code 聚合 overview 8 项指标 |
| `get_finance_revenue_area()` | `v_dws_finance_area_daily` | 按 area_code 聚合 revenue 板块数据 |
| `get_finance_board_cache()` | `v_dws_finance_board_cache` | 查询缓存 |
| `set_finance_board_cache()` | `dws.dws_finance_board_cache` | 写入/更新缓存 |
### RLS 隔离
所有查询通过 `SET LOCAL app.current_site_id = :site_id` 设置门店隔离参数RLS 视图的 `WHERE site_id = current_setting('app.current_site_id')::bigint` 自动过滤。
---
## 2. 兼容性影响
| 组件 | 影响 |
|------|------|
| ETL 任务 | 无影响。FDW 配置不修改 ETL 库 |
| 后端 API | `fdw_queries.py` 新增 4 个函数,`board_service.py` 改为调用新函数 |
| 小程序 | 无直接影响API 签名不变) |
| 现有 FDW 外部表 | 无影响。新视图通过直连 ETL 库访问,不经过 `fdw_etl` Schema |
---
## 3. 回滚策略
后端回滚:
1. 恢复 `board_service.py` 到旧逻辑(从 `dws_finance_daily_summary` 取数)
2. 移除 `fdw_queries.py` 中的 4 个新函数
数据库回滚:
```sql
DROP VIEW IF EXISTS dws.v_dws_finance_board_cache;
DROP TABLE IF EXISTS dws.dws_finance_board_cache;
DROP VIEW IF EXISTS dws.v_dws_finance_area_daily;
DROP TABLE IF EXISTS dws.dws_finance_area_daily;
```
---
## 4. 验证 SQL
```sql
-- 1. 验证 RLS 视图可访问(需先设置 site_id
SET LOCAL app.current_site_id = '1';
SELECT COUNT(*) FROM dws.v_dws_finance_area_daily;
-- 2. 验证缓存视图可访问
SET LOCAL app.current_site_id = '1';
SELECT COUNT(*) FROM dws.v_dws_finance_board_cache;
-- 3. 验证 app_reader 角色有 SELECT 权限
SELECT has_table_privilege('app_reader', 'dws.v_dws_finance_area_daily', 'SELECT') AS daily_ok,
has_table_privilege('app_reader', 'dws.v_dws_finance_board_cache', 'SELECT') AS cache_ok;
-- 4. 验证区域日粒度数据完整性(每天 9 行)
SET LOCAL app.current_site_id = '1';
SELECT stat_date, COUNT(*) AS cnt
FROM dws.v_dws_finance_area_daily
GROUP BY stat_date
HAVING COUNT(*) != 9;
```

View File

@@ -0,0 +1,74 @@
# BD 手册idx_coach_tasks_rb_unique_active
| 字段 | 值 |
|------|-----|
| 数据库 | zqyy_app |
| Schema | biz |
| 表 | coach_tasks |
| 索引名 | idx_coach_tasks_rb_unique_active |
| 类型 | UNIQUE (partial) |
| 创建日期 | 2026-03-25 |
| 迁移脚本 | `db/zqyy_app/migrations/2026-03-25__relationship_building_baseline.sql` |
## 索引定义
```sql
CREATE UNIQUE INDEX idx_coach_tasks_rb_unique_active
ON biz.coach_tasks (site_id, assistant_id, member_id)
WHERE task_type = 'relationship_building' AND status = 'active';
```
## 用途
保证每个 `(site_id, assistant_id, member_id)` 组合最多存在 1 条 `status = 'active'``relationship_building` 任务。
支持 `_generate_baseline_relationship_tasks()` 中的 upsert 操作:
```sql
INSERT INTO biz.coach_tasks (...)
VALUES (...)
ON CONFLICT (site_id, assistant_id, member_id)
WHERE task_type = 'relationship_building' AND status = 'active'
DO NOTHING
```
## 业务背景
保底 relationship_building 任务:对每个助教,所有确切发生过服务关系(`session_count > 0`)的客户都生成一条 relationship_building 任务。partial unique index 确保幂等性——重复运行不会产生重复任务。
## 影响范围
- 写入方:`task_generator._generate_baseline_relationship_tasks()` — upsert 依赖此索引
- 读取方:`task_manager.get_task_list_v2()` — 查询不直接使用此索引,但受益于去重保证
- 不影响其他 task_type 的任务partial index 仅覆盖 `relationship_building` + `active`
## 回滚
```sql
DROP INDEX IF EXISTS biz.idx_coach_tasks_rb_unique_active;
```
回滚后 `_generate_baseline_relationship_tasks()``ON CONFLICT` 子句会报错(无匹配索引),需同步回滚代码。
## 验证 SQL
```sql
-- 1. 确认索引存在
SELECT indexname, indexdef FROM pg_indexes
WHERE indexname = 'idx_coach_tasks_rb_unique_active';
-- 2. 确认无重复
SELECT site_id, assistant_id, member_id, COUNT(*)
FROM biz.coach_tasks
WHERE task_type = 'relationship_building' AND status = 'active'
GROUP BY site_id, assistant_id, member_id
HAVING COUNT(*) > 1;
-- 应返回 0 行
-- 3. 测试 upsert 幂等性dry run
EXPLAIN INSERT INTO biz.coach_tasks
(site_id, assistant_id, member_id, task_type, status, priority_score)
VALUES (1, 1, 1, 'relationship_building', 'active', 0)
ON CONFLICT (site_id, assistant_id, member_id)
WHERE task_type = 'relationship_building' AND status = 'active'
DO NOTHING;
```

View File

@@ -0,0 +1,230 @@
# BD 手册public Schema — RBAC 与工作流8 表)
> 目标库:`zqyy_app`(通过 `APP_DB_DSN` 连接)
> DDL 位置:`docs/database/ddl/zqyy_app__public.sql`
> 关联文档:`BD_manual_scheduled_tasks.md`(调度相关 3 表)、`BD_manual_member_retention_clue.md`(维客线索表)
---
## 1. 概述
`public` Schema 共 12 张表,本文档覆盖其中 RBAC角色权限+ 工作流(任务审批)共 8 张表。其余 4 张表已有独立文档:
| 表 | 文档 |
|----|------|
| `scheduled_tasks``task_queue``task_execution_log` | `BD_manual_scheduled_tasks.md` |
| `member_retention_clue` | `BD_manual_member_retention_clue.md` |
### 表关系
```
admin_users管理后台登录账户roles 数组字段)
users ──┬── user_roles ── roles ── role_permissions ── permissions
│ (用户-角色映射) (角色) (角色-权限映射) (权限)
├── tasks任务指派creator_id / assignee_id → users
│ │
└── approvals审批记录approver_id → userstask_id → tasks
```
---
## 2. 表结构
### public.admin_users9 字段)
管理后台admin-web / tenant-admin登录账户。通过 JWT `aud=admin``aud=tenant-admin` 认证。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | SERIAL | PK | 自增主键 |
| `username` | VARCHAR(64) | NOT NULL, UNIQUE | 登录用户名 |
| `password_hash` | VARCHAR(256) | NOT NULL | bcrypt 密码哈希 |
| `display_name` | VARCHAR(128) | 可空 | 显示名称 |
| `site_id` | BIGINT | NOT NULL | 所属门店 ID |
| `is_active` | BOOLEAN | DEFAULT true | 是否启用 |
| `roles` | TEXT[] | NOT NULL, DEFAULT '{site_admin}' | 角色数组(`site_admin` / `tenant_admin` 等) |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
> 注意:`admin_users.roles` 是 TEXT 数组字段(快捷权限标记),与 `roles` 表的 RBAC 体系并行使用。admin-web 当前主要依赖此数组字段做权限判断。
### public.users8 字段)
小程序端用户C 端会员),通过微信 code → JWT `aud=miniapp` 认证。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `wx_openid` | TEXT | UNIQUE可空 | 微信 OpenID登录唯一标识 |
| `mobile` | TEXT | 可空 | 手机号 |
| `nickname` | TEXT | 可空 | 用户昵称 |
| `status` | INTEGER | DEFAULT 1 | 状态1=正常) |
| `site_id` | BIGINT | NOT NULL | 所属门店 ID |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
### public.roles5 字段)
角色定义表,按门店隔离。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | SERIAL | PK | 自增主键 |
| `name` | TEXT | NOT NULL, UNIQUE | 角色名称(全局唯一) |
| `description` | TEXT | 可空 | 角色描述 |
| `site_id` | BIGINT | NOT NULL | 所属门店 ID |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
### public.permissions4 字段)
权限定义表(资源+动作组合)。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | SERIAL | PK | 自增主键 |
| `resource` | TEXT | NOT NULL | 资源标识(如 `etl``users` |
| `action` | TEXT | NOT NULL | 操作标识(如 `read``write``execute` |
| `description` | TEXT | 可空 | 权限描述 |
### public.role_permissions2 字段)
角色-权限关联表(多对多)。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `role_id` | INTEGER | PK, FK → roles(id) ON DELETE CASCADE | 角色 ID |
| `permission_id` | INTEGER | PK, FK → permissions(id) ON DELETE CASCADE | 权限 ID |
### public.user_roles3 字段)
用户-角色关联表(多对多),按门店隔离。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `user_id` | BIGINT | PK, FK → users(id) ON DELETE CASCADE | 用户 ID |
| `role_id` | INTEGER | PK, FK → roles(id) ON DELETE CASCADE | 角色 ID |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
### public.tasks9 字段)
任务指派表,用于门店内任务管理。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `title` | TEXT | NOT NULL | 任务标题 |
| `description` | TEXT | 可空 | 任务描述 |
| `status` | TEXT | DEFAULT 'pending' | 状态:`pending` / `in_progress` / `completed` |
| `assignee_id` | BIGINT | FK → users(id),可空 | 负责人 ID |
| `creator_id` | BIGINT | FK → users(id),可空 | 创建人 ID |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| `updated_at` | TIMESTAMPTZ | DEFAULT NOW() | 更新时间 |
### public.approvals7 字段)
审批记录表,关联到任务。
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| `id` | BIGSERIAL | PK | 自增主键 |
| `task_id` | BIGINT | FK → tasks(id) ON DELETE CASCADE可空 | 关联任务 ID |
| `approver_id` | BIGINT | FK → users(id),可空 | 审批人 ID |
| `status` | TEXT | DEFAULT 'pending' | 状态:`pending` / `approved` / `rejected` |
| `comment` | TEXT | 可空 | 审批意见 |
| `site_id` | BIGINT | NOT NULL | 门店 ID |
| `created_at` | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
---
## 3. 约束与索引
| 表 | 约束/索引名 | 类型 | 说明 |
|----|-----------|------|------|
| `admin_users` | `admin_users_pkey` | PK | `(id)` |
| `admin_users` | `admin_users_username_key` | UNIQUE | `(username)` |
| `admin_users` | `idx_admin_users_site` | INDEX | `(site_id)` |
| `users` | `users_pkey` | PK | `(id)` |
| `users` | `users_wx_openid_key` | UNIQUE | `(wx_openid)` |
| `users` | `idx_users_site_id` | INDEX | `(site_id)` |
| `users` | `idx_users_mobile` | INDEX | `(mobile)` |
| `roles` | `roles_pkey` | PK | `(id)` |
| `roles` | `roles_name_key` | UNIQUE | `(name)` |
| `roles` | `idx_roles_site_id` | INDEX | `(site_id)` |
| `permissions` | `permissions_pkey` | PK | `(id)` |
| `permissions` | `permissions_resource_action_key` | UNIQUE | `(resource, action)` |
| `role_permissions` | `role_permissions_pkey` | PK | `(role_id, permission_id)` |
| `role_permissions` | FK `role_id` | FK | → roles(id) ON DELETE CASCADE |
| `role_permissions` | FK `permission_id` | FK | → permissions(id) ON DELETE CASCADE |
| `user_roles` | `user_roles_pkey` | PK | `(user_id, role_id)` |
| `user_roles` | FK `user_id` | FK | → users(id) ON DELETE CASCADE |
| `user_roles` | FK `role_id` | FK | → roles(id) ON DELETE CASCADE |
| `user_roles` | `idx_user_roles_site_id` | INDEX | `(site_id)` |
| `tasks` | `tasks_pkey` | PK | `(id)` |
| `tasks` | FK `assignee_id` | FK | → users(id) |
| `tasks` | FK `creator_id` | FK | → users(id) |
| `tasks` | `idx_tasks_site_id` | INDEX | `(site_id)` |
| `tasks` | `idx_tasks_status` | INDEX | `(status)` |
| `tasks` | `idx_tasks_assignee_id` | INDEX | `(assignee_id)` |
| `approvals` | `approvals_pkey` | PK | `(id)` |
| `approvals` | FK `task_id` | FK | → tasks(id) ON DELETE CASCADE |
| `approvals` | FK `approver_id` | FK | → users(id) |
| `approvals` | `idx_approvals_site_id` | INDEX | `(site_id)` |
| `approvals` | `idx_approvals_task_id` | INDEX | `(task_id)` |
---
## 4. 兼容性影响
| 组件 | 影响 |
|------|------|
| admin-web | 直接依赖。登录用 `admin_users`,权限判断用 `admin_users.roles` 数组 |
| tenant-admin | 直接依赖。登录也用 `admin_users``aud=tenant-admin`),按 `site_id` 隔离 |
| 后端 API | `admin_users` 用于 JWT 签发和验证RBAC 表roles/permissions/user_roles用于细粒度权限控制 |
| 小程序 | 通过 `users` 表完成微信登录注册和用户信息管理 |
| ETL | 无直接影响 |
---
## 5. 验证 SQL
```sql
-- 1. 验证 8 张表存在
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name IN ('admin_users', 'users', 'roles', 'permissions',
'role_permissions', 'user_roles', 'tasks', 'approvals')
ORDER BY table_name;
-- 预期8 行
-- 2. 验证 admin_users 字段数量
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'admin_users';
-- 预期9
-- 3. 验证 admin_users.roles 默认值
SELECT column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'admin_users' AND column_name = 'roles';
-- 预期:'{site_admin}'::text[]
-- 4. 验证外键关系
SELECT tc.table_name, tc.constraint_name, ccu.table_name AS references_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu ON tc.constraint_name = ccu.constraint_name
WHERE tc.table_schema = 'public'
AND tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name IN ('approvals', 'tasks', 'role_permissions', 'user_roles')
ORDER BY tc.table_name;
-- 预期7 行外键
-- 5. 验证 permissions 唯一约束
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_schema = 'public' AND table_name = 'permissions' AND constraint_type = 'UNIQUE';
-- 预期permissions_resource_action_key
```

View File

@@ -0,0 +1,134 @@
# BD 手册scheduled_tasks 新增字段P16 最小运行间隔)
## 概述
P16 为 `public.scheduled_tasks` 表新增 3 个字段,支持调度任务最小运行间隔机制。调度器轮询时检查并发状态和间隔约束,避免任务重复执行。
所有变更位于 `zqyy_app` / `test_zqyy_app` 数据库,`public` Schema。
## 变更原因
- P16 调度任务最小运行间隔需求:管理员可为每个任务配置最小间隔,任务即使调度到期也不会在间隔内重复执行
- 新增 `last_success_at` 字段区分"最后执行时间"和"最后成功时间",支持更精确的间隔计算
## 变更说明
| 库 | Schema | 表 | 变更类型 | 说明 |
|----|--------|---|---------|------|
| zqyy_app | public | scheduled_tasks | 新增字段 ×4 | min_run_interval_value, min_run_interval_unit, last_success_at, min_run_intervals |
---
## 新增字段明细
| 列名 | 类型 | 约束 | 默认值 | 说明 |
|------|------|------|--------|------|
| min_run_interval_value | INTEGER | NOT NULL | 0 | 最小间隔数值0=无限制,与现有行为完全一致) |
| min_run_interval_unit | VARCHAR(20) | NOT NULL | 'minutes' | 间隔单位:`minutes` / `hours` / `days` |
| last_success_at | TIMESTAMPTZ | — | NULL | 最后一次成功执行的时间 |
| min_run_intervals | JSONB | NOT NULL | '{}' | 每任务子代码级别的最小运行间隔配置key=task_code, value=interval_seconds |
### DDL
```sql
ALTER TABLE scheduled_tasks ADD COLUMN min_run_interval_value INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scheduled_tasks ADD COLUMN min_run_interval_unit VARCHAR(20) NOT NULL DEFAULT 'minutes';
ALTER TABLE scheduled_tasks ADD COLUMN last_success_at TIMESTAMPTZ;
COMMENT ON COLUMN scheduled_tasks.min_run_interval_value IS '最小间隔数值0=无限制)';
COMMENT ON COLUMN scheduled_tasks.min_run_interval_unit IS '间隔单位minutes/hours/days';
COMMENT ON COLUMN scheduled_tasks.last_success_at IS '最后一次成功执行的时间';
```
---
## 业务逻辑
调度器(`scheduler.py`)轮询时对每个到期任务执行以下检查:
1. **并发检查**`last_status = 'running'` → 跳过本次入队,日志记录 `skipped_concurrent`
2. **间隔检查**`min_run_interval_value > 0``now() - last_run_at < min_interval_seconds` → 跳过本次执行,推进 `next_run_at`,日志记录 `skipped_interval`
3. **首次执行**`last_run_at IS NULL`(从未执行)→ 跳过间隔检查,正常执行
4. **强制执行**`force=true` 参数绕过所有检查(并发 + 间隔),直接入队
### 间隔转换
```python
def _convert_interval_to_seconds(value: int, unit: str) -> int:
multipliers = {"minutes": 60, "hours": 3600, "days": 86400}
return value * multipliers.get(unit, 60)
```
### last_success_at 更新规则
- 任务成功完成时:`last_status='completed'`, `last_success_at=NOW()`
- 任务失败时:`last_status='failed'``last_success_at` 不变
---
## 兼容性
| 组件 | 影响 |
|------|------|
| 后端 API | `POST /api/schedules``PUT /api/schedules/{id}` 请求体新增 `min_run_interval_value``min_run_interval_unit``GET /api/schedules` 响应新增 3 个字段。`POST /api/schedules/{id}/run` 新增 `force` 查询参数 |
| 前端 ScheduleTab | 创建/编辑表单新增「最小运行间隔」配置行。列表新增「最小间隔」和「上次成功」列。手动执行确认框新增「强制执行」Checkbox |
| ETL | 无影响。ETL 任务调度由 `scheduled_tasks` 表驱动,新增字段默认值 0 表示无限制,向后兼容 |
| 小程序 | 无影响 |
| 调度器 | `scheduler.py``check_and_enqueue()` 新增并发检查和间隔检查逻辑 |
## 回滚策略
```sql
BEGIN;
ALTER TABLE scheduled_tasks DROP COLUMN IF EXISTS min_run_interval_value;
ALTER TABLE scheduled_tasks DROP COLUMN IF EXISTS min_run_interval_unit;
ALTER TABLE scheduled_tasks DROP COLUMN IF EXISTS last_success_at;
COMMIT;
```
注意:
- 回滚后所有任务恢复为无间隔限制的行为
- 回滚前需确认后端代码已移除对这 3 个字段的引用
- `last_success_at` 数据丢失不可恢复
## 验证 SQL
```sql
-- 1. 验证新增字段存在且类型正确
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'scheduled_tasks'
AND column_name IN ('min_run_interval_value', 'min_run_interval_unit', 'last_success_at');
-- 预期3 行
-- min_run_interval_value | integer | 0
-- min_run_interval_unit | character varying | 'minutes'::character varying
-- last_success_at | timestamp with time zone | NULL
-- 2. 验证现有任务的新字段默认值
SELECT id, name, min_run_interval_value, min_run_interval_unit, last_success_at
FROM scheduled_tasks
LIMIT 5;
-- 预期min_run_interval_value=0, min_run_interval_unit='minutes', last_success_at=NULL除非已手动配置
-- 3. 验证已配置间隔的任务数量
SELECT COUNT(*) FROM scheduled_tasks WHERE min_run_interval_value > 0;
-- 预期:≥ 0初始状态为 0配置后递增
-- 4. 验证字段注释
SELECT col_description(
(SELECT oid FROM pg_class WHERE relname = 'scheduled_tasks'),
(SELECT attnum FROM pg_attribute WHERE attrelid = 'scheduled_tasks'::regclass AND attname = 'min_run_interval_value')
);
-- 预期:'最小间隔数值0=无限制)'
```
## 关联文件
- DDL 基线public`docs/database/ddl/zqyy_app__public.sql`
- 迁移脚本:`db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql`
- 调度器逻辑:`apps/backend/app/services/scheduler.py`
- 后端路由:`apps/backend/app/routers/schedules.py`
- 后端 Schema`apps/backend/app/schemas/schedules.py`
- 前端组件:`apps/admin-web/src/components/ScheduleTab.tsx`
- Spec`.kiro/specs/admin-web-enhancement/`
- PRD`docs/prd/specs/P16-task-min-run-interval.md`

View File

@@ -0,0 +1,105 @@
# BD 手册user_site_roles / user_assistant_binding 软删除
## 变更日期
2026-03-24
## 变更说明
`auth.user_site_roles``auth.user_assistant_binding` 两张表上新增软删除字段,替代原有的物理删除(`DELETE`)。
### 新增字段
| 表 | 字段 | 类型 | 默认值 | 说明 |
|----|------|------|--------|------|
| `auth.user_site_roles` | `is_removed` | `boolean` | `false` | 软删除标记 |
| `auth.user_site_roles` | `removed_at` | `timestamptz` | `NULL` | 移除时间戳 |
| `auth.user_assistant_binding` | `is_removed` | `boolean` | `false` | 软删除标记 |
| `auth.user_assistant_binding` | `removed_at` | `timestamptz` | `NULL` | 移除时间戳 |
### 新增索引
| 索引名 | 表 | 类型 | 说明 |
|--------|-----|------|------|
| `ix_user_site_roles_active` | `user_site_roles` | 部分索引 `WHERE is_removed = false` | 加速活跃记录查询 |
| `ix_user_assistant_binding_active` | `user_assistant_binding` | 部分索引 `WHERE is_removed = false` | 加速活跃记录查询 |
## 兼容性影响
### 后端 API已同步修改
所有查询 `user_site_roles``user_assistant_binding` 的位置均已添加 `AND is_removed = false` 过滤:
- `xcx_auth.py`登录、me 接口、切换门店、刷新令牌、获取门店列表、dev 调试接口
- `tenant_users.py`:用户列表、编辑用户、更新绑定、移除用户
- `role.py`:权限查询、门店列表、角色检查
- `task_manager.py`:获取助教 ID
- `task_generator.py`:门店助教规模检查、入驻时间保护
- `performance_service.py`:助教信息查询
### ETL
无直接影响。ETL 不写入这两张表。
### 小程序
无代码改动。被移除的用户在小程序端会因角色查询返回空而进入已有的无权限路由。
### 租户管理后台
`remove_user` 操作从 `DELETE` 改为 `UPDATE SET is_removed = true, removed_at = now()`
## 查询规则(强制)
所有读取 `user_site_roles``user_assistant_binding` 的 SELECT 查询,必须包含 `AND is_removed = false`(或等效的 JOIN 条件 `AND xxx.is_removed = false`)。
例外:
- 管理后台需要查看已移除记录的场景(如审计日志)
- dev 调试接口中的物理删除操作(仅开发模式)
## 回滚策略
```sql
-- 1. 恢复所有被软删除的记录
UPDATE auth.user_site_roles SET is_removed = false, removed_at = NULL WHERE is_removed = true;
UPDATE auth.user_assistant_binding SET is_removed = false, removed_at = NULL WHERE is_removed = true;
-- 2. 删除索引
DROP INDEX IF EXISTS auth.ix_user_site_roles_active;
DROP INDEX IF EXISTS auth.ix_user_assistant_binding_active;
-- 3. 删除字段
ALTER TABLE auth.user_site_roles DROP COLUMN IF EXISTS removed_at;
ALTER TABLE auth.user_site_roles DROP COLUMN IF EXISTS is_removed;
ALTER TABLE auth.user_assistant_binding DROP COLUMN IF EXISTS removed_at;
ALTER TABLE auth.user_assistant_binding DROP COLUMN IF EXISTS is_removed;
```
注意:回滚后需同步还原后端代码中所有 `AND is_removed = false` 过滤条件,并将 `remove_user` 恢复为 `DELETE`
## 验证 SQL
```sql
-- 1. 确认字段存在且默认值正确
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_schema = 'auth'
AND table_name IN ('user_site_roles', 'user_assistant_binding')
AND column_name IN ('is_removed', 'removed_at')
ORDER BY table_name, column_name;
-- 2. 确认部分索引存在
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'auth'
AND indexname IN ('ix_user_site_roles_active', 'ix_user_assistant_binding_active');
-- 3. 确认现有数据未被误标记(所有记录 is_removed 应为 false
SELECT 'user_site_roles' AS tbl, COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_removed = true) AS removed
FROM auth.user_site_roles
UNION ALL
SELECT 'user_assistant_binding', COUNT(*),
COUNT(*) FILTER (WHERE is_removed = true)
FROM auth.user_assistant_binding;
-- 4. 确认活跃记录查询走部分索引EXPLAIN 检查)
EXPLAIN SELECT * FROM auth.user_site_roles WHERE user_id = 1 AND site_id = 1 AND is_removed = false;
```
## 迁移脚本
`db/zqyy_app/migrations/20260324_soft_delete_user_site_roles_and_binding.sql`

View File

@@ -0,0 +1,544 @@
# BD 手册租户管理后台表NS4 tenant-admin-web
## 概述
NS4 租户管理后台新增 6 张表,分布在 `auth``biz` 两个 Schema 中。`auth.tenant_admins` 为租户管理员认证表,与小程序 `auth.users`(微信登录)完全隔离;`biz` Schema 下 5 张表支撑 Excel 数据上传功能上传日志、助教奖罚、3 张 staging 暂存表)。
所有表位于 `zqyy_app` / `test_zqyy_app` 数据库。
## 变更说明
| 库 | Schema | 表 | 变更类型 | 说明 |
|----|--------|---|---------|------|
| zqyy_app | auth | tenant_admins | 新建 | 租户管理员认证表 |
| zqyy_app | biz | excel_upload_log | 新建 | Excel 上传记录表 |
| zqyy_app | biz | salary_adjustments | 新建 | 助教奖罚明细表 |
| zqyy_app | biz | stg_finance_expense | 新建 | 财务支出暂存表 |
| zqyy_app | biz | stg_platform_income | 新建 | 团购收入暂存表 |
| zqyy_app | biz | stg_recharge_commission | 新建 | 充值业绩归属暂存表 |
---
## 1. auth.tenant_admins — 租户管理员表
独立于小程序 `auth.users`,使用用户名+密码登录JWT `aud=tenant-admin` 隔离。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| username | VARCHAR(50) | UNIQUE NOT NULL | 登录用户名 |
| password_hash | VARCHAR(255) | NOT NULL | bcrypt 哈希密码 |
| display_name | VARCHAR(100) | — | 显示名称 |
| tenant_id | BIGINT | NOT NULL | 所属租户 ID |
| managed_site_ids | BIGINT[] | NOT NULL | 管辖门店 ID 列表(数据隔离依据) |
| admin_type | VARCHAR(20) | NOT NULL DEFAULT 'tenant_admin' | 管理员类型tenant_admin租户管理员/ site_admin店铺管理员CHECK 约束 |
| is_active | BOOLEAN | DEFAULT true | 账号状态false=禁用,登录返回 403仅控制启用/禁用,与软删除无关) |
| deleted_at | TIMESTAMPTZ | DEFAULT NULL | 软删除时间戳NULL=正常,非 NULL=已删除(与 is_active 分离) |
| created_by | BIGINT | — | 创建者(管理员 ID |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| last_login_at | TIMESTAMPTZ | — | 最后登录时间 |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| tenant_admins_pkey | PRIMARY KEY | id | 主键 |
| tenant_admins_username_key | UNIQUE | username | 用户名唯一 |
| chk_admin_type | CHECK | admin_type | admin_type IN ('tenant_admin', 'site_admin') |
| idx_tenant_admin_tenant | INDEX (btree) | tenant_id | 按租户查询 |
| idx_tenant_admins_active_not_deleted | INDEX (btree, partial) | is_active WHERE deleted_at IS NULL | 加速列表和登录查询(仅索引未删除记录) |
| idx_tenant_admins_username_lower | UNIQUE (partial) | LOWER(username) WHERE deleted_at IS NULL | 大小写不敏感唯一约束2026-03-23 |
---
## 2. biz.excel_upload_log — Excel 上传记录表
记录每次 Excel 上传的批次信息,支撑上传→校验→冲突→确认的完整流程。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| upload_type | VARCHAR(30) | NOT NULL, CHECK | 模板类型(见枚举) |
| file_name | VARCHAR(255) | NOT NULL | 原始文件名 |
| uploaded_by | BIGINT | NOT NULL | 上传人(管理员 ID |
| row_count | INTEGER | DEFAULT 0 | 数据行数 |
| conflict_count | INTEGER | DEFAULT 0 | 冲突行数 |
| resolved_count | INTEGER | DEFAULT 0 | 已解决冲突数 |
| status | VARCHAR(20) | NOT NULL, CHECK | 批次状态(见枚举) |
| error_detail | JSONB | — | 错误详情 / 临时缓存上传数据 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 上传时间 |
| confirmed_at | TIMESTAMPTZ | — | 确认写入时间 |
### upload_type 枚举值
| 值 | 说明 |
|----|------|
| expense | 财务支出 |
| platform_income | 团购收入 |
| salary_adj | 助教奖罚 |
| recharge_commission | 充值业绩归属 |
### status 枚举值
| 值 | 说明 |
|----|------|
| pending | 待确认(已上传校验通过,等待用户确认写入) |
| confirmed | 已确认(数据已写入目标表) |
| failed | 失败(写入过程出错,已回滚) |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| excel_upload_log_pkey | PRIMARY KEY | id | 主键 |
| CHECK (upload_type) | CHECK | upload_type | 限制模板类型枚举 |
| CHECK (status) | CHECK | status | 限制状态枚举 |
| idx_excel_log_site | INDEX (btree) | (site_id, created_at DESC) | 按门店+时间查询 |
---
## 3. biz.salary_adjustments — 助教奖罚明细表
直接写入 biz Schema非 staging记录助教扣款/奖金明细。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| assistant_id | BIGINT | — | 匹配到的助教 ID可空人员匹配失败时为 NULL |
| assistant_name | VARCHAR(100) | NOT NULL | 助教姓名Excel 原始值) |
| assistant_number | VARCHAR(50) | NOT NULL | 助教编号Excel 原始值) |
| salary_month | VARCHAR(7) | NOT NULL | 月份(格式 YYYY-MM |
| adjustment_type | VARCHAR(20) | NOT NULL, CHECK | 类型deduction扣款/ bonus奖金 |
| amount | NUMERIC(12,2) | NOT NULL, CHECK (> 0) | 金额(正数) |
| reason | VARCHAR(200) | NOT NULL | 原因说明 |
| upload_batch_id | BIGINT | FK → excel_upload_log(id) | 上传批次 |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
| created_by | BIGINT | — | 上传人(管理员 ID |
### adjustment_type 枚举值
| 值 | Excel 中文值 | 说明 |
|----|-------------|------|
| deduction | 扣款 | 扣款 |
| bonus | 奖金 | 奖金 |
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| salary_adjustments_pkey | PRIMARY KEY | id | 主键 |
| CHECK (adjustment_type) | CHECK | adjustment_type | 限制类型枚举 |
| CHECK (amount) | CHECK | amount | 金额必须 > 0 |
| salary_adjustments_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |
| idx_salary_adj_site_month | INDEX (btree) | (site_id, salary_month) | 按门店+月份查询 |
| idx_salary_adj_assistant_month | INDEX (btree) | (assistant_id, salary_month) | 按助教+月份查询 |
---
## 4. biz.stg_finance_expense — 财务支出暂存表
通过 Excel 上传写入,等待 ETL 同步到正式表。
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| expense_month | VARCHAR(7) | NOT NULL | 月份(格式 YYYY-MM |
| category | VARCHAR(50) | NOT NULL | 支出类别8 值枚举) |
| amount | NUMERIC(12,2) | NOT NULL | 金额 |
| remark | TEXT | — | 备注 |
| upload_batch_id | BIGINT | FK → excel_upload_log(id) | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间NULL=未同步) |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
### 冲突检测主键
`(site_id, expense_month, category)` — 同门店同月份同类别视为冲突。
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| stg_finance_expense_pkey | PRIMARY KEY | id | 主键 |
| stg_finance_expense_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |
---
## 5. biz.stg_platform_income — 团购收入暂存表
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| income_month | VARCHAR(7) | NOT NULL | 月份(格式 YYYY-MM |
| platform_name | VARCHAR(100) | NOT NULL | 平台名称 |
| amount | NUMERIC(12,2) | NOT NULL | 收入金额 |
| remark | TEXT | — | 备注 |
| upload_batch_id | BIGINT | FK → excel_upload_log(id) | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间NULL=未同步) |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
### 冲突检测主键
`(site_id, income_month, platform_name)` — 同门店同月份同平台视为冲突。
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| stg_platform_income_pkey | PRIMARY KEY | id | 主键 |
| stg_platform_income_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |
---
## 6. biz.stg_recharge_commission — 充值业绩归属暂存表
### 表结构
| 列名 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGSERIAL | PRIMARY KEY | 自增主键 |
| site_id | BIGINT | NOT NULL | 门店 ID |
| recharge_date | DATE | NOT NULL | 充值日期 |
| member_name | VARCHAR(100) | NOT NULL | 会员名称 |
| recharge_amount | NUMERIC(12,2) | NOT NULL | 充值金额 |
| assigned_assistant | VARCHAR(100) | NOT NULL | 归属助教 |
| reward_amount | NUMERIC(12,2) | NOT NULL | 奖励金额 |
| upload_batch_id | BIGINT | FK → excel_upload_log(id) | 上传批次 |
| synced_at | TIMESTAMPTZ | — | ETL 同步时间NULL=未同步) |
| created_at | TIMESTAMPTZ | DEFAULT NOW() | 创建时间 |
### 冲突检测主键
`(site_id, recharge_date, member_name, assigned_assistant)` — 同门店同日期同会员同助教视为冲突。
### 约束与索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| stg_recharge_commission_pkey | PRIMARY KEY | id | 主键 |
| stg_recharge_commission_upload_batch_id_fkey | FOREIGN KEY | upload_batch_id → excel_upload_log(id) | 关联上传批次 |
---
## 兼容性
- **后端 API**5 个新路由模块tenant_auth / tenant_users / tenant_excel / tenant_clues / admin_tenant_admins全部依赖上述表
- **ETL**3 张 staging 表stg_finance_expense / stg_platform_income / stg_recharge_commission`synced_at` 字段供 ETL 同步标记ETL 读取 `synced_at IS NULL` 的行进行同步
- **小程序**:无直接影响(租户管理后台独立认证体系)
- **管理后台admin-web**:新增租户管理员 CRUD 页面,调用 admin_tenant_admins 路由
## 回滚策略
### 完整回滚(按依赖顺序)
```sql
BEGIN;
-- 先删除有外键依赖的表
DROP TABLE IF EXISTS biz.salary_adjustments CASCADE;
DROP TABLE IF EXISTS biz.stg_finance_expense CASCADE;
DROP TABLE IF EXISTS biz.stg_platform_income CASCADE;
DROP TABLE IF EXISTS biz.stg_recharge_commission CASCADE;
-- 再删除被引用的表
DROP TABLE IF EXISTS biz.excel_upload_log CASCADE;
-- 最后删除认证表
DROP TABLE IF EXISTS auth.tenant_admins CASCADE;
-- 清理序列CASCADE 已处理,此处为显式确认)
DROP SEQUENCE IF EXISTS biz.excel_upload_log_id_seq;
DROP SEQUENCE IF EXISTS biz.salary_adjustments_id_seq;
DROP SEQUENCE IF EXISTS biz.stg_finance_expense_id_seq;
DROP SEQUENCE IF EXISTS biz.stg_platform_income_id_seq;
DROP SEQUENCE IF EXISTS biz.stg_recharge_commission_id_seq;
DROP SEQUENCE IF EXISTS auth.tenant_admins_id_seq;
COMMIT;
```
## 验证步骤
```sql
-- 1. 确认 auth.tenant_admins 表存在且列完整12 列,含 admin_type + deleted_at
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'auth' AND table_name = 'tenant_admins'
ORDER BY ordinal_position;
-- 预期12 行id, username, password_hash, display_name, tenant_id,
-- managed_site_ids, admin_type, is_active, deleted_at, created_by, created_at, last_login_at
-- 2. 确认 tenant_admins 唯一约束
SELECT conname, contype FROM pg_constraint
WHERE conrelid = 'auth.tenant_admins'::regclass;
-- 预期:包含 tenant_admins_pkey (p) 和 tenant_admins_username_key (u)
-- 3. 确认 biz.excel_upload_log 表存在且 CHECK 约束正确
SELECT conname, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = 'biz.excel_upload_log'::regclass AND contype = 'c';
-- 预期2 行upload_type CHECK 和 status CHECK
-- 4. 确认 biz.salary_adjustments 外键和 CHECK 约束
SELECT conname, contype, pg_get_constraintdef(oid) FROM pg_constraint
WHERE conrelid = 'biz.salary_adjustments'::regclass AND contype IN ('c', 'f');
-- 预期3 行adjustment_type CHECK, amount CHECK, upload_batch_id FK
-- 5. 确认 3 张 staging 表的外键
SELECT c.conname, c.conrelid::regclass AS table_name
FROM pg_constraint c
WHERE c.confrelid = 'biz.excel_upload_log'::regclass AND c.contype = 'f';
-- 预期4 行salary_adjustments + 3 张 staging 表各 1 个 FK
-- 6. 确认索引
SELECT schemaname, tablename, indexname FROM pg_indexes
WHERE tablename IN ('tenant_admins', 'excel_upload_log', 'salary_adjustments',
'stg_finance_expense', 'stg_platform_income', 'stg_recharge_commission')
ORDER BY tablename, indexname;
-- 预期tenant_admins 3 个pkey + idx_tenant_admin_tenant + idx_tenant_admins_active_not_deleted
-- excel_upload_log 2 个pkey + idx_excel_log_site
-- salary_adjustments 3 个pkey + idx_salary_adj_site_month + idx_salary_adj_assistant_month
-- stg_* 各 1 个pkey
-- 7. 确认 6 张表均可查询
SELECT 'auth.tenant_admins' AS tbl, COUNT(*) FROM auth.tenant_admins
UNION ALL SELECT 'biz.excel_upload_log', COUNT(*) FROM biz.excel_upload_log
UNION ALL SELECT 'biz.salary_adjustments', COUNT(*) FROM biz.salary_adjustments
UNION ALL SELECT 'biz.stg_finance_expense', COUNT(*) FROM biz.stg_finance_expense
UNION ALL SELECT 'biz.stg_platform_income', COUNT(*) FROM biz.stg_platform_income
UNION ALL SELECT 'biz.stg_recharge_commission', COUNT(*) FROM biz.stg_recharge_commission;
-- 预期6 行,各表行数 ≥ 0
```
## NS4.1 变更补充2026-03-22
### auth.tenant_admins 行为变更
NS4.1 对 `auth.tenant_admins` 表做了以下调整:
#### deleted_at 软删除字段(已合并入主 DDL
`deleted_at` 字段已合并到主迁移脚本 `2026-03-20__ns4_tenant_admin_tables.sql` 中,不再需要独立的 ALTER TABLE 迁移。字段语义:
- `NULL` = 正常记录
-`NULL` = 已删除(时间戳记录删除时间)
-`is_active` 分离:`is_active` 控制启用/禁用,`deleted_at` 控制软删除
- 部分索引 `idx_tenant_admins_active_not_deleted` 仅索引 `deleted_at IS NULL` 的记录
#### 软删除逻辑
`DELETE /api/admin/tenant-admins/{id}` 端点实际执行软删除:将 `is_active` 设置为 `false`,不物理删除行。已禁用的管理员再次删除返回 409。
```sql
-- 软删除
UPDATE auth.tenant_admins SET is_active = false WHERE id = :id AND is_active = true;
```
#### tenant_id 来源变更
创建管理员时,`tenant_id` 不再是自由输入,而是从 `biz.tenants` 表中选择。后端校验 `tenant_id``biz.tenants` 中存在且 `is_active=true`
```sql
-- 创建时校验
SELECT id FROM biz.tenants WHERE id = :tenant_id AND is_active = true;
```
#### username 可编辑
`PATCH /api/admin/tenant-admins/{id}` 端点支持修改 `username`。修改时校验全局唯一性(大小写不敏感),冲突返回 409 Conflict。
```sql
-- 唯一性校验(大小写不敏感)
SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER(:new_username) AND id != :current_id AND deleted_at IS NULL;
```
#### 列表过滤
`GET /api/admin/tenant-admins` 端点默认只返回 `is_active=true` 的记录。新增 `include_inactive` 查询参数,设为 `true` 时返回所有记录(含已禁用)。
列表查询 JOIN `biz.tenants` 获取 `tenant_name` 字段。
---
## 关联文件
- DDL 基线auth`docs/database/ddl/zqyy_app__auth.sql`
- DDL 基线biz`docs/database/ddl/zqyy_app__biz.sql`
- 迁移脚本:`db/zqyy_app/migrations/2026-03-20__ns4_tenant_admin_tables.sql`
- 迁移脚本:`db/zqyy_app/migrations/2026-03-23__case_insensitive_username.sql`
- 迁移脚本:`db/zqyy_app/migrations/2026-03-23__cleanup_roles_add_admin_type.sql`
- 后端路由:`apps/backend/app/routers/tenant_auth.py``tenant_users.py``tenant_excel.py``tenant_clues.py``admin_tenant_admins.py``tenant_site_admins.py`
- 后端 Schema`apps/backend/app/schemas/tenant_excel.py``admin_tenant_admins.py`
- 认证模块:`apps/backend/app/auth/tenant_admins.py`
- Spec`.kiro/specs/tenant-admin-web/`
---
## NS4.2 变更补充2026-03-23
### auth.tenant_admins 用户名大小写不敏感
#### 变更说明
登录、创建、编辑管理员时,用户名统一转小写存储,查询使用 `LOWER()` 比较。避免 `Admin``admin` 被视为不同账号。
#### 新增索引
| 名称 | 类型 | 列 | 说明 |
|------|------|---|------|
| idx_tenant_admins_username_lower | UNIQUE (partial) | LOWER(username) WHERE deleted_at IS NULL | 大小写不敏感唯一约束 |
#### 代码变更
| 文件 | 变更 |
|------|------|
| `tenant_auth.py` | 登录查询 `WHERE LOWER(username) = LOWER(%s)` |
| `admin_tenant_admins.py` | 创建 `INSERT ... VALUES (LOWER(%s), ...)`;编辑 `SET username = LOWER(%s)`;唯一性校验 `LOWER()` 比较 |
#### 兼容性
- 后端 API登录和 CRUD 接口透明兼容,无需前端改动
- ETL无影响
- 小程序:无影响(独立认证体系)
- 管理后台admin-web无需改动后端统一处理
#### 回滚策略
```sql
-- 1. 删除函数索引
DROP INDEX IF EXISTS auth.idx_tenant_admins_username_lower;
-- 2. 代码回滚:恢复 WHERE username = %s不带 LOWER
-- 注意:已小写化的用户名不可逆,但不影响功能
```
#### 验证 SQL
```sql
-- 1. 确认索引存在
SELECT indexname FROM pg_indexes
WHERE tablename = 'tenant_admins' AND indexname = 'idx_tenant_admins_username_lower';
-- 预期1 行
-- 2. 确认无大写用户名残留
SELECT username FROM auth.tenant_admins WHERE username != LOWER(username);
-- 预期0 行
-- 3. 确认大小写不敏感登录可用
SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER('Admin') AND deleted_at IS NULL;
-- 预期:与 SELECT ... WHERE LOWER(username) = 'admin' 结果一致
```
---
## NS4.3 变更补充2026-03-23
### 角色体系隔离 + 店铺管理员支持
#### 变更说明
| 对象 | 变更类型 | 说明 |
|------|---------|------|
| `auth.tenant_admins.admin_type` | 新增字段 | `VARCHAR(20) NOT NULL DEFAULT 'tenant_admin'`,区分租户管理员(`tenant_admin`)和店铺管理员(`site_admin` |
| `chk_admin_type` | 新增约束 | `CHECK (admin_type IN ('tenant_admin', 'site_admin'))` |
| `auth.roles``site_admin` / `tenant_admin` | 删除 | 小程序 RBAC 体系不需要这两个角色,租户/店铺管理员的区分通过 `admin_type` 列实现 |
| `auth.role_permissions` | 删除关联 | 删除 `site_admin` / `tenant_admin` 对应的 10 条权限映射 |
| `auth.roles``head_coach` / `manager` | 新增 | 小程序端新增教练和管理员角色 |
#### 业务规则
- 租户管理员(`admin_type='tenant_admin'`):可管理所有管辖门店,可创建/编辑/删除店铺管理员
- 店铺管理员(`admin_type='site_admin'`):仅可管理分配的门店,不可创建其他管理员
- 登录后界面一致,仅数据范围不同(由 `managed_site_ids` 控制)
- 用户名格式:`{第一个管辖店铺的 site_code} + 最长 50 字符`
- 只有 `admin_type='tenant_admin'` 的管理员可以访问 `/api/tenant/site-admins/*` 端点
#### 新增后端端点(店铺管理员 CRUD
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| GET | `/api/tenant/site-admins` | 列出店铺管理员 | 仅 tenant_admin |
| POST | `/api/tenant/site-admins` | 创建店铺管理员 | 仅 tenant_admin |
| PATCH | `/api/tenant/site-admins/{id}` | 编辑店铺管理员 | 仅 tenant_admin |
| DELETE | `/api/tenant/site-admins/{id}` | 删除店铺管理员(软删除) | 仅 tenant_admin |
| POST | `/api/tenant/site-admins/{id}/reset-password` | 重置密码 | 仅 tenant_admin |
#### 代码变更
| 文件 | 变更 |
|------|------|
| `tenant_auth.py` | 登录查询加入 `admin_type`JWT 签发包含 `admin_type` |
| `tenant_admins.py` | `CurrentTenantAdmin` dataclass 加入 `admin_type` 字段 |
| `tenant_site_admins.py` | 新增路由模块5 个 CRUD 端点 |
| `main.py` | 注册 `tenant_site_admins` 路由 |
| `admin_tenant_admins.py` | SQL 查询加入 `ta.admin_type` 列 |
| `xcx_auth.py` | `dev-switch-role` 硬编码更新(删除 site_admin/tenant_admin新增 head_coach/manager |
#### 兼容性
| 组件 | 影响 |
|------|------|
| 后端 API | 直接依赖。JWT 新增 `admin_type` 字段;新增 5 个店铺管理员端点admin-web 列表接口返回 `admin_type` |
| 租户管理后台tenant-admin | 直接依赖。菜单根据 `adminType` 动态显示;新增店铺管理员管理页面 |
| 管理后台admin-web | 间接依赖。租户管理员列表新增"类型"列显示 |
| 小程序 | 间接依赖。`auth.roles` 删除 site_admin/tenant_admin新增 head_coach/manager |
| ETL | 无影响 |
#### 回滚策略
```sql
BEGIN;
-- 1. 回滚 admin_type 列
ALTER TABLE auth.tenant_admins DROP CONSTRAINT IF EXISTS chk_admin_type;
ALTER TABLE auth.tenant_admins DROP COLUMN IF EXISTS admin_type;
-- 2. 回滚角色变更(恢复 site_admin/tenant_admin删除 head_coach/manager
DELETE FROM auth.role_permissions
WHERE role_id IN (SELECT id FROM auth.roles WHERE code IN ('head_coach', 'manager'));
DELETE FROM auth.roles WHERE code IN ('head_coach', 'manager');
INSERT INTO auth.roles (code, name, description)
VALUES ('site_admin', '店铺管理员', '单店管理员,可查看所有看板和审核用户'),
('tenant_admin', '租户管理员', '连锁管理员,可管理多店铺和所有功能');
-- 恢复 site_admin/tenant_admin 的权限映射(各 5 条)
INSERT INTO auth.role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM auth.roles r, auth.permissions p
WHERE r.code IN ('site_admin', 'tenant_admin');
-- 3. 代码回滚:移除 tenant_site_admins 路由JWT 移除 admin_type恢复 dev-switch-role 硬编码
COMMIT;
```
#### 验证 SQL
```sql
-- 1. 确认 admin_type 列存在且默认值正确
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_schema = 'auth' AND table_name = 'tenant_admins' AND column_name = 'admin_type';
-- 预期admin_type, character varying, 'tenant_admin'::character varying
-- 2. 确认 CHECK 约束
SELECT conname, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'auth.tenant_admins'::regclass AND conname = 'chk_admin_type';
-- 预期chk_admin_type, CHECK ((admin_type)::text = ANY (...))
-- 3. 确认角色体系4 条,无 site_admin/tenant_admin
SELECT code, name FROM auth.roles ORDER BY id;
-- 预期coach, staff, head_coach, manager
-- 4. 确认角色-权限映射11 条)
SELECT r.code, COUNT(rp.permission_id) AS perm_count
FROM auth.roles r
JOIN auth.role_permissions rp ON r.id = rp.role_id
GROUP BY r.code ORDER BY r.code;
-- 预期coach=2, head_coach=2, manager=5, staff=2共 11 条head_coach 仅 view_tasks+view_board
```

View File

@@ -2,7 +2,9 @@
## DDL 基线(`ddl/` 子目录) ## DDL 基线(`ddl/` 子目录)
从测试库自动导出的完整 DDL按 schema 分文件。重新生成:`python scripts/ops/gen_consolidated_ddl.py` 从测试库自动导出的完整 DDL按 schema 分文件。重新生成:`PYTHONUTF8=1 python tools/db/gen_consolidated_ddl.py`
最近一次刷新:**2026-04-05**(合并了截至 2026-03-31 的全部迁移)。
| 文件 | 数据库 | Schema | 内容 | | 文件 | 数据库 | Schema | 内容 |
|------|--------|--------|------| |------|--------|--------|------|
@@ -10,12 +12,13 @@
| `etl_feiqiu__ods.sql` | etl_feiqiu | ods | 原始数据层23 表) | | `etl_feiqiu__ods.sql` | etl_feiqiu | ods | 原始数据层23 表) |
| `etl_feiqiu__dwd.sql` | etl_feiqiu | dwd | 明细数据层42 表) | | `etl_feiqiu__dwd.sql` | etl_feiqiu | dwd | 明细数据层42 表) |
| `etl_feiqiu__core.sql` | etl_feiqiu | core | 跨门店标准化7 表) | | `etl_feiqiu__core.sql` | etl_feiqiu | core | 跨门店标准化7 表) |
| `etl_feiqiu__dws.sql` | etl_feiqiu | dws | 汇总数据层36 + 物化视图 | | `etl_feiqiu__dws.sql` | etl_feiqiu | dws | 汇总数据层38 表) |
| `etl_feiqiu__app.sql` | etl_feiqiu | app | RLS 视图层(仅视图,无表) | | `etl_feiqiu__app.sql` | etl_feiqiu | app | RLS 视图层(仅视图,无表) |
| `zqyy_app__public.sql` | zqyy_app | public | 小程序业务表12 表) | | `zqyy_app__public.sql` | zqyy_app | public | 小程序业务表12 表) |
| `zqyy_app__auth.sql` | zqyy_app | auth | 用户认证与权限(8 表) | | `zqyy_app__auth.sql` | zqyy_app | auth | 用户认证与权限(9 表) |
| `zqyy_app__biz.sql` | zqyy_app | biz | 核心业务表(任务/备注/触发器/AI7 表) | | `zqyy_app__biz.sql` | zqyy_app | biz | 核心业务表(21 表) |
| `fdw.sql` | — | — | FDW 正向跨库映射配置etl→app | | `fdw.sql` | — | — | FDW 正向映射zqyy_app → etl_feiqiu.app |
| `fdw_reverse.sql` | — | — | FDW 反向映射etl_feiqiu → zqyy_app |
## 业务库文档BD_Manual — zqyy_app / 跨模块) ## 业务库文档BD_Manual — zqyy_app / 跨模块)
@@ -23,31 +26,39 @@
| 文件 | 说明 | | 文件 | 说明 |
|------|------| |------|------|
| `BD_Manual_auth_tables.md` | zqyy_app auth schema 表结构 | | `BD_manual_public_rbac_tables.md` | zqyy_app public schema RBAC 与工作流admin_users/users/roles/permissions/user_roles/tasks/approvals8 表) |
| `BD_Manual_biz_tables.md` | zqyy_app biz schema 表结构 | | `BD_manual_scheduled_tasks.md` | zqyy_app public schema 调度任务表scheduled_tasks/task_queue/task_execution_log |
| `BD_Manual_ai_tables.md` | zqyy_app AI 相关表 | | `BD_manual_auth_tables.md` | zqyy_app auth schema 表结构9 表) |
| `BD_Manual_auth_biz_schemas.md` | auth + biz schema 建库说明 | | `BD_manual_auth_biz_schemas.md` | auth + biz schema 建库说明 |
| `BD_Manual_app_schema_rls_views.md` | app schema RLS 视图 | | `BD_manual_biz_tables.md` | zqyy_app biz schema 核心业务表coach_tasks/notes/trigger_jobs12 字段) |
| `BD_Manual_fdw_etl_setup.md` | FDW 跨库映射配置 | | `BD_manual_biz_registry_tables.md` | zqyy_app biz schema 注册体系connectors/tenants/sites/site_code_history |
| `BD_Manual_member_retention_clue.md` | zqyy_app 维客线索表 | | `BD_manual_ai_tables.md` | zqyy_app biz schema AI 相关表ai_conversations/ai_messages/ai_cache/ai_run_logs/ai_trigger_jobs |
| `BD_manual_tenant_admin_tables.md` | zqyy_app auth/biz 租户管理后台表tenant_admins/excel_upload_log/salary_adjustments/stg_* |
| `BD_manual_member_retention_clue.md` | zqyy_app 维客线索表 |
| `BD_manual_app_schema_rls_views.md` | ETL app schema RLS 视图49 视图) |
| `BD_manual_fdw_etl_setup.md` | FDW 正向跨库映射配置zqyy_app → etl_feiqiu |
| `BD_manual_fdw_reverse_retention_clue.md` | FDW 反向映射etl_feiqiu → zqyy_app 维客线索) |
## 归档(`_archived/` 子目录) ## 归档(`_archived/` 子目录)
已吸收进 DDL 基线的迁移变更记录,仅供历史参考: 已吸收进 DDL 基线的迁移变更记录,仅供历史参考:
- 迁移变更类 BD_Manual加列、改约束、删表、FDW 变更、tenant_id 类型变更等) - 迁移变更类 BD_Manual加列、改约束、删表、FDW 变更、tenant_id 类型变更、tenant_admins 软删除等)
- `etl_feiqiu_schema_migration.md`(旧迁移汇总) - `etl_feiqiu_schema_migration.md`(旧迁移汇总)
- `zqyy_app_admin_web_tables.md`(建表记录) - `zqyy_app_admin_web_tables.md`(建表记录)
## 注意事项 ## 文档分工
- `fdw.sql` 仅包含正向映射etl_feiqiu → zqyy_app反向映射zqyy_app → etl_feiqiu的可执行脚本在 `db/fdw/setup_fdw_reverse*.sql` | 位置 | 管辖范围 |
- DDL 基线最近一次从测试库导出日期2026-03-15合并了 2026-02-27 至 2026-03-09 的全部迁移) |------|---------|
- ETL 专属文档ODS→DWD 映射、DWS 表文档):`apps/etl/connectors/feiqiu/docs/database/` | `docs/database/`(本目录) | 业务库(zqyy_app) BD_Manual + 跨模块(FDW/RLS) + DDL 基线 |
| `apps/etl/connectors/feiqiu/docs/database/` | ETL 专属(ODS/DWD/DWS 表级文档、API 映射) |
| `db/` | 运行时资产(迁移脚本/FDW 配置/建库脚本) |
| `tools/db/` | 数据库工具(DDL 生成/验证/一致性检查) |
## 相关资源 ## 相关资源
- 种子数据:已合并进各 DDL 文件末尾(不再单独维护) - 种子数据:已合并进各 DDL 文件末尾(不再单独维护)
- FDW 配置(可执行):`db/fdw/`(含正向 + 反向 + 测试环境版本) - FDW 配置(可执行):`db/fdw/`(含正向 + 反向 + 测试环境版本)
- DDL 生成脚本:`scripts/ops/gen_consolidated_ddl.py` - DDL 生成脚本:`tools/db/gen_consolidated_ddl.py`
- 迁移脚本(活跃):`db/etl_feiqiu/migrations/``db/zqyy_app/migrations/`当前已清空1.0 基线已统一 - 迁移脚本(活跃):`db/etl_feiqiu/migrations/`11 个)`db/zqyy_app/migrations/`8 个
- 迁移脚本归档:`db/_archived/ddl_baseline_2026-02-22/` - 迁移脚本归档:`db/_archived/ddl_baseline_2026-02-22/`

View File

@@ -0,0 +1,59 @@
# BD 手册 — auth.tenant_admins 新增 deleted_at 软删除字段
## 变更日期
2026-03-22
## 变更说明
### 新增字段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `deleted_at` | `TIMESTAMPTZ` | `NULL` | 软删除时间戳NULL=正常,非 NULL=已删除 |
### 新增索引
| 索引名 | 说明 |
|--------|------|
| `idx_tenant_admins_active_not_deleted` | 部分索引 `(is_active) WHERE deleted_at IS NULL`,加速列表和登录查询 |
### 语义变更
- 删除与禁用分离:`is_active` 仅控制启用/禁用,`deleted_at` 控制软删除
- DELETE 接口改为设置 `deleted_at = NOW()`,不再检查 `is_active` 状态
- 所有查询(列表、登录、编辑、重置密码)默认过滤 `deleted_at IS NULL`
- 用户名唯一性校验仅在未删除记录中生效
## 兼容性影响
| 模块 | 影响 |
|------|------|
| 后端 DELETE 接口 | 改为设置 `deleted_at`,不再返回 409已禁用 |
| 后端列表接口 | 新增 `deleted_at IS NULL` 过滤,已删除记录不再出现 |
| 租户登录接口 | 新增 `deleted_at IS NULL` 过滤,已删除账号无法登录 |
| 编辑/重置密码接口 | WHERE 条件加 `deleted_at IS NULL`,已删除记录返回 404 |
| 前端 | 无需改动,删除按钮已存在 |
| ETL | 无影响(不涉及 tenant_admins 表) |
## 回滚策略
```sql
DROP INDEX IF EXISTS auth.idx_tenant_admins_active_not_deleted;
ALTER TABLE auth.tenant_admins DROP COLUMN IF EXISTS deleted_at;
```
回滚后需同步还原后端代码中的 DELETE 接口逻辑。
## 验证 SQL
```sql
-- 1) 字段存在性
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'auth' AND table_name = 'tenant_admins' AND column_name = 'deleted_at';
-- 预期1 行
-- 2) 索引存在性
SELECT indexname FROM pg_indexes
WHERE schemaname = 'auth' AND tablename = 'tenant_admins'
AND indexname = 'idx_tenant_admins_active_not_deleted';
-- 预期1 行
-- 3) 现有数据不受影响
SELECT COUNT(*) FROM auth.tenant_admins WHERE deleted_at IS NOT NULL;
-- 预期0
```

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- etl_feiqiu / appRLS 视图层) -- etl_feiqiu / appRLS 视图层)
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -39,6 +39,17 @@ SELECT id,
FROM dws.dws_assistant_daily_detail d; FROM dws.dws_assistant_daily_detail d;
; ;
CREATE OR REPLACE VIEW app.v_cfg_area_category AS
SELECT DISTINCT category_code,
category_name,
display_name,
short_name,
sort_order
FROM dws.cfg_area_category
WHERE ((is_active = true) AND ((category_code)::text <> ALL ((ARRAY['SPECIAL'::character varying, 'OTHER'::character varying])::text[])))
ORDER BY sort_order;
;
CREATE OR REPLACE VIEW app.v_cfg_assistant_level_price AS CREATE OR REPLACE VIEW app.v_cfg_assistant_level_price AS
SELECT price_id, SELECT price_id,
level_code, level_code,
@@ -104,19 +115,6 @@ SELECT tier_id,
FROM dws.cfg_performance_tier; FROM dws.cfg_performance_tier;
; ;
CREATE OR REPLACE VIEW app.v_cfg_area_category AS
SELECT DISTINCT
category_code,
category_name,
display_name,
short_name,
sort_order
FROM dws.cfg_area_category
WHERE is_active = TRUE
AND category_code NOT IN ('SPECIAL', 'OTHER')
ORDER BY sort_order;
;
CREATE OR REPLACE VIEW app.v_dim_assistant AS CREATE OR REPLACE VIEW app.v_dim_assistant AS
SELECT assistant_id, SELECT assistant_id,
user_id, user_id,
@@ -606,6 +604,26 @@ SELECT contribution_id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint); WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
; ;
CREATE OR REPLACE VIEW app.v_dws_assistant_project_tag AS
SELECT id,
site_id,
tenant_id,
assistant_id,
time_window,
category_code,
category_name,
short_name,
duration_seconds,
total_seconds,
percentage,
is_tagged,
computed_at,
created_at,
updated_at
FROM dws.dws_assistant_project_tag
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_assistant_recharge_commission AS CREATE OR REPLACE VIEW app.v_dws_assistant_recharge_commission AS
SELECT id, SELECT id,
site_id, site_id,
@@ -671,6 +689,92 @@ SELECT id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint); WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
; ;
CREATE OR REPLACE VIEW app.v_dws_coach_area_hours AS
SELECT id,
site_id,
tenant_id,
stat_month,
assistant_id,
area_code,
base_hours,
bonus_hours,
room_hours,
effective_hours,
trashed_hours,
base_service_count,
bonus_service_count,
room_service_count,
created_at,
updated_at
FROM dws.dws_coach_area_hours
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_finance_area_daily AS
SELECT id,
site_id,
tenant_id,
stat_date,
area_code,
table_fee_amount,
goods_amount,
assistant_pd_amount,
assistant_cx_amount,
gross_amount,
discount_groupbuy,
discount_vip,
discount_manual,
discount_gift_card,
discount_rounding,
discount_other,
discount_total,
confirmed_income,
cash_pay_amount,
cash_paper_amount,
scan_pay_amount,
groupbuy_pay_amount,
recharge_cash_inflow,
cash_inflow_total,
cash_outflow_total,
cash_balance_change,
card_consume_total,
recharge_card_consume,
gift_card_consume,
recharge_cash,
first_recharge_cash,
renewal_cash,
order_count,
created_at,
updated_at
FROM dws.dws_finance_area_daily
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_finance_board_cache AS
SELECT id,
site_id,
time_range,
area_code,
start_date,
end_date,
prev_start_date,
prev_end_date,
occurrence,
discount,
discount_rate,
confirmed_revenue,
cash_in,
cash_out,
cash_balance,
balance_rate,
data_fingerprint,
computed_at,
created_at,
updated_at
FROM dws.dws_finance_board_cache
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_finance_daily_summary AS CREATE OR REPLACE VIEW app.v_dws_finance_daily_summary AS
SELECT id, SELECT id,
site_id, site_id,
@@ -691,6 +795,8 @@ SELECT id,
confirmed_income, confirmed_income,
cash_inflow_total, cash_inflow_total,
cash_pay_amount, cash_pay_amount,
cash_paper_amount,
scan_pay_amount,
groupbuy_pay_amount, groupbuy_pay_amount,
platform_settlement_amount, platform_settlement_amount,
platform_fee_amount, platform_fee_amount,
@@ -963,7 +1069,54 @@ SELECT newconv_id,
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint); WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
; ;
CREATE OR REPLACE VIEW app.v_dws_member_project_tag AS
SELECT id,
site_id,
tenant_id,
member_id,
time_window,
category_code,
category_name,
short_name,
duration_seconds,
total_seconds,
percentage,
is_tagged,
computed_at,
created_at,
updated_at
FROM dws.dws_member_project_tag
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_member_spending_power_index AS
SELECT spi_id,
site_id,
member_id,
spend_30,
spend_90,
recharge_90,
orders_30,
orders_90,
visit_days_30,
visit_days_90,
avg_ticket_90,
active_weeks_90,
daily_spend_ewma_90,
score_level_raw,
score_speed_raw,
score_stability_raw,
score_level_display,
score_speed_display,
score_stability_display,
raw_score,
display_score,
calc_time,
created_at,
updated_at
FROM dws.dws_member_spending_power_index
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW app.v_dws_member_visit_detail AS CREATE OR REPLACE VIEW app.v_dws_member_visit_detail AS
SELECT id, SELECT id,
@@ -1182,34 +1335,3 @@ SELECT site_id,
FROM core.dim_site s; FROM core.dim_site s;
; ;
-- 2026-03-19 新增BOARD 看板所需的 3 个 RLS 视图
-- 迁移脚本db/etl_feiqiu/migrations/2026-03-19_add_board_rls_views.sql
CREATE OR REPLACE VIEW app.v_dws_assistant_project_tag AS
SELECT id, site_id, tenant_id, assistant_id, time_window,
category_code, category_name, short_name,
duration_seconds, total_seconds, percentage, is_tagged,
computed_at, created_at, updated_at
FROM dws.dws_assistant_project_tag
WHERE site_id = current_setting('app.current_site_id')::bigint;
CREATE OR REPLACE VIEW app.v_dws_member_project_tag AS
SELECT id, site_id, tenant_id, member_id, time_window,
category_code, category_name, short_name,
duration_seconds, total_seconds, percentage, is_tagged,
computed_at, created_at, updated_at
FROM dws.dws_member_project_tag
WHERE site_id = current_setting('app.current_site_id')::bigint;
CREATE OR REPLACE VIEW app.v_dws_member_spending_power_index AS
SELECT spi_id, site_id, member_id,
spend_30, spend_90, recharge_90,
orders_30, orders_90, visit_days_30, visit_days_90,
avg_ticket_90, active_weeks_90, daily_spend_ewma_90,
score_level_raw, score_speed_raw, score_stability_raw,
score_level_display, score_speed_display, score_stability_display,
raw_score, display_score,
calc_time, created_at, updated_at
FROM dws.dws_member_spending_power_index
WHERE site_id = current_setting('app.current_site_id')::bigint;

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- etl_feiqiu / core跨门店标准化维度/事实) -- etl_feiqiu / core跨门店标准化维度/事实)
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- etl_feiqiu / dwd明细数据层 -- etl_feiqiu / dwd明细数据层
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -284,7 +284,11 @@ CREATE TABLE dwd.dim_member_ex (
scd2_start_time timestamp with time zone NOT NULL, scd2_start_time timestamp with time zone NOT NULL,
scd2_end_time timestamp with time zone, scd2_end_time timestamp with time zone,
scd2_is_current integer, scd2_is_current integer,
scd2_version integer scd2_version integer,
other_pay_money_sum numeric(18,2),
last_consume_time timestamp with time zone,
non_consume_day_num integer,
first_consumption integer
); );
CREATE TABLE dwd.dim_site ( CREATE TABLE dwd.dim_site (
@@ -617,7 +621,9 @@ CREATE TABLE dwd.dwd_assistant_service_log_ex (
composite_grade_time timestamp with time zone, composite_grade_time timestamp with time zone,
assistant_team_name text, assistant_team_name text,
operator_id bigint, operator_id bigint,
operator_name text operator_name text,
deduct_leave_seconds integer DEFAULT 0,
order_from integer
); );
CREATE TABLE dwd.dwd_goods_stock_movement ( CREATE TABLE dwd.dwd_goods_stock_movement (
@@ -660,7 +666,8 @@ CREATE TABLE dwd.dwd_goods_stock_summary (
current_stock numeric(18,4), current_stock numeric(18,4),
site_id bigint, site_id bigint,
tenant_id bigint, tenant_id bigint,
fetched_at timestamp with time zone NOT NULL fetched_at timestamp with time zone NOT NULL,
create_time timestamp with time zone
); );
CREATE TABLE dwd.dwd_groupbuy_redemption ( CREATE TABLE dwd.dwd_groupbuy_redemption (
@@ -986,7 +993,8 @@ CREATE TABLE dwd.dwd_settlement_head_ex (
salesman_name character varying(100), salesman_name character varying(100),
order_remark character varying(255), order_remark character varying(255),
operator_id bigint, operator_id bigint,
salesman_user_id bigint salesman_user_id bigint,
order_from integer
); );
CREATE TABLE dwd.dwd_store_goods_sale ( CREATE TABLE dwd.dwd_store_goods_sale (
@@ -1045,7 +1053,10 @@ CREATE TABLE dwd.dwd_store_goods_sale_ex (
push_money numeric(18,2), push_money numeric(18,2),
is_single_order integer, is_single_order integer,
sales_type integer, sales_type integer,
operator_id bigint operator_id bigint,
activity_amount numeric(18,2) DEFAULT 0,
activity_id bigint DEFAULT 0,
order_from integer
); );
CREATE TABLE dwd.dwd_table_fee_adjust ( CREATE TABLE dwd.dwd_table_fee_adjust (
@@ -1128,7 +1139,8 @@ CREATE TABLE dwd.dwd_table_fee_log_ex (
operator_id bigint, operator_id bigint,
salesman_user_id bigint, salesman_user_id bigint,
salesman_org_id bigint, salesman_org_id bigint,
order_consumption_type integer order_consumption_type integer,
order_from integer
); );
-- 约束(主键 / 唯一 / 外键) -- 约束(主键 / 唯一 / 外键)

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- etl_feiqiu / dws汇总数据层 -- etl_feiqiu / dws汇总数据层
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -21,6 +21,9 @@ CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_order_contribution_contribution_
CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_project_tag_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_project_tag_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_recharge_commission_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_recharge_commission_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_salary_calc_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_assistant_salary_calc_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_coach_area_hours_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_area_daily_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_board_cache_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_daily_summary_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_daily_summary_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_discount_detail_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_discount_detail_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_expense_summary_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS dws.dws_finance_expense_summary_id_seq AS bigint;
@@ -364,6 +367,86 @@ CREATE TABLE dws.dws_assistant_salary_calc (
updated_at timestamp with time zone DEFAULT now() NOT NULL updated_at timestamp with time zone DEFAULT now() NOT NULL
); );
CREATE TABLE dws.dws_coach_area_hours (
id bigint DEFAULT nextval('dws.dws_coach_area_hours_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
tenant_id bigint NOT NULL,
stat_month date NOT NULL,
assistant_id bigint NOT NULL,
area_code character varying(20) NOT NULL,
base_hours numeric(10,2) DEFAULT 0 NOT NULL,
bonus_hours numeric(10,2) DEFAULT 0 NOT NULL,
room_hours numeric(10,2) DEFAULT 0 NOT NULL,
effective_hours numeric(10,2) DEFAULT 0 NOT NULL,
trashed_hours numeric(10,2) DEFAULT 0 NOT NULL,
base_service_count integer DEFAULT 0 NOT NULL,
bonus_service_count integer DEFAULT 0 NOT NULL,
room_service_count integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE dws.dws_finance_area_daily (
id bigint DEFAULT nextval('dws.dws_finance_area_daily_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
tenant_id bigint NOT NULL,
stat_date date NOT NULL,
area_code character varying(20) NOT NULL,
table_fee_amount numeric(14,2) DEFAULT 0 NOT NULL,
goods_amount numeric(14,2) DEFAULT 0 NOT NULL,
assistant_pd_amount numeric(14,2) DEFAULT 0 NOT NULL,
assistant_cx_amount numeric(14,2) DEFAULT 0 NOT NULL,
gross_amount numeric(14,2) DEFAULT 0 NOT NULL,
discount_groupbuy numeric(14,2) DEFAULT 0 NOT NULL,
discount_vip numeric(14,2) DEFAULT 0 NOT NULL,
discount_manual numeric(14,2) DEFAULT 0 NOT NULL,
discount_gift_card numeric(14,2) DEFAULT 0 NOT NULL,
discount_rounding numeric(14,2) DEFAULT 0 NOT NULL,
discount_other numeric(14,2) DEFAULT 0 NOT NULL,
discount_total numeric(14,2) DEFAULT 0 NOT NULL,
confirmed_income numeric(14,2) DEFAULT 0 NOT NULL,
cash_pay_amount numeric(14,2) DEFAULT 0 NOT NULL,
cash_paper_amount numeric(14,2) DEFAULT 0 NOT NULL,
scan_pay_amount numeric(14,2) DEFAULT 0 NOT NULL,
groupbuy_pay_amount numeric(14,2) DEFAULT 0 NOT NULL,
recharge_cash_inflow numeric(14,2) DEFAULT 0 NOT NULL,
cash_inflow_total numeric(14,2) DEFAULT 0 NOT NULL,
cash_outflow_total numeric(14,2) DEFAULT 0 NOT NULL,
cash_balance_change numeric(14,2) DEFAULT 0 NOT NULL,
card_consume_total numeric(14,2) DEFAULT 0 NOT NULL,
recharge_card_consume numeric(14,2) DEFAULT 0 NOT NULL,
gift_card_consume numeric(14,2) DEFAULT 0 NOT NULL,
recharge_cash numeric(14,2) DEFAULT 0 NOT NULL,
first_recharge_cash numeric(14,2) DEFAULT 0 NOT NULL,
renewal_cash numeric(14,2) DEFAULT 0 NOT NULL,
order_count integer DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE dws.dws_finance_board_cache (
id bigint DEFAULT nextval('dws.dws_finance_board_cache_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
time_range character varying(20) NOT NULL,
area_code character varying(20) NOT NULL,
start_date date NOT NULL,
end_date date NOT NULL,
prev_start_date date,
prev_end_date date,
occurrence numeric(14,2) DEFAULT 0 NOT NULL,
discount numeric(14,2) DEFAULT 0 NOT NULL,
discount_rate numeric(8,4) DEFAULT 0 NOT NULL,
confirmed_revenue numeric(14,2) DEFAULT 0 NOT NULL,
cash_in numeric(14,2) DEFAULT 0 NOT NULL,
cash_out numeric(14,2) DEFAULT 0 NOT NULL,
cash_balance numeric(14,2) DEFAULT 0 NOT NULL,
balance_rate numeric(8,4) DEFAULT 0 NOT NULL,
data_fingerprint character varying(64),
computed_at timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE dws.dws_finance_daily_summary ( CREATE TABLE dws.dws_finance_daily_summary (
id bigint DEFAULT nextval('dws.dws_finance_daily_summary_id_seq'::regclass) NOT NULL, id bigint DEFAULT nextval('dws.dws_finance_daily_summary_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL, site_id bigint NOT NULL,
@@ -406,7 +489,9 @@ CREATE TABLE dws.dws_finance_daily_summary (
guest_order_count integer DEFAULT 0 NOT NULL, guest_order_count integer DEFAULT 0 NOT NULL,
avg_order_amount numeric(12,2) DEFAULT 0 NOT NULL, avg_order_amount numeric(12,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL updated_at timestamp with time zone DEFAULT now() NOT NULL,
cash_paper_amount numeric(14,2) DEFAULT 0 NOT NULL,
scan_pay_amount numeric(14,2) DEFAULT 0 NOT NULL
); );
CREATE TABLE dws.dws_finance_discount_detail ( CREATE TABLE dws.dws_finance_discount_detail (
@@ -481,14 +566,14 @@ CREATE TABLE dws.dws_finance_recharge_summary (
total_card_balance numeric(14,2) DEFAULT 0 NOT NULL, total_card_balance numeric(14,2) DEFAULT 0 NOT NULL,
cash_card_balance numeric(14,2) DEFAULT 0 NOT NULL, cash_card_balance numeric(14,2) DEFAULT 0 NOT NULL,
gift_card_balance numeric(14,2) DEFAULT 0 NOT NULL, gift_card_balance numeric(14,2) DEFAULT 0 NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
gift_liquor_balance numeric(14,2) DEFAULT 0 NOT NULL, gift_liquor_balance numeric(14,2) DEFAULT 0 NOT NULL,
gift_table_fee_balance numeric(14,2) DEFAULT 0 NOT NULL, gift_table_fee_balance numeric(14,2) DEFAULT 0 NOT NULL,
gift_voucher_balance numeric(14,2) DEFAULT 0 NOT NULL, gift_voucher_balance numeric(14,2) DEFAULT 0 NOT NULL,
gift_liquor_recharge numeric(14,2) DEFAULT 0 NOT NULL, gift_liquor_recharge numeric(14,2) DEFAULT 0 NOT NULL,
gift_table_fee_recharge numeric(14,2) DEFAULT 0 NOT NULL, gift_table_fee_recharge numeric(14,2) DEFAULT 0 NOT NULL,
gift_voucher_recharge numeric(14,2) DEFAULT 0 NOT NULL, gift_voucher_recharge numeric(14,2) DEFAULT 0 NOT NULL
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
); );
CREATE TABLE dws.dws_goods_stock_daily_summary ( CREATE TABLE dws.dws_goods_stock_daily_summary (
@@ -631,7 +716,13 @@ CREATE TABLE dws.dws_member_assistant_relation_index (
ml_display numeric(4,2) DEFAULT 0 NOT NULL, ml_display numeric(4,2) DEFAULT 0 NOT NULL,
calc_time timestamp with time zone DEFAULT now() NOT NULL, calc_time timestamp with time zone DEFAULT now() NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL updated_at timestamp with time zone DEFAULT now() NOT NULL,
recall_created_total integer DEFAULT 0 NOT NULL,
recall_completed_total integer DEFAULT 0 NOT NULL,
follow_up_created_total integer DEFAULT 0 NOT NULL,
follow_up_completed_total integer DEFAULT 0 NOT NULL,
total_created integer DEFAULT 0 NOT NULL,
total_completed integer DEFAULT 0 NOT NULL
); );
CREATE TABLE dws.dws_member_consumption_summary ( CREATE TABLE dws.dws_member_consumption_summary (
@@ -976,6 +1067,12 @@ ALTER TABLE dws.dws_assistant_project_tag ADD CONSTRAINT uk_dws_assistant_projec
ALTER TABLE dws.dws_assistant_recharge_commission ADD CONSTRAINT dws_assistant_recharge_commission_pkey PRIMARY KEY (id); ALTER TABLE dws.dws_assistant_recharge_commission ADD CONSTRAINT dws_assistant_recharge_commission_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_assistant_salary_calc ADD CONSTRAINT dws_assistant_salary_calc_pkey PRIMARY KEY (id); ALTER TABLE dws.dws_assistant_salary_calc ADD CONSTRAINT dws_assistant_salary_calc_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_assistant_salary_calc ADD CONSTRAINT uk_dws_assistant_salary UNIQUE (site_id, assistant_id, salary_month, assistant_level_code); ALTER TABLE dws.dws_assistant_salary_calc ADD CONSTRAINT uk_dws_assistant_salary UNIQUE (site_id, assistant_id, salary_month, assistant_level_code);
ALTER TABLE dws.dws_coach_area_hours ADD CONSTRAINT dws_coach_area_hours_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_coach_area_hours ADD CONSTRAINT dws_coach_area_hours_site_id_stat_month_assistant_id_area_c_key UNIQUE (site_id, stat_month, assistant_id, area_code);
ALTER TABLE dws.dws_finance_area_daily ADD CONSTRAINT dws_finance_area_daily_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_finance_area_daily ADD CONSTRAINT dws_finance_area_daily_site_id_stat_date_area_code_key UNIQUE (site_id, stat_date, area_code);
ALTER TABLE dws.dws_finance_board_cache ADD CONSTRAINT dws_finance_board_cache_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_finance_board_cache ADD CONSTRAINT dws_finance_board_cache_site_id_time_range_area_code_key UNIQUE (site_id, time_range, area_code);
ALTER TABLE dws.dws_finance_daily_summary ADD CONSTRAINT dws_finance_daily_summary_pkey PRIMARY KEY (id); ALTER TABLE dws.dws_finance_daily_summary ADD CONSTRAINT dws_finance_daily_summary_pkey PRIMARY KEY (id);
ALTER TABLE dws.dws_finance_daily_summary ADD CONSTRAINT uk_dws_finance_daily UNIQUE (site_id, stat_date); ALTER TABLE dws.dws_finance_daily_summary ADD CONSTRAINT uk_dws_finance_daily UNIQUE (site_id, stat_date);
ALTER TABLE dws.dws_finance_discount_detail ADD CONSTRAINT dws_finance_discount_detail_pkey PRIMARY KEY (id); ALTER TABLE dws.dws_finance_discount_detail ADD CONSTRAINT dws_finance_discount_detail_pkey PRIMARY KEY (id);
@@ -1099,6 +1196,92 @@ CREATE INDEX idx_mv_finance_daily_l3 ON dws.mv_dws_finance_daily_summary_l3 USIN
CREATE INDEX idx_mv_finance_daily_l4 ON dws.mv_dws_finance_daily_summary_l4 USING btree (site_id, stat_date); CREATE INDEX idx_mv_finance_daily_l4 ON dws.mv_dws_finance_daily_summary_l4 USING btree (site_id, stat_date);
-- 视图 -- 视图
CREATE OR REPLACE VIEW dws.v_dws_coach_area_hours AS
SELECT id,
site_id,
tenant_id,
stat_month,
assistant_id,
area_code,
base_hours,
bonus_hours,
room_hours,
effective_hours,
trashed_hours,
base_service_count,
bonus_service_count,
room_service_count,
created_at,
updated_at
FROM dws.dws_coach_area_hours
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW dws.v_dws_finance_area_daily AS
SELECT id,
site_id,
tenant_id,
stat_date,
area_code,
table_fee_amount,
goods_amount,
assistant_pd_amount,
assistant_cx_amount,
gross_amount,
discount_groupbuy,
discount_vip,
discount_manual,
discount_gift_card,
discount_rounding,
discount_other,
discount_total,
confirmed_income,
cash_pay_amount,
cash_paper_amount,
scan_pay_amount,
groupbuy_pay_amount,
recharge_cash_inflow,
cash_inflow_total,
cash_outflow_total,
cash_balance_change,
card_consume_total,
recharge_card_consume,
gift_card_consume,
recharge_cash,
first_recharge_cash,
renewal_cash,
order_count,
created_at,
updated_at
FROM dws.dws_finance_area_daily
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW dws.v_dws_finance_board_cache AS
SELECT id,
site_id,
time_range,
area_code,
start_date,
end_date,
prev_start_date,
prev_end_date,
occurrence,
discount,
discount_rate,
confirmed_revenue,
cash_in,
cash_out,
cash_balance,
balance_rate,
data_fingerprint,
computed_at,
created_at,
updated_at
FROM dws.dws_finance_board_cache
WHERE (site_id = (current_setting('app.current_site_id'::text))::bigint);
;
CREATE OR REPLACE VIEW dws.v_member_recall_priority AS CREATE OR REPLACE VIEW dws.v_member_recall_priority AS
SELECT dws_member_winback_index.site_id, SELECT dws_member_winback_index.site_id,
dws_member_winback_index.tenant_id, dws_member_winback_index.tenant_id,
@@ -1510,625 +1693,3 @@ CREATE INDEX idx_mv_finance_daily_l2 ON dws.mv_dws_finance_daily_summary_l2 USIN
CREATE INDEX idx_mv_finance_daily_l3 ON dws.mv_dws_finance_daily_summary_l3 USING btree (site_id, stat_date); CREATE INDEX idx_mv_finance_daily_l3 ON dws.mv_dws_finance_daily_summary_l3 USING btree (site_id, stat_date);
CREATE INDEX idx_mv_finance_daily_l4 ON dws.mv_dws_finance_daily_summary_l4 USING btree (site_id, stat_date); CREATE INDEX idx_mv_finance_daily_l4 ON dws.mv_dws_finance_daily_summary_l4 USING btree (site_id, stat_date);
-- =============================================================================
-- 种子数据DWS 配置表初始数据(绩效档位、等级定价、奖金规则、区域分类、技能映射)
-- =============================================================================
-- =============================================================================
-- DWS 配置表初始数据
-- 版本: v4.0
-- 创建日期: 2026-02-01
-- 更新日期: 2026-02-21
-- AI_CHANGELOG [2026-02-21] 取消全文注释,数据已写入 test_etl_feiqiu
-- 新增 2025-01-01~2026-02-28 统一提成档位基础课18元/小时打赏课40%
-- 新增 GUARANTEE 保底奖金规则按等级初级12000/中级16000/高级18000/星级23000
-- 历史分档口径截止日期调整为 2024-12-31
-- 描述: 初始化配置表数据,包含绩效档位、等级定价、奖金规则、区域分类、技能映射
-- =============================================================================
-- =============================================================================
-- 1. cfg_performance_tier - 绩效档位配置(含历史口径)
-- 数据来源DWS 数据库处理需求.md
-- 三段时间线:
-- 2000-01-01 ~ 2024-12-31: 旧方案6档阶梯抽成
-- 2025-01-01 ~ 2026-02-28: 统一提成不分档基础课18元/小时打赏课40%
-- 2026-03-01 ~ 9999-12-31: 新方案5档阶梯抽成
-- =============================================================================
TRUNCATE TABLE dws.cfg_performance_tier RESTART IDENTITY CASCADE;
INSERT INTO dws.cfg_performance_tier (
tier_code, tier_name, tier_level,
min_hours, max_hours,
base_deduction, bonus_deduction_ratio, vacation_days, vacation_unlimited,
is_new_hire_tier, effective_from, effective_to, description
) VALUES
-- 旧方案至2024-12-31
('T0', '0档-淘汰压力', 0,
0, 100,
28.00, 0.50, 3, FALSE,
FALSE, '2000-01-01', '2024-12-31',
'旧方案H<100专业课抽成28元/小时打赏课抽成50%休假3天'),
('T1', '1档-及格档', 1,
100, 130,
18.00, 0.40, 4, FALSE,
FALSE, '2000-01-01', '2024-12-31',
'旧方案100≤H<130专业课抽成18元/小时打赏课抽成40%休假4天'),
('T2', '2档-良好档', 2,
130, 160,
15.00, 0.38, 4, FALSE,
FALSE, '2000-01-01', '2024-12-31',
'旧方案130≤H<160专业课抽成15元/小时打赏课抽成38%休假4天'),
('T3', '3档-优秀档', 3,
160, 190,
13.00, 0.35, 5, FALSE,
FALSE, '2000-01-01', '2024-12-31',
'旧方案160≤H<190专业课抽成13元/小时打赏课抽成35%休假5天'),
('T4', '4档-卓越加速档', 4,
190, 220,
10.00, 0.33, 6, FALSE,
FALSE, '2000-01-01', '2024-12-31',
'旧方案190≤H<220专业课抽成10元/小时打赏课抽成33%休假6天'),
('T5', '5档-冠军加速档', 5,
220, NULL,
8.00, 0.30, 0, TRUE,
FALSE, '2000-01-01', '2024-12-31',
'旧方案H≥220专业课抽成8元/小时打赏课抽成30%,休假自由'),
-- 2025-01-01 ~ 2026-02-28: 统一提成(不分档,所有助教统一规则)
-- CHANGE 2026-02-21 | 新增统一提成档位基础课球房提成18元/小时打赏课球房提成40%
('T0', '统一档', 0,
0, NULL,
18.00, 0.40, 0, FALSE,
FALSE, '2025-01-01', '2026-02-28',
'2025-01-01~2026-02-28统一规则基础课球房提成18元/小时打赏课球房提成40%,不分档位'),
-- 新方案2026-03-01起
('T0', '0档-淘汰压力', 0,
0, 120,
28.00, 0.50, 3, FALSE,
FALSE, '2026-03-01', '9999-12-31',
'新方案H<120专业课抽成28元/小时打赏课抽成50%休假3天'),
('T1', '1档-及格档', 1,
120, 150,
18.00, 0.40, 4, FALSE,
FALSE, '2026-03-01', '9999-12-31',
'新方案120≤H<150专业课抽成18元/小时打赏课抽成40%休假4天'),
('T2', '2档-良好档', 2,
150, 180,
13.00, 0.35, 5, FALSE,
FALSE, '2026-03-01', '9999-12-31',
'新方案150≤H<180专业课抽成13元/小时打赏课抽成35%休假5天'),
('T3', '3档-优秀档', 3,
180, 210,
10.00, 0.30, 6, FALSE,
FALSE, '2026-03-01', '9999-12-31',
'新方案180≤H<210专业课抽成10元/小时打赏课抽成30%休假6天'),
('T4', '4档-销冠竞争', 4,
210, NULL,
8.00, 0.25, 0, TRUE,
FALSE, '2026-03-01', '9999-12-31',
'新方案H≥210专业课抽成8元/小时打赏课抽成25%,休假自由');
-- =============================================================================
-- 2. cfg_assistant_level_price - 助教等级定价
-- 说明:
-- - level_code 来自 dim_assistant.assistant_level
-- - 8=助教管理, 10=初级, 20=中级, 30=高级, 40=星级
-- - 价格为客户支付价格(对外价格),助教收入=客户支付-档位抽成
-- - 包厢课基础课统一138元/小时(不随等级变化)
-- =============================================================================
TRUNCATE TABLE dws.cfg_assistant_level_price RESTART IDENTITY CASCADE;
INSERT INTO dws.cfg_assistant_level_price (
level_code, level_name,
base_course_price, bonus_course_price,
effective_from, effective_to, description
) VALUES
(10, '初级',
98.00, 190.00,
'2000-01-01', '9999-12-31',
'初级助教基础课98元/时附加课190元/时(客户支付价格)'),
(20, '中级',
108.00, 190.00,
'2000-01-01', '9999-12-31',
'中级助教基础课108元/时附加课190元/时(客户支付价格)'),
(30, '高级',
118.00, 190.00,
'2000-01-01', '9999-12-31',
'高级助教基础课118元/时附加课190元/时(客户支付价格)'),
(40, '星级',
138.00, 190.00,
'2000-01-01', '9999-12-31',
'星级助教基础课138元/时附加课190元/时(客户支付价格)'),
(8, '助教管理',
98.00, 190.00,
'2000-01-01', '9999-12-31',
'助教管理:不参与客户服务计费,默认按初级价格');
-- =============================================================================
-- 3. cfg_bonus_rules - 奖金规则配置
-- 说明:
-- - SPRINT: 冲刺奖金历史口径至2024-12-31
-- - GUARANTEE: 保底月薪线2025-01-01~2026-02-28按等级区分
-- * 保底规则:总课时达标 + 打赏课≥10小时 → 触发保底月薪线
-- * 保底含义:实发 = MAX(课时收入+奖金, 保底金额),非额外奖金
-- * rule_code 中 LV10/LV20/LV30/LV40 对应 level_code
-- - TOP_RANK: Top3排名奖金2026-03-01起
-- CHANGE 2026-02-21 | 新增 GUARANTEE 保底奖金规则
-- =============================================================================
TRUNCATE TABLE dws.cfg_bonus_rules RESTART IDENTITY CASCADE;
INSERT INTO dws.cfg_bonus_rules (
rule_type, rule_code, rule_name,
threshold_hours, rank_position, bonus_amount,
is_cumulative, priority,
effective_from, effective_to, description
) VALUES
-- 冲刺奖金历史口径至2024-12-31
('SPRINT', 'SPRINT_190', '冲刺奖金190',
190.00, NULL, 300.00,
FALSE, 1,
'2000-01-01', '2024-12-31',
'历史口径业绩≥190小时获得300元冲刺奖金不累计'),
('SPRINT', 'SPRINT_220', '冲刺奖金220',
220.00, NULL, 800.00,
FALSE, 2,
'2000-01-01', '2024-12-31',
'历史口径业绩≥220小时获得800元冲刺奖金覆盖190档'),
-- 保底奖金2025-01-01 ~ 2026-02-28
-- 按助教等级区分需同时满足总课时和打赏课最低时数≥10小时
('GUARANTEE', 'GUAR_LV10', '初级保底奖金',
130.00, NULL, 12000.00,
FALSE, 10,
'2025-01-01', '2026-02-28',
'初级保底完成130小时课程含≥10小时打赏课保底月薪线12000元实发=MAX(课时收入+奖金, 12000)'),
('GUARANTEE', 'GUAR_LV20', '中级保底奖金',
150.00, NULL, 16000.00,
FALSE, 20,
'2025-01-01', '2026-02-28',
'中级保底完成150小时课程含≥10小时打赏课保底月薪线16000元实发=MAX(课时收入+奖金, 16000)'),
('GUARANTEE', 'GUAR_LV30', '高级保底奖金',
160.00, NULL, 18000.00,
FALSE, 30,
'2025-01-01', '2026-02-28',
'高级保底完成160小时课程含≥10小时打赏课保底月薪线18000元实发=MAX(课时收入+奖金, 18000)'),
('GUARANTEE', 'GUAR_LV40', '星级保底奖金',
170.00, NULL, 23000.00,
FALSE, 40,
'2025-01-01', '2026-02-28',
'星级保底完成170小时课程含≥10小时打赏课保底月薪线23000元实发=MAX(课时收入+奖金, 23000)'),
-- Top排名奖金2026-03-01起
('TOP_RANK', 'TOP_1', 'Top1排名奖金',
NULL, 1, 1000.00,
FALSE, 0,
'2026-03-01', '9999-12-31',
'月度排名第一获得1000元并列都算'),
('TOP_RANK', 'TOP_2', 'Top2排名奖金',
NULL, 2, 600.00,
FALSE, 0,
'2026-03-01', '9999-12-31',
'月度排名第二获得600元并列都算'),
('TOP_RANK', 'TOP_3', 'Top3排名奖金',
NULL, 3, 400.00,
FALSE, 0,
'2026-03-01', '9999-12-31',
'月度排名第三获得400元并列都算');
-- =============================================================================
-- 4. cfg_area_category - 台区分类映射(纯台桌级精确映射)
-- 说明:
-- - 每台桌一行精确映射source_area_name=区域, source_table_name=台桌名
-- - 不使用 LIKE 模糊匹配,仅 EXACT + DEFAULT 兜底
-- - 数据来源: 用户提供的完整台桌清单2026-03-09
-- =============================================================================
TRUNCATE TABLE dws.cfg_area_category RESTART IDENTITY CASCADE;
INSERT INTO dws.cfg_area_category (
source_area_name, source_table_name, category_code, category_name,
display_name, short_name,
match_type, match_priority, is_active, description, sort_order
) VALUES
-- ============ BILLIARD 🎱 中式/追分 (sort_order=10) ============
-- A区18台
('A区', 'A1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A1', 10),
('A区', 'A2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A2', 10),
('A区', 'A3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A3', 10),
('A区', 'A4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A4', 10),
('A区', 'A5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A5', 10),
('A区', 'A6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A6', 10),
('A区', 'A7', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A7', 10),
('A区', 'A8', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A8', 10),
('A区', 'A9', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A9', 10),
('A区', 'A10', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A10', 10),
('A区', 'A11', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A11', 10),
('A区', 'A12', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A12', 10),
('A区', 'A13', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A13', 10),
('A区', 'A14', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A14', 10),
('A区', 'A15', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A15', 10),
('A区', 'A16', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A16', 10),
('A区', 'A17', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A17', 10),
('A区', 'A18', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'A区-A18', 10),
-- B区15台
('B区', 'B1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B1', 10),
('B区', 'B2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B2', 10),
('B区', 'B3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B3', 10),
('B区', 'B4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B4', 10),
('B区', 'B5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B5', 10),
('B区', 'B6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B6', 10),
('B区', 'B7', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B7', 10),
('B区', 'B8', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B8', 10),
('B区', 'B9', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B9', 10),
('B区', 'B10', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B10', 10),
('B区', 'B11', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B11', 10),
('B区', 'B12', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B12', 10),
('B区', 'B13', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B13', 10),
('B区', 'B14', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B14', 10),
('B区', 'B15', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'B区-B15', 10),
-- C区6台
('C区', 'C1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C1', 10),
('C区', 'C2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C2', 10),
('C区', 'C3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C3', 10),
('C区', 'C4', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C4', 10),
('C区', 'C5', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C5', 10),
('C区', 'C6', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'C区-C6', 10),
-- VIP包厢 BILLIARD3台
('VIP包厢', 'VIP1', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP1', 10),
('VIP包厢', 'VIP2', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP2', 10),
('VIP包厢', 'VIP3', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'VIP包厢-VIP3', 10),
-- TV台1台
('TV台', 'TV', 'BILLIARD', '🎱 中式/追分', '🎱 中式/追分', '🎱', 'EXACT', 10, TRUE, 'TV台-TV', 10),
-- ============ SNOOKER 斯诺克 ============
('VIP包厢', 'VIP5', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, 'VIP包厢-VIP5→斯诺克', 20),
('斯诺克区', 'S1', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S1', 20),
('斯诺克区', 'S2', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S2', 20),
('斯诺克区', 'S3', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S3', 20),
('斯诺克区', 'S4', 'SNOOKER', '斯诺克', '斯诺克', '', 'EXACT', 10, TRUE, '斯诺克区-S4', 20),
-- ============ MAHJONG 🀄 麻将/棋牌 ============
('666', '董事办', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '666-董事办', 30),
('666', '666', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '666-666', 30),
('麻将房', '1', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-1', 30),
('麻将房', 'M2', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M2', 30),
('麻将房', 'M3', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M3', 30),
('麻将房', 'M4', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M4', 30),
('麻将房', 'M5', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '麻将房-M5', 30),
('M7', 'M7', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M7-M7', 30),
('M7', '大包麻将房', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M7-大包麻将房', 30),
('M8', 'M8', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, 'M8-M8', 30),
('发财', '发财', 'MAHJONG', '🀄 麻将/棋牌', '🀄 麻将/棋牌', '🀄', 'EXACT', 10, TRUE, '发财-发财', 30),
-- ============ KTV 🎤 团建/K歌 ============
('K包', '常乐', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-常乐', 40),
('K包', '幸会(纯k)', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-幸会(纯k)', 40),
('K包', '虚拟188', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-虚拟188', 40),
('K包', '888', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'K包-888', 40),
('k包活动区', '大包', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'k包活动区-大包', 40),
('k包活动区', '小包', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, 'k包活动区-小包', 40),
('幸会158', '纯k', 'KTV', '🎤 团建/K歌', '🎤 团建/K歌', '🎤', 'EXACT', 10, TRUE, '幸会158-纯k', 40),
-- ============ SPECIAL 补时长/虚拟台 ============
('补时长', '补时长', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长', 900),
('补时长', '补时长2', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长2', 900),
('补时长', '补时长3', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长3', 900),
('补时长', '补时长4', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长4', 900),
('补时长', '补时长5', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长5', 900),
('补时长', '补时长6', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长6', 900),
('补时长', '补时长7', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '补时长-补时长7', 900),
('虚拟台', '虚拟台1号', 'SPECIAL', '补时长', '补时长', '', 'EXACT', 10, TRUE, '虚拟台-虚拟台1号', 900),
-- ============ OTHER 兜底 ============
('DEFAULT', NULL, 'OTHER', '其他', '其他', '', 'DEFAULT', 999, TRUE, '兜底规则:无法匹配的归入其他', 999);
-- =============================================================================
-- 5. cfg_skill_type - 技能→课程类型映射
-- 说明:
-- - 将 skill_id 映射到课程类型
-- - 基础课/陪打: skill_id = 2791903611396869
-- - 附加课/超休: skill_id = 2807440316432197
-- - 避免依赖 skill_name 文本匹配
-- =============================================================================
TRUNCATE TABLE dws.cfg_skill_type RESTART IDENTITY CASCADE;
INSERT INTO dws.cfg_skill_type (
skill_id, skill_name,
course_type_code, course_type_name,
is_active, description
) VALUES
(2791903611396869, '台球基础陪打',
'BASE', '基础课',
TRUE, '基础课:陪打服务,按助教等级计价'),
(2807440316432197, '台球超休服务',
'BONUS', '附加课',
TRUE, '附加课:超休/激励课固定190元/小时'),
(2807440316432198, '包厢服务',
'BASE', '基础课',
TRUE, '包厢服务归入基础课统计统一按138元/小时计价');
-- =============================================================================
-- 6~8. 优惠类型/支出类型/平台类型 — 作为代码常量使用,不单独建表
-- =============================================================================
-- 优惠类型: GROUPBUY/VIP/GIFT_CARD/MANUAL/ROUNDING/BIG_CUSTOMER/OTHER
-- 支出类型: RENT/UTILITY/PROPERTY/SALARY/REIMBURSE/PLATFORM_FEE/OTHER
-- 平台类型: MEITUAN/DOUYIN/DIANPING/OTHER
-- =============================================================================
-- 验证数据插入
-- =============================================================================
DO $
DECLARE
v_tier_count INTEGER;
v_price_count INTEGER;
v_bonus_count INTEGER;
v_area_count INTEGER;
v_skill_count INTEGER;
BEGIN
SELECT COUNT(*) INTO v_tier_count FROM dws.cfg_performance_tier;
SELECT COUNT(*) INTO v_price_count FROM dws.cfg_assistant_level_price;
SELECT COUNT(*) INTO v_bonus_count FROM dws.cfg_bonus_rules;
SELECT COUNT(*) INTO v_area_count FROM dws.cfg_area_category;
SELECT COUNT(*) INTO v_skill_count FROM dws.cfg_skill_type;
RAISE NOTICE '配置数据初始化完成:';
RAISE NOTICE ' - cfg_performance_tier: % 条', v_tier_count;
RAISE NOTICE ' - cfg_assistant_level_price: % 条', v_price_count;
RAISE NOTICE ' - cfg_bonus_rules: % 条', v_bonus_count;
RAISE NOTICE ' - cfg_area_category: % 条', v_area_count;
RAISE NOTICE ' - cfg_skill_type: % 条', v_skill_count;
END;
$;
-- =============================================================================
-- 种子数据指数算法参数NCI/WBI/RS/OS/MS/ML/SPI
-- =============================================================================
-- =============================================================================
-- 指数算法参数初始化脚本
-- 版本: v3.0
-- 创建日期: 2026-02-13
-- 描述: 仅保留 RS / OS / MS / ML / NCI / WBI 指数参数(已移除 RECALL / INTIMACY
-- AI_CHANGELOG [2026-02-13] 移除 RECALL/INTIMACY 参数及 ML 废弃参数source_mode/recharge_attribute_hours
-- =============================================================================
-- 清理旧版指数参数
DELETE FROM dws.cfg_index_parameters WHERE index_type IN ('RECALL', 'INTIMACY');
-- 清理 ML 已废弃参数
DELETE FROM dws.cfg_index_parameters WHERE index_type = 'ML' AND param_name IN ('source_mode', 'recharge_attribute_hours');
INSERT INTO dws.cfg_index_parameters
(index_type, param_name, param_value, description, effective_from)
VALUES
('NCI', 'active_new_penalty', 0.200000, 'active-new suppression multiplier', DATE '2026-02-06'),
('NCI', 'active_new_recency_days', 7.000000, 'active-new recency window (days)', DATE '2026-02-06'),
('NCI', 'active_new_visit_threshold_14d', 2.000000, 'active-new threshold in 14d visits', DATE '2026-02-06'),
('NCI', 'amount_base_M0', 300.000000, 'spend log base M0', DATE '2026-02-06'),
('NCI', 'balance_base_B0', 500.000000, 'balance log base B0', DATE '2026-02-06'),
('NCI', 'compression_mode', 0.000000, 'compression mode', DATE '2026-02-06'),
('NCI', 'enable_stop_high_balance_exception', 0.000000, 'enable high-balance STOP exception', DATE '2026-02-06'),
('NCI', 'ewma_alpha', 0.200000, 'EWMA alpha', DATE '2026-02-06'),
('NCI', 'h_recharge', 7.000000, 'recharge decay half-life (days)', DATE '2026-02-06'),
('NCI', 'high_balance_threshold', 1000.000000, 'high-balance threshold', DATE '2026-02-06'),
('NCI', 'lookback_days_recency', 60.000000, 'recency lookback window (days)', DATE '2026-02-06'),
('NCI', 'new_days_threshold', 30.000000, 'new member days threshold', DATE '2026-02-06'),
('NCI', 'new_recharge_max_visits', 10.000000, 'max visits for new-recharge grouping', DATE '2026-02-06'),
('NCI', 'new_visit_threshold', 2.000000, 'new member visit threshold', DATE '2026-02-06'),
('NCI', 'no_touch_days_new', 3.000000, 'no-touch threshold (days)', DATE '2026-02-06'),
('NCI', 'percentile_lower', 5.000000, 'lower percentile', DATE '2026-02-06'),
('NCI', 'percentile_upper', 95.000000, 'upper percentile', DATE '2026-02-06'),
('NCI', 'recharge_recent_days', 14.000000, 'recent recharge window (days)', DATE '2026-02-06'),
('NCI', 'salvage_end', 60.000000, 'salvage decay end day', DATE '2026-02-06'),
('NCI', 'salvage_start', 30.000000, 'salvage decay start day', DATE '2026-02-06'),
('NCI', 't2_target_days', 7.000000, 'second-visit target window (days)', DATE '2026-02-06'),
('NCI', 'use_smoothing', 1.000000, 'enable smoothing', DATE '2026-02-06'),
('NCI', 'value_w_bal', 0.800000, 'value weight for balance', DATE '2026-02-06'),
('NCI', 'value_w_spend', 1.000000, 'value weight for spend', DATE '2026-02-06'),
('NCI', 'visit_lookback_days', 180.000000, 'visit history lookback (days)', DATE '2026-02-06'),
('NCI', 'w_need', 1.600000, 'need weight', DATE '2026-02-06'),
('NCI', 'w_re', 0.800000, 'recharge pressure weight', DATE '2026-02-06'),
('NCI', 'w_value', 1.000000, 'value weight', DATE '2026-02-06'),
('NCI', 'w_welcome', 1.000000, 'welcome-stage weight', DATE '2026-02-06'),
('NCI', 'welcome_window_days', 3.000000, 'welcome outreach window for first touch (days)', DATE '2026-02-06'),
('WBI', 'amount_base_M0', 300.000000, 'spend log base M0', DATE '2026-02-06'),
('WBI', 'balance_base_B0', 500.000000, 'balance log base B0', DATE '2026-02-06'),
('WBI', 'compression_mode', 0.000000, 'compression mode', DATE '2026-02-06'),
('WBI', 'enable_stop_high_balance_exception', 0.000000, 'enable high-balance STOP exception', DATE '2026-02-06'),
('WBI', 'ewma_alpha', 0.200000, 'EWMA alpha', DATE '2026-02-06'),
('WBI', 'h_recharge', 7.000000, 'recharge decay half-life (days)', DATE '2026-02-06'),
('WBI', 'high_balance_threshold', 1000.000000, 'high-balance threshold', DATE '2026-02-06'),
('WBI', 'lookback_days_recency', 60.000000, 'recency lookback window (days)', DATE '2026-02-06'),
('WBI', 'new_days_threshold', 30.000000, 'new member days threshold', DATE '2026-02-06'),
('WBI', 'new_recharge_max_visits', 10.000000, 'max visits for new-recharge grouping', DATE '2026-02-06'),
('WBI', 'new_visit_threshold', 2.000000, 'new member visit threshold', DATE '2026-02-06'),
('WBI', 'overdue_alpha', 2.000000, 'overdue fallback alpha', DATE '2026-02-06'),
('WBI', 'overdue_weight_blend_min_samples', 8.000000, 'minimum samples to fully trust weighted overdue CDF', DATE '2026-02-07'),
('WBI', 'overdue_weight_halflife_days', 30.000000, 'overdue weighted-CDF interval half-life (days)', DATE '2026-02-07'),
('WBI', 'percentile_lower', 5.000000, 'lower percentile', DATE '2026-02-06'),
('WBI', 'percentile_upper', 95.000000, 'upper percentile', DATE '2026-02-06'),
('WBI', 'recency_gate_days', 14.000000, 'recency suppression gate center (days)', DATE '2026-02-06'),
('WBI', 'recency_gate_slope_days', 3.000000, 'recency suppression slope (days)', DATE '2026-02-06'),
('WBI', 'recency_hard_floor_days', 14.000000, 'hard floor for winback recency (days)', DATE '2026-02-06'),
('WBI', 'recharge_recent_days', 14.000000, 'recent recharge window (days)', DATE '2026-02-06'),
('WBI', 'use_smoothing', 1.000000, 'enable smoothing', DATE '2026-02-06'),
('WBI', 'value_w_bal', 1.000000, 'value weight for balance', DATE '2026-02-06'),
('WBI', 'value_w_spend', 1.000000, 'value weight for spend', DATE '2026-02-06'),
('WBI', 'visit_lookback_days', 180.000000, 'visit history lookback (days)', DATE '2026-02-06'),
('WBI', 'w_drop', 1.000000, 'drop weight', DATE '2026-02-06'),
('WBI', 'w_over', 2.000000, 'overdue weight', DATE '2026-02-06'),
('WBI', 'w_re', 0.400000, 'recharge pressure weight', DATE '2026-02-06'),
('WBI', 'w_value', 1.200000, 'value weight', DATE '2026-02-06')
ON CONFLICT (index_type, param_name, effective_from) DO UPDATE SET
param_value = EXCLUDED.param_value,
description = EXCLUDED.description,
updated_at = NOW();
-- =============================================================================
-- 关系指数RS/OS/MS/ML参数
-- 生效时间:北京时间 2026-01-01按数据库日期管理
-- =============================================================================
INSERT INTO dws.cfg_index_parameters
(index_type, param_name, param_value, description, effective_from)
VALUES
-- RS关系强度
('RS', 'lookback_days', 60.000000, '服务行为回溯窗口(天)', DATE '2026-01-01'),
('RS', 'session_merge_hours', 4.000000, '会话合并阈值(小时)', DATE '2026-01-01'),
('RS', 'incentive_weight', 1.500000, '激励课权重', DATE '2026-01-01'),
('RS', 'halflife_session', 14.000000, '会话半衰期(天)', DATE '2026-01-01'),
('RS', 'halflife_last', 10.000000, '最近一次服务半衰期(天)', DATE '2026-01-01'),
('RS', 'weight_f', 1.000000, '频次项权重', DATE '2026-01-01'),
('RS', 'weight_d', 0.700000, '时长项权重', DATE '2026-01-01'),
('RS', 'gate_alpha', 0.600000, '最近服务门控指数', DATE '2026-01-01'),
('RS', 'percentile_lower', 5.000000, '展示分下分位', DATE '2026-01-01'),
('RS', 'percentile_upper', 95.000000, '展示分上分位', DATE '2026-01-01'),
('RS', 'compression_mode', 1.000000, '压缩模式0=none,1=log1p,2=asinh', DATE '2026-01-01'),
('RS', 'use_smoothing', 1.000000, '是否启用分位平滑', DATE '2026-01-01'),
('RS', 'ewma_alpha', 0.200000, 'EWMA平滑系数', DATE '2026-01-01'),
-- OS归属份额
('OS', 'min_rs_raw_for_ownership', 0.050000, '参与归属计算的最小RS_raw', DATE '2026-01-01'),
('OS', 'min_total_rs_raw', 0.100000, '形成稳定归属的最小sum_rs', DATE '2026-01-01'),
('OS', 'ownership_main_threshold', 0.600000, '主责阈值', DATE '2026-01-01'),
('OS', 'ownership_comanage_threshold', 0.350000, '共管阈值', DATE '2026-01-01'),
('OS', 'ownership_gap_threshold', 0.150000, '主责与次席份额差阈值', DATE '2026-01-01'),
('OS', 'eps', 0.000001, '数值稳定项', DATE '2026-01-01'),
-- MS升温动量
('MS', 'lookback_days', 60.000000, '服务行为回溯窗口(天)', DATE '2026-01-01'),
('MS', 'session_merge_hours', 4.000000, '会话合并阈值(小时)', DATE '2026-01-01'),
('MS', 'incentive_weight', 1.500000, '激励课权重', DATE '2026-01-01'),
('MS', 'halflife_short', 7.000000, '短期半衰期(天)', DATE '2026-01-01'),
('MS', 'halflife_long', 30.000000, '长期半衰期(天)', DATE '2026-01-01'),
('MS', 'eps', 0.000001, '数值稳定项', DATE '2026-01-01'),
('MS', 'percentile_lower', 5.000000, '展示分下分位', DATE '2026-01-01'),
('MS', 'percentile_upper', 95.000000, '展示分上分位', DATE '2026-01-01'),
('MS', 'compression_mode', 1.000000, '压缩模式0=none,1=log1p,2=asinh', DATE '2026-01-01'),
('MS', 'use_smoothing', 1.000000, '是否启用分位平滑', DATE '2026-01-01'),
('MS', 'ewma_alpha', 0.200000, 'EWMA平滑系数', DATE '2026-01-01'),
-- ML付费关联
('ML', 'lookback_days', 60.000000, '充值行为回溯窗口(天)', DATE '2026-01-01'),
('ML', 'amount_base', 500.000000, '金额压缩基准', DATE '2026-01-01'),
('ML', 'halflife_recharge', 21.000000, '充值半衰期(天)', DATE '2026-01-01'),
('ML', 'percentile_lower', 5.000000, '展示分下分位', DATE '2026-01-01'),
('ML', 'percentile_upper', 95.000000, '展示分上分位', DATE '2026-01-01'),
('ML', 'compression_mode', 1.000000, '压缩模式0=none,1=log1p,2=asinh', DATE '2026-01-01'),
('ML', 'use_smoothing', 1.000000, '是否启用分位平滑', DATE '2026-01-01'),
('ML', 'ewma_alpha', 0.200000, 'EWMA平滑系数', DATE '2026-01-01')
ON CONFLICT (index_type, param_name, effective_from) DO UPDATE SET
param_value = EXCLUDED.param_value,
description = EXCLUDED.description,
updated_at = NOW();
-- =============================================================================
-- SPI消费力指数参数
-- 生效时间:北京时间 2026-02-23
-- =============================================================================
INSERT INTO dws.cfg_index_parameters
(index_type, param_name, param_value, description, effective_from)
VALUES
-- 窗口参数
('SPI', 'spend_window_short_days', 30.000000, '短期消费窗口(天)', DATE '2026-02-23'),
('SPI', 'spend_window_long_days', 90.000000, '长期消费窗口(天)', DATE '2026-02-23'),
('SPI', 'ewma_alpha_daily_spend', 0.300000, '日消费 EWMA 平滑系数', DATE '2026-02-23'),
-- 金额压缩基数(基于典型台球门店消费水平的初始默认值)
('SPI', 'amount_base_spend_30', 500.000000, '30天消费额压缩基数', DATE '2026-02-23'),
('SPI', 'amount_base_spend_90', 1500.000000, '90天消费额压缩基数', DATE '2026-02-23'),
('SPI', 'amount_base_ticket_90', 200.000000, '90天客单价压缩基数', DATE '2026-02-23'),
('SPI', 'amount_base_recharge_90', 1000.000000, '90天充值额压缩基数', DATE '2026-02-23'),
('SPI', 'amount_base_speed_abs', 100.000000, '绝对速度压缩基数', DATE '2026-02-23'),
('SPI', 'amount_base_ewma_90', 50.000000, '日消费EWMA压缩基数', DATE '2026-02-23'),
-- Level 子分权重
('SPI', 'w_level_spend_30', 0.300000, 'Level子分30天消费权重', DATE '2026-02-23'),
('SPI', 'w_level_spend_90', 0.350000, 'Level子分90天消费权重', DATE '2026-02-23'),
('SPI', 'w_level_ticket_90', 0.200000, 'Level子分90天客单权重', DATE '2026-02-23'),
('SPI', 'w_level_recharge_90', 0.150000, 'Level子分90天充值权重', DATE '2026-02-23'),
-- Speed 子分权重
('SPI', 'w_speed_abs', 0.500000, 'Speed子分绝对速度权重', DATE '2026-02-23'),
('SPI', 'w_speed_rel', 0.300000, 'Speed子分相对速度权重', DATE '2026-02-23'),
('SPI', 'w_speed_ewma', 0.200000, 'Speed子分EWMA速度权重', DATE '2026-02-23'),
-- 总分权重
('SPI', 'weight_level', 0.600000, 'SPI总分Level子分权重', DATE '2026-02-23'),
('SPI', 'weight_speed', 0.300000, 'SPI总分Speed子分权重', DATE '2026-02-23'),
('SPI', 'weight_stability', 0.100000, 'SPI总分Stability子分权重', DATE '2026-02-23'),
-- 稳定性参数
('SPI', 'stability_window_days', 90.000000, '稳定性计算窗口(天)', DATE '2026-02-23'),
('SPI', 'use_stability', 1.000000, '是否启用稳定性子分0=关闭,1=启用', DATE '2026-02-23'),
-- 映射与平滑
('SPI', 'percentile_lower', 5.000000, '展示分下分位', DATE '2026-02-23'),
('SPI', 'percentile_upper', 95.000000, '展示分上分位', DATE '2026-02-23'),
('SPI', 'compression_mode', 1.000000, '压缩模式0=none,1=log1p,2=asinh', DATE '2026-02-23'),
('SPI', 'use_smoothing', 1.000000, '是否启用分位平滑', DATE '2026-02-23'),
('SPI', 'ewma_alpha', 0.200000, 'EWMA平滑系数', DATE '2026-02-23'),
-- 速度计算
('SPI', 'speed_epsilon', 0.000001, '速度计算防除零小量', DATE '2026-02-23')
ON CONFLICT (index_type, param_name, effective_from) DO UPDATE SET
param_value = EXCLUDED.param_value,
description = EXCLUDED.description,
updated_at = NOW();
-- =============================================================================
-- 验证
-- =============================================================================
DO $
DECLARE
rs_count INTEGER;
os_count INTEGER;
ms_count INTEGER;
ml_count INTEGER;
nci_count INTEGER;
wbi_count INTEGER;
spi_count INTEGER;
BEGIN
SELECT COUNT(*) INTO rs_count
FROM dws.cfg_index_parameters
WHERE index_type = 'RS';
SELECT COUNT(*) INTO os_count
FROM dws.cfg_index_parameters
WHERE index_type = 'OS';
SELECT COUNT(*) INTO ms_count
FROM dws.cfg_index_parameters
WHERE index_type = 'MS';
SELECT COUNT(*) INTO ml_count
FROM dws.cfg_index_parameters
WHERE index_type = 'ML';
SELECT COUNT(*) INTO nci_count
FROM dws.cfg_index_parameters
WHERE index_type = 'NCI';
SELECT COUNT(*) INTO wbi_count
FROM dws.cfg_index_parameters
WHERE index_type = 'WBI';
SELECT COUNT(*) INTO spi_count
FROM dws.cfg_index_parameters
WHERE index_type = 'SPI';
RAISE NOTICE 'RS 参数数量: %', rs_count;
RAISE NOTICE 'OS 参数数量: %', os_count;
RAISE NOTICE 'MS 参数数量: %', ms_count;
RAISE NOTICE 'ML 参数数量: %', ml_count;
RAISE NOTICE '新客转化参数数量: %', nci_count;
RAISE NOTICE '唤回指数参数数量: %', wbi_count;
RAISE NOTICE 'SPI 消费力指数参数数量: %', spi_count;
END $;
SELECT
index_type,
param_name,
param_value,
description,
effective_from
FROM dws.cfg_index_parameters
ORDER BY index_type, param_name, effective_from;

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- etl_feiqiu / metaETL 调度元数据) -- etl_feiqiu / metaETL 调度元数据)
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -75,105 +75,3 @@ ALTER TABLE meta.etl_run ADD CONSTRAINT etl_run_pkey PRIMARY KEY (run_id);
ALTER TABLE meta.etl_task ADD CONSTRAINT etl_task_pkey PRIMARY KEY (task_id); ALTER TABLE meta.etl_task ADD CONSTRAINT etl_task_pkey PRIMARY KEY (task_id);
ALTER TABLE meta.etl_task ADD CONSTRAINT etl_task_task_code_store_id_key UNIQUE (task_code, store_id); ALTER TABLE meta.etl_task ADD CONSTRAINT etl_task_task_code_store_id_key UNIQUE (task_code, store_id);
-- =============================================================================
-- 种子数据ODS 任务注册
-- =============================================================================
WITH target_store AS (
SELECT 2790685415443269::bigint AS store_id -- TODO: 替换为实际 store_id
),
task_codes AS (
SELECT unnest(ARRAY[
'ODS_ASSISTANT_ACCOUNT',
'ODS_ASSISTANT_LEDGER',
'ODS_SETTLEMENT_RECORDS',
'ODS_TABLE_USE',
'ODS_PAYMENT',
'ODS_REFUND',
'ODS_PLATFORM_COUPON',
'ODS_MEMBER',
'ODS_MEMBER_CARD',
'ODS_MEMBER_BALANCE',
'ODS_RECHARGE_SETTLE',
'ODS_GROUP_PACKAGE',
'ODS_GROUP_BUY_REDEMPTION',
'ODS_INVENTORY_STOCK',
'ODS_INVENTORY_CHANGE',
'ODS_TABLES',
'ODS_GOODS_CATEGORY',
'ODS_STORE_GOODS',
'ODS_STORE_GOODS_SALES',
'ODS_TABLE_FEE_DISCOUNT',
'ODS_TENANT_GOODS'
]) AS task_code
)
INSERT INTO meta.etl_task (task_code, store_id, enabled)
SELECT t.task_code, s.store_id, TRUE
FROM task_codes t CROSS JOIN target_store s
ON CONFLICT (task_code, store_id) DO UPDATE
SET enabled = EXCLUDED.enabled;
-- =============================================================================
-- 种子数据:调度任务注册
-- =============================================================================
WITH target_store AS (
SELECT 2790685415443269::bigint AS store_id -- TODO: 替换为实际 store_id
),
task_codes AS (
SELECT unnest(ARRAY[
'ASSISTANTS',
'COUPON_USAGE',
'CHECK_CUTOFF',
'DWD_LOAD_FROM_ODS',
'DWD_QUALITY_CHECK',
'INIT_DWD_SCHEMA',
'INIT_DWS_SCHEMA',
'INIT_ODS_SCHEMA',
'INVENTORY_CHANGE',
'LEDGER',
'MANUAL_INGEST',
'MEMBERS',
'MEMBERS_DWD',
'ODS_JSON_ARCHIVE',
'ORDERS',
'PACKAGES_DEF',
'PAYMENTS',
'PAYMENTS_DWD',
'PRODUCTS',
'REFUNDS',
'TABLE_DISCOUNT',
'TABLES',
'TICKET_DWD',
'TOPUPS',
'DWS_BUILD_ORDER_SUMMARY',
'DWS_ASSISTANT_DAILY',
'DWS_ASSISTANT_MONTHLY',
'DWS_ASSISTANT_CUSTOMER',
'DWS_ASSISTANT_SALARY',
'DWS_ASSISTANT_FINANCE',
'DWS_MEMBER_CONSUMPTION',
'DWS_MEMBER_VISIT',
'DWS_FINANCE_DAILY',
'DWS_FINANCE_RECHARGE',
'DWS_FINANCE_INCOME_STRUCTURE',
'DWS_FINANCE_DISCOUNT_DETAIL',
'DWS_GOODS_STOCK_DAILY',
'DWS_GOODS_STOCK_WEEKLY',
'DWS_GOODS_STOCK_MONTHLY',
'DWS_WINBACK_INDEX',
'DWS_NEWCONV_INDEX',
'DWS_RELATION_INDEX',
'DWS_ASSISTANT_PROJECT_TAG',
'DWS_MEMBER_PROJECT_TAG',
'DWS_ML_MANUAL_IMPORT'
]) AS task_code
)
INSERT INTO meta.etl_task (task_code, store_id, enabled)
SELECT t.task_code, s.store_id, TRUE
FROM task_codes t CROSS JOIN target_store s
ON CONFLICT (task_code, store_id) DO UPDATE
SET enabled = EXCLUDED.enabled,
updated_at = now();

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- etl_feiqiu / ods原始数据层 -- etl_feiqiu / ods原始数据层
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -148,7 +148,9 @@ CREATE TABLE ods.assistant_service_records (
content_hash text NOT NULL, content_hash text NOT NULL,
source_file text, source_file text,
source_endpoint text, source_endpoint text,
fetched_at timestamp with time zone DEFAULT now() fetched_at timestamp with time zone DEFAULT now(),
deduct_leave_seconds integer DEFAULT 0,
order_from integer
); );
CREATE TABLE ods.goods_stock_movements ( CREATE TABLE ods.goods_stock_movements (
@@ -198,7 +200,8 @@ CREATE TABLE ods.goods_stock_summary (
source_endpoint text, source_endpoint text,
fetched_at timestamp with time zone DEFAULT now(), fetched_at timestamp with time zone DEFAULT now(),
payload jsonb NOT NULL, payload jsonb NOT NULL,
siteid bigint siteid bigint,
createtime timestamp without time zone
); );
CREATE TABLE ods.group_buy_package_details ( CREATE TABLE ods.group_buy_package_details (
@@ -394,7 +397,11 @@ CREATE TABLE ods.member_profiles (
source_endpoint text, source_endpoint text,
fetched_at timestamp with time zone DEFAULT now(), fetched_at timestamp with time zone DEFAULT now(),
payload jsonb NOT NULL, payload jsonb NOT NULL,
birthday date birthday date,
other_pay_money_sum numeric(18,2),
last_consume_time timestamp without time zone,
non_consume_day_num integer,
first_consumption integer
); );
CREATE TABLE ods.member_stored_value_cards ( CREATE TABLE ods.member_stored_value_cards (
@@ -719,7 +726,8 @@ CREATE TABLE ods.settlement_records (
source_file text, source_file text,
source_endpoint text, source_endpoint text,
fetched_at timestamp with time zone DEFAULT now(), fetched_at timestamp with time zone DEFAULT now(),
payload jsonb NOT NULL payload jsonb NOT NULL,
orderfrom integer
); );
CREATE TABLE ods.site_tables_master ( CREATE TABLE ods.site_tables_master (
@@ -939,7 +947,10 @@ CREATE TABLE ods.store_goods_sales_records (
content_hash text NOT NULL, content_hash text NOT NULL,
source_file text, source_file text,
source_endpoint text, source_endpoint text,
fetched_at timestamp with time zone DEFAULT now() fetched_at timestamp with time zone DEFAULT now(),
activity_amount numeric(18,2) DEFAULT 0,
activity_id bigint DEFAULT 0,
order_from integer
); );
CREATE TABLE ods.table_fee_discount_records ( CREATE TABLE ods.table_fee_discount_records (
@@ -1025,7 +1036,8 @@ CREATE TABLE ods.table_fee_transactions (
content_hash text NOT NULL, content_hash text NOT NULL,
source_file text, source_file text,
source_endpoint text, source_endpoint text,
fetched_at timestamp with time zone DEFAULT now() fetched_at timestamp with time zone DEFAULT now(),
order_from integer
); );
CREATE TABLE ods.tenant_goods_master ( CREATE TABLE ods.tenant_goods_master (

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- FDW 跨库映射(在 zqyy_app 中执行) -- FDW 跨库映射(在 zqyy_app 中执行)
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源db/fdw/setup_fdw.sql -- 来源db/fdw/setup_fdw.sql
-- ============================================================================= -- =============================================================================

View File

@@ -0,0 +1,86 @@
-- =============================================================================
-- FDW 反向映射配置(生产环境)— 在 etl_feiqiu 数据库中执行
-- 用途:通过 postgres_fdw 将 zqyy_app.member_retention_clue 只读映射到 etl_feiqiu
-- 使 ETL DWS 任务无需直接连接业务库即可读取维客线索数据。
-- 方向etl_feiqiu → zqyy_app与 setup_fdw.sql 的 zqyy_app → etl_feiqiu 方向相反)
-- 前提zqyy_app 数据库已部署 member_retention_clue 表
-- 测试环境版本setup_fdw_reverse_test.sql指向 test_zqyy_app
-- CHANGE 2026-02-26 | member_birthday_manual → member_retention_clue维客线索重构
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. 安装 postgres_fdw 扩展(如已安装则跳过)
-- -----------------------------------------------------------------------------
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
-- -----------------------------------------------------------------------------
-- 2. 创建外部服务器(指向 zqyy_app 业务库)
-- 部署时按实际环境替换 host / port
-- -----------------------------------------------------------------------------
CREATE SERVER IF NOT EXISTS zqyy_app_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'localhost', dbname 'zqyy_app', port '5432');
-- -----------------------------------------------------------------------------
-- 3. 创建用户映射(只读角色)
-- -----------------------------------------------------------------------------
CREATE USER MAPPING IF NOT EXISTS FOR etl_user
SERVER zqyy_app_server
OPTIONS (user 'app_reader', password '***');
-- -----------------------------------------------------------------------------
-- 4. 创建目标 schema存放来自业务库的外部表
-- -----------------------------------------------------------------------------
CREATE SCHEMA IF NOT EXISTS fdw_app;
-- -----------------------------------------------------------------------------
-- 5. 创建外部表member_retention_clue
-- 映射 zqyy_app.public.member_retention_clueETL 侧只读
-- -----------------------------------------------------------------------------
CREATE FOREIGN TABLE IF NOT EXISTS fdw_app.member_retention_clue (
id BIGINT,
member_id BIGINT,
category VARCHAR(20),
summary VARCHAR(200),
detail TEXT,
recorded_by_assistant_id BIGINT,
recorded_by_name VARCHAR(50),
recorded_at TIMESTAMPTZ,
site_id BIGINT
) SERVER zqyy_app_server
OPTIONS (schema_name 'public', table_name 'member_retention_clue');
-- -----------------------------------------------------------------------------
-- 6. 授权:允许 etl_user 访问 fdw_app schema 及其外部表
-- -----------------------------------------------------------------------------
GRANT USAGE ON SCHEMA fdw_app TO etl_user;
GRANT SELECT ON ALL TABLES IN SCHEMA fdw_app TO etl_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_app GRANT SELECT ON TABLES TO etl_user;
-- =============================================================================
-- 回滚脚本(按逆序执行)
-- =============================================================================
-- ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_app REVOKE SELECT ON TABLES FROM etl_user;
-- REVOKE SELECT ON ALL TABLES IN SCHEMA fdw_app FROM etl_user;
-- REVOKE USAGE ON SCHEMA fdw_app FROM etl_user;
-- DROP FOREIGN TABLE IF EXISTS fdw_app.member_retention_clue;
-- DROP SCHEMA IF EXISTS fdw_app CASCADE;
-- DROP USER MAPPING IF EXISTS FOR etl_user SERVER zqyy_app_server;
-- DROP SERVER IF EXISTS zqyy_app_server CASCADE;
-- =============================================================================
-- 验证 SQL
-- =============================================================================
-- 1. 确认外部服务器存在
-- SELECT srvname, srvoptions FROM pg_foreign_server
-- WHERE srvname = 'zqyy_app_server';
-- 2. 确认外部表列结构完整9 列)
-- SELECT column_name, data_type FROM information_schema.columns
-- WHERE table_schema = 'fdw_app' AND table_name = 'member_retention_clue'
-- ORDER BY ordinal_position;
-- 3. 确认外部表可读取
-- SELECT COUNT(*) FROM fdw_app.member_retention_clue;

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- zqyy_app / auth用户认证与权限 -- zqyy_app / auth用户认证与权限
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -10,12 +10,22 @@ CREATE SCHEMA IF NOT EXISTS auth;
CREATE SEQUENCE IF NOT EXISTS auth.permissions_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.permissions_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS auth.roles_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.roles_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS auth.site_code_mapping_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.site_code_mapping_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS auth.tenant_admins_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS auth.user_applications_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.user_applications_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS auth.user_assistant_binding_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.user_assistant_binding_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS auth.user_site_roles_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.user_site_roles_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS auth.users_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS auth.users_id_seq AS integer;
-- 表 -- 表
CREATE TABLE auth._archived_site_code_mapping (
id integer DEFAULT nextval('auth.site_code_mapping_id_seq'::regclass) NOT NULL,
site_code character varying(10) NOT NULL,
site_id bigint NOT NULL,
site_name character varying(200),
tenant_id bigint,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE auth.permissions ( CREATE TABLE auth.permissions (
id integer DEFAULT nextval('auth.permissions_id_seq'::regclass) NOT NULL, id integer DEFAULT nextval('auth.permissions_id_seq'::regclass) NOT NULL,
code character varying(100) NOT NULL, code character varying(100) NOT NULL,
@@ -37,13 +47,19 @@ CREATE TABLE auth.roles (
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL
); );
CREATE TABLE auth.site_code_mapping ( CREATE TABLE auth.tenant_admins (
id integer DEFAULT nextval('auth.site_code_mapping_id_seq'::regclass) NOT NULL, id bigint DEFAULT nextval('auth.tenant_admins_id_seq'::regclass) NOT NULL,
site_code character varying(10) NOT NULL, username character varying(50) NOT NULL,
site_id bigint NOT NULL, password_hash character varying(255) NOT NULL,
site_name character varying(200), display_name character varying(100),
tenant_id bigint, tenant_id bigint NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL managed_site_ids _int8 NOT NULL,
is_active boolean DEFAULT true,
created_by bigint,
created_at timestamp with time zone DEFAULT now(),
last_login_at timestamp with time zone,
deleted_at timestamp with time zone,
admin_type character varying(20) DEFAULT 'tenant_admin'::character varying NOT NULL
); );
CREATE TABLE auth.user_applications ( CREATE TABLE auth.user_applications (
@@ -68,7 +84,9 @@ CREATE TABLE auth.user_assistant_binding (
assistant_id bigint, assistant_id bigint,
staff_id bigint, staff_id bigint,
binding_type character varying(20) NOT NULL, binding_type character varying(20) NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL,
is_removed boolean DEFAULT false NOT NULL,
removed_at timestamp with time zone
); );
CREATE TABLE auth.user_site_roles ( CREATE TABLE auth.user_site_roles (
@@ -76,7 +94,9 @@ CREATE TABLE auth.user_site_roles (
user_id integer NOT NULL, user_id integer NOT NULL,
site_id bigint NOT NULL, site_id bigint NOT NULL,
role_id integer NOT NULL, role_id integer NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL,
is_removed boolean DEFAULT false NOT NULL,
removed_at timestamp with time zone
); );
CREATE TABLE auth.users ( CREATE TABLE auth.users (
@@ -88,10 +108,17 @@ CREATE TABLE auth.users (
phone character varying(20), phone character varying(20),
status character varying(20) DEFAULT 'new'::character varying NOT NULL, status character varying(20) DEFAULT 'new'::character varying NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL updated_at timestamp with time zone DEFAULT now() NOT NULL,
rejection_count integer DEFAULT 0 NOT NULL,
avatar_url character varying(500)
); );
-- 约束(主键 / 唯一 / 外键) -- 约束(主键 / 唯一 / 外键)
ALTER TABLE auth._archived_site_code_mapping ADD CONSTRAINT site_code_mapping_pkey PRIMARY KEY (id);
ALTER TABLE auth._archived_site_code_mapping ADD CONSTRAINT site_code_mapping_site_code_key UNIQUE (site_code);
ALTER TABLE auth._archived_site_code_mapping ADD CONSTRAINT site_code_mapping_site_id_key UNIQUE (site_id);
ALTER TABLE auth._archived_site_code_mapping ADD CONSTRAINT uq_site_code_mapping_site_code UNIQUE (site_code);
ALTER TABLE auth._archived_site_code_mapping ADD CONSTRAINT uq_site_code_mapping_site_id UNIQUE (site_id);
ALTER TABLE auth.permissions ADD CONSTRAINT permissions_pkey PRIMARY KEY (id); ALTER TABLE auth.permissions ADD CONSTRAINT permissions_pkey PRIMARY KEY (id);
ALTER TABLE auth.permissions ADD CONSTRAINT permissions_code_key UNIQUE (code); ALTER TABLE auth.permissions ADD CONSTRAINT permissions_code_key UNIQUE (code);
ALTER TABLE auth.permissions ADD CONSTRAINT uq_permissions_code UNIQUE (code); ALTER TABLE auth.permissions ADD CONSTRAINT uq_permissions_code UNIQUE (code);
@@ -103,11 +130,8 @@ ALTER TABLE auth.role_permissions ADD CONSTRAINT role_permissions_pkey PRIMARY K
ALTER TABLE auth.roles ADD CONSTRAINT roles_pkey PRIMARY KEY (id); ALTER TABLE auth.roles ADD CONSTRAINT roles_pkey PRIMARY KEY (id);
ALTER TABLE auth.roles ADD CONSTRAINT roles_code_key UNIQUE (code); ALTER TABLE auth.roles ADD CONSTRAINT roles_code_key UNIQUE (code);
ALTER TABLE auth.roles ADD CONSTRAINT uq_roles_code UNIQUE (code); ALTER TABLE auth.roles ADD CONSTRAINT uq_roles_code UNIQUE (code);
ALTER TABLE auth.site_code_mapping ADD CONSTRAINT site_code_mapping_pkey PRIMARY KEY (id); ALTER TABLE auth.tenant_admins ADD CONSTRAINT tenant_admins_pkey PRIMARY KEY (id);
ALTER TABLE auth.site_code_mapping ADD CONSTRAINT site_code_mapping_site_code_key UNIQUE (site_code); ALTER TABLE auth.tenant_admins ADD CONSTRAINT tenant_admins_username_key UNIQUE (username);
ALTER TABLE auth.site_code_mapping ADD CONSTRAINT site_code_mapping_site_id_key UNIQUE (site_id);
ALTER TABLE auth.site_code_mapping ADD CONSTRAINT uq_site_code_mapping_site_code UNIQUE (site_code);
ALTER TABLE auth.site_code_mapping ADD CONSTRAINT uq_site_code_mapping_site_id UNIQUE (site_id);
ALTER TABLE auth.user_applications ADD CONSTRAINT fk_user_applications_user_id FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; ALTER TABLE auth.user_applications ADD CONSTRAINT fk_user_applications_user_id FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE auth.user_applications ADD CONSTRAINT user_applications_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE; ALTER TABLE auth.user_applications ADD CONSTRAINT user_applications_user_id_fkey FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE;
ALTER TABLE auth.user_applications ADD CONSTRAINT user_applications_pkey PRIMARY KEY (id); ALTER TABLE auth.user_applications ADD CONSTRAINT user_applications_pkey PRIMARY KEY (id);
@@ -126,60 +150,14 @@ ALTER TABLE auth.users ADD CONSTRAINT uq_users_wx_openid UNIQUE (wx_openid);
ALTER TABLE auth.users ADD CONSTRAINT users_wx_openid_key UNIQUE (wx_openid); ALTER TABLE auth.users ADD CONSTRAINT users_wx_openid_key UNIQUE (wx_openid);
-- 索引 -- 索引
CREATE INDEX ix_site_code_mapping_site_code ON auth.site_code_mapping USING btree (site_code); CREATE INDEX ix_site_code_mapping_site_code ON auth._archived_site_code_mapping USING btree (site_code);
CREATE INDEX idx_tenant_admin_tenant ON auth.tenant_admins USING btree (tenant_id);
CREATE INDEX idx_tenant_admins_active_not_deleted ON auth.tenant_admins USING btree (is_active) WHERE (deleted_at IS NULL);
CREATE INDEX ix_user_applications_status ON auth.user_applications USING btree (status); CREATE INDEX ix_user_applications_status ON auth.user_applications USING btree (status);
CREATE INDEX ix_user_applications_user_id ON auth.user_applications USING btree (user_id); CREATE INDEX ix_user_applications_user_id ON auth.user_applications USING btree (user_id);
CREATE INDEX idx_user_assistant_binding_active ON auth.user_assistant_binding USING btree (user_id, site_id) WHERE (is_removed = false);
CREATE INDEX idx_user_site_roles_active ON auth.user_site_roles USING btree (user_id, site_id) WHERE (is_removed = false);
CREATE INDEX ix_user_site_roles_user_site ON auth.user_site_roles USING btree (user_id, site_id); CREATE INDEX ix_user_site_roles_user_site ON auth.user_site_roles USING btree (user_id, site_id);
CREATE INDEX ix_users_status ON auth.users USING btree (status); CREATE INDEX ix_users_status ON auth.users USING btree (status);
CREATE INDEX ix_users_wx_openid ON auth.users USING btree (wx_openid); CREATE INDEX ix_users_wx_openid ON auth.users USING btree (wx_openid);
-- =============================================================================
-- 种子数据权限列表5 条)
-- =============================================================================
INSERT INTO auth.permissions (code, name, description) VALUES
('view_tasks', '查看任务', '允许查看任务列表和任务详情'),
('view_board', '查看看板', '允许查看数据看板概览'),
('view_board_finance', '查看财务看板', '允许查看财务相关的数据看板'),
('view_board_customer', '查看客户看板', '允许查看客户相关的数据看板'),
('view_board_coach', '查看助教看板', '允许查看助教相关的数据看板')
ON CONFLICT (code) DO NOTHING;
-- =============================================================================
-- 种子数据默认角色4 条)
-- =============================================================================
INSERT INTO auth.roles (code, name, description) VALUES
('coach', '助教', '球房助教,可查看任务和助教看板'),
('staff', '员工', '球房员工,可查看任务和数据看板'),
('site_admin', '店铺管理员', '单店管理员,可查看所有看板'),
('tenant_admin', '租户管理员', '租户级管理员,拥有全部权限')
ON CONFLICT (code) DO NOTHING;
-- =============================================================================
-- 种子数据:角色-权限映射14 条)
-- =============================================================================
INSERT INTO auth.role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM auth.roles r
CROSS JOIN auth.permissions p
WHERE (r.code, p.code) IN (
('coach', 'view_tasks'),
('coach', 'view_board_coach'),
('staff', 'view_tasks'),
('staff', 'view_board'),
('site_admin', 'view_tasks'),
('site_admin', 'view_board'),
('site_admin', 'view_board_finance'),
('site_admin', 'view_board_customer'),
('site_admin', 'view_board_coach'),
('tenant_admin', 'view_tasks'),
('tenant_admin', 'view_board'),
('tenant_admin', 'view_board_finance'),
('tenant_admin', 'view_board_customer'),
('tenant_admin', 'view_board_coach')
)
ON CONFLICT (role_id, permission_id) DO NOTHING;

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- zqyy_app / biz核心业务表任务/备注/触发器)) -- zqyy_app / biz核心业务表任务/备注/触发器))
-- 生成日期2026-03-20 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -10,9 +10,23 @@ CREATE SCHEMA IF NOT EXISTS biz;
CREATE SEQUENCE IF NOT EXISTS biz.ai_cache_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS biz.ai_cache_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.ai_conversations_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS biz.ai_conversations_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.ai_messages_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS biz.ai_messages_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.ai_run_logs_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.ai_trigger_jobs_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.cfg_task_generator_params_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.coach_task_history_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS biz.coach_task_history_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.coach_task_transfer_log_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.coach_tasks_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS biz.coach_tasks_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.connectors_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS biz.dws_assistant_task_monthly_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.excel_upload_log_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.notes_id_seq AS bigint; CREATE SEQUENCE IF NOT EXISTS biz.notes_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.salary_adjustments_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.site_code_history_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS biz.sites_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS biz.stg_finance_expense_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.stg_platform_income_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.stg_recharge_commission_id_seq AS bigint;
CREATE SEQUENCE IF NOT EXISTS biz.tenants_id_seq AS integer;
CREATE SEQUENCE IF NOT EXISTS biz.trigger_jobs_id_seq AS integer; CREATE SEQUENCE IF NOT EXISTS biz.trigger_jobs_id_seq AS integer;
-- 表 -- 表
@@ -25,7 +39,8 @@ CREATE TABLE biz.ai_cache (
score integer, score integer,
triggered_by character varying(100), triggered_by character varying(100),
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone expires_at timestamp with time zone,
status character varying(20) DEFAULT 'valid'::character varying
); );
CREATE TABLE biz.ai_conversations ( CREATE TABLE biz.ai_conversations (
@@ -36,12 +51,13 @@ CREATE TABLE biz.ai_conversations (
site_id bigint NOT NULL, site_id bigint NOT NULL,
source_page character varying(100), source_page character varying(100),
source_context jsonb, source_context jsonb,
created_at timestamp with time zone DEFAULT now() NOT NULL,
context_type character varying(20), context_type character varying(20),
context_id character varying(50), context_id character varying(50),
title character varying(200), title character varying(200),
last_message text, last_message text,
last_message_at timestamp with time zone, last_message_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL session_id character varying(100)
); );
CREATE TABLE biz.ai_messages ( CREATE TABLE biz.ai_messages (
@@ -50,10 +66,54 @@ CREATE TABLE biz.ai_messages (
role character varying(10) NOT NULL, role character varying(10) NOT NULL,
content text NOT NULL, content text NOT NULL,
tokens_used integer, tokens_used integer,
reference_card jsonb, created_at timestamp with time zone DEFAULT now() NOT NULL,
reference_card jsonb
);
CREATE TABLE biz.ai_run_logs (
id bigint DEFAULT nextval('biz.ai_run_logs_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
app_type character varying(30) NOT NULL,
trigger_type character varying(20) NOT NULL,
member_id bigint,
request_prompt text,
response_text text,
tokens_used integer DEFAULT 0,
latency_ms integer,
status character varying(20) DEFAULT 'pending'::character varying NOT NULL,
error_message text,
session_id character varying(100),
created_at timestamp with time zone DEFAULT now() NOT NULL,
finished_at timestamp with time zone,
alert_status character varying(20) DEFAULT NULL::character varying
);
CREATE TABLE biz.ai_trigger_jobs (
id bigint DEFAULT nextval('biz.ai_trigger_jobs_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
event_type character varying(30) NOT NULL,
connector_type character varying(30) DEFAULT 'feiqiu'::character varying,
member_id bigint,
payload jsonb,
status character varying(20) DEFAULT 'pending'::character varying NOT NULL,
is_forced boolean DEFAULT false,
app_chain character varying(100),
started_at timestamp with time zone,
finished_at timestamp with time zone,
error_message text,
created_at timestamp with time zone DEFAULT now() NOT NULL created_at timestamp with time zone DEFAULT now() NOT NULL
); );
CREATE TABLE biz.cfg_task_generator_params (
id bigint DEFAULT nextval('biz.cfg_task_generator_params_id_seq'::regclass) NOT NULL,
site_id bigint,
param_key character varying(64) NOT NULL,
param_value numeric NOT NULL,
description text,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
updated_by bigint
);
CREATE TABLE biz.coach_task_history ( CREATE TABLE biz.coach_task_history (
id bigint DEFAULT nextval('biz.coach_task_history_id_seq'::regclass) NOT NULL, id bigint DEFAULT nextval('biz.coach_task_history_id_seq'::regclass) NOT NULL,
task_id bigint NOT NULL, task_id bigint NOT NULL,
@@ -66,6 +126,20 @@ CREATE TABLE biz.coach_task_history (
created_at timestamp with time zone DEFAULT now() created_at timestamp with time zone DEFAULT now()
); );
CREATE TABLE biz.coach_task_transfer_log (
id bigint DEFAULT nextval('biz.coach_task_transfer_log_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
member_id bigint NOT NULL,
from_assistant_id bigint NOT NULL,
to_assistant_id bigint NOT NULL,
from_task_id bigint NOT NULL,
to_task_id bigint,
transfer_reason text,
guard_checks jsonb,
transfer_score numeric,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE biz.coach_tasks ( CREATE TABLE biz.coach_tasks (
id bigint DEFAULT nextval('biz.coach_tasks_id_seq'::regclass) NOT NULL, id bigint DEFAULT nextval('biz.coach_tasks_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL, site_id bigint NOT NULL,
@@ -81,7 +155,50 @@ CREATE TABLE biz.coach_tasks (
completed_task_type character varying(50), completed_task_type character varying(50),
parent_task_id bigint, parent_task_id bigint,
created_at timestamp with time zone DEFAULT now(), created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now() updated_at timestamp with time zone DEFAULT now(),
transfer_count integer DEFAULT 0 NOT NULL,
transferred_from bigint,
transferred_at timestamp with time zone
);
CREATE TABLE biz.connectors (
id integer DEFAULT nextval('biz.connectors_id_seq'::regclass) NOT NULL,
connector_key character varying(50) NOT NULL,
display_name character varying(100) NOT NULL,
is_active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE biz.dws_assistant_task_monthly (
id bigint DEFAULT nextval('biz.dws_assistant_task_monthly_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
assistant_id bigint NOT NULL,
stat_month date NOT NULL,
recall_created integer DEFAULT 0 NOT NULL,
follow_up_created integer DEFAULT 0 NOT NULL,
relationship_created integer DEFAULT 0 NOT NULL,
total_created integer DEFAULT 0 NOT NULL,
recall_completed integer DEFAULT 0 NOT NULL,
follow_up_completed integer DEFAULT 0 NOT NULL,
total_completed integer DEFAULT 0 NOT NULL,
abandoned_count integer DEFAULT 0 NOT NULL,
transferred_count integer DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE biz.excel_upload_log (
id bigint DEFAULT nextval('biz.excel_upload_log_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
upload_type character varying(30) NOT NULL,
file_name character varying(255) NOT NULL,
uploaded_by bigint NOT NULL,
row_count integer DEFAULT 0,
conflict_count integer DEFAULT 0,
resolved_count integer DEFAULT 0,
status character varying(20) NOT NULL,
error_detail jsonb,
created_at timestamp with time zone DEFAULT now(),
confirmed_at timestamp with time zone
); );
CREATE TABLE biz.notes ( CREATE TABLE biz.notes (
@@ -102,6 +219,89 @@ CREATE TABLE biz.notes (
score smallint score smallint
); );
CREATE TABLE biz.salary_adjustments (
id bigint DEFAULT nextval('biz.salary_adjustments_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
assistant_id bigint,
assistant_name character varying(100) NOT NULL,
assistant_number character varying(50) NOT NULL,
salary_month character varying(7) NOT NULL,
adjustment_type character varying(20) NOT NULL,
amount numeric(12,2) NOT NULL,
reason character varying(200) NOT NULL,
upload_batch_id bigint,
created_at timestamp with time zone DEFAULT now(),
created_by bigint
);
CREATE TABLE biz.site_code_history (
id integer DEFAULT nextval('biz.site_code_history_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
site_code character varying(6) NOT NULL,
is_current boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
retired_at timestamp with time zone
);
CREATE TABLE biz.sites (
id integer DEFAULT nextval('biz.sites_id_seq'::regclass) NOT NULL,
tenant_id integer NOT NULL,
site_id bigint NOT NULL,
site_name character varying(200),
site_code character varying(6),
site_label character varying(50),
is_active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE biz.stg_finance_expense (
id bigint DEFAULT nextval('biz.stg_finance_expense_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
expense_month character varying(7) NOT NULL,
category character varying(50) NOT NULL,
amount numeric(12,2) NOT NULL,
remark text,
upload_batch_id bigint,
synced_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE biz.stg_platform_income (
id bigint DEFAULT nextval('biz.stg_platform_income_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
income_month character varying(7) NOT NULL,
platform_name character varying(100) NOT NULL,
amount numeric(12,2) NOT NULL,
remark text,
upload_batch_id bigint,
synced_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE biz.stg_recharge_commission (
id bigint DEFAULT nextval('biz.stg_recharge_commission_id_seq'::regclass) NOT NULL,
site_id bigint NOT NULL,
recharge_date date NOT NULL,
member_name character varying(100) NOT NULL,
recharge_amount numeric(12,2) NOT NULL,
assigned_assistant character varying(100) NOT NULL,
reward_amount numeric(12,2) NOT NULL,
upload_batch_id bigint,
synced_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now()
);
CREATE TABLE biz.tenants (
id integer DEFAULT nextval('biz.tenants_id_seq'::regclass) NOT NULL,
connector_id integer NOT NULL,
tenant_id bigint NOT NULL,
tenant_name character varying(200),
is_active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE TABLE biz.trigger_jobs ( CREATE TABLE biz.trigger_jobs (
id integer DEFAULT nextval('biz.trigger_jobs_id_seq'::regclass) NOT NULL, id integer DEFAULT nextval('biz.trigger_jobs_id_seq'::regclass) NOT NULL,
job_type character varying(100) NOT NULL, job_type character varying(100) NOT NULL,
@@ -111,7 +311,10 @@ CREATE TABLE biz.trigger_jobs (
last_run_at timestamp with time zone, last_run_at timestamp with time zone,
next_run_at timestamp with time zone, next_run_at timestamp with time zone,
status character varying(20) DEFAULT 'enabled'::character varying NOT NULL, status character varying(20) DEFAULT 'enabled'::character varying NOT NULL,
created_at timestamp with time zone DEFAULT now() created_at timestamp with time zone DEFAULT now(),
last_error text,
description text,
last_stats jsonb
); );
-- 约束(主键 / 唯一 / 外键) -- 约束(主键 / 唯一 / 外键)
@@ -119,12 +322,42 @@ ALTER TABLE biz.ai_cache ADD CONSTRAINT ai_cache_pkey PRIMARY KEY (id);
ALTER TABLE biz.ai_conversations ADD CONSTRAINT ai_conversations_pkey PRIMARY KEY (id); ALTER TABLE biz.ai_conversations ADD CONSTRAINT ai_conversations_pkey PRIMARY KEY (id);
ALTER TABLE biz.ai_messages ADD CONSTRAINT ai_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES biz.ai_conversations(id) ON DELETE CASCADE; ALTER TABLE biz.ai_messages ADD CONSTRAINT ai_messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES biz.ai_conversations(id) ON DELETE CASCADE;
ALTER TABLE biz.ai_messages ADD CONSTRAINT ai_messages_pkey PRIMARY KEY (id); ALTER TABLE biz.ai_messages ADD CONSTRAINT ai_messages_pkey PRIMARY KEY (id);
ALTER TABLE biz.ai_run_logs ADD CONSTRAINT ai_run_logs_pkey PRIMARY KEY (id);
ALTER TABLE biz.ai_trigger_jobs ADD CONSTRAINT ai_trigger_jobs_pkey PRIMARY KEY (id);
ALTER TABLE biz.cfg_task_generator_params ADD CONSTRAINT cfg_task_generator_params_pkey PRIMARY KEY (id);
ALTER TABLE biz.cfg_task_generator_params ADD CONSTRAINT cfg_task_generator_params_site_id_param_key_key UNIQUE (site_id, param_key);
ALTER TABLE biz.coach_task_history ADD CONSTRAINT coach_task_history_task_id_fkey FOREIGN KEY (task_id) REFERENCES biz.coach_tasks(id); ALTER TABLE biz.coach_task_history ADD CONSTRAINT coach_task_history_task_id_fkey FOREIGN KEY (task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.coach_task_history ADD CONSTRAINT coach_task_history_pkey PRIMARY KEY (id); ALTER TABLE biz.coach_task_history ADD CONSTRAINT coach_task_history_pkey PRIMARY KEY (id);
ALTER TABLE biz.coach_task_transfer_log ADD CONSTRAINT coach_task_transfer_log_from_task_id_fkey FOREIGN KEY (from_task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.coach_task_transfer_log ADD CONSTRAINT coach_task_transfer_log_to_task_id_fkey FOREIGN KEY (to_task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.coach_task_transfer_log ADD CONSTRAINT coach_task_transfer_log_pkey PRIMARY KEY (id);
ALTER TABLE biz.coach_tasks ADD CONSTRAINT coach_tasks_parent_task_id_fkey FOREIGN KEY (parent_task_id) REFERENCES biz.coach_tasks(id); ALTER TABLE biz.coach_tasks ADD CONSTRAINT coach_tasks_parent_task_id_fkey FOREIGN KEY (parent_task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.coach_tasks ADD CONSTRAINT fk_coach_tasks_transferred_from FOREIGN KEY (transferred_from) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.coach_tasks ADD CONSTRAINT coach_tasks_pkey PRIMARY KEY (id); ALTER TABLE biz.coach_tasks ADD CONSTRAINT coach_tasks_pkey PRIMARY KEY (id);
ALTER TABLE biz.connectors ADD CONSTRAINT connectors_pkey PRIMARY KEY (id);
ALTER TABLE biz.connectors ADD CONSTRAINT connectors_connector_key_key UNIQUE (connector_key);
ALTER TABLE biz.dws_assistant_task_monthly ADD CONSTRAINT dws_assistant_task_monthly_pkey PRIMARY KEY (id);
ALTER TABLE biz.dws_assistant_task_monthly ADD CONSTRAINT dws_assistant_task_monthly_site_id_assistant_id_stat_month_key UNIQUE (site_id, assistant_id, stat_month);
ALTER TABLE biz.excel_upload_log ADD CONSTRAINT excel_upload_log_pkey PRIMARY KEY (id);
ALTER TABLE biz.notes ADD CONSTRAINT notes_task_id_fkey FOREIGN KEY (task_id) REFERENCES biz.coach_tasks(id); ALTER TABLE biz.notes ADD CONSTRAINT notes_task_id_fkey FOREIGN KEY (task_id) REFERENCES biz.coach_tasks(id);
ALTER TABLE biz.notes ADD CONSTRAINT notes_pkey PRIMARY KEY (id); ALTER TABLE biz.notes ADD CONSTRAINT notes_pkey PRIMARY KEY (id);
ALTER TABLE biz.salary_adjustments ADD CONSTRAINT salary_adjustments_upload_batch_id_fkey FOREIGN KEY (upload_batch_id) REFERENCES biz.excel_upload_log(id);
ALTER TABLE biz.salary_adjustments ADD CONSTRAINT salary_adjustments_pkey PRIMARY KEY (id);
ALTER TABLE biz.site_code_history ADD CONSTRAINT site_code_history_pkey PRIMARY KEY (id);
ALTER TABLE biz.site_code_history ADD CONSTRAINT site_code_history_site_code_key UNIQUE (site_code);
ALTER TABLE biz.sites ADD CONSTRAINT sites_tenant_id_fkey FOREIGN KEY (tenant_id) REFERENCES biz.tenants(id);
ALTER TABLE biz.sites ADD CONSTRAINT sites_pkey PRIMARY KEY (id);
ALTER TABLE biz.sites ADD CONSTRAINT sites_site_code_key UNIQUE (site_code);
ALTER TABLE biz.sites ADD CONSTRAINT sites_site_id_key UNIQUE (site_id);
ALTER TABLE biz.stg_finance_expense ADD CONSTRAINT stg_finance_expense_upload_batch_id_fkey FOREIGN KEY (upload_batch_id) REFERENCES biz.excel_upload_log(id);
ALTER TABLE biz.stg_finance_expense ADD CONSTRAINT stg_finance_expense_pkey PRIMARY KEY (id);
ALTER TABLE biz.stg_platform_income ADD CONSTRAINT stg_platform_income_upload_batch_id_fkey FOREIGN KEY (upload_batch_id) REFERENCES biz.excel_upload_log(id);
ALTER TABLE biz.stg_platform_income ADD CONSTRAINT stg_platform_income_pkey PRIMARY KEY (id);
ALTER TABLE biz.stg_recharge_commission ADD CONSTRAINT stg_recharge_commission_upload_batch_id_fkey FOREIGN KEY (upload_batch_id) REFERENCES biz.excel_upload_log(id);
ALTER TABLE biz.stg_recharge_commission ADD CONSTRAINT stg_recharge_commission_pkey PRIMARY KEY (id);
ALTER TABLE biz.tenants ADD CONSTRAINT tenants_connector_id_fkey FOREIGN KEY (connector_id) REFERENCES biz.connectors(id);
ALTER TABLE biz.tenants ADD CONSTRAINT tenants_pkey PRIMARY KEY (id);
ALTER TABLE biz.tenants ADD CONSTRAINT tenants_connector_id_tenant_id_key UNIQUE (connector_id, tenant_id);
ALTER TABLE biz.trigger_jobs ADD CONSTRAINT trigger_jobs_pkey PRIMARY KEY (id); ALTER TABLE biz.trigger_jobs ADD CONSTRAINT trigger_jobs_pkey PRIMARY KEY (id);
ALTER TABLE biz.trigger_jobs ADD CONSTRAINT trigger_jobs_job_name_key UNIQUE (job_name); ALTER TABLE biz.trigger_jobs ADD CONSTRAINT trigger_jobs_job_name_key UNIQUE (job_name);
@@ -132,32 +365,26 @@ ALTER TABLE biz.trigger_jobs ADD CONSTRAINT trigger_jobs_job_name_key UNIQUE (jo
CREATE INDEX idx_ai_cache_cleanup ON biz.ai_cache USING btree (cache_type, site_id, target_id, created_at); CREATE INDEX idx_ai_cache_cleanup ON biz.ai_cache USING btree (cache_type, site_id, target_id, created_at);
CREATE INDEX idx_ai_cache_lookup ON biz.ai_cache USING btree (cache_type, site_id, target_id, created_at DESC); CREATE INDEX idx_ai_cache_lookup ON biz.ai_cache USING btree (cache_type, site_id, target_id, created_at DESC);
CREATE INDEX idx_ai_conv_app_site ON biz.ai_conversations USING btree (app_id, site_id, created_at DESC); CREATE INDEX idx_ai_conv_app_site ON biz.ai_conversations USING btree (app_id, site_id, created_at DESC);
CREATE INDEX idx_ai_conv_user_site ON biz.ai_conversations USING btree (user_id, site_id, created_at DESC);
CREATE INDEX idx_ai_conv_context ON biz.ai_conversations USING btree (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE (context_type IS NOT NULL); CREATE INDEX idx_ai_conv_context ON biz.ai_conversations USING btree (user_id, site_id, context_type, context_id, last_message_at DESC NULLS LAST) WHERE (context_type IS NOT NULL);
CREATE INDEX idx_ai_conv_last_msg ON biz.ai_conversations USING btree (user_id, site_id, last_message_at DESC NULLS LAST); CREATE INDEX idx_ai_conv_last_msg ON biz.ai_conversations USING btree (user_id, site_id, last_message_at DESC NULLS LAST);
CREATE INDEX idx_ai_conv_user_site ON biz.ai_conversations USING btree (user_id, site_id, created_at DESC);
CREATE INDEX idx_ai_msg_conv ON biz.ai_messages USING btree (conversation_id, created_at); CREATE INDEX idx_ai_msg_conv ON biz.ai_messages USING btree (conversation_id, created_at);
CREATE INDEX idx_ai_run_logs_alert ON biz.ai_run_logs USING btree (alert_status, created_at DESC) WHERE ((status)::text = ANY ((ARRAY['failed'::character varying, 'timeout'::character varying, 'circuit_open'::character varying])::text[]));
CREATE INDEX idx_ai_run_logs_created ON biz.ai_run_logs USING btree (created_at);
CREATE INDEX idx_ai_run_logs_created_brin ON biz.ai_run_logs USING brin (created_at) WITH (pages_per_range='32');
CREATE INDEX idx_ai_run_logs_site_app ON biz.ai_run_logs USING btree (site_id, app_type);
CREATE INDEX idx_ai_run_logs_status ON biz.ai_run_logs USING btree (status);
CREATE INDEX idx_ai_trigger_jobs_dedup ON biz.ai_trigger_jobs USING btree (event_type, member_id, site_id, created_at) WHERE ((status)::text <> 'skipped_duplicate'::text);
CREATE INDEX idx_ai_trigger_jobs_site ON biz.ai_trigger_jobs USING btree (site_id, event_type);
CREATE INDEX idx_ai_trigger_jobs_status ON biz.ai_trigger_jobs USING btree (status);
CREATE INDEX idx_transfer_log_member ON biz.coach_task_transfer_log USING btree (member_id, created_at DESC);
CREATE INDEX idx_transfer_log_site_created ON biz.coach_task_transfer_log USING btree (site_id, created_at DESC);
CREATE INDEX idx_coach_tasks_assistant_status ON biz.coach_tasks USING btree (site_id, assistant_id, status); CREATE INDEX idx_coach_tasks_assistant_status ON biz.coach_tasks USING btree (site_id, assistant_id, status);
CREATE UNIQUE INDEX idx_coach_tasks_site_assistant_member_type ON biz.coach_tasks USING btree (site_id, assistant_id, member_id, task_type) WHERE ((status)::text = 'active'::text); CREATE UNIQUE INDEX idx_coach_tasks_site_assistant_member_type ON biz.coach_tasks USING btree (site_id, assistant_id, member_id, task_type) WHERE ((status)::text = 'active'::text);
CREATE INDEX idx_task_monthly_assistant ON biz.dws_assistant_task_monthly USING btree (assistant_id, stat_month DESC);
CREATE INDEX idx_task_monthly_site_month ON biz.dws_assistant_task_monthly USING btree (site_id, stat_month DESC);
CREATE INDEX idx_excel_log_site ON biz.excel_upload_log USING btree (site_id, created_at DESC);
CREATE INDEX idx_notes_target ON biz.notes USING btree (site_id, target_type, target_id); CREATE INDEX idx_notes_target ON biz.notes USING btree (site_id, target_type, target_id);
CREATE INDEX idx_salary_adj_assistant_month ON biz.salary_adjustments USING btree (assistant_id, salary_month);
CREATE INDEX idx_salary_adj_site_month ON biz.salary_adjustments USING btree (site_id, salary_month);
-- =============================================================================
-- 种子数据触发器配置4 条)
-- =============================================================================
INSERT INTO biz.trigger_jobs (job_type, job_name, trigger_condition, trigger_config, next_run_at)
VALUES
('task_generator', 'task_generator', 'cron',
'{"cron_expression": "0 7 * * *"}',
(CURRENT_DATE + 1) + INTERVAL '7 hours'),
('task_expiry_check', 'task_expiry_check', 'interval',
'{"interval_seconds": 3600}',
NOW() + INTERVAL '1 hour'),
('recall_completion_check', 'recall_completion_check', 'event',
'{"event_name": "etl_data_updated"}',
NULL),
('note_reclassify_backfill', 'note_reclassify_backfill', 'event',
'{"event_name": "recall_completed"}',
NULL)
ON CONFLICT (job_name) DO NOTHING;

View File

@@ -1,6 +1,6 @@
-- ============================================================================= -- =============================================================================
-- zqyy_app / public小程序业务表 -- zqyy_app / public小程序业务表
-- 生成日期2026-03-15 -- 生成日期2026-04-05
-- 来源:测试库(通过脚本自动导出) -- 来源:测试库(通过脚本自动导出)
-- ============================================================================= -- =============================================================================
@@ -24,7 +24,8 @@ CREATE TABLE public.admin_users (
site_id bigint NOT NULL, site_id bigint NOT NULL,
is_active boolean DEFAULT true, is_active boolean DEFAULT true,
created_at timestamp with time zone DEFAULT now(), created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now() updated_at timestamp with time zone DEFAULT now(),
roles _text DEFAULT '{site_admin}'::text[] NOT NULL
); );
CREATE TABLE public.approvals ( CREATE TABLE public.approvals (
@@ -47,7 +48,8 @@ CREATE TABLE public.member_retention_clue (
recorded_by_name character varying(50), recorded_by_name character varying(50),
recorded_at timestamp with time zone DEFAULT now() NOT NULL, recorded_at timestamp with time zone DEFAULT now() NOT NULL,
site_id bigint NOT NULL, site_id bigint NOT NULL,
source character varying(20) DEFAULT 'manual'::character varying NOT NULL source character varying(20) DEFAULT 'manual'::character varying NOT NULL,
is_hidden boolean DEFAULT false NOT NULL
); );
CREATE TABLE public.permissions ( CREATE TABLE public.permissions (
@@ -83,7 +85,11 @@ CREATE TABLE public.scheduled_tasks (
run_count integer DEFAULT 0, run_count integer DEFAULT 0,
last_status character varying(20), last_status character varying(20),
created_at timestamp with time zone DEFAULT now(), created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now() updated_at timestamp with time zone DEFAULT now(),
min_run_interval_value integer DEFAULT 0 NOT NULL,
min_run_interval_unit character varying(20) DEFAULT 'minutes'::character varying NOT NULL,
last_success_at timestamp with time zone,
min_run_intervals jsonb DEFAULT '{}'::jsonb NOT NULL
); );
CREATE TABLE public.task_execution_log ( CREATE TABLE public.task_execution_log (
@@ -101,7 +107,8 @@ CREATE TABLE public.task_execution_log (
error_log text, error_log text,
summary jsonb, summary jsonb,
created_at timestamp with time zone DEFAULT now(), created_at timestamp with time zone DEFAULT now(),
schedule_id uuid schedule_id uuid,
config jsonb
); );
CREATE TABLE public.task_queue ( CREATE TABLE public.task_queue (
@@ -198,20 +205,3 @@ CREATE INDEX idx_user_roles_site_id ON public.user_roles USING btree (site_id);
CREATE INDEX idx_users_mobile ON public.users USING btree (mobile); CREATE INDEX idx_users_mobile ON public.users USING btree (mobile);
CREATE INDEX idx_users_site_id ON public.users USING btree (site_id); CREATE INDEX idx_users_site_id ON public.users USING btree (site_id);
-- =============================================================================
-- 种子数据Web 管理后台默认管理员账号
-- 默认密码admin123bcrypt hashcost=12
-- 生产环境部署后务必立即修改密码
-- =============================================================================
INSERT INTO admin_users (username, password_hash, display_name, site_id, is_active)
VALUES (
'admin',
'$2b$12$2MTWlJKL0HTgHIkv5Rmpie2pQ9PkeJu0iciLbzPEpPcA94ZakIQzq',
'默认管理员',
1,
TRUE
)
ON CONFLICT (username) DO NOTHING;

View File

@@ -0,0 +1,753 @@
# 设计文档 — P14AI 模块改造 — DashScope 迁移 + 调度器完善
## 概述
本设计将 AI 模块从 `openai` SDK通用模型 API迁移到 `dashscope` SDKApplication API使 8 个百炼智能体应用能通过各自的 `app_id` 调用,充分利用百炼控制台配置的 System Prompt 和 MCP 工具。同时修复调度器 asyncio 嵌套问题、打通事件触发链、新增熔断/限流/Token 预算控制,并完成相关数据库变更。
### 设计决策与理由
| 决策 | 理由 |
|------|------|
| `asyncio.to_thread()` 包装同步 SDK | `dashscope.Application.call()` 是同步方法FastAPI 全 async`to_thread` 是标准库方案,无需引入第三方异步包装 |
| 流式调用用 `asyncio.Queue` 桥接 | `Application.call(stream=True)` 返回同步迭代器,需在线程中消费后桥接到 async generator保持 SSE 端点的 async 特性 |
| 熔断器内存实现(非 Redis | 单实例部署,无需分布式状态;按 `app_id` 独立计数,结构简单 |
| 限流器内存计数器 | 同上,单实例部署;滑动窗口计数器足够 |
| Token 预算从 `ai_run_logs` 聚合 | 避免引入额外计数表,利用已有日志数据;查询频率低(每次调用前一次) |
| session_id 云端 + 本地双轨 | 百炼云端管理上下文减少 token 消耗,本地持久化保证 session 过期后可恢复 |
| 内部 API 用 `INTERNAL_API_TOKEN` 而非 JWT | ETL 进程是内部服务,不需要用户身份;简单 token 认证足够且配置简单 |
| App2~8 纯重试不做本地 JSON 修复 | 百炼控制台已配置 System Prompt 要求 JSON 输出,本地修复容易引入错误数据 |
## 架构
### 整体架构
```mermaid
graph TB
subgraph "外部触发"
ETL["ETL DWS 任务"]
XCX["小程序助教"]
TM["task_manager"]
end
subgraph "入口层"
SSE["SSE 端点<br/>POST /api/xcx/chat/stream"]
IAPI["内部触发 API<br/>POST /api/internal/ai/trigger"]
EVT["事件发射<br/>fire_event()"]
end
subgraph "防护层"
CB["CircuitBreaker<br/>按 app_id 独立"]
RL["RateLimiter<br/>用户/门店维度"]
BT["BudgetTracker<br/>日/月 token 预算"]
end
subgraph "核心层"
DSC["DashScopeClient<br/>Application.call() 包装"]
DISP["Dispatcher<br/>事件调度 + 调用链编排"]
end
subgraph "服务层"
CONV["ConversationService<br/>session_id 双轨"]
CACHE["AICacheService<br/>status 字段 + 过期策略"]
end
subgraph "存储层"
DB_CONV["ai_conversations<br/>+session_id"]
DB_MSG["ai_messages"]
DB_CACHE["ai_cache<br/>+status"]
DB_LOG["ai_run_logs<br/>新表"]
DB_JOB["ai_trigger_jobs<br/>新表"]
DB_CLUE["member_retention_clue"]
end
ETL -->|HTTP POST| IAPI
XCX -->|API 路由| EVT
TM -->|fire_event| EVT
SSE --> CB --> RL --> BT --> DSC
IAPI --> DISP
EVT --> DISP
DISP --> CB
DSC -->|App1 流式| SSE
DSC -->|App2~8 单轮| DISP
DISP --> CONV
DISP --> CACHE
DSC --> DB_LOG
CONV --> DB_CONV
CONV --> DB_MSG
CACHE --> DB_CACHE
DISP --> DB_JOB
DISP --> DB_CLUE
```
### 调用链流程
```mermaid
sequenceDiagram
participant ETL as ETL DWS
participant API as Internal AI API
participant DISP as Dispatcher
participant CB as CircuitBreaker
participant RL as RateLimiter
participant BT as BudgetTracker
participant DSC as DashScopeClient
participant DB as PostgreSQL
ETL->>API: POST /api/internal/ai/trigger
API->>DB: INSERT ai_trigger_jobs (pending)
API-->>ETL: {trigger_job_id, status: "pending"}
API->>DISP: asyncio.create_task(handle_event)
DISP->>DISP: 去重检查 (event_type, member_id, site_id, date)
loop 调用链每一步 (App3→App8→App7)
DISP->>CB: 检查 app_id 熔断状态
CB-->>DISP: closed/open/half_open
DISP->>RL: 检查门店限流
RL-->>DISP: allowed/rejected
DISP->>BT: 检查 token 预算
BT-->>DISP: within_budget/exceeded
DISP->>DB: INSERT ai_run_logs (pending→running)
DISP->>DSC: Application.call(app_id, prompt)
DSC-->>DISP: response
DISP->>DB: UPDATE ai_run_logs (success/failed)
DISP->>DB: UPSERT ai_cache
end
DISP->>DB: UPDATE ai_trigger_jobs (completed)
```
## 组件与接口
### 1. DashScopeClient替代 BailianClient
文件:`apps/backend/app/ai/dashscope_client.py`(完全重写 `bailian_client.py`
```python
class DashScopeClient:
"""DashScope Application API 统一封装层。"""
MAX_RETRIES = 3
BASE_INTERVAL = 1 # 秒
def __init__(self, api_key: str, workspace_id: str | None = None):
"""初始化。dashscope 通过全局 dashscope.api_key 设置密钥。"""
async def call_app_stream(
self,
app_id: str,
prompt: str,
session_id: str | None = None,
biz_params: dict | None = None,
) -> AsyncGenerator[str, None]:
"""App1 流式调用。
在线程中消费同步迭代器,通过 asyncio.Queue 桥接到 async generator。
"""
async def call_app(
self,
app_id: str,
prompt: str,
session_id: str | None = None,
biz_params: dict | None = None,
) -> tuple[dict, int, str | None]:
"""App2~8 单轮调用。
返回 (parsed_json, tokens_used, new_session_id)。
"""
async def _call_with_retry(self, func: Callable, **kwargs) -> Any:
"""指数退避重试1s→2s→4sHTTP 4xx 不重试5xx/超时/连接错误重试。"""
```
流式桥接实现要点:
```python
async def call_app_stream(self, app_id, prompt, session_id=None, biz_params=None):
queue: asyncio.Queue[str | None] = asyncio.Queue()
def _consume_in_thread():
"""在线程中消费同步迭代器,逐 chunk 放入 queue。"""
response = Application.call(
app_id=app_id, prompt=prompt, session_id=session_id,
biz_params=biz_params, stream=True, incremental_output=True,
)
for chunk in response:
if chunk.status_code == 200:
text = chunk.output.get("text", "")
if text:
# 线程安全地放入 queue
asyncio.run_coroutine_threadsafe(queue.put(text), loop)
else:
raise DashScopeApiError(chunk.message, chunk.status_code)
asyncio.run_coroutine_threadsafe(queue.put(None), loop) # 结束信号
loop = asyncio.get_running_loop()
loop.run_in_executor(None, _consume_in_thread)
while True:
item = await queue.get()
if item is None:
break
yield item
```
### 2. CircuitBreaker熔断器
文件:`apps/backend/app/ai/circuit_breaker.py`(新增)
```python
class CircuitState(enum.Enum):
CLOSED = "closed" # 正常
OPEN = "open" # 熔断中
HALF_OPEN = "half_open" # 探测中
class CircuitBreaker:
"""按 app_id 独立的熔断器。"""
def __init__(self, failure_threshold: int = 5, recovery_timeout: int = 60):
self._breakers: dict[str, _BreakerState] = {}
def check(self, app_id: str) -> CircuitState:
"""检查当前状态OPEN 时直接拒绝。"""
def record_success(self, app_id: str) -> None:
"""记录成功HALF_OPEN→CLOSED。"""
def record_failure(self, app_id: str) -> None:
"""记录失败连续达阈值→OPENHALF_OPEN 失败→重新 OPEN。"""
class _BreakerState:
state: CircuitState
failure_count: int
last_failure_time: float
last_state_change: float
```
### 3. RateLimiter限流器
文件:`apps/backend/app/ai/rate_limiter.py`(新增)
```python
class RateLimiter:
"""滑动窗口内存限流器。"""
def __init__(self):
self._user_windows: dict[str, deque[float]] = {} # App1: user_id → 时间戳队列
self._store_windows: dict[str, deque[float]] = {} # App2~8: site_id → 时间戳队列
def check_user_rate(self, user_id: str, limit: int = 10, window_seconds: int = 60) -> bool:
"""App1 每用户每分钟限流。返回 True 表示允许。"""
def check_store_rate(self, site_id: int, limit: int = 100, window_seconds: int = 3600) -> bool:
"""App2~8 每门店每小时限流。返回 True 表示允许。"""
```
### 4. BudgetTrackerToken 预算追踪)
文件:`apps/backend/app/ai/budget_tracker.py`(新增)
```python
class BudgetTracker:
"""Token 预算追踪器,从 ai_run_logs 聚合。"""
def __init__(
self,
daily_limit: int = 100_000,
monthly_limit: int = 2_000_000,
):
self.daily_limit = daily_limit
self.monthly_limit = monthly_limit
def check_budget(self) -> BudgetStatus:
"""检查当前预算状态。返回 BudgetStatus(allowed, daily_used, monthly_used)。"""
def _get_daily_usage(self) -> int:
"""从 ai_run_logs 聚合今日 token 消耗。"""
def _get_monthly_usage(self) -> int:
"""从 ai_run_logs 聚合本月 token 消耗。"""
@dataclass
class BudgetStatus:
allowed: bool
daily_used: int
monthly_used: int
reason: str | None = None # "daily_exceeded" / "monthly_exceeded" / None
```
### 5. Dispatcher调度器重写
文件:`apps/backend/app/ai/dispatcher.py`(重写)
关键变更:
- 移除所有 `asyncio.run()``asyncio.new_event_loop()`
- 所有入口改为 `async def`,用 `asyncio.create_task()` 发起后台任务
- 超时用 `asyncio.wait_for()`
- 集成 CircuitBreaker、RateLimiter、BudgetTracker
- 新增 `ai_trigger_jobs` 记录
- 新增去重逻辑
```python
class AIDispatcher:
"""AI 事件调度与调用链编排器。"""
def __init__(
self,
client: DashScopeClient,
cache_svc: AICacheService,
conv_svc: ConversationService,
circuit_breaker: CircuitBreaker,
rate_limiter: RateLimiter,
budget_tracker: BudgetTracker,
): ...
async def handle_trigger(self, event: TriggerEvent) -> int:
"""统一事件入口。写 ai_trigger_jobs 后异步执行调用链。返回 trigger_job_id。"""
async def _execute_chain(self, job_id: int, event: TriggerEvent) -> None:
"""执行调用链,根据 event_type 分发。"""
async def _check_dedup(self, event: TriggerEvent) -> bool:
"""去重检查:(event_type, member_id, site_id, date)。"""
async def _run_step(self, app_name: str, app_id: str, prompt: str, context: dict) -> dict | None:
"""执行单步:熔断检查→限流检查→预算检查→调用→记录日志。"""
# 事件处理器
async def _handle_consumption(self, event: TriggerEvent) -> None: ...
async def _handle_note(self, event: TriggerEvent) -> None: ...
async def _handle_task_assigned(self, event: TriggerEvent) -> None: ...
async def _handle_dws_completed(self, event: TriggerEvent) -> None: ...
```
### 6. AI Config环境变量
文件:`apps/backend/app/ai/config.py`(重写)
```python
@dataclass(frozen=True)
class AIConfig:
"""AI 模块配置,从环境变量加载。"""
api_key: str # DASHSCOPE_API_KEY
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID可选
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
app_id_3_clue: str # ...
app_id_4_analysis: str
app_id_5_tactics: str
app_id_6_note: str
app_id_7_customer: str
app_id_8_consolidate: str
internal_api_token: str # INTERNAL_API_TOKEN
@classmethod
def from_env(cls) -> "AIConfig":
"""从环境变量加载,缺失必需变量时立即报错。"""
```
### 7. Internal AI API内部触发接口
文件:`apps/backend/app/routers/internal_ai.py`(新增)
```python
router = APIRouter(prefix="/api/internal/ai", tags=["internal-ai"])
@router.post("/trigger")
async def trigger_ai_event(body: TriggerRequest, token: str = Depends(verify_internal_token)):
"""接收 ETL/内部事件,写 ai_trigger_jobs 后异步执行。"""
class TriggerRequest(BaseModel):
event_type: str # consumption / dws_completed / note_created / task_assigned
connector_type: str = "feiqiu"
site_id: int
member_id: int | None = None
payload: dict | None = None
def verify_internal_token(authorization: str = Header(...)) -> str:
"""校验 Internal-Token {token}"""
```
### 8. SSE 端点适配
文件:`apps/backend/app/routers/xcx_chat.py`(修改)
变更要点:
- `_get_bailian_client()``_get_dashscope_client()`
- `bailian.chat_stream(messages)``client.call_app_stream(app_id, prompt, session_id, biz_params)`
- `_build_ai_messages()` 改为构建 `prompt` + `biz_params`
- 流结束后记录 `ai_run_logs`
- 保持 SSE 事件格式不变:`event: message` / `event: done` / `event: error`
### 9. ETL 触发集成
文件:`apps/etl/connectors/feiqiu/tasks/` 相关 DWS 任务(修改)
变更要点:
- DWS 任务完成后,通过 `httpx` 发送 `POST /api/internal/ai/trigger`
- 携带 `Authorization: Internal-Token {INTERNAL_API_TOKEN}` Header
- 事件类型:`dws_completed`(触发 App2 预生成)或 `consumption`(触发消费事件链)
### 10. AI 运行日志服务
文件:`apps/backend/app/ai/run_log_service.py`(新增)
```python
class AIRunLogService:
"""AI 运行日志 CRUD。"""
def create_log(self, site_id: int, app_type: str, trigger_type: str, **kwargs) -> int:
"""创建日志记录status: pending返回 log_id。"""
def update_running(self, log_id: int) -> None:
"""更新为 running。"""
def update_success(self, log_id: int, response_text: str, tokens_used: int, latency_ms: int) -> None:
"""更新为 success。"""
def update_failed(self, log_id: int, error_message: str, latency_ms: int) -> None:
"""更新为 failed。"""
def update_timeout(self, log_id: int, latency_ms: int) -> None:
"""更新为 timeout。"""
def get_daily_token_usage(self) -> int:
"""聚合今日 token 消耗。"""
def get_monthly_token_usage(self) -> int:
"""聚合本月 token 消耗。"""
```
## 数据模型
### 新增表
#### biz.ai_run_logsAI 运行记录)
```sql
CREATE TABLE biz.ai_run_logs (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
app_type VARCHAR(30) NOT NULL, -- app1_chat / app2_finance / ...
trigger_type VARCHAR(20) NOT NULL, -- user / scheduled / event / forced
member_id BIGINT,
request_prompt TEXT, -- 截断前 2000 字符
response_text TEXT,
tokens_used INTEGER DEFAULT 0,
latency_ms INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending / running / success / failed / timeout / budget_exceeded
error_message TEXT,
session_id VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ
);
CREATE INDEX idx_ai_run_logs_site_app ON biz.ai_run_logs(site_id, app_type);
CREATE INDEX idx_ai_run_logs_created ON biz.ai_run_logs(created_at);
CREATE INDEX idx_ai_run_logs_status ON biz.ai_run_logs(status);
```
#### biz.ai_trigger_jobs调度运行记录
```sql
CREATE TABLE biz.ai_trigger_jobs (
id BIGSERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
event_type VARCHAR(30) NOT NULL,
connector_type VARCHAR(30) DEFAULT 'feiqiu',
member_id BIGINT,
payload JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
-- pending / running / completed / failed / skipped_duplicate / budget_exceeded
is_forced BOOLEAN DEFAULT false,
app_chain VARCHAR(100), -- 如 "app3→app8→app7"
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_ai_trigger_jobs_site ON biz.ai_trigger_jobs(site_id, event_type);
CREATE INDEX idx_ai_trigger_jobs_dedup ON biz.ai_trigger_jobs(event_type, member_id, site_id, (created_at::date))
WHERE status NOT IN ('skipped_duplicate');
CREATE INDEX idx_ai_trigger_jobs_status ON biz.ai_trigger_jobs(status);
```
### 已有表变更
#### biz.ai_conversations — 新增字段
```sql
ALTER TABLE biz.ai_conversations ADD COLUMN session_id VARCHAR(100);
```
- 存储百炼 session_id格式 `conv_{conversation_id}_{created_timestamp}`
- 仅 App1 使用
#### biz.ai_cache — 新增字段
```sql
ALTER TABLE biz.ai_cache ADD COLUMN status VARCHAR(20) DEFAULT 'valid'
CHECK (status IN ('valid', 'expired', 'invalidated', 'generating'));
```
- `valid`:有效缓存
- `expired`:已过期(定时任务标记)
- `invalidated`手动失效admin-web 操作)
- `generating`:正在生成中(防并发读取不完整数据)
### 缓存过期策略
| App | cache_type | expires_at |
|-----|-----------|------------|
| App2 | app2_finance | 当日 23:59:59 |
| App3 | app3_clue | 7 天 |
| App4 | app4_analysis | 7 天 |
| App5 | app5_tactics | 7 天 |
| App6 | app6_note_analysis | 30 天 |
| App7 | app7_customer_analysis | 7 天 |
| App8 | app8_clue_consolidated | 7 天 |
### 环境变量映射
| 旧变量 | 新变量 | 说明 |
|--------|--------|------|
| `BAILIAN_API_KEY` | `DASHSCOPE_API_KEY` | API Key |
| `BAILIAN_BASE_URL` | _(删除)_ | Application API 不需要 base_url |
| `BAILIAN_MODEL` | _(删除)_ | 通过 app_id 指定应用 |
| _(新增)_ | `DASHSCOPE_WORKSPACE_ID` | 工作空间 ID可选 |
| `BAILIAN_APP_ID_1_CHAT` | `DASHSCOPE_APP_ID_1_CHAT` | App1 |
| `BAILIAN_APP_ID_2_FINANCE` | `DASHSCOPE_APP_ID_2_FINANCE` | App2 |
| `BAILIAN_APP_ID_3_CLUE` | `DASHSCOPE_APP_ID_3_CLUE` | App3 |
| `BAILIAN_APP_ID_4_ANALYSIS` | `DASHSCOPE_APP_ID_4_ANALYSIS` | App4 |
| `BAILIAN_APP_ID_5_TACTICS` | `DASHSCOPE_APP_ID_5_TACTICS` | App5 |
| `BAILIAN_APP_ID_6_NOTE` | `DASHSCOPE_APP_ID_6_NOTE` | App6 |
| `BAILIAN_APP_ID_7_CUSTOMER` | `DASHSCOPE_APP_ID_7_CUSTOMER` | App7 |
| `BAILIAN_APP_ID_8_CONSOLIDATE` | `DASHSCOPE_APP_ID_8_CONSOLIDATE` | App8 |
| _(新增)_ | `INTERNAL_API_TOKEN` | 内部 API 认证 token |
## 正确性属性
*属性Property是在系统所有合法执行路径上都应成立的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 重试策略正确性
*对于任意* DashScopeClient 调用和任意错误类型序列HTTP 4xx 错误应立即抛出不重试HTTP 5xx/超时/连接错误应最多重试 3 次(间隔 1s→2s→4s非合法 JSON 响应应触发重试而非本地修复。
**Validates: Requirements 1.5, 1.6**
### Property 2: 环境变量校验完整性
*对于任意* 必需环境变量(`DASHSCOPE_API_KEY` 和 8 个 `DASHSCOPE_APP_ID_*`)的子集缺失,`AIConfig.from_env()` 应抛出异常,不返回包含空字符串的配置对象。
**Validates: Requirements 2.5**
### Property 3: session_id 格式不变量
*对于任意* conversation_id正整数和 created_timestamp生成的 session_id 应匹配正则 `^conv_\d+_\d+$`,且从 session_id 可以反解出原始 conversation_id。
**Validates: Requirements 3.1**
### Property 4: 对话复用规则
*对于任意* 入口类型和时间差组合对话复用决策应满足task 入口始终复用已有对话、customer/coach 入口在 3 天内复用、general 入口始终新建。
**Validates: Requirements 3.6**
### Property 5: 熔断器 app_id 隔离
*对于任意* 两个不同的 app_id对其中一个记录任意次数的失败不应改变另一个 app_id 的熔断状态。
**Validates: Requirements 5.1**
### Property 6: 熔断器状态机转换
*对于任意* app_id 和任意成功/失败事件序列,熔断器状态转换应满足:连续 5 次失败→OPENOPEN 经过 60 秒→HALF_OPENHALF_OPEN + 成功→CLOSEDHALF_OPEN + 失败→OPEN。且 CLOSED 状态下任何成功事件应重置失败计数。
**Validates: Requirements 5.2, 5.3, 5.4, 5.5**
### Property 7: 限流器窗口控制
*对于任意* 用户 ID 或门店 ID在滑动窗口内App1: 60 秒/10 次App2~8: 3600 秒/100 次)请求次数未超过阈值时应允许,超过阈值时应拒绝;窗口外的历史请求不影响当前判断。
**Validates: Requirements 6.1, 6.2**
### Property 8: Token 预算检查正确性
*对于任意* 一组 `ai_run_logs` 记录(含 `tokens_used``created_at`),日聚合应等于当日所有 `status='success'` 记录的 `tokens_used` 之和,月聚合应等于当月之和;当日聚合 ≥ 100,000 或月聚合 ≥ 2,000,000 时,`check_budget()` 应返回 `allowed=false`
**Validates: Requirements 7.1, 7.3**
### Property 9: 事件类型到调用链映射
*对于任意* 事件类型Dispatcher 应执行正确的调用链:`consumption`(无助教)→ App3→App8→App7`consumption`(有助教)→ App3→App8→App7 + App4→App5`note_created` → App6→App8`task_assigned` → App4→App5`dws_completed` → App28 个时间维度)。
**Validates: Requirements 9.1, 9.2, 9.3, 11.1**
### Property 10: 调用链容错不中断
*对于任意* 调用链和任意失败步骤位置,该步骤失败后链中后续步骤仍应继续执行(使用已有缓存),不中断整条链。
**Validates: Requirements 9.4**
### Property 11: 内部 API 认证
*对于任意* HTTP 请求到 `/api/internal/ai/trigger`,当 `Authorization` Header 缺失或 token 不匹配 `INTERNAL_API_TOKEN` 时应返回 HTTP 401token 匹配时应正常处理请求。
**Validates: Requirements 10.2, 10.3**
### Property 12: 事件去重与强制执行
*对于任意* 两个具有相同 `(event_type, member_id, site_id, date)` 的自动触发事件,第二个应被跳过(`skipped_duplicate`);但当 `is_forced=true` 时,即使存在重复也应正常执行。
**Validates: Requirements 12.1, 12.2**
### Property 13: App8 幂等写入
*对于任意* member_id 和日期,多次执行 App8 写入 `member_retention_clue` 后,该 member 该天的记录数应恒为 1DELETE + INSERT 事务保证)。
**Validates: Requirements 12.3**
### Property 14: 缓存过期策略正确性
*对于任意* App 类型和写入时间,缓存记录的 `expires_at` 应匹配该 App 的过期策略App2 为当日 23:59:59、App3/4/5/7/8 为写入时间 + 7 天、App6 为写入时间 + 30 天。
**Validates: Requirements 13.1, 11.3**
### Property 15: 缓存查询过滤
*对于任意* 缓存数据集(包含各种 `status``expires_at` 值),查询结果应仅包含 `status='valid'``expires_at > now()``expires_at IS NULL` 的记录;`generating``expired``invalidated` 状态的记录不应出现在查询结果中。
**Validates: Requirements 13.4**
### Property 16: 缓存保留上限
*对于任意* App 类型App2~8和任意数量的缓存写入清理后每个 App 的 `ai_cache` 记录数不超过 20,000 条App1 对话记录不受此限制。
**Validates: Requirements 13.5**
### Property 17: SSE 事件流格式
*对于任意* 流式对话输出序列包括正常完成和中途错误SSE 事件流应由零或多个 `event: message` 事件组成,最终以恰好一个 `event: done`(正常)或 `event: error`(异常)事件结束。
**Validates: Requirements 15.2**
### Property 18: AI 运行日志状态机
*对于任意* AI 调用(成功/失败/超时/预算超限),`ai_run_logs` 记录的状态转换应满足:正常调用经历 `pending→running→success/failed/timeout`;预算超限直接创建 `budget_exceeded` 状态。且 `success` 状态的记录必须包含 `response_text``tokens_used``latency_ms``finished_at``failed` 状态必须包含 `error_message`
**Validates: Requirements 16.1, 16.2, 16.3, 16.4, 16.5**
### Property 19: Prompt 截断不变量
*对于任意* 长度的 prompt 字符串,存储到 `ai_run_logs.request_prompt` 后的长度不超过 2000 字符;长度 ≤ 2000 的 prompt 应完整保留。
**Validates: Requirements 16.6**
### Property 20: Application API 响应解析
*对于任意* 合法的 `Application.call()` 响应对象(`response.output.text` 为 JSON 字符串),解析应正确提取并返回等价的 Python dict`response.output.text` 为非法 JSON 时应触发重试。
**Validates: Requirements 4.4**
## 错误处理
### 异常层级
```
DashScopeError (基类)
├── DashScopeApiError — Application API 调用失败(重试耗尽)
│ ├── DashScopeAuthError — API Key 无效HTTP 401
│ └── DashScopeTimeoutError — 调用超时
├── DashScopeJsonParseError — 响应 JSON 解析失败(重试耗尽)
├── CircuitOpenError — 熔断器处于 OPEN 状态
├── RateLimitExceededError — 限流阈值超限
└── BudgetExceededError — Token 预算超限
```
### 错误处理策略
| 错误类型 | App1用户对话 | App2~8后台任务 |
|---------|-----------------|-------------------|
| `DashScopeApiError` (5xx) | SSE `event: error` + 友好提示 | 记录 `failed``ai_run_logs`,链继续 |
| `DashScopeAuthError` (401) | SSE `event: error` + "AI 服务配置异常" | 记录 `failed`,链中断(全局配置问题) |
| `DashScopeTimeoutError` | SSE `event: error` + "AI 响应超时" | 记录 `timeout`,链继续 |
| `DashScopeJsonParseError` | 不适用App1 不解析 JSON | 重试 3 次后记录 `failed`,链继续 |
| `CircuitOpenError` | SSE `event: error` + "AI 服务暂时不可用" | 记录 `circuit_open`,跳过该步骤 |
| `RateLimitExceededError` | HTTP 429 + "请求过于频繁" | 记录 `rate_limited`,跳过执行 |
| `BudgetExceededError` | SSE `event: error` + "AI 额度已用完" | 记录 `budget_exceeded`,跳过执行 |
| session_id 过期 | 从本地加载历史重建,对用户透明 | 不适用App2~8 无 session |
| App8 幂等写入失败 | 不适用 | 事务回滚,记录错误到 `ai_trigger_jobs` |
### 降级策略
- 熔断状态下App1 返回友好提示App2~8 跳过执行,前端展示已有缓存
- 预算超限App1 返回"额度已用完"App2~8 跳过,记录 `budget_exceeded`
- 限流超限App1 返回"请求过于频繁"App2~8 跳过,记录 `rate_limited`
- 调用链某步失败:记录错误,后续步骤使用已有缓存继续
## 测试策略
### 属性测试Property-Based Testing
使用 `hypothesis` 库(项目已有依赖),每个属性测试最少运行 100 次迭代。
测试文件位于 `tests/` 目录Monorepo 级属性测试),按组件分文件:
| 测试文件 | 覆盖属性 |
|---------|---------|
| `tests/test_circuit_breaker_props.py` | Property 5隔离、Property 6状态机 |
| `tests/test_rate_limiter_props.py` | Property 7窗口控制 |
| `tests/test_budget_tracker_props.py` | Property 8预算检查 |
| `tests/test_dispatcher_props.py` | Property 9调用链映射、Property 10容错、Property 12去重 |
| `tests/test_cache_service_props.py` | Property 14过期策略、Property 15查询过滤、Property 16保留上限 |
| `tests/test_dashscope_client_props.py` | Property 1重试、Property 20响应解析 |
| `tests/test_ai_config_props.py` | Property 2环境变量校验 |
| `tests/test_session_props.py` | Property 3session_id 格式、Property 4对话复用 |
| `tests/test_run_log_props.py` | Property 18日志状态机、Property 19Prompt 截断) |
| `tests/test_sse_props.py` | Property 17SSE 格式) |
每个属性测试必须包含注释标签:
```python
# Feature: P14-ai-dashscope-migration, Property 6: 熔断器状态机转换
@given(st.lists(st.sampled_from(["success", "failure"]), min_size=1, max_size=50))
def test_circuit_breaker_state_machine(events):
...
```
### 单元测试
单元测试覆盖属性测试不适合的场景:
| 测试文件 | 覆盖内容 |
|---------|---------|
| `tests/test_internal_ai_api.py` | Property 11认证、API 端点集成 |
| `tests/test_app8_idempotent.py` | Property 13幂等写入、事务回滚 |
| `tests/test_sse_endpoint.py` | SSE 端点集成、错误事件 |
| `tests/test_session_recovery.py` | session_id 过期恢复流程Requirements 3.5 |
| `tests/test_app2_pregenerate.py` | App2 预生成 8 个时间维度Requirements 11.2 |
### 测试配置
```python
# conftest.py 中的 hypothesis 配置
from hypothesis import settings
settings.register_profile("ci", max_examples=200, deadline=10000)
settings.register_profile("dev", max_examples=100, deadline=5000)
```
### 测试依赖
- 属性测试:`hypothesis`(已有)
- Mock`unittest.mock`(标准库)
- DashScope 调用mock `Application.call()`,不依赖真实 API
- 数据库:使用 `test_zqyy_app` 测试库(遵循 `testing-env.md` 规范)

View File

@@ -0,0 +1,246 @@
# 需求文档 — P14AI 模块改造 — DashScope 迁移 + 调度器完善
## 简介
当前 AI 模块使用 `openai` SDK 的通用模型 API`chat.completions.create`),但项目的 8 个 App 均为百炼控制台创建的智能体应用(各有独立 `app_id`)。通用模型 API 不接受 `app_id`,等于绕过了百炼控制台配置的 System Prompt、MCP 工具等全部能力。
本 spec 将 SDK 从 `openai` 切换到 `dashscope`Application API一步到位完成迁移同时修复调度器 asyncio 嵌套问题、打通事件触发链、新增熔断/限流/Token 预算控制,并完成相关数据库变更。
### 依赖
- P5AI 集成层)— 现有 AI 模块基础架构、8 个 App 实现、缓存/对话服务
- RNS1.4CHAT 对齐)— CHAT 路径迁移、SSE 端点、对话复用规则
### 来源文档
- `docs/prd/specs/P14-ai-dashscope-migration.md` — PRD 主文档
- `docs/reports/2026-03-21__ai_module_issues.md` — 18 个问题清单4 P0 / 6 P1 / 5 P2 / 3 P3
### 不在本 spec 范围
- admin-web AI 监控后台P15
- 全链路测试重建与历史回填P15
- 多门店支持BACKLOG当前写死 `2790685415443269`
- 消息队列替代 HTTP 触发(单独 PRD
- Prompt 版本管理BACKLOG
- 前端刷新机制(前端改动,不在本 spec
## 术语表
- **Backend**FastAPI 后端应用,位于 `apps/backend/`
- **ETL**:飞球 Connector ETL 管道,位于 `apps/etl/connectors/feiqiu/`
- **DashScope_Client**:新的 DashScope Application API 客户端,替代现有 `BailianClient`
- **Application_API**`dashscope.Application.call()` 方法,百炼智能体应用的原生调用接口
- **App1**:通用对话应用(流式,支持多轮 session_id
- **App2**财务洞察应用单轮DWS 完成后预生成)
- **App3**:维客线索应用(单轮,消费事件触发)
- **App4**:关系分析应用(单轮,消费/任务事件触发)
- **App5**:话术参考应用(单轮,依赖 App4 结果)
- **App6**:备注分析应用(单轮,备注事件触发)
- **App7**:客户分析应用(单轮,消费事件触发)
- **App8**:维客线索整理应用(单轮,整合 App3/App6 线索)
- **session_id**:百炼云端对话管理标识,格式 `conv_{conversation_id}_{created_timestamp}`
- **Circuit_Breaker**:熔断器,按 app_id 独立计数,连续失败后暂停请求
- **Rate_Limiter**:限流器,按用户/门店维度控制请求频率
- **Budget_Tracker**Token 预算追踪器,按日/月聚合 token 消耗
- **Internal_AI_API**:内部触发接口 `POST /api/internal/ai/trigger`ETL 通过此接口触发 AI 调用链
- **Dispatcher**AI 事件调度与调用链编排器,位于 `apps/backend/app/ai/dispatcher.py`
- **ai_run_logs**AI 运行记录表,记录每次 Application API 调用的输入/输出/耗时/token
- **ai_trigger_jobs**:调度运行记录表,记录事件触发的调用链执行状态
## 需求
### 需求 1SDK 替换 — openai 切换到 dashscope Application API
**用户故事:** 作为后端开发者,我希望将 AI 客户端从 `openai` SDK 切换到 `dashscope` Application API以便 8 个百炼智能体应用能通过各自的 `app_id` 调用,使用百炼控制台配置的 System Prompt 和 MCP 工具。
#### 验收标准
1. THE DashScope_Client SHALL 使用 `dashscope.Application.call()` 替代 `openai.AsyncOpenAI.chat.completions.create()`,所有 8 个 App 通过各自的 `app_id` 参数调用 Application API
2. THE DashScope_Client SHALL 使用 `asyncio.to_thread()` 包装同步的 `Application.call()` 方法,避免阻塞 FastAPI 事件循环
3. WHEN App1 进行流式调用时THE DashScope_Client SHALL 在独立线程中消费 `Application.call(stream=True)` 返回的同步迭代器,通过 `asyncio.Queue` 桥接到 async generator逐 chunk 输出文本
4. WHEN App2~8 进行单轮调用时THE DashScope_Client SHALL 通过 `prompt` 参数传入后端拼好的完整数据 JSON不使用 `messages` 数组
5. THE DashScope_Client SHALL 保留指数退避重试机制:最多 3 次重试,间隔 1s → 2s → 4sHTTP 4xx 不重试5xx/超时/连接错误重试
6. WHEN Application API 返回非合法 JSON 时THE DashScope_Client SHALL 纯重试(最大 3 次),不做本地解析修复
7. THE Backend SHALL 在 `pyproject.toml` 中移除 `openai` 依赖,新增 `dashscope` 依赖
### 需求 2环境变量统一 — BAILIAN_* 迁移到 DASHSCOPE_*
**用户故事:** 作为运维人员,我希望环境变量从 `BAILIAN_*` 统一迁移到 `DASHSCOPE_*` 前缀,以便与 DashScope SDK 的命名规范保持一致。
#### 验收标准
1. THE Backend SHALL 废弃并删除以下环境变量:`BAILIAN_API_KEY``BAILIAN_BASE_URL``BAILIAN_MODEL`
2. THE Backend SHALL 新增以下环境变量:`DASHSCOPE_API_KEY`DashScope API Key`DASHSCOPE_WORKSPACE_ID`(百炼工作空间 ID可选
3. THE Backend SHALL 将 8 个 App ID 环境变量从 `BAILIAN_APP_ID_*` 前缀重命名为 `DASHSCOPE_APP_ID_*` 前缀(`DASHSCOPE_APP_ID_1_CHAT``DASHSCOPE_APP_ID_8_CONSOLIDATE`
4. THE Backend SHALL 更新 `.env``.env.template` 文件,反映所有环境变量变更
5. THE Backend SHALL 在启动时校验必需环境变量(`DASHSCOPE_API_KEY` 和 8 个 `DASHSCOPE_APP_ID_*`),缺失时立即报错,禁止静默回退空字符串
### 需求 3App1 对话管理 — session_id 云端 + 本地双轨
**用户故事:** 作为助教用户,我希望与 AI 助手的多轮对话能通过百炼 session_id 保持上下文连贯,同时本地持久化消息记录,以便在 session 过期后仍能恢复对话。
#### 验收标准
1. WHEN App1 创建新对话时THE Backend SHALL 生成 session_id格式 `conv_{conversation_id}_{created_timestamp}`),存储到 `ai_conversations.session_id` 字段
2. WHEN App1 发送消息时THE DashScope_Client SHALL 携带 `session_id` 参数调用 Application API由百炼云端管理对话上下文
3. THE DashScope_Client SHALL 通过 `biz_params.user_prompt_params` 传递用户信息:`User_ID`(用户 ID`Role`(角色)、`Nickname`(昵称)
4. THE Backend SHALL 同时将每条消息写入本地 `ai_messages` 表,实现云端 + 本地双轨持久化
5. IF session_id 过期(百炼返回 session 无效错误THEN THE Backend SHALL 从本地 `ai_messages` 加载最近 20 条历史消息,用 `messages` 数组(不带 session_id重新调用百炼并将百炼返回的新 session_id 更新到本地
6. THE Backend SHALL 保持现有对话复用规则不变task 入口无时限复用、customer/coach 入口 3 天时限、general 入口始终新建
### 需求 4App2~8 单轮 Prompt 调用
**用户故事:** 作为后端开发者,我希望 App2~8 统一使用单轮 `prompt` 调用模式,以便简化调用逻辑并充分利用百炼控制台配置的 System Prompt。
#### 验收标准
1. THE Backend SHALL 为 App2~8 的每次调用使用 `build_prompt()` 函数拼好完整数据 JSON通过 `Application.call(app_id=..., prompt=data_json)` 传入
2. THE Backend SHALL 不再为 App2~8 在代码中维护 System Prompt以百炼控制台配置为准
3. THE Backend SHALL 不再为 App2~8 使用 `messages` 数组或 `response_format` 参数
4. WHEN Application API 返回结果时THE Backend SHALL 解析 `response.output.text` 字段获取 JSON 内容,解析失败时按需求 1 第 6 条重试
5. THE Backend SHALL 为每次 App2~8 调用记录 `ai_run_logs`(详见需求 10
### 需求 5熔断器
**用户故事:** 作为系统管理员,我希望 AI 调用具备熔断保护,以便在百炼服务异常时快速降级,避免无效请求堆积。
#### 验收标准
1. THE Circuit_Breaker SHALL 按 `app_id` 独立计数App1 熔断不影响 App2~8反之亦然
2. WHEN 某个 app_id 连续 5 次调用失败时THE Circuit_Breaker SHALL 进入熔断状态,持续 60 秒内所有该 app_id 的请求直接返回降级响应
3. WHEN 熔断 60 秒后THE Circuit_Breaker SHALL 进入半开状态,放行 1 个请求进行探测
4. WHEN 半开状态的探测请求成功时THE Circuit_Breaker SHALL 关闭熔断,恢复正常调用
5. WHEN 半开状态的探测请求失败时THE Circuit_Breaker SHALL 重新进入熔断状态,再等待 60 秒
6. WHILE Circuit_Breaker 处于熔断状态THE Backend SHALL 对 App1 请求返回友好提示"AI 服务暂时不可用,请稍后重试",对 App2~8 后台任务记录 `circuit_open` 状态并跳过执行
### 需求 6限流
**用户故事:** 作为系统管理员,我希望 AI 调用具备限流保护,以便防止单个用户或门店过度消耗 AI 资源。
#### 验收标准
1. THE Rate_Limiter SHALL 对 App1 实施每用户每分钟 10 次的请求频率限制
2. THE Rate_Limiter SHALL 对 App2~8 实施每门店每小时 100 次(合计)的请求频率限制
3. WHEN 请求超过限流阈值时THE Rate_Limiter SHALL 对 App1 返回友好提示"请求过于频繁,请稍后再试",对 App2~8 后台任务记录 `rate_limited` 状态并跳过执行
4. THE Rate_Limiter SHALL 使用内存计数器实现(单实例部署),不依赖外部 Redis
### 需求 7Token 预算控制
**用户故事:** 作为系统管理员,我希望 AI 调用具备 Token 预算控制,以便防止 API 费用失控。
#### 验收标准
1. THE Budget_Tracker SHALL 从 `ai_run_logs.tokens_used` 按日/月聚合计算已消耗 token 数
2. THE Budget_Tracker SHALL 支持日预算上限(默认 100,000 tokens和月预算上限默认 2,000,000 tokens
3. WHEN 日预算或月预算超限时THE Backend SHALL 对 App1 用户对话返回友好提示"AI 服务今日额度已用完,请明天再试"
4. WHEN 日预算或月预算超限时THE Backend SHALL 对 App2~8 后台任务跳过执行,记录 `budget_exceeded` 状态到 `ai_run_logs`
5. THE Budget_Tracker SHALL 在每次 AI 调用前检查预算,调用后更新 token 消耗记录
### 需求 8调度器 asyncio 修复
**用户故事:** 作为后端开发者,我希望修复 dispatcher.py 中的 asyncio 嵌套问题,以便事件处理器在 FastAPI 事件循环中正常工作,不再因 `asyncio.run()` 嵌套而报错。
#### 验收标准
1. THE Dispatcher SHALL 移除所有 `asyncio.run()``asyncio.new_event_loop()` 调用
2. THE Dispatcher SHALL 将所有事件处理器入口改为 `async def`,使用 `asyncio.create_task()` 发起后台异步任务
3. THE Dispatcher SHALL 使用 `asyncio.wait_for()` 实现超时控制,替代同步超时机制
4. THE Dispatcher SHALL 确保事件处理器在 FastAPI lifespan 中注册后,能在已有事件循环中正常执行调用链
### 需求 9事件触发链打通
**用户故事:** 作为系统管理员,我希望消费事件、备注事件、任务分配事件能正确触发对应的 AI 调用链,以便 AI 分析结果能自动生成,无需人工干预。
#### 验收标准
1. WHEN ETL DWS 任务完成后发送消费事件时THE Dispatcher SHALL 执行调用链App3 → App8 → App7无助教时或 App3 → App8 → App7 + App4 → App5有助教时
2. WHEN 小程序助教提交备注时THE Dispatcher SHALL 执行调用链App6 → App8
3. WHEN task_manager 自动分配任务时THE Dispatcher SHALL 执行调用链App4 → App5
4. THE Dispatcher SHALL 在调用链中某步失败时记录错误日志,后续应用使用已有缓存继续执行,不中断整条链
5. THE Dispatcher SHALL 将每次事件触发记录到 `ai_trigger_jobs` 表,包含事件类型、执行链、状态、耗时
### 需求 10ETL → 后端内部触发 API
**用户故事:** 作为 ETL 开发者,我希望 DWS 任务完成后能通过 HTTP 接口触发后端 AI 调用链,以便实现 ETL 与 AI 模块的自动联动。
#### 验收标准
1. THE Backend SHALL 实现 `POST /api/internal/ai/trigger` 端点,接受 JSON 请求体包含:`event_type`(事件类型)、`connector_type`(连接器类型,默认 `feiqiu`)、`site_id`(门店 ID`member_id`(会员 ID可选`payload`(事件附加数据)
2. THE Internal_AI_API SHALL 使用独立的 `INTERNAL_API_TOKEN` 环境变量进行认证,通过 HTTP Header `Authorization: Internal-Token {token}` 传递,不走 JWT
3. IF 认证 token 缺失或不匹配THEN THE Internal_AI_API SHALL 返回 HTTP 401
4. THE Internal_AI_API SHALL 将事件写入 `ai_trigger_jobs` 表后立即返回 `{ trigger_job_id, status: "pending" }`,调用链在后台异步执行
5. THE Internal_AI_API SHALL 设计为连接器无关接口,`connector_type` 字段标识来源,为多平台扩展预留
6. THE ETL SHALL 在 DWS 任务完成后通过 HTTP POST 调用 Internal_AI_API传递消费事件或 DWS 完成事件
### 需求 11App2 预生成
**用户故事:** 作为门店管理者我希望财务洞察App2在每日 DWS 数据刷新后自动预生成,以便打开页面时能立即看到最新分析结果,无需等待。
#### 验收标准
1. WHEN ETL 通过 Internal_AI_API 发送 `event_type: "dws_completed"` 事件时THE Dispatcher SHALL 触发 App2 预生成任务
2. THE Dispatcher SHALL 为当前门店(`site_id: 2790685415443269`)生成 8 个时间维度的财务洞察:今日、昨日、本周、上周、本月、上月、本季、上季
3. THE Dispatcher SHALL 将 App2 预生成结果写入 `ai_cache`cache_type: `app2_finance`),过期时间为当日 23:59:59
4. THE Dispatcher SHALL 确保 App2 预生成每日调用量为 1 门店 × 8 维度 = 8 次
### 需求 12幂等与去重
**用户故事:** 作为系统管理员,我希望 AI 事件触发具备幂等去重能力,以便重复事件不会导致重复执行和资源浪费。
#### 验收标准
1. THE Dispatcher SHALL 对自动触发的事件按 `(event_type, member_id, site_id, date)` 进行去重,重复事件跳过执行并记录 `skipped_duplicate` 状态
2. WHEN 手动重跑时(`is_forced = true`THE Dispatcher SHALL 允许强制执行,跳过去重检查,在 `ai_trigger_jobs` 中明确标记 `is_forced`
3. THE Backend SHALL 对 App8 写入 `member_retention_clue` 业务表时实施强幂等:在事务中先 DELETE 再 INSERT同一 member 同一天只执行一次
4. IF App8 幂等写入事务失败THEN THE Backend SHALL 自动回滚,记录错误到 `ai_trigger_jobs.error_message`
### 需求 13缓存策略
**用户故事:** 作为后端开发者,我希望 AI 缓存按 App 类型设置不同的过期时间和状态管理,以便缓存数据的新鲜度与业务需求匹配。
#### 验收标准
1. THE Backend SHALL 为每个 App 设置独立的缓存过期策略App2 当日 23:59:59、App3/App4/App5/App7/App8 为 7 天、App6 为 30 天
2. THE Backend SHALL 在 `ai_cache` 表新增 `status` 字段,支持四种状态:`valid`(有效)、`expired`(已过期)、`invalidated`(手动失效)、`generating`(生成中)
3. WHEN 写入新缓存前THE Backend SHALL 将 `status` 设为 `generating`,写入完成后更新为 `valid`,防止并发读取到不完整数据
4. THE Backend SHALL 在查询缓存时仅返回 `status = 'valid'` 且未过期(`expires_at > now()``expires_at IS NULL`)的记录
5. THE Backend SHALL 对 App2~8 每个 App 保留最新 20,000 条 `ai_cache` 记录超限时清理最旧记录App1 对话记录不自动删除
### 需求 14数据库变更
**用户故事:** 作为后端开发者,我希望新增必要的数据库表和字段,以便支持 AI 运行日志、事件调度记录、session_id 管理和缓存状态管理。
#### 验收标准
1. THE Backend SHALL 在 `biz` schema 新增 `ai_run_logs` 表,包含字段:`id`BIGSERIAL PK`site_id`BIGINT NOT NULL`app_type`VARCHAR(30))、`trigger_type`VARCHAR(20))、`member_id`BIGINT 可空)、`request_prompt`TEXT截断前 2000 字符)、`response_text`TEXT`tokens_used`INTEGER`latency_ms`INTEGER`status`VARCHAR(20))、`error_message`TEXT`session_id`VARCHAR(100))、`created_at`TIMESTAMPTZ`finished_at`TIMESTAMPTZ
2. THE Backend SHALL 在 `biz` schema 新增 `ai_trigger_jobs` 表,包含字段:`id`BIGSERIAL PK`site_id`BIGINT NOT NULL`event_type`VARCHAR(30))、`connector_type`VARCHAR(30) 默认 `feiqiu`)、`member_id`BIGINT 可空)、`payload`JSONB`status`VARCHAR(20))、`is_forced`BOOLEAN 默认 false`app_chain`VARCHAR(100))、`started_at`TIMESTAMPTZ`finished_at`TIMESTAMPTZ`error_message`TEXT`created_at`TIMESTAMPTZ
3. THE Backend SHALL 在 `ai_conversations` 表新增 `session_id` 字段VARCHAR(100)),用于存储百炼 session_id
4. THE Backend SHALL 在 `ai_cache` 表新增 `status` 字段VARCHAR(20) 默认 `valid`CHECK 约束限制为 `valid`/`expired`/`invalidated`/`generating`
5. THE Backend SHALL 为 `ai_run_logs` 创建索引:`(site_id, app_type)``(created_at)``(status)`
6. THE Backend SHALL 为 `ai_trigger_jobs` 创建索引:`(site_id, event_type)`、去重索引 `(event_type, member_id, site_id, created_at::date)` WHERE status NOT IN ('skipped_duplicate')、`(status)`
7. THE Backend SHALL 编写迁移脚本 `db/zqyy_app/migrations/YYYYMMDD_p14_ai_module.sql`,包含所有 DDL 变更,并编写对应的回滚脚本
### 需求 15SSE 端点适配
**用户故事:** 作为助教用户,我希望 AI 对话的 SSE 流式端点能适配 DashScope Application API 的流式输出,以便继续获得逐字显示的对话体验。
#### 验收标准
1. THE Backend SHALL 适配 SSE 端点(`POST /api/xcx/chat/stream`),将 DashScope_Client 的 async generator 输出转换为 SSE 事件流
2. THE Backend SHALL 保持现有 SSE 事件格式不变:`event: message`(逐 token`event: done`(流结束)、`event: error`(错误)
3. WHEN DashScope_Client 流式调用过程中发生错误时THE Backend SHALL 发送 `event: error` 事件并关闭连接,不导致客户端挂起
4. THE Backend SHALL 在流式调用完成后记录 `ai_run_logs`,包含 token 消耗和响应耗时
### 需求 16AI 运行日志记录
**用户故事:** 作为系统管理员,我希望每次 AI 调用都有完整的运行日志,以便追踪调用状态、排查问题和统计 token 消耗。
#### 验收标准
1. THE Backend SHALL 在每次 Application API 调用前创建 `ai_run_logs` 记录status: `pending`),调用开始时更新为 `running`
2. WHEN 调用成功时THE Backend SHALL 更新 `ai_run_logs` 状态为 `success`,记录 `response_text``tokens_used``latency_ms``finished_at`
3. WHEN 调用失败时THE Backend SHALL 更新 `ai_run_logs` 状态为 `failed`,记录 `error_message``latency_ms``finished_at`
4. WHEN 调用超时时THE Backend SHALL 更新 `ai_run_logs` 状态为 `timeout`
5. WHEN 预算超限跳过执行时THE Backend SHALL 创建 `ai_run_logs` 记录,状态为 `budget_exceeded`
6. THE Backend SHALL 将 `request_prompt` 截断为前 2000 字符后存储,避免大 prompt 占用过多存储空间

View File

@@ -0,0 +1,252 @@
# 实施计划P14 — AI 模块改造 — DashScope 迁移 + 调度器完善
## 概述
按依赖关系从底层到顶层逐步实施:环境变量/配置 → DashScope 客户端 → 防护层(熔断/限流/预算) → 运行日志 → 调度器 → API 路由 → 服务适配 → ETL 触发 → 数据库迁移 → 收尾。每个任务构建在前序任务之上,确保无孤立代码。
## 任务
- [x] 1. 环境变量与配置基础
- [x] 1.1 重写 `apps/backend/app/ai/config.py`,实现 `AIConfig` dataclass
- 定义所有 `DASHSCOPE_*` 环境变量字段和 `INTERNAL_API_TOKEN`
- 实现 `from_env()` 类方法,缺失必需变量时立即抛出异常
- 删除所有 `BAILIAN_*` 引用
- _需求: 2.1, 2.2, 2.3, 2.5_
- [x] 1.2 编写属性测试:环境变量校验完整性
- **Property 2: 环境变量校验完整性**
- 对任意必需变量子集缺失,`from_env()` 应抛出异常,不返回含空字符串的配置
- **验证: 需求 2.5**
- [x] 1.3 更新 `.env``.env.template``pyproject.toml`
- `.env` / `.env.template``BAILIAN_*``DASHSCOPE_*`,新增 `DASHSCOPE_WORKSPACE_ID``INTERNAL_API_TOKEN`
- `pyproject.toml`:移除 `openai` 依赖,新增 `dashscope` 依赖
- _需求: 2.4, 1.7_
- [x] 2. DashScopeClient 核心客户端
- [x] 2.1 创建 `apps/backend/app/ai/dashscope_client.py`,实现 `DashScopeClient`
- `call_app()` — App2~8 单轮调用,`asyncio.to_thread()` 包装,返回 `(parsed_json, tokens_used, new_session_id)`
- `call_app_stream()` — App1 流式调用,线程消费同步迭代器 + `asyncio.Queue` 桥接 async generator
- `_call_with_retry()` — 指数退避重试1s→2s→4sHTTP 4xx 不重试5xx/超时/连接错误重试
- 非合法 JSON 响应纯重试(最大 3 次),不做本地修复
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
- [x] 2.2 定义异常层级
- `DashScopeError` 基类及子类:`DashScopeApiError``DashScopeAuthError``DashScopeTimeoutError``DashScopeJsonParseError``CircuitOpenError``RateLimitExceededError``BudgetExceededError`
- _需求: 1.5, 5.6, 6.3, 7.3_
- [x] 2.3 编写属性测试:重试策略正确性
- **Property 1: 重试策略正确性**
- 4xx 立即抛出不重试5xx/超时/连接错误最多重试 3 次,非法 JSON 触发重试
- **验证: 需求 1.5, 1.6**
- [x] 2.4 编写属性测试Application API 响应解析
- **Property 20: Application API 响应解析**
- 合法 JSON 正确解析为 dict非法 JSON 触发重试
- **验证: 需求 4.4**
- [x] 3. 检查点 — 确保所有测试通过
- 确保所有测试通过ask the user if questions arise.
- [x] 4. 防护层熔断器、限流器、Token 预算
- [x] 4.1 创建 `apps/backend/app/ai/circuit_breaker.py`,实现 `CircuitBreaker`
-`app_id` 独立计数,`_BreakerState` 内部状态
- `check()` / `record_success()` / `record_failure()` 方法
- 状态机CLOSED → OPEN连续 5 次失败)→ HALF_OPEN60 秒后)→ CLOSED/OPEN
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
- [x] 4.2 编写属性测试:熔断器 app_id 隔离 + 状态机转换
- **Property 5: 熔断器 app_id 隔离**
- **Property 6: 熔断器状态机转换**
- 不同 app_id 互不影响;状态转换符合 CLOSED→OPEN→HALF_OPEN→CLOSED/OPEN 规则
- **验证: 需求 5.1, 5.2, 5.3, 5.4, 5.5**
- [x] 4.3 创建 `apps/backend/app/ai/rate_limiter.py`,实现 `RateLimiter`
- 滑动窗口内存计数器
- `check_user_rate()` — App1 每用户每分钟 10 次
- `check_store_rate()` — App2~8 每门店每小时 100 次
- _需求: 6.1, 6.2, 6.3, 6.4_
- [x] 4.4 编写属性测试:限流器窗口控制
- **Property 7: 限流器窗口控制**
- 窗口内未超阈值允许,超阈值拒绝;窗口外历史不影响当前判断
- **验证: 需求 6.1, 6.2**
- [x] 4.5 创建 `apps/backend/app/ai/budget_tracker.py`,实现 `BudgetTracker`
- `check_budget()``ai_run_logs` 聚合日/月 token 消耗
- 日预算 100,000 / 月预算 2,000,000
- 返回 `BudgetStatus(allowed, daily_used, monthly_used, reason)`
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5_
- [x] 4.6 编写属性测试Token 预算检查正确性
- **Property 8: Token 预算检查正确性**
- 日聚合 = 当日 success 记录 tokens_used 之和;超限时 allowed=false
- **验证: 需求 7.1, 7.3**
- [x] 5. AI 运行日志服务
- [x] 5.1 创建 `apps/backend/app/ai/run_log_service.py`,实现 `AIRunLogService`
- `create_log()` — 创建 pending 记录
- `update_running()` / `update_success()` / `update_failed()` / `update_timeout()` — 状态转换
- `get_daily_token_usage()` / `get_monthly_token_usage()` — 聚合查询
- `request_prompt` 截断为前 2000 字符
- _需求: 16.1, 16.2, 16.3, 16.4, 16.5, 16.6_
- [x] 5.2 编写属性测试AI 运行日志状态机 + Prompt 截断
- **Property 18: AI 运行日志状态机**
- **Property 19: Prompt 截断不变量**
- 状态转换正确pending→running→success/failed/timeoutprompt ≤ 2000 字符
- **验证: 需求 16.1, 16.2, 16.3, 16.4, 16.5, 16.6**
- [x] 6. 检查点 — 确保所有测试通过
- 确保所有测试通过ask the user if questions arise.
- [x] 7. Dispatcher 调度器重写
- [x] 7.1 重写 `apps/backend/app/ai/dispatcher.py`,实现 `AIDispatcher`
- 移除所有 `asyncio.run()``asyncio.new_event_loop()` 调用
- 所有入口改为 `async def`,用 `asyncio.create_task()` 发起后台任务
- 超时用 `asyncio.wait_for()`
- 集成 CircuitBreaker、RateLimiter、BudgetTracker
- `_run_step()` — 单步执行:熔断检查→限流检查→预算检查→调用→记录日志
- _需求: 8.1, 8.2, 8.3, 8.4_
- [x] 7.2 实现事件触发链编排
- `_handle_consumption()` — 消费事件App3→App8→App7无助教/ +App4→App5有助教
- `_handle_note()` — 备注事件App6→App8
- `_handle_task_assigned()` — 任务分配App4→App5
- `_handle_dws_completed()` — DWS 完成App2 预生成8 个时间维度)
- 调用链某步失败时记录错误,后续步骤使用已有缓存继续
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 11.1, 11.2, 11.3, 11.4_
- [x] 7.3 实现幂等去重逻辑
- `_check_dedup()` — 按 `(event_type, member_id, site_id, date)` 去重
- `is_forced=true` 时跳过去重检查
- 写入 `ai_trigger_jobs` 记录
- _需求: 12.1, 12.2_
- [x] 7.4 编写属性测试:事件类型到调用链映射
- **Property 9: 事件类型到调用链映射**
- 各事件类型映射到正确的 App 调用链
- **验证: 需求 9.1, 9.2, 9.3, 11.1**
- [x] 7.5 编写属性测试:调用链容错不中断
- **Property 10: 调用链容错不中断**
- 任意步骤失败后后续步骤仍继续执行
- **验证: 需求 9.4**
- [x] 7.6 编写属性测试:事件去重与强制执行
- **Property 12: 事件去重与强制执行**
- 重复自动事件跳过;`is_forced=true` 正常执行
- **验证: 需求 12.1, 12.2**
- [x] 8. 检查点 — 确保所有测试通过 ✅ 76 passed
- 确保所有测试通过ask the user if questions arise.
- [x] 9. API 路由与端点
- [x] 9.1 创建 `apps/backend/app/routers/internal_ai.py`,实现内部触发 API
- `POST /api/internal/ai/trigger` 端点
- `TriggerRequest` Pydantic 模型event_type, connector_type, site_id, member_id, payload
- `verify_internal_token()` 依赖注入,校验 `Authorization: Internal-Token {token}`
- 写入 `ai_trigger_jobs` 后立即返回 `{trigger_job_id, status: "pending"}`,后台异步执行
- 在 FastAPI app 中注册路由
- _需求: 10.1, 10.2, 10.3, 10.4, 10.5_
- [x] 9.2 编写属性测试:内部 API 认证
- **Property 11: 内部 API 认证**
- token 缺失/不匹配返回 401匹配时正常处理
- **验证: 需求 10.2, 10.3**
- [x] 9.3 适配 `apps/backend/app/routers/xcx_chat.py` SSE 端点
- 替换 `_get_bailian_client()``_get_dashscope_client()`
- 调用 `client.call_app_stream(app_id, prompt, session_id, biz_params)`
- 构建 `prompt` + `biz_params`User_ID, Role, Nickname
- 保持 SSE 事件格式:`event: message` / `event: done` / `event: error`
- 流结束后记录 `ai_run_logs`
- _需求: 15.1, 15.2, 15.3, 15.4, 3.3_
- [x] 9.4 编写属性测试SSE 事件流格式
- **Property 17: SSE 事件流格式**
- 零或多个 `event: message`,最终以恰好一个 `done``error` 结束
- **验证: 需求 15.2**
- [x] 10. 服务层适配
- [x] 10.1 适配 `apps/backend/app/services/ai/chat_service.py` — session_id 双轨逻辑
- 新对话生成 session_id格式 `conv_{conversation_id}_{created_timestamp}`
- 每条消息同时写入本地 `ai_messages`
- session_id 过期时从本地加载最近 20 条历史重建
- 保持对话复用规则task 无时限、customer/coach 3 天、general 新建
- _需求: 3.1, 3.2, 3.4, 3.5, 3.6_
- [x] 10.2 编写属性测试session_id 格式 + 对话复用规则
- **Property 3: session_id 格式不变量**
- **Property 4: 对话复用规则**
- session_id 匹配 `^conv_\d+_\d+$`;复用规则按入口类型正确判断
- **验证: 需求 3.1, 3.6**
- [x] 10.3 适配 `apps/backend/app/services/ai/cache_service.py` — 缓存状态与过期策略
- 新增 `status` 字段处理valid/expired/invalidated/generating
- 写入前设 `generating`,完成后设 `valid`
- 查询仅返回 `status='valid'` 且未过期的记录
- 按 App 类型设置过期时间App2 当日 23:59:59、App3~5/7/8 七天、App6 三十天)
- App2~8 每 App 保留最新 20,000 条,超限清理最旧记录
- _需求: 13.1, 13.2, 13.3, 13.4, 13.5_
- [x] 10.4 编写属性测试:缓存过期策略 + 查询过滤 + 保留上限
- **Property 14: 缓存过期策略正确性**
- **Property 15: 缓存查询过滤**
- **Property 16: 缓存保留上限**
- 过期时间匹配策略;查询仅返回 valid 且未过期;每 App ≤ 20,000 条
- **验证: 需求 13.1, 13.4, 13.5**
- [x] 10.5 实现 App8 幂等写入 `member_retention_clue`
- 事务中 DELETE + INSERT同一 member 同一天只执行一次
- 事务失败自动回滚,记录错误到 `ai_trigger_jobs.error_message`
- _需求: 12.3, 12.4_
- [x] 10.6 编写属性测试App8 幂等写入
- **Property 13: App8 幂等写入**
- 多次执行后同一 member 同一天记录数恒为 1
- **验证: 需求 12.3**
- [x] 11. 检查点 — 确保所有测试通过 ✅ 106 passed
- 确保所有测试通过ask the user if questions arise.
- [x] 12. ETL 触发集成
- [x] 12.1 修改 `apps/etl/connectors/feiqiu/tasks/` 相关 DWS 任务
- DWS 任务完成后通过 `requests` 发送 `POST /api/internal/ai/trigger`
- 携带 `Authorization: Internal-Token {INTERNAL_API_TOKEN}` Header
- 事件类型:`dws_completed`App2 预生成)/ `consumption`(消费事件链)
- 新增 `utils/ai_trigger.py` 工具函数 + `BaseDwsTask.AI_TRIGGER_EVENT` 类属性
- `FinanceDailyTask``dws_completed``MemberConsumptionTask``consumption`
- _需求: 10.6_
- [x] 13. 数据库迁移
- [x] 13.1 编写迁移脚本 `db/zqyy_app/migrations/2026-03-22__p14_ai_module.sql`
- CREATE TABLE `biz.ai_run_logs`(含 3 个索引)
- CREATE TABLE `biz.ai_trigger_jobs`(含 3 个索引,含去重部分索引)
- ALTER TABLE `biz.ai_conversations` ADD COLUMN `session_id`
- ALTER TABLE `biz.ai_cache` ADD COLUMN `status` + CHECK 约束
- 编写对应回滚脚本(逆序 DROP/ALTER+ 验证 SQL7 条)
- _需求: 14.1, 14.2, 14.3, 14.4, 14.5, 14.6, 14.7_
- [x] 14. 收尾:文档与集成验证
- [x] 14.1 更新 BD 手册
- 更新 `docs/database/BD_Manual_ai_tables.md`:新增 `ai_run_logs``ai_trigger_jobs` 表结构
- 更新 `ai_cache.status``ai_conversations.session_id` 字段说明
- _需求: 14.1, 14.2, 14.3, 14.4_
- [x] 14.2 文档同步
- 更新 `docs/prd/ai-app-prompts.md`:环境变量映射 BAILIAN_* → DASHSCOPE_*
- 更新 `apps/backend/README.md`AI 模块架构说明
- 更新 `docs/DOCUMENTATION-MAP.md`:新增文档条目
- _需求: 2.4_
- [x] 15. 最终检查点 — 确保所有测试通过 ✅ 505 passedP14 全部 15 个测试文件通过80 个预存失败均非 P14 相关)
- 确保所有测试通过ask the user if questions arise.
## 说明
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务关联了具体的需求编号,确保可追溯
- 属性测试任务标注了对应的 Property 编号和验证的需求条款
- 检查点确保增量验证,避免问题累积
- 实现语言Python与设计文档一致

View File

@@ -0,0 +1,559 @@
# 设计文档 — NS4.1 + P16Admin-Web 管理后台增强
> 权威参考:实施过程中如遇细节不明确,应优先查阅 PRD 原文:
> - `docs/prd/Neo_Specs/NS4.1-tenant-admin-redesign.md` — NS4.1 完整设计(数据模型 DDL、页面布局、接口设计、迁移步骤、边界条件
> - `docs/prd/specs/P16-task-min-run-interval.md` — P16 完整设计调度器逻辑、API 扩展、前端变更、边界条件)
## 概述
本设计覆盖两个独立模块:模块 ANS4.1 租户管理员页面重构 + 项目级注册体系)和模块 BP16 调度任务最小运行间隔)。两者改动文件无重叠,共享收尾流程。
### 设计决策与理由
| 决策 | 理由 |
|------|------|
| `biz.connectors/tenants/sites` 三级表而非扁平表 | 预留多连接器扩展,当前仅飞球一个连接器但表结构已就绪 |
| `site_code_history` 全局 UNIQUE 含历史 | 保护已提交但未审核的用户申请,旧 code 映射不丢失 |
| `auth.site_code_mapping` 迁移后重命名而非删除 | 安全回滚,迁移期间新旧并行 |
| `min_run_interval` 放表级列而非 `schedule_config` JSONB | 便于 SQL 查询和索引,避免 JSONB 解析开销 |
| 间隔检查基于 `last_run_at`(开始时间)而非 `last_success_at` | 防止失败后立即重试导致资源浪费,失败后仍需等待间隔 |
| 并发检查基于 `last_status = 'running'` | 简单有效,无需引入分布式锁 |
| `force=true` 绕过所有检查 | 应急场景需要管理员完全控制 |
## 架构
### 模块 A注册体系数据流
```
admin-webTenantAdmins 页面)
├─ 创建管理员 → 选择租户biz.tenants→ 选择门店biz.sites
├─ 管理简写ID → PUT /api/admin/sites/{site_id}/site-code
└─ 删除管理员 → DELETE /api/admin/tenant-admins/{id}(软删除)
↓ APIadmin_registry.py + admin_tenant_admins.py
数据库biz schema
biz.connectors (1) → biz.tenants (N) → biz.sites (N) → biz.site_code_history (N)
↑ ETL 同步dim_site → biz.sites
小程序端:用户申请时 site_code 查询
auth.site_code_mapping → biz.sites + biz.site_code_history
```
### 模块 B调度器间隔检查流程
```
Admin WebScheduleTab.tsx
├─ 创建/编辑表单 → min_run_interval_value + min_run_interval_unit
└─ 手动执行 → force=true/false
↓ APIschedules.py
数据库scheduled_tasks 表
├─ min_run_interval_value (INTEGER)
├─ min_run_interval_unit (VARCHAR)
└─ last_success_at (TIMESTAMPTZ)
↓ 调度器轮询scheduler.py每 30 秒)
判断逻辑:
1. 并发检查 → last_status == 'running' → 跳过
2. 间隔检查 → now - last_run_at < min_interval → 跳过
3. 正常入队 → task_queue.enqueue()
↓ 任务执行完成回调
更新 scheduled_tasks
- 成功 → last_status='completed', last_success_at=NOW()
- 失败 → last_status='failed'last_success_at 不变)
```
## 组件与接口
### 模块 A 组件
#### 1. admin_registry.py — 注册体系路由(新建)
文件:`apps/backend/app/routers/admin_registry.py`
```python
router = APIRouter(prefix="/api/admin", tags=["admin-registry"])
@router.get("/tenants")
async def list_tenants() -> list[TenantItem]:
"""所有活跃租户(含连接器名称)。"""
@router.get("/tenants/{tenant_id}/sites")
async def list_tenant_sites(tenant_id: int) -> list[SiteItem]:
"""指定租户下所有活跃店铺。"""
@router.put("/sites/{site_id}/site-code")
async def update_site_code(site_id: int, body: UpdateSiteCodeRequest) -> SiteCodeResult:
"""设置/修改店铺简写ID事务内执行历史记录管理。"""
@router.get("/sites/{site_id}/site-code-history")
async def get_site_code_history(site_id: int) -> list[SiteCodeHistoryItem]:
"""查看简写ID 变更历史。"""
```
#### 2. admin_registry.py — Schema新建
文件:`apps/backend/app/schemas/admin_registry.py`
```python
class TenantItem(BaseModel):
id: int
tenant_id: int
tenant_name: str | None
connector_name: str
is_active: bool
class SiteItem(BaseModel):
id: int
site_id: int
site_name: str | None
site_code: str | None
site_label: str | None
is_active: bool
class UpdateSiteCodeRequest(BaseModel):
new_code: str # 6 位3+3 格式,统一大写
class SiteCodeResult(BaseModel):
site_id: int
old_code: str | None
new_code: str
history_cleaned: bool # 旧 code 是否被清理
class SiteCodeHistoryItem(BaseModel):
id: int
site_code: str
is_current: bool
created_at: datetime
retired_at: datetime | None
class SiteSyncResult(BaseModel):
inserted: int # 新增店铺数
updated: int # 更新店铺数
```
#### 3. admin_tenant_admins.py — 扩展(修改)
文件:`apps/backend/app/routers/admin_tenant_admins.py`
变更要点:
- 新增 `DELETE /api/admin/tenant-admins/{id}` 端点(软删除)
- 修改 `POST /api/admin/tenant-admins` 创建逻辑:`tenant_id``biz.tenants` 校验存在性
- 修改 `GET /api/admin/tenant-admins` 列表:默认 `is_active=true`,新增 `include_inactive` 参数
- 修改 `PATCH /api/admin/tenant-admins/{id}` 编辑逻辑:支持修改 `username`(校验全局唯一性,冲突返回 409
- Schema 扩展:`TenantAdminListItem` 新增 `tenant_name` 字段JOIN `biz.tenants`
#### 4. site_code 查询切换
涉及文件:
- `apps/backend/app/routers/tenant_users.py``match-suggestions` 中的 site_code 查询
- `apps/backend/app/auth/tenant_admins.py` — 如有 site_code 相关逻辑
- `apps/miniprogram/` — 用户申请时的 site_code 验证
切换策略:
```sql
-- 旧SELECT * FROM auth.site_code_mapping WHERE site_code = :code
-- 新:优先查 biz.sites再查 biz.site_code_history
SELECT s.site_id, s.site_name, s.tenant_id
FROM biz.sites s
WHERE s.site_code = :code AND s.is_active = true
UNION ALL
SELECT s.site_id, s.site_name, s.tenant_id
FROM biz.site_code_history h
JOIN biz.sites s ON s.site_id = h.site_id
WHERE h.site_code = :code AND h.is_current = false AND s.is_active = true
LIMIT 1;
```
#### 5. admin-web 前端组件
文件:`apps/admin-web/src/pages/TenantAdmins/index.tsx`(重构)
文件:`apps/admin-web/src/api/registry.ts`(新建)
文件:`apps/admin-web/src/api/tenantAdmins.ts`(修改)
前端变更要点:
- 列表页新增「删除」按钮 + 「显示已禁用」开关
- 创建弹窗改为 2 步 Steps 组件
- 新增简写ID 管理弹窗Modal 内嵌 Table + 编辑行)
- `registry.ts` 封装 `GET /api/admin/tenants``GET /api/admin/tenants/{id}/sites``POST /api/admin/sites/sync`
#### 6. ETL 店铺同步逻辑(新建)
文件:`apps/backend/app/routers/admin_registry.py`(同注册体系路由)或独立 service
```python
@router.post("/sites/sync")
async def sync_sites() -> SiteSyncResult:
"""手动触发店铺同步FDW 读取 dwd.dim_site → 对比 biz.sites → INSERT/UPDATE。"""
class SiteSyncResult(BaseModel):
inserted: int # 新增店铺数
updated: int # 更新店铺数
```
同步逻辑:
- 通过 FDW 读取 ETL 库 `dwd.dim_site``scd2_is_current=1`
- 对比 `biz.sites`:新 site_id → INSERTsite_code 留空),名称/标签变更 → UPDATE
- 不删除已有记录
- 预留定时触发入口ETL DWD 完成后通过内部 API 调用)
- 数据迁移(任务 1完成后需执行一次初始同步补充 `auth.site_code_mapping` 中没有但 `dwd.dim_site` 中有的店铺
### 模块 B 组件
#### 1. scheduler.py — 核心逻辑扩展(修改)
文件:`apps/backend/app/services/scheduler.py`
```python
def _convert_interval_to_seconds(value: int, unit: str) -> int:
"""将间隔值转换为秒数。"""
multipliers = {"minutes": 60, "hours": 3600, "days": 86400}
return value * multipliers.get(unit, 60)
async def check_and_enqueue(self):
"""扩展后的轮询逻辑。"""
# SQL 查询扩展:新增读取 min_run_interval_value, min_run_interval_unit, last_run_at, last_status
for task in due_tasks:
# 1. 并发检查
if task.last_status == "running":
logger.info(f"Task {task.id} skipped: concurrent execution")
continue
# 2. 间隔检查
if task.min_run_interval_value > 0 and task.last_run_at:
min_seconds = _convert_interval_to_seconds(task.min_run_interval_value, task.min_run_interval_unit)
if (now - task.last_run_at).total_seconds() < min_seconds:
# 推进 next_run_at
logger.info(f"Task {task.id} skipped: interval not reached")
continue
# 3. 正常入队
await self.enqueue(task)
```
任务完成回调扩展:
```python
async def on_task_completed(self, task_id: int, success: bool):
if success:
await db.execute(
"UPDATE scheduled_tasks SET last_status='completed', last_success_at=NOW() WHERE id=:id",
{"id": task_id}
)
else:
await db.execute(
"UPDATE scheduled_tasks SET last_status='failed' WHERE id=:id",
{"id": task_id}
)
```
#### 2. schedules.py — API 路由扩展(修改)
文件:`apps/backend/app/routers/schedules.py`
文件:`apps/backend/app/schemas/schedules.py`
Schema 扩展:
```python
class CreateScheduleRequest(BaseModel):
# ... 现有字段 ...
min_run_interval_value: int = 0
min_run_interval_unit: str = "minutes"
class UpdateScheduleRequest(BaseModel):
# ... 现有字段 ...
min_run_interval_value: int | None = None
min_run_interval_unit: str | None = None
class ScheduleResponse(BaseModel):
# ... 现有字段 ...
min_run_interval_value: int
min_run_interval_unit: str
last_success_at: datetime | None
```
手动执行扩展:
```python
@router.post("/{schedule_id}/run")
async def run_schedule(schedule_id: int, force: bool = False):
if not force:
task = await get_task(schedule_id)
if task.last_status == "running":
raise HTTPException(409, "任务正在执行中")
if task.min_run_interval_value > 0 and task.last_run_at:
min_seconds = _convert_interval_to_seconds(...)
remaining = min_seconds - (now - task.last_run_at).total_seconds()
if remaining > 0:
raise HTTPException(409, f"最小运行间隔未到,距下次可执行还有 {int(remaining // 60)} 分钟")
await enqueue_task(schedule_id, force=force)
```
#### 3. ScheduleTab.tsx — 前端扩展(修改)
文件:`apps/admin-web/src/components/ScheduleTab.tsx`
变更要点:
- 创建/编辑表单新增「最小运行间隔」行:`InputNumber` + `Select`(分钟/小时/天)
- 列表表格新增「最小间隔」列和「上次成功」列
- 手动执行确认框新增「强制执行」Checkbox
## 数据模型
### 模块 A新建表
#### biz.connectors连接器注册表
```sql
CREATE TABLE biz.connectors (
id SERIAL PRIMARY KEY,
connector_key VARCHAR(50) NOT NULL UNIQUE,
display_name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE biz.connectors IS '连接器注册表:记录本项目接入的上游 SaaS 系统';
```
#### biz.tenants租户注册表
```sql
CREATE TABLE biz.tenants (
id SERIAL PRIMARY KEY,
connector_id INTEGER NOT NULL REFERENCES biz.connectors(id),
tenant_id BIGINT NOT NULL,
tenant_name VARCHAR(200),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (connector_id, tenant_id)
);
COMMENT ON TABLE biz.tenants IS '租户注册表连接器下的租户tenant_id 来自上游系统';
```
#### biz.sites店铺注册表
```sql
CREATE TABLE biz.sites (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES biz.tenants(id),
site_id BIGINT NOT NULL UNIQUE,
site_name VARCHAR(200),
site_code VARCHAR(6) UNIQUE,
site_label VARCHAR(50),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE biz.sites IS '店铺注册表:合并原 auth.site_code_mapping增加租户关联和简写ID管理';
COMMENT ON COLUMN biz.sites.site_code IS '当前生效的简写ID6位字符3+3格式全局唯一';
```
#### biz.site_code_history简写ID 变更历史)
```sql
CREATE TABLE biz.site_code_history (
id SERIAL PRIMARY KEY,
site_id BIGINT NOT NULL,
site_code VARCHAR(6) NOT NULL,
is_current BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
retired_at TIMESTAMPTZ,
UNIQUE (site_code)
);
COMMENT ON TABLE biz.site_code_history IS '简写ID变更历史增量记录所有使用过的简写ID';
COMMENT ON COLUMN biz.site_code_history.is_current IS 'true=当前生效的简写ID每个 site_id 最多一条 is_current=true';
```
### 模块 A种子数据
```sql
-- 连接器
INSERT INTO biz.connectors (connector_key, display_name) VALUES ('feiqiu', '飞球');
-- 租户(从 dwd.dim_site 提取)
INSERT INTO biz.tenants (connector_id, tenant_id, tenant_name)
VALUES (1, 2790683160709957, '朗朗桌球');
-- 店铺(从 auth.site_code_mapping 迁移真实数据)
INSERT INTO biz.sites (tenant_id, site_id, site_name, site_code)
SELECT t.id, scm.site_id, scm.site_name, scm.site_code
FROM auth.site_code_mapping scm
JOIN biz.tenants t ON t.tenant_id = scm.tenant_id
WHERE scm.tenant_id IS NOT NULL;
-- 简写ID 历史(为已有 code 创建记录)
INSERT INTO biz.site_code_history (site_id, site_code, is_current)
SELECT site_id, site_code, true
FROM biz.sites
WHERE site_code IS NOT NULL;
```
### 模块 B已有表变更
#### public.scheduled_tasks — 新增字段
```sql
ALTER TABLE scheduled_tasks ADD COLUMN min_run_interval_value INTEGER NOT NULL DEFAULT 0;
ALTER TABLE scheduled_tasks ADD COLUMN min_run_interval_unit VARCHAR(20) NOT NULL DEFAULT 'minutes';
ALTER TABLE scheduled_tasks ADD COLUMN last_success_at TIMESTAMPTZ;
COMMENT ON COLUMN scheduled_tasks.min_run_interval_value IS '最小间隔数值0=无限制)';
COMMENT ON COLUMN scheduled_tasks.min_run_interval_unit IS '间隔单位minutes/hours/days';
COMMENT ON COLUMN scheduled_tasks.last_success_at IS '最后一次成功执行的时间';
```
### 模块 A废弃表处理
迁移完成并验证后:
```sql
ALTER TABLE auth.site_code_mapping RENAME TO auth._archived_site_code_mapping;
COMMENT ON TABLE auth._archived_site_code_mapping IS '已废弃2026-03-22数据已迁移至 biz.sites保留供回滚';
```
## 正确性属性
### Property 1: 简写ID 全局唯一性
*对于任意* 简写ID 设置/修改操作,新 code 在 `biz.sites.site_code``biz.site_code_history.site_code` 中均不存在时才允许写入;已存在时应拒绝并返回错误。
**验证: 需求 A3.2**
### Property 2: 简写ID 变更事务完整性
*对于任意* 简写ID 修改操作old_code → new_code事务完成后应满足`biz.sites.site_code = new_code``site_code_history` 中 old_code 的 `is_current=false``retired_at IS NOT NULL`new_code 的 `is_current=true`。事务失败时所有变更回滚。
**验证: 需求 A3.1**
### Property 3: 简写ID 格式校验
*对于任意* 输入字符串简写ID 校验应仅接受 6 位字符3 位字母/数字 + 3 位数字),统一转为大写存储;不符合格式的输入应被拒绝。
**验证: 需求 A3.2**
### Property 4: 租户管理员软删除一致性
*对于任意* 管理员删除操作,删除后该管理员 `is_active=false`;默认列表查询不返回该记录;`include_inactive=true` 时返回。
**验证: 需求 A2.3, A2.7**
### Property 5: 数据迁移完整性
*对于* `auth.site_code_mapping` 中所有 `tenant_id IS NOT NULL` 的记录,迁移后 `biz.sites` 中应存在对应的 `site_id` 记录,且 `site_code` 值一致。
**验证: 需求 A1.5**
### Property 6: site_code 查询切换等价性
*对于任意* site_code 查询,切换到 `biz.sites` + `biz.site_code_history` 后的查询结果应与原 `auth.site_code_mapping` 查询结果等价(相同 site_code 映射到相同 site_id
**验证: 需求 A6.1, A6.2**
### Property 7: 间隔转换正确性
*对于任意* `(value, unit)` 组合,`_convert_interval_to_seconds()` 应返回正确的秒数minutes × 60、hours × 3600、days × 86400value=0 时返回 0。
**验证: 需求 B2.4**
### Property 8: 调度器间隔跳过正确性
*对于任意* 到期任务,当 `min_run_interval_value > 0``now() - last_run_at < min_interval_seconds` 时应跳过执行;当 `min_run_interval_value = 0``last_run_at IS NULL` 时应正常执行。
**验证: 需求 B2.1, B2.2**
### Property 9: 调度器并发跳过正确性
*对于任意* 到期任务,当 `last_status = 'running'` 时应跳过入队;其他状态正常处理。
**验证: 需求 B2.1**
### Property 10: 强制执行绕过所有检查
*对于任意* 手动执行请求,当 `force=true` 时应绕过间隔检查和并发检查,直接入队执行。
**验证: 需求 B3.4**
### Property 11: last_success_at 仅成功时更新
*对于任意* 任务执行结果,成功时 `last_success_at` 更新为当前时间;失败时 `last_success_at` 保持不变。
**验证: 需求 B2.3**
## 错误处理
### 模块 A 错误处理
| 场景 | HTTP 状态码 | 响应 |
|------|------------|------|
| 简写ID 格式不合法 | 422 | "简写ID 格式错误,需 6 位3+3 模式)" |
| 简写ID 已被占用 | 409 | "简写ID '{code}' 已被使用" |
| 租户不存在 | 404 | "租户不存在" |
| 店铺不存在 | 404 | "店铺不存在" |
| 管理员不存在 | 404 | "管理员不存在" |
| 管理员已禁用再次删除 | 409 | "管理员已处于禁用状态" |
### 模块 B 错误处理
| 场景 | HTTP 状态码 | 响应 |
|------|------------|------|
| 手动执行 + 间隔未到 + force=false | 409 | "最小运行间隔未到,距下次可执行还有 X 分钟" |
| 手动执行 + 任务正在运行 + force=false | 409 | "任务正在执行中" |
| 无效间隔单位 | 422 | "间隔单位必须为 minutes/hours/days" |
## 测试策略
### 属性测试Property-Based Testing
使用 `hypothesis` 库,测试文件位于 `tests/` 目录。
| 测试文件 | 覆盖属性 |
|---------|---------|
| `tests/test_site_code_props.py` | Property 1唯一性、Property 2事务完整性、Property 3格式校验 |
| `tests/test_tenant_admin_props.py` | Property 4软删除一致性 |
| `tests/test_scheduler_interval_props.py` | Property 7间隔转换、Property 8间隔跳过、Property 9并发跳过、Property 10强制执行、Property 11last_success_at |
### 单元测试
| 测试文件 | 覆盖内容 |
|---------|---------|
| `apps/backend/tests/unit/test_admin_registry.py` | 注册体系 API 边界条件 |
| `apps/backend/tests/unit/test_scheduler_interval.py` | 调度器间隔逻辑边界条件 |
### 集成验证
- 数据迁移验证Property 5迁移完整性通过一次性验证脚本检查
- site_code 查询切换验证Property 6等价性通过对比新旧查询结果检查
## 涉及文件汇总
### 模块 ANS4.1
| 模块 | 文件路径 | 操作 |
|------|---------|------|
| DDL 迁移 | `db/zqyy_app/migrations/2026-03-22__ns41_registry_tables.sql` | 新增 |
| DDL 基线 | `docs/database/ddl/zqyy_app__biz.sql` | 修改 |
| Schema | `apps/backend/app/schemas/admin_registry.py` | 新建 |
| Schema | `apps/backend/app/schemas/admin_tenant_admins.py` | 修改 |
| 路由 | `apps/backend/app/routers/admin_registry.py` | 新建(含同步端点 `POST /api/admin/sites/sync` |
| 路由 | `apps/backend/app/routers/admin_tenant_admins.py` | 修改 |
| 路由 | `apps/backend/app/routers/tenant_users.py` | 修改site_code 查询切换) |
| 主入口 | `apps/backend/app/main.py` | 修改(注册新路由) |
| 前端 API | `apps/admin-web/src/api/registry.ts` | 新建(含 sync API 封装) |
| 前端 API | `apps/admin-web/src/api/tenantAdmins.ts` | 修改 |
| 前端页面 | `apps/admin-web/src/pages/TenantAdmins/index.tsx` | 重构 |
| BD 手册 | `docs/database/BD_Manual_biz_registry_tables.md` | 新建 |
| BD 手册 | `docs/database/BD_Manual_tenant_admin_tables.md` | 修改 |
### 模块 BP16
| 模块 | 文件路径 | 操作 |
|------|---------|------|
| DDL 迁移 | `db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql` | 新增 |
| DDL 基线 | `docs/database/ddl/zqyy_app__public.sql` | 修改 |
| Schema | `apps/backend/app/schemas/schedules.py` | 修改 |
| 调度器 | `apps/backend/app/services/scheduler.py` | 修改 |
| 路由 | `apps/backend/app/routers/schedules.py` | 修改 |
| 任务队列 | `apps/backend/app/services/task_queue.py` | 可能修改(回调) |
| 前端 | `apps/admin-web/src/components/ScheduleTab.tsx` | 修改 |
| BD 手册 | `docs/database/BD_Manual_scheduled_tasks.md` | 新建/修改 |

View File

@@ -0,0 +1,181 @@
# 需求文档 — NS4.1 + P16Admin-Web 管理后台增强
## 简介
本 Spec 合并两个独立需求NS4.1(租户管理员页面重构 + 项目级注册体系)和 P16调度任务最小运行间隔机制。两者均为 admin-web 管理后台的功能增强,改动文件无重叠,合并执行以减少上下文切换。
### 合并理由
- 两者都是 admin-web 后台功能迭代,共享同一前端项目(`apps/admin-web/`)和后端项目(`apps/backend/`
- NS4.1 改动集中在租户管理员页面(`TenantAdmins/`+ 新建注册体系路由P16 改动集中在调度任务页面(`ScheduleTab.tsx`+ 调度器逻辑
- 改动文件完全不重叠,合并不增加冲突风险
- 共享收尾流程DDL 合并、BD 手册、文档同步)
### 依赖
- NS4租户管理后台基础设施`tenant-admin-web` spec 已完成
- P3用户认证体系`03-miniapp-auth-system` spec 已完成
### 来源文档(权威参考)
实施过程中如遇细节不明确,应优先查阅以下 PRD 原文:
- `docs/prd/Neo_Specs/NS4.1-tenant-admin-redesign.md` — NS4.1 PRD 主文档(数据模型 DDL、页面布局、接口设计、迁移步骤、边界条件
- `docs/prd/specs/P16-task-min-run-interval.md` — P16 PRD 主文档调度器逻辑、API 扩展、前端变更、边界条件)
### 不在本 spec 范围
- P15AI 监控后台 + 测试重建 + 回填)— 单独 Spec
- 多连接器完整实现(仅预留 `biz.connectors` 表结构)
- `dwd.dim_site` 物理迁移(保留在 `dwd` schema
- 租户管理员自助注册
- 简写ID 自动生成
- ETL 任务注册机制修改(`TaskMeta`/`TaskRegistry`
- 批量 seed SQL 设定初始间隔值
## 术语表
- **admin-web**:系统管理后台(`apps/admin-web/`),面向系统管理员
- **tenant-admin**:租户管理后台(`apps/tenant-admin/`),面向租户管理员
- **biz.connectors**:连接器注册表,记录接入的上游 SaaS 系统
- **biz.tenants**:租户注册表,连接器下的租户
- **biz.sites**:店铺注册表,合并原 `auth.site_code_mapping`
- **biz.site_code_history**简写ID 变更历史表
- **site_code**店铺简写ID6 位字符3+3 格式,如 `LLQ001`
- **min_run_interval**:调度任务最小运行间隔,任务开始执行后的最小等待时间
- **scheduled_tasks**:调度任务表(`public.scheduled_tasks`),存储 ETL 调度配置
- **scheduler.py**:调度器核心逻辑,每 30 秒轮询到期任务
## 需求
### 模块 ANS4.1 — 租户管理员页面重构 + 项目级注册体系
#### 需求 A1项目级注册体系 — 连接器/租户/店铺三级表
**用户故事:** 作为系统管理员,我希望建立「连接器 → 租户 → 店铺」三级注册体系,以便统一管理上游 SaaS 系统、租户和店铺的关系并为简写ID 提供归属。
##### 验收标准
1. THE Backend SHALL 在 `biz` schema 新建 `connectors`id, connector_key UNIQUE, display_name, is_active, created_at初始数据插入 `('feiqiu', '飞球')`
2. THE Backend SHALL 在 `biz` schema 新建 `tenants`id, connector_id FK, tenant_id BIGINT, tenant_name, is_active, created_at, updated_atUNIQUE(connector_id, tenant_id)
3. THE Backend SHALL 在 `biz` schema 新建 `sites`id, tenant_id FK, site_id BIGINT UNIQUE, site_name, site_code VARCHAR(6) UNIQUE, site_label, is_active, created_at, updated_at
4. THE Backend SHALL 在 `biz` schema 新建 `site_code_history`id, site_id BIGINT, site_code VARCHAR(6) UNIQUE, is_current BOOLEAN, created_at, retired_at
5. THE Backend SHALL 将 `auth.site_code_mapping` 中真实数据(`tenant_id IS NOT NULL`)迁移到 `biz.sites`,并为已有 `site_code` 创建 `site_code_history` 记录(`is_current=true`
6. THE Backend SHALL 在迁移完成并验证后,将 `auth.site_code_mapping` 重命名为 `auth._archived_site_code_mapping`
#### 需求 A1b数据迁移后初始同步
**用户故事:** 作为系统管理员,我希望迁移完成后立即运行一次 ETL 同步,补充 `biz.sites` 中缺失的店铺(`dwd.dim_site` 中有但 `auth.site_code_mapping` 中没有的),确保注册体系数据完整。
##### 验收标准
1. THE Backend SHALL 在数据迁移A1完成后、代码切换A6之前执行一次店铺同步复用 A5 的同步逻辑),将 `dwd.dim_site``scd2_is_current=1`)中存在但 `biz.sites` 中不存在的店铺补充插入
2. THE Backend SHALL 在初始同步完成后输出同步结果(新增数/更新数),供验证使用
#### 需求 A2租户/店铺管理 API
**用户故事:** 作为系统管理员,我希望通过 API 查询租户列表和店铺列表,以便在创建管理员时选择所属租户和管辖门店。
##### 验收标准
1. THE Backend SHALL 实现 `GET /api/admin/tenants` 端点,返回所有活跃租户(含连接器名称)
2. THE Backend SHALL 实现 `GET /api/admin/tenants/{tenant_id}/sites` 端点,返回指定租户下所有活跃店铺(含当前 site_code
3. THE Backend SHALL 实现 `DELETE /api/admin/tenant-admins/{id}` 端点,软删除管理员(`is_active=false`
4. THE Backend SHALL 实现 `PUT /api/admin/sites/{site_id}/site-code` 端点,设置/修改店铺简写ID
5. THE Backend SHALL 实现 `GET /api/admin/sites/{site_id}/site-code-history` 端点查看简写ID 变更历史
6. THE Backend SHALL 修改 `POST /api/admin/tenant-admins` 端点,创建时 `tenant_id``biz.tenants` 选择,`managed_site_ids``biz.sites` 选择
7. THE Backend SHALL 修改 `GET /api/admin/tenant-admins` 端点,默认只返回 `is_active=true`,增加 `include_inactive` 参数
8. THE Backend SHALL 修改 `PATCH /api/admin/tenant-admins/{id}` 端点,支持修改 `username`(需校验全局唯一性,冲突返回 409
#### 需求 A3简写ID 管理逻辑
**用户故事:** 作为系统管理员我希望在管理后台设置和修改店铺简写ID并保留变更历史以便保护已提交但未审核的用户申请。
##### 验收标准
1. WHEN 修改简写ID 时THE Backend SHALL 在事务内执行:旧 code 标记 `is_current=false` + `retired_at=NOW()`,新 code 插入 `site_code_history``is_current=true`),更新 `biz.sites.site_code`
2. THE Backend SHALL 校验新 code 格式6 位3+3 模式,统一大写存储)和全局唯一性(含 `biz.sites.site_code` + `biz.site_code_history.site_code`
3. WHEN 旧 code 有未审核申请引用(`auth.user_applications WHERE site_code = :old_code AND status = 'pending'`THE Backend SHALL 保留历史记录不删除
4. WHEN 旧 code 无任何申请引用时THE Backend SHALL 从 `biz.site_code_history` 中删除该条记录
#### 需求 A4租户管理员页面重构admin-web
**用户故事:** 作为系统管理员,我希望在 admin-web 中通过改进的界面管理租户管理员,支持 2 步创建流程、软删除和简写ID 管理。
##### 验收标准
1. THE admin-web SHALL 重构租户管理员列表页,新增「删除」操作按钮(二次确认 → 软删除),默认只显示活跃记录,可选「显示已禁用」开关
2. THE admin-web SHALL 实现 2 步创建流程:第 1 步选择租户(下拉 `biz.tenants`+ 输入账号信息 + 选择管辖门店(`biz.sites`);第 2 步可选设置简写ID
3. THE admin-web SHALL 在编辑弹窗中增加「管理简写ID」区域展示该租户下所有店铺及其当前 code支持修改编辑时所属租户`tenant_id`)为只读不可修改;用户名(`username`)可修改(需校验唯一性)
4. THE admin-web SHALL 新增简写ID 管理弹窗,展示变更历史,支持修改操作
5. THE admin-web SHALL 新增 `src/api/registry.ts` 封装租户/店铺列表 API 调用
#### 需求 A5ETL 店铺信息增量同步
**用户故事:** 作为系统管理员,我希望 ETL 完成后能自动同步店铺信息到业务库,以便 `biz.sites` 中的店铺名称和标签保持最新。
##### 验收标准
1. THE Backend SHALL 实现店铺同步逻辑:通过 FDW 读取 ETL 库 `dwd.dim_site``scd2_is_current=1`),对比 `biz.sites`,新增店铺 INSERT`site_code` 留空),名称/标签变更 UPDATE
2. THE Backend SHALL 不删除已有店铺记录(即使上游标记为关闭)
3. THE Backend SHALL 支持手动触发同步(管理后台按钮或 API 端点)
4. THE Backend SHALL 支持定时触发同步(随 ETL 日常调度DWD 层完成后通过内部 API 触发)
#### 需求 A6后端代码切换 — site_code 查询源
**用户故事:** 作为后端开发者,我希望所有读取 `auth.site_code_mapping` 的代码切换到 `biz.sites` + `biz.site_code_history`,以便完成数据迁移。
##### 验收标准
1. THE Backend SHALL 将所有读取 `auth.site_code_mapping` 的查询切换到 `biz.sites`
2. THE 小程序端 SHALL 将用户申请时的 `site_code` 查询从 `auth.site_code_mapping` 切换到 `biz.sites` + `biz.site_code_history`
3. THE Backend SHALL 确保切换后所有现有功能(用户申请、关联建议匹配等)正常工作
---
### 模块 BP16 — 调度任务最小运行间隔机制
#### 需求 B1scheduled_tasks 表扩展
**用户故事:** 作为管理员,我希望为每个调度任务设置最小运行间隔,使任务即使调度到期也不会在间隔内重复执行。
##### 验收标准
1. THE Backend SHALL 在 `scheduled_tasks` 表新增 `min_run_interval_value`INTEGER DEFAULT 0`min_run_interval_unit`VARCHAR(20) DEFAULT 'minutes')、`last_success_at`TIMESTAMPTZ NULL3 个字段
2. THE Backend SHALL 确保 `min_run_interval_value = 0` 表示无限制,与现有行为完全一致(向后兼容)
#### 需求 B2调度器核心逻辑 — 并发检查 + 间隔检查
**用户故事:** 作为管理员,我希望调度器在轮询时自动检查最小间隔和并发状态,避免任务重复执行或并发执行。
##### 验收标准
1. THE scheduler SHALL 在 `check_and_enqueue()` 中新增并发检查:若 `last_status = 'running'`,跳过本次入队,日志记录 `skipped_concurrent`
2. THE scheduler SHALL 在 `check_and_enqueue()` 中新增间隔检查:若 `min_run_interval_value > 0``now() - last_run_at < min_interval_seconds`,跳过本次执行并推进 `next_run_at`,日志记录 `skipped_interval`
3. WHEN `last_run_at IS NULL`从未执行THE scheduler SHALL 跳过间隔检查,正常执行
4. THE scheduler SHALL 在任务成功完成时同时更新 `last_success_at = NOW()`,失败时不更新 `last_success_at`
5. THE scheduler SHALL 实现 `_convert_interval_to_seconds(value, unit)` 辅助函数,支持 `minutes`/`hours`/`days` 单位
#### 需求 B3API 扩展 — 创建/更新/手动执行
**用户故事:** 作为管理员,我希望通过 API 配置最小运行间隔,并在必要时强制执行任务。
##### 验收标准
1. THE Backend SHALL 在 `POST /api/schedules``PUT /api/schedules/{id}` 端点的请求体中新增 `min_run_interval_value`int, default=0`min_run_interval_unit`str, default='minutes'
2. THE Backend SHALL 在 `GET /api/schedules` 响应中新增 `min_run_interval_value``min_run_interval_unit``last_success_at` 字段
3. THE Backend SHALL 在 `POST /api/schedules/{id}/run` 端点新增 `force: bool = False` 查询参数
4. WHEN `force=true`THE Backend SHALL 绕过最小间隔和并发检查,直接入队执行
5. WHEN `force=false` 且间隔未到时THE Backend SHALL 返回 409 Conflict提示"最小运行间隔未到,距下次可执行还有 X 分钟"
6. WHEN `force=false` 且任务正在运行时THE Backend SHALL 返回 409 Conflict提示"任务正在执行中"
#### 需求 B4Admin Web 前端 — ScheduleTab 扩展
**用户故事:** 作为管理员,我希望在调度任务管理界面中看到和配置最小运行间隔。
##### 验收标准
1. THE admin-web SHALL 在创建/编辑调度任务表单中新增「最小运行间隔」行:`InputNumber`(数值)+ `Select`(单位:分钟/小时/天),数值为 0 时显示提示"无限制"
2. THE admin-web SHALL 在任务列表表格中新增「最小间隔」列(显示如"10 天"、"无限制")和「上次成功」列(相对时间)
3. THE admin-web SHALL 在手动执行确认框中新增「强制执行(忽略最小间隔)」勾选项,默认不勾选
4. WHEN 勾选强制执行时THE admin-web SHALL 调用 `POST /api/schedules/{id}/run?force=true`

View File

@@ -0,0 +1,308 @@
# 实施计划NS4.1 + P16 — Admin-Web 管理后台增强
> 权威参考:实施过程中如遇细节不明确,应优先查阅 PRD 原文:
> - `docs/prd/Neo_Specs/NS4.1-tenant-admin-redesign.md` — NS4.1 完整设计(数据模型 DDL、页面布局、接口设计、迁移步骤、边界条件
> - `docs/prd/specs/P16-task-min-run-interval.md` — P16 完整设计调度器逻辑、API 扩展、前端变更、边界条件)
## 概述
按依赖关系分两条并行线实施:模块 ANS4.1 注册体系 + 租户管理员重构)和模块 BP16 调度任务间隔。两者改动文件无重叠可交替执行。整体顺序DDL 迁移 → 后端 API → 前端页面 → 数据迁移/切换 → 收尾。
后端使用 PythonFastAPI + Pydantic前端使用 TypeScriptReact + Vite + Ant Design
## 任务
### 阶段一DDL 迁移(模块 A + B
- [x] 1. DDL 迁移 — 模块 A注册体系四张新表
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/2026-03-22__ns41_registry_tables.sql`
- CREATE TABLE `biz.connectors`id SERIAL PK, connector_key VARCHAR(50) UNIQUE, display_name, is_active, created_at
- CREATE TABLE `biz.tenants`id SERIAL PK, connector_id FK, tenant_id BIGINT, tenant_name, is_active, created_at, updated_at, UNIQUE(connector_id, tenant_id)
- CREATE TABLE `biz.sites`id SERIAL PK, tenant_id FK, site_id BIGINT UNIQUE, site_name, site_code VARCHAR(6) UNIQUE, site_label, is_active, created_at, updated_at
- CREATE TABLE `biz.site_code_history`id SERIAL PK, site_id BIGINT, site_code VARCHAR(6) UNIQUE, is_current BOOLEAN, created_at, retired_at
- INSERT 种子数据connectors('feiqiu')、tenants(朗朗桌球)
- INSERT 迁移数据:从 `auth.site_code_mapping` 迁移真实数据到 `biz.sites`,创建 `site_code_history` 记录
- 编写回滚脚本(逆序 DROP TABLE
- _需求: A1.1, A1.2, A1.3, A1.4, A1.5_
- [x] 2. DDL 迁移 — 模块 Bscheduled_tasks 新增字段
- [x] 2.1 创建迁移脚本 `db/zqyy_app/migrations/2026-03-22__p16_min_run_interval.sql`
- ALTER TABLE `scheduled_tasks` ADD COLUMN `min_run_interval_value` INTEGER NOT NULL DEFAULT 0
- ALTER TABLE `scheduled_tasks` ADD COLUMN `min_run_interval_unit` VARCHAR(20) NOT NULL DEFAULT 'minutes'
- ALTER TABLE `scheduled_tasks` ADD COLUMN `last_success_at` TIMESTAMPTZ
- 添加 COMMENT ON COLUMN 注释
- 编写回滚脚本ALTER TABLE DROP COLUMN
- _需求: B1.1, B1.2_
### 阶段二:后端 API — 模块 A注册体系 + 管理员重构)
- [x] 3. 后端 Schema — 注册体系
- [x] 3.1 创建 `apps/backend/app/schemas/admin_registry.py`
- 定义 `TenantItem`id, tenant_id, tenant_name, connector_name, is_active
- 定义 `SiteItem`id, site_id, site_name, site_code, site_label, is_active
- 定义 `UpdateSiteCodeRequest`new_code: str
- 定义 `SiteCodeResult`site_id, old_code, new_code, history_cleaned
- 定义 `SiteCodeHistoryItem`id, site_code, is_current, created_at, retired_at
- _需求: A2.1, A2.2, A2.4, A2.5_
- [x] 3.2 修改 `apps/backend/app/schemas/admin_tenant_admins.py`
- `TenantAdminListItem` 新增 `tenant_name` 字段
- `TenantAdminCreateRequest` 添加字段说明注释tenant_id 从 biz.tenants 选择)
- _需求: A2.6_
- [x] 4. 后端路由 — 注册体系 API
- [x] 4.1 创建 `apps/backend/app/routers/admin_registry.py`
- `GET /api/admin/tenants` — 所有活跃租户列表JOIN biz.connectors 获取 connector_name
- `GET /api/admin/tenants/{tenant_id}/sites` — 指定租户下所有活跃店铺
- `PUT /api/admin/sites/{site_id}/site-code` — 设置/修改简写ID事务内执行
- 校验格式6 位3+3统一大写
- 校验全局唯一biz.sites + biz.site_code_history
- 事务:旧 code 标记 retired → 新 code 插入 history → 更新 sites.site_code
- 检查旧 code 是否有未审核申请引用,决定是否清理历史记录
- `GET /api/admin/sites/{site_id}/site-code-history` — 简写ID 变更历史
- _需求: A2.1, A2.2, A2.4, A2.5, A3.1, A3.2, A3.3, A3.4_
- [x] 4.2 在 `apps/backend/app/main.py` 中注册 admin_registry router
- _需求: A2.1_
- [x] 5. 后端路由 — 管理员 CRUD 扩展
- [x] 5.1 修改 `apps/backend/app/routers/admin_tenant_admins.py`
- 新增 `DELETE /api/admin/tenant-admins/{id}` — 软删除is_active=false已禁用返回 409
- 修改 `POST /api/admin/tenant-admins` — 创建时校验 tenant_id 在 biz.tenants 中存在
- 修改 `GET /api/admin/tenant-admins` — 默认 is_active=true新增 include_inactive 查询参数
- 修改 `PATCH /api/admin/tenant-admins/{id}` — 支持修改 `username`(校验全局唯一性,冲突返回 409
- 列表查询 JOIN biz.tenants 获取 tenant_name
- _需求: A2.3, A2.6, A2.7, A2.8, A4.1_
- [x] 6. 编写属性测试 — 模块 A
- [x] 6.1 创建 `tests/test_site_code_props.py`
- **Property 1: 简写ID 全局唯一性** — 使用 Hypothesis 生成随机 code验证已存在的 code 被拒绝
- **Property 2: 简写ID 变更事务完整性** — 验证事务后 sites.site_code、history.is_current、history.retired_at 状态一致
- **Property 3: 简写ID 格式校验** — 生成随机字符串,验证仅 6 位 3+3 格式通过
- **验证: 需求 A3.1, A3.2**
- [x] 6.2 创建 `tests/test_tenant_admin_props.py`(扩展已有文件或新建)
- **Property 4: 租户管理员软删除一致性** — 删除后默认列表不返回include_inactive 返回
- **验证: 需求 A2.3, A2.7**
- [x] 7. 检查点 — 模块 A 后端验证
- 确保注册体系 API 和管理员 CRUD 扩展所有测试通过ask the user if questions arise.
### 阶段三:后端 API — 模块 B调度器间隔
- [x] 8. 后端 Schema + 路由 — 调度器间隔
- [x] 8.1 修改 `apps/backend/app/schemas/schedules.py`
- `CreateScheduleRequest` 新增 `min_run_interval_value`int, default=0`min_run_interval_unit`str, default='minutes'
- `UpdateScheduleRequest` 新增同上两个可选字段
- `ScheduleResponse` 新增 `min_run_interval_value``min_run_interval_unit``last_success_at`
- _需求: B3.1, B3.2_
- [x] 8.2 修改 `apps/backend/app/services/scheduler.py`
- 新增 `_convert_interval_to_seconds(value, unit)` 辅助函数
- 扩展 `check_and_enqueue()` SQL 查询:新增读取 min_run_interval_value, min_run_interval_unit, last_run_at, last_status
- 新增并发检查last_status == 'running' → 跳过,日志记录 skipped_concurrent
- 新增间隔检查min_run_interval_value > 0 且 now - last_run_at < min_interval → 跳过,推进 next_run_at
- _需求: B2.1, B2.2, B2.4_
- [x] 8.3 修改任务完成回调(`scheduler.py``task_queue.py`
- 成功时:`last_status='completed'`, `last_success_at=NOW()`
- 失败时:`last_status='failed'`last_success_at 不变)
- _需求: B2.3_
- [x] 8.4 修改 `apps/backend/app/routers/schedules.py`
- 创建/更新端点支持新字段写入
- 列表端点响应包含新字段
- `POST /api/schedules/{id}/run` 新增 `force: bool = False` 查询参数
- force=false 时检查并发和间隔,不满足返回 409
- force=true 时绕过所有检查
- _需求: B3.1, B3.2, B3.3, B3.4, B3.5_
- [x] 9. 编写属性测试 — 模块 B
- [x] 9.1 创建 `tests/test_scheduler_interval_props.py`
- **Property 7: 间隔转换正确性** — 生成随机 (value, unit),验证秒数计算正确
- **Property 8: 调度器间隔跳过正确性** — 生成随机任务状态,验证跳过/执行决策
- **Property 9: 调度器并发跳过正确性** — last_status='running' 时跳过
- **Property 10: 强制执行绕过所有检查** — force=true 时无论状态都执行
- **Property 11: last_success_at 仅成功时更新** — 成功更新,失败不变
- **验证: 需求 B2.1, B2.2, B2.3, B2.4, B3.4**
- [x] 10. 检查点 — 模块 B 后端验证
- 确保调度器间隔逻辑和 API 扩展所有测试通过ask the user if questions arise.
### 阶段四ETL 店铺同步(模块 A
- [x] 11. 后端 — 店铺信息增量同步
- [x] 11.1 实现同步逻辑(新建 service 或在 admin_registry 路由中实现)
- 通过 FDW 读取 ETL 库 `dwd.dim_site``scd2_is_current=1`
- 对比 `biz.sites`:新增店铺 INSERTsite_code 留空tenant_id 通过 dim_site.tenant_id 关联 biz.tenants名称/标签变更 UPDATE
- 不删除已有店铺记录
- _需求: A5.1, A5.2_
- [x] 11.2 实现手动触发端点
- `POST /api/admin/sites/sync` — 手动触发同步,返回同步结果(新增数/更新数)
- _需求: A5.3_
- [x] 11.3 执行一次初始同步(数据迁移补数据)
- 在 DDL 迁移(任务 1完成后、代码切换任务 15之前调用同步逻辑补充 `auth.site_code_mapping` 中没有但 `dwd.dim_site` 中有的店铺
- 输出同步结果(新增数/更新数)供验证使用
- _需求: A1b.1, A1b.2_
- [x] 11.4 预留定时触发入口(随 ETL DWD 完成后通过内部 API 触发)
- _需求: A5.4_
### 阶段五:前端页面
- [x] 12. 前端 — 模块 A租户管理员页面重构
- [x] 12.1 创建 `apps/admin-web/src/api/registry.ts`
- 封装 `GET /api/admin/tenants``GET /api/admin/tenants/{id}/sites` API 调用
- 封装 `PUT /api/admin/sites/{site_id}/site-code``GET /api/admin/sites/{site_id}/site-code-history`
- 封装 `POST /api/admin/sites/sync`(手动同步)
- _需求: A4.5_
- [x] 12.2 修改 `apps/admin-web/src/api/tenantAdmins.ts`
- 新增 `deleteTenantAdmin(id)` API 调用
- 修改 `listTenantAdmins` 支持 `include_inactive` 参数
- _需求: A4.1_
- [x] 12.3 重构 `apps/admin-web/src/pages/TenantAdmins/index.tsx`
- 列表页新增「删除」操作按钮Popconfirm 二次确认 → 调用 DELETE API
- 列表页新增「显示已禁用」Switch 开关
- 列表页新增「简写ID」操作按钮打开简写ID 管理弹窗)
- 列表新增「租户」列(显示 tenant_name
- 编辑弹窗中 `username` 改为可编辑需校验唯一性409 时提示"用户名已存在"`tenant_id` 只读
- _需求: A4.1, A4.3, A2.8_
- [x] 12.4 实现 2 步创建流程
- 使用 Ant Design Steps 组件
- 第 1 步选择租户Select数据源 GET /api/admin/tenants→ 输入用户名/密码/显示名称 → 选择管辖门店Select multiple数据源 GET /api/admin/tenants/{id}/sites
- 第 2 步展示所选租户下所有店铺可为每个店铺设置简写ID可跳过
- _需求: A4.2_
- [x] 12.5 实现简写ID 管理弹窗
- Modal 内嵌 Table店铺名称、当前 ID、操作修改
- 修改行Input + 保存/取消按钮格式校验6 位 3+3
- 变更历史区域:展示 site_code_history 列表
- _需求: A4.3, A4.4_
- [x] 13. 前端 — 模块 BScheduleTab 扩展
- [x] 13.1 修改 `apps/admin-web/src/components/ScheduleTab.tsx`
- 创建/编辑表单新增「最小运行间隔」行InputNumber数值+ Select单位分钟/小时/天)
- 数值为 0 时显示 placeholder "无限制"
- 位置:在调度类型配置区域下方
- _需求: B4.1_
- [x] 13.2 修改列表表格
- 新增「最小间隔」列:显示格式如"10 天"、"1 小时"、"无限制"value=0 时)
- 新增「上次成功」列:显示 last_success_at 的相对时间dayjs fromNow
- _需求: B4.2_
- [x] 13.3 修改手动执行确认框
- 新增 Checkbox「强制执行忽略最小间隔默认不勾选
- 勾选后调用 `POST /api/schedules/{id}/run?force=true`
- 不勾选时调用 `POST /api/schedules/{id}/run`409 时展示错误提示
- _需求: B4.3, B4.4_
- [x] 14. 检查点 — 前端页面验证
- 确保所有前端组件渲染正常API 调用层工作正确ask the user if questions arise.
### 阶段六:数据迁移与代码切换
- [x] 15. site_code 查询源切换
- [x] 15.1 修改 `apps/backend/app/routers/tenant_users.py`
- `match-suggestions` 中的 site_code 查询从 `auth.site_code_mapping` 切换到 `biz.sites` + `biz.site_code_history`
- _需求: A6.1_
- [x] 15.2 搜索并修改所有其他引用 `auth.site_code_mapping` 的代码
- 小程序端用户申请时的 site_code 验证
- 其他后端路由中的 site_code 查询
- _需求: A6.1, A6.2_
- [x] 15.3 验证切换后功能正常
- 用户申请流程中 site_code 查询正确
- 关联建议匹配正确
- _需求: A6.3_
- [x] 16. 废弃原表
- [x] 16.1 验证 `biz.sites` 数据与 `auth.site_code_mapping` 一致
- 编写验证 SQL 对比两表数据
- _需求: A1.5_
- [x] 16.2 重命名原表为 `auth._archived_site_code_mapping`
- _需求: A1.6_
### 阶段七:收尾
- [x] 17. 数据库变更审计与 DDL 合并
- [x] 17.1 审计本次实现中对数据库的所有改动
- 检查新建表biz.connectors/tenants/sites/site_code_history、新增字段scheduled_tasks 三字段、废弃表auth.site_code_mapping
- [x] 17.2 执行两个迁移脚本到测试库(`test_zqyy_app`
- 验证新表和新字段已正确创建(使用 BD 手册中的验证 SQL
- [x] 17.3 合并到主 DDL 基线文件
- 模块 A 新表 → `docs/database/ddl/zqyy_app__biz.sql`
- 模块 B 新字段 → `docs/database/ddl/zqyy_app__public.sql`
- [x] 17.4 验证回滚脚本可执行(任务 1、2 中已编写)
- [x] 18. BD 手册更新
- [x] 18.1 创建 `docs/database/BD_Manual_biz_registry_tables.md`
- 覆盖 biz.connectors、biz.tenants、biz.sites、biz.site_code_history 四张表
- 包含:字段明细、约束与索引、验证 SQL≥3 条)、回滚策略
- _规范: db-docs.md_
- [x] 18.2 更新 `docs/database/BD_Manual_tenant_admin_tables.md`
- 补充软删除逻辑说明、tenant_id 从 biz.tenants 选择的变更
- [x] 18.3 创建/更新 `docs/database/BD_Manual_scheduled_tasks.md`
- 新增 min_run_interval_value、min_run_interval_unit、last_success_at 字段说明
- 包含:字段明细、约束、验证 SQL、回滚策略
- [x] 19. 前后端联调与集成验证
- [x] 19.1 启动后端服务,使用测试库验证各端点完整请求-响应链路
- 验证注册体系 APItenants/sites/site-codeJSON 响应结构与 Schema 定义一致
- 验证调度器 APIschedules新增字段和 force 参数正常工作
- 验证权限校验在真实请求中生效
- [x] 19.2 前端联调验证
- 确认租户管理员页面能正确调用新增 API 并渲染数据2 步创建、删除、简写ID 管理)
- 确认 ScheduleTab 扩展字段正确展示和提交
- 验证空数据/降级场景下前端不崩溃
- [x] 20. 文档同步更新
- [x] 20.1 更新后端 API 参考文档
-`apps/backend/docs/API-REFERENCE.md` 新增 admin_registry 路由模块文档
- 更新 schedules 路由模块文档(新增字段和 force 参数)
- 更新 `apps/backend/README.md` 路由模块摘要
- [x] 20.2 更新 admin-web README
-`apps/admin-web/README.md` 更新页面说明租户管理员重构、ScheduleTab 扩展)
- [x] 20.3 更新文档地图
-`docs/DOCUMENTATION-MAP.md` 新增本次模块条目BD 手册、Spec
- _规范: doc-map.md_
- [x] 21. 最终检查点 — 全量验证
- 运行 Monorepo 属性测试:`cd C:\NeoZQYY && pytest tests/ -v`
- 运行后端单元测试:`cd apps/backend && pytest tests/ -v`
- 确保所有属性测试Property 1-11和单元测试全部通过
- 确保 DDL 迁移已合并到主基线
- 确保 BD 手册已同步更新
- 确保 API 文档、后端 README、admin-web README、文档地图均已更新
- 确保前端页面连接真实后端运行正常(租户管理员页面 + ScheduleTab
- 确保 `auth.site_code_mapping` 已废弃重命名
- ask the user if questions arise.
- [x] 22. 服务清理
- [x] 22.1 关闭浏览器、停止后端和前端服务、清理资源
- 停止 uvicorn 后端进程controlPwshProcess stop
- 停止前端开发服务器controlPwshProcess stop
## 备注
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务引用了具体的需求编号以确保可追溯性A1-A6 对应 NS4.1B1-B4 对应 P16
- 属性测试验证 11 个正确性属性Property 1-11单元测试验证具体边界条件
- 检查点任务确保增量验证,避免问题累积(任务 7、10、14、21
- 模块 A 和模块 B 改动文件无重叠,可交替执行
- 后端使用 PythonFastAPI + Pydantic + Hypothesis前端使用 TypeScriptReact + Vite + Ant Design
- 数据迁移采用渐进策略:新建表 → 迁移数据 → 切换代码 → 验证 → 废弃原表
- 收尾阶段遵循 `spec-closing-checklist.md`(全栈类 Spec步骤 1-6 全覆盖):
- 步骤 1最终测试→ 任务 21
- 步骤 2前后端联调→ 任务 19
- 步骤 3DDL 合并)→ 任务 17
- 步骤 4BD 手册)→ 任务 18
- 步骤 5文档同步→ 任务 20
- 步骤 6服务清理→ 任务 22

View File

@@ -0,0 +1,579 @@
# 技术设计文档admin-web 后台重构优化
## 概述
本次重构将 admin-web 从 18 个页面 / 11 个一级菜单重组为 7 个一级菜单模块,核心目标:
1. **菜单精简**11 → 7 个一级菜单按功能域聚合ETL、触发器、AI 等)
2. **新增仪表盘**:登录后默认展示运行状态 Dashboard聚合 OpsPanel + DB 健康 + AI 概览
3. **触发器统一管理**聚合三套触发器数据源biz.trigger_jobs / biz.ai_trigger_jobs / scheduled_tasks
4. **新增后端 API**PATCH /api/trigger-jobs/{id}/config编辑触发器配置、GET /api/admin/db-health数据库健康、GET /api/admin/triggers/unified触发器统一列表
5. **渐进式迁移**:新页面用目标路由开发,老页面测试通过后移入 `_archived/`
### 设计决策
- **Tab 视图而非子路由**ETL 任务管理和触发器管理使用 Ant Design Tabs 组件 + URL 查询参数(`?tab=xxx`而非嵌套路由。理由Tab 切换不触发组件卸载,状态保持更自然。
- **复用而非重写**Dashboard 通过 import 现有 OpsPanel / AIDashboard 组件的子模块实现聚合,不重写已有逻辑。
- **后端最小改动**:仅新增 3 个 API 端点,不修改现有业务逻辑。
## 架构
### 整体架构
```mermaid
graph TB
subgraph "admin-web 前端"
Router["React Router 7<br/>路由层"]
Layout["AppLayout<br/>Sider + Content"]
subgraph "7 个一级模块"
Dashboard["运行状态<br/>/dashboard"]
ETL["ETL 任务管理<br/>/etl-tasks"]
MiniApp["小程序任务管理<br/>/task-engine/*"]
Triggers["触发器管理<br/>/triggers"]
Tenant["租户管理员<br/>/tenant-admins"]
Settings["系统设置<br/>/settings"]
Logs["日志调试<br/>/logs/*"]
end
end
subgraph "后端 APIFastAPI"
ExistingAPI["现有 API 端点"]
NewPATCH["PATCH /api/trigger-jobs/{id}/config"]
NewDBHealth["GET /api/admin/db-health"]
NewUnified["GET /api/admin/triggers/unified"]
end
subgraph "数据库"
TriggerJobs["biz.trigger_jobs"]
AITriggerJobs["biz.ai_trigger_jobs"]
ScheduledTasks["scheduled_tasks"]
FourDBs["4 个数据库健康检测"]
end
Router --> Layout
Layout --> Dashboard & ETL & MiniApp & Triggers & Tenant & Settings & Logs
Dashboard --> ExistingAPI & NewDBHealth
Triggers --> ExistingAPI & NewPATCH & NewUnified
NewUnified --> TriggerJobs & AITriggerJobs & ScheduledTasks
NewDBHealth --> FourDBs
```
### 路由结构
```
/login → Login不变
/dashboard → Dashboard默认首页
/ → Redirect → /dashboard
/etl-tasks?tab=config|manager|status → ETLTasks合并 3 页面)
/task-engine/trigger-jobs → TriggerJobs移动路由
/task-engine/transfer-log → TransferLog不变
/task-engine/pending-review → PendingReview不变
/task-engine/config → TaskEngineConfig不变
/triggers?tab=all|biz|ai|etl → TriggerManager
/tenant-admins → TenantAdmins不变
/settings/env-config → EnvConfig移动路由
/logs/dev-trace → DevTrace移动路由
/logs/ai-run-logs → AIRunLogs移动路由
/logs/db-viewer → DBViewer移动路由
/log-viewer → Redirect → /etl-tasks?tab=manager
```
### 菜单结构映射
| 序号 | 一级菜单 | 子项/Tab | 路由 |
|------|----------|----------|------|
| 1 | 运行状态 | — | /dashboard |
| 2 | ETL 任务管理 | 任务配置 / 任务管理 / ETL 状态Tab | /etl-tasks?tab=config\|manager\|status |
| 3 | 小程序任务管理 | 定时任务 / 转移日志 / 待审核任务 / 参数管理 | /task-engine/* |
| 4 | 触发器管理 | 全部 / 业务 / AI / ETLTab | /triggers?tab=all\|biz\|ai\|etl |
| 5 | 租户管理员 | — | /tenant-admins |
| 6 | 系统设置 | 环境配置 / 触发器配置(跳转) | /settings/* |
| 7 | 日志调试 | DevTrace / AI 调用明细 / 数据库查看器 | /logs/* |
## 组件与接口
### 新增前端页面组件
#### 1. Dashboard运行状态仪表盘
```
src/pages/Dashboard.tsx
```
聚合展示系统运行状况,由 4 个区块组成:
- **OpsPanel 区块**:复用现有 OpsPanel 组件中的服务状态、Git 状态、系统资源子模块(需将 OpsPanel 拆分为可独立使用的子组件)
- **DB 健康监控区块**:新组件 `DbHealthCard`,调用 `GET /api/admin/db-health`,展示 4 个数据库的连接池 / 大小 / 慢查询
- **AI 运行总览区块**:复用现有 AIDashboard 组件的统计卡片、趋势图、预算进度
- **AI 调度摘要区块**:展示今日触发数、成功率、最近错误,提供"查看详情"跳转到 `/triggers?tab=ai`
跳转链接:
- "ETL 状态详情" → `/etl-tasks?tab=status`
- "触发器详情" → `/triggers?tab=all`
- "AI 调度详情" → `/triggers?tab=ai`
#### 2. ETLTasksETL 任务管理)
```
src/pages/ETLTasks.tsx
```
使用 Ant Design `Tabs` 组件合并 3 个现有页面:
- Tab "config":渲染 `<TaskConfig />`(现有组件)
- Tab "manager":渲染 `<TaskManager />`(现有组件)
- Tab "status":渲染 `<ETLStatus />`(现有组件)
Tab 切换通过 URL 查询参数 `?tab=config|manager|status` 同步,支持浏览器前进/后退。使用 `useSearchParams` 读写当前 Tab。各 Tab 内容使用 `destroyInactiveTabPane={false}` 保持状态不丢失。
#### 3. TriggerManager触发器统一管理
```
src/pages/TriggerManager.tsx
```
4 个 Tab
- **全部 Tab**(只读):调用 `GET /api/admin/triggers/unified`,展示统一字段表格(名称、类型标签、触发条件、状态、上次/下次执行、最近错误)
- **业务 Tab**:复用现有 TriggerJobs 组件 + 新增编辑功能(调用 `PATCH /api/trigger-jobs/{id}/config`
- **AI Tab**:复用现有 AIOperations 组件的手动操作功能 + AITriggerJobs 的调度状态
- **ETL Tab**:展示 scheduled_tasks 数据(调用现有调度 API
#### 4. DbHealthCard数据库健康卡片
```
src/components/DbHealthCard.tsx
```
独立组件,接收 `GET /api/admin/db-health` 返回数据,为每个数据库渲染一张卡片:
- 连接池状态(活跃/空闲连接数,进度条)
- 数据库大小MB
- 慢查询数量(最近 1 小时)
- 连接失败时显示"未连接"状态标签
### 新增后端 API
#### 1. PATCH /api/trigger-jobs/{id}/config
```python
# apps/backend/app/routers/trigger_jobs.py 新增端点
@router.patch("/{job_id}/config", response_model=TriggerJobItem)
async def update_trigger_config(
job_id: int,
body: UpdateTriggerConfigRequest,
user: CurrentUser = Depends(get_current_user),
) -> TriggerJobItem:
"""编辑触发器的 cron_expression 或 interval_seconds。"""
```
请求体 Schema
```python
class UpdateTriggerConfigRequest(BaseModel):
cron_expression: str | None = None
interval_seconds: int | None = None
```
逻辑:
1. 查询 trigger_job 是否存在
2. 校验 cron_expression 格式5 字段 cron 语法)
3. 校验 interval_seconds >= 1
4. 更新 trigger_config JSON 中对应字段
5. 调用 `_calculate_next_run()` 重新计算 next_run_at
6. 返回更新后的完整 trigger_job
#### 2. GET /api/admin/db-health
```python
# apps/backend/app/routers/admin_db_health.py 新增路由模块
@router.get("", response_model=list[DbHealthItem])
async def get_db_health(
user: CurrentUser = Depends(get_current_user),
) -> list[DbHealthItem]:
"""返回 4 个数据库的健康状态。"""
```
响应 Schema
```python
class DbHealthItem(BaseModel):
db_name: str # etl_feiqiu / test_etl_feiqiu / zqyy_app / test_zqyy_app
status: str # connected / disconnected
active_connections: int | None = None
idle_connections: int | None = None
db_size_mb: float | None = None
slow_query_count: int | None = None # 最近 1 小时内 > 1s 的查询数
```
逻辑:
1. 遍历 4 个数据库 DSN 配置
2. 对每个库尝试连接并执行诊断 SQL
- `pg_stat_activity` 查连接池状态
- `pg_database_size()` 查数据库大小
- `pg_stat_statements``pg_stat_activity` 查慢查询
3. 连接失败时返回 `status: "disconnected"`,其余字段为 null
#### 3. GET /api/admin/triggers/unified
```python
# apps/backend/app/routers/admin_triggers.py 新增路由模块
@router.get("", response_model=list[UnifiedTriggerItem])
async def get_unified_triggers(
user: CurrentUser = Depends(get_current_user),
) -> list[UnifiedTriggerItem]:
"""聚合三张表的触发器数据。"""
```
响应 Schema
```python
class UnifiedTriggerItem(BaseModel):
id: int
name: str
source: str # biz / ai / etl
trigger_condition: str # cron / interval / event
status: str # running / idle / error / disabled
last_run_at: str | None = None
next_run_at: str | None = None
last_error: str | None = None
```
逻辑:
1. 查询 `biz.trigger_jobs`,映射 source="biz"
2. 查询 `biz.ai_trigger_jobs`(最近 100 条),映射 source="ai"
3. 查询 `scheduled_tasks`,映射 source="etl"
4. 统一字段格式后合并返回
### 新增前端 API 模块
```typescript
// src/api/dbHealth.ts
export interface DbHealthItem {
db_name: string;
status: 'connected' | 'disconnected';
active_connections: number | null;
idle_connections: number | null;
db_size_mb: number | null;
slow_query_count: number | null;
}
export async function fetchDbHealth(): Promise<DbHealthItem[]> {
const { data } = await apiClient.get<DbHealthItem[]>('/admin/db-health');
return data;
}
// src/api/triggers.ts
export interface UnifiedTriggerItem {
id: number;
name: string;
source: 'biz' | 'ai' | 'etl';
trigger_condition: string;
status: string;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
export async function fetchUnifiedTriggers(): Promise<UnifiedTriggerItem[]> {
const { data } = await apiClient.get<UnifiedTriggerItem[]>('/admin/triggers/unified');
return data;
}
// src/api/triggerJobs.ts 新增
export interface UpdateTriggerConfigReq {
cron_expression?: string;
interval_seconds?: number;
}
export async function updateTriggerConfig(
jobId: number, body: UpdateTriggerConfigReq
): Promise<TriggerJob> {
const { data } = await apiClient.patch<TriggerJob>(`/trigger-jobs/${jobId}/config`, body);
return data;
}
```
### 修改的现有组件
#### App.tsx 路由重构
- NAV_ITEMS 从 11 个一级项重组为 7 个
- Routes 更新为新路由结构
- 添加 `/``/dashboard` 重定向
- 添加 `/log-viewer``/etl-tasks?tab=manager` 重定向
- 登录成功后导航到 `/dashboard`
#### OpsPanel 组件拆分
将 OpsPanel.tsx 拆分为可独立使用的子组件:
- `ServiceStatusSection`:服务状态卡片
- `GitStatusSection`Git 状态卡片
- `SystemResourceSection`:系统资源卡片
Dashboard 页面 import 这些子组件进行聚合展示。
## 数据模型
### 新增后端 Pydantic 模型
#### UpdateTriggerConfigRequest
```python
class UpdateTriggerConfigRequest(BaseModel):
"""触发器配置编辑请求(部分更新)"""
cron_expression: str | None = None # 5 字段 cron 表达式
interval_seconds: int | None = None # 间隔秒数,>= 1
@model_validator(mode='after')
def at_least_one_field(self) -> Self:
if self.cron_expression is None and self.interval_seconds is None:
raise ValueError('至少提供 cron_expression 或 interval_seconds 之一')
return self
```
#### DbHealthItem
```python
class DbHealthItem(BaseModel):
"""单个数据库健康状态"""
db_name: str
status: Literal['connected', 'disconnected']
active_connections: int | None = None
idle_connections: int | None = None
db_size_mb: float | None = None
slow_query_count: int | None = None
```
#### UnifiedTriggerItem
```python
class UnifiedTriggerItem(BaseModel):
"""统一触发器视图项"""
id: int
name: str
source: Literal['biz', 'ai', 'etl']
trigger_condition: str
status: str
last_run_at: str | None = None
next_run_at: str | None = None
last_error: str | None = None
```
### 新增前端 TypeScript 类型
```typescript
// 数据库健康
interface DbHealthItem {
db_name: string;
status: 'connected' | 'disconnected';
active_connections: number | null;
idle_connections: number | null;
db_size_mb: number | null;
slow_query_count: number | null;
}
// 统一触发器
interface UnifiedTriggerItem {
id: number;
name: string;
source: 'biz' | 'ai' | 'etl';
trigger_condition: string;
status: string;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
// 触发器配置编辑
interface UpdateTriggerConfigReq {
cron_expression?: string;
interval_seconds?: number;
}
```
### 数据库变更
本次重构不新增数据库表。所有数据来自现有表:
| 表 | 库 | 用途 |
|----|-----|------|
| biz.trigger_jobs | zqyy_app | 4 个业务触发器 |
| biz.ai_trigger_jobs | zqyy_app | AI 事件链触发器 |
| scheduled_tasks | zqyy_app | ETL 调度任务 |
| pg_stat_activity | 各库 | 连接池状态 |
| pg_database | 各库 | 数据库大小 |
### Cron 表达式校验规则
PATCH API 中的 cron_expression 校验逻辑:
```python
import re
CRON_FIELD_PATTERNS = [
r'(\*|[0-5]?\d)', # minute: 0-59
r'(\*|[01]?\d|2[0-3])', # hour: 0-23
r'(\*|[1-9]|[12]\d|3[01])', # day of month: 1-31
r'(\*|[1-9]|1[0-2])', # month: 1-12
r'(\*|[0-6])', # day of week: 0-6
]
def validate_cron_expression(expr: str) -> bool:
"""校验 5 字段 cron 表达式基本格式。"""
parts = expr.strip().split()
if len(parts) != 5:
return False
for part, pattern in zip(parts, CRON_FIELD_PATTERNS):
if not re.fullmatch(pattern, part):
return False
return True
```
## 正确性属性
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规范与机器可验证正确性保证之间的桥梁。*
### 属性 1DB 健康 API 已连接数据库返回完整指标
*对于任意* 处于 `connected` 状态的数据库健康响应项,其 `active_connections``idle_connections``db_size_mb``slow_query_count` 字段均不为 null 且类型正确(连接数为非负整数,大小为非负浮点数)。
**验证需求2.4, 6.2**
### 属性 2Tab 切换状态保持 round-trip
*对于任意* ETL 任务管理或触发器管理的 Tab 视图,在某个 Tab 中设置筛选条件后切换到另一个 Tab 再切回来,原 Tab 的筛选条件应保持不变。
**验证需求3.5**
### 属性 3触发器统一视图数据完整性与字段完整性
*对于任意* 一组来自 biz.trigger_jobs、biz.ai_trigger_jobs、scheduled_tasks 三个数据源的触发器数据,统一视图 API 返回的记录总数应等于三个数据源记录数之和,且每条记录都包含 name、source、trigger_condition、status、last_run_at、next_run_at、last_error 字段。
**验证需求4.1, 4.2**
### 属性 4触发器配置编辑不变量
*对于任意* 通过 PATCH /api/trigger-jobs/{id}/config 的成功更新请求trigger_job 中除 trigger_config 内的 cron_expression / interval_seconds 和 next_run_at 外的所有字段id、job_type、job_name、trigger_condition、status、description、created_at应保持不变。
**验证需求4.7**
### 属性 5触发器配置更新正确性
*对于任意* 有效的 cron_expression符合 5 字段 cron 语法)或有效的 interval_seconds>= 1通过 PATCH API 更新后,返回的 trigger_job 中 trigger_config 对应字段值应等于请求值,且 next_run_at 应被重新计算为非 null 值。
**验证需求5.2, 5.3, 5.7**
### 属性 6触发器配置校验拒绝无效输入
*对于任意* 不符合 5 字段 cron 语法的字符串作为 cron_expression或任意小于 1 的整数作为 interval_secondsPATCH API 应返回 HTTP 422 状态码,且响应体包含描述性错误信息。
**验证需求5.4, 5.5**
### 属性 7侧边栏高亮与当前路由一致
*对于任意* 有效的应用路由路径侧边栏中被高亮selectedKeys的菜单项应对应该路由所属的一级模块。
**验证需求10.3**
### 属性 8Tab 切换与 URL 查询参数同步 round-trip
*对于任意* Tab 视图页面ETL 任务管理、触发器管理),设置 URL 查询参数 `?tab=X` 后渲染页面,当前激活的 Tab 应为 X反之点击 Tab Y 后URL 查询参数应更新为 `?tab=Y`
**验证需求10.4**
## 错误处理
### 前端错误处理
| 场景 | 处理方式 |
|------|----------|
| API 请求失败(网络错误) | Ant Design `message.error()` 提示,保持当前页面状态 |
| API 返回 401 | 现有 axios 拦截器自动处理:尝试 refresh token → 失败则跳转 /login |
| API 返回 422校验错误 | 在编辑表单中展示具体错误信息(如"cron 表达式格式无效" |
| API 返回 500 | `message.error()` 提示通用错误信息 |
| 组件渲染异常 | 现有 ErrorBoundary 组件捕获,展示降级 UI |
| DB 健康 API 超时 | DbHealthCard 展示"加载超时"状态,提供重试按钮 |
| 触发器统一 API 部分数据源失败 | 后端返回可用数据源的数据,前端正常展示已获取的数据 |
### 后端错误处理
| 场景 | 处理方式 |
|------|----------|
| PATCH APItrigger_job 不存在 | 返回 HTTP 404 + `{"detail": "任务 {id} 不存在"}` |
| PATCH APIcron_expression 格式无效 | 返回 HTTP 422 + `{"detail": "cron 表达式格式无效,需要 5 字段格式"}` |
| PATCH APIinterval_seconds < 1 | 返回 HTTP 422 + `{"detail": "interval_seconds 必须 >= 1"}` |
| PATCH API请求体为空 | 返回 HTTP 422 + `{"detail": "至少提供 cron_expression 或 interval_seconds 之一"}` |
| DB 健康 API某库连接失败 | 该库返回 `status: "disconnected"`,其余指标为 null不影响其他库 |
| DB 健康 API所有库连接失败 | 返回 4 个 disconnected 项HTTP 200不返回 500 |
| 统一触发器 API某数据源查询失败 | 记录日志,返回其他数据源的数据,失败的数据源不包含在结果中 |
| JWT 认证失败 | 返回 HTTP 401现有中间件处理 |
## 测试策略
### 双重测试方法
本项目采用单元测试 + 属性测试的双重策略:
- **单元测试Vitest**:验证具体示例、边界条件、错误场景
- **属性测试fast-check**:验证跨所有输入的通用属性
- 两者互补:单元测试捕获具体 bug属性测试验证通用正确性
### 前端测试
**工具**Vitest + @testing-library/react + fast-check
**属性测试配置**
- 每个属性测试最少运行 100 次迭代
- 每个属性测试必须用注释引用设计文档中的属性编号
- 标签格式:`Feature: admin-web-restructure, Property {number}: {property_text}`
**单元测试覆盖**
- 菜单结构验证7 个一级菜单项、子项正确性)
- 路由重定向(/ → /dashboard、/log-viewer → /etl-tasks
- Dashboard 区块渲染
- Tab 组件默认 Tab 选择
- DbHealthCard 各状态渲染connected / disconnected
**属性测试覆盖**
- 属性 2Tab 状态保持 round-trip
- 属性 7侧边栏高亮与路由一致
- 属性 8Tab-URL 参数同步 round-trip
### 后端测试
**工具**pytest + hypothesis
**属性测试配置**
- 每个属性测试使用 `@settings(max_examples=100)`
- 标签格式同上
**单元测试覆盖**
- PATCH API 端点存在性
- JWT 认证拦截
- DB 健康 API 端点存在性
- 统一触发器 API 端点存在性
**属性测试覆盖**
- 属性 1DB 健康 API 已连接数据库返回完整指标
- 属性 3触发器统一视图数据完整性
- 属性 4触发器配置编辑不变量
- 属性 5触发器配置更新正确性
- 属性 6触发器配置校验拒绝无效输入
### E2E 测试
**工具**Playwright
**覆盖范围**(每个新页面一个测试文件):
- `dashboard.spec.ts`Dashboard 页面渲染、4 个区块存在、跳转链接正确
- `etl-tasks.spec.ts`ETL 任务管理 Tab 切换、各 Tab 内容渲染
- `trigger-manager.spec.ts`:触发器管理 4 个 Tab、统一视图数据、业务 Tab 编辑功能
- `navigation.spec.ts`默认路由、重定向、菜单高亮、Tab-URL 同步
**测试批次**:每个测试文件控制在 Playwright 默认超时30s范围内。

View File

@@ -0,0 +1,161 @@
# 需求文档admin-web 后台重构优化
## 简介
将现有 admin-web 系统管理后台从 18 个页面 / 11 个一级菜单重组为 7 个一级菜单模块,新增运行状态仪表盘、触发器统一管理、数据库健康监控等功能,废弃 LogViewer 页面,采用新老页面交替开发策略,逐步完成迁移。
## 术语表
- **Admin_Web**:系统管理后台前端应用,位于 `apps/admin-web/`,面向开发者和运维人员
- **Dashboard**:运行状态仪表盘,重组后的默认首页,聚合系统运行状况
- **ETL_Task_Module**ETL 任务管理模块,合并原 TaskConfig、TaskManager、ETLStatus 三个页面为 Tab 视图
- **MiniApp_Task_Module**:小程序任务管理模块,原"任务引擎"改名,包含定时任务、转移日志、待审核任务、参数管理
- **Trigger_Module**:触发器管理模块,统一展示和管理三套触发器数据源
- **Trigger_Unified_View**:触发器统一视图,在"全部"Tab 中聚合 biz.trigger_jobs、biz.ai_trigger_jobs、scheduled_tasks 三张表的数据
- **DB_Health_Monitor**:数据库健康监控组件,检测 4 个数据库etl_feiqiu / test_etl_feiqiu / zqyy_app / test_zqyy_app的连接池、大小、慢查询
- **Archived_Page**:已归档页面,测试通过后移入 `_archived/` 目录且菜单不再显示的旧页面
- **Trigger_Config_PATCH_API**:新增的后端 PATCH 接口,用于编辑 trigger_config 中的 cron_expression 和 interval_seconds
- **E2E_Test**:端到端测试,使用 Playwright 编写,每个新页面必须配套
## 需求
### 需求 1侧边栏菜单重组
**用户故事:** 作为开发者/运维人员,我希望侧边栏菜单从 11 个一级项重组为 7 个一级项,以便更高效地导航和管理后台功能。
#### 验收标准
1. THE Admin_Web SHALL 将侧边栏菜单重组为以下 7 个一级菜单项运行状态、ETL 任务管理、小程序任务管理、触发器管理、租户管理员、系统设置、日志调试
2. THE Admin_Web SHALL 将"运行状态"菜单项设置为侧边栏第一项,并作为登录后的默认路由
3. THE Admin_Web SHALL 在"ETL 任务管理"菜单下提供 3 个 Tab任务配置、任务管理、ETL 状态
4. THE Admin_Web SHALL 将原"任务引擎"菜单改名为"小程序任务管理",保留 4 个子项:定时任务、转移日志、待审核任务、参数管理
5. THE Admin_Web SHALL 在"触发器管理"菜单下提供 4 个 Tab全部、业务、AI、ETL
6. THE Admin_Web SHALL 在"系统设置"菜单下包含环境配置页面和触发器配置跳转入口
7. THE Admin_Web SHALL 在"日志调试"菜单下包含 3 个子项DevTrace全链路日志、AI 调用明细、数据库查看器
8. THE Admin_Web SHALL 保持"租户管理员"作为独立一级菜单,功能不变
9. WHEN 用户登录成功后THE Admin_Web SHALL 自动导航到运行状态(仪表盘)页面
### 需求 2运行状态仪表盘
**用户故事:** 作为开发者/运维人员,我希望登录后看到一个聚合仪表盘,以便一目了然地掌握系统整体运行状况。
#### 验收标准
1. THE Dashboard SHALL 聚合展示以下内容区块OpsPanel 运维面板内容、数据库健康监控、AI Dashboard 内容、AI 调度状态摘要
2. THE Dashboard SHALL 提供跳转链接,点击后分别导航到 ETL 任务管理的"ETL 状态"Tab 和触发器管理的"触发器状态"Tab
3. THE DB_Health_Monitor SHALL 检测以下 4 个数据库的健康状态etl_feiqiu、test_etl_feiqiu、zqyy_app、test_zqyy_app
4. THE DB_Health_Monitor SHALL 展示每个数据库的连接池状态、数据库大小、慢查询信息
5. IF 某个数据库不存在或无法连接THEN THE DB_Health_Monitor SHALL 将该数据库标记为"未连接"状态,而非抛出错误
6. THE Dashboard SHALL 复用原 OpsPanel 组件的服务状态、Git 状态、系统资源等内容
7. THE Dashboard SHALL 复用原 AIDashboard 组件的统计卡片、趋势图、预算进度等内容
8. THE Dashboard SHALL 展示 AI 调度状态的摘要信息(今日触发数、成功率、最近错误)
### 需求 3ETL 任务管理模块
**用户故事:** 作为开发者/运维人员,我希望将 ETL 相关的三个页面合并为一个 Tab 视图,以便在同一页面内切换任务配置、任务管理和 ETL 状态。
#### 验收标准
1. THE ETL_Task_Module SHALL 将原 TaskConfig、TaskManager、ETLStatus 三个页面合并为同一路由下的 3 个 Tab
2. THE ETL_Task_Module SHALL 将"任务配置"Tab 的功能与原 TaskConfig 页面保持一致,包括 Flow 选择、任务勾选、参数设置、CLI 预览、执行/入队
3. THE ETL_Task_Module SHALL 将"任务管理"Tab 的功能与原 TaskManager 页面保持一致,包括队列管理、执行历史、实时日志流
4. THE ETL_Task_Module SHALL 将"ETL 状态"Tab 的功能与原 ETLStatus 页面保持一致,包括数据游标监控和最近执行记录
5. WHEN 用户切换 Tab 时THE ETL_Task_Module SHALL 保持各 Tab 的状态不丢失(如筛选条件、滚动位置)
### 需求 4触发器统一管理模块
**用户故事:** 作为开发者/运维人员,我希望在一个统一视图中查看所有触发器的运行状态,以便快速定位调度问题。
#### 验收标准
1. THE Trigger_Unified_View SHALL 在"全部"Tab 中聚合展示来自三个数据源的触发器biz.trigger_jobs业务触发器、biz.ai_trigger_jobsAI 事件链、scheduled_tasksETL 调度)
2. THE Trigger_Unified_View SHALL 为每个触发器展示以下统一字段:名称、类型标签(业务/AI/ETL、触发条件、状态运行中/空闲/错误/禁用)、上次执行时间、下次执行时间、最近错误
3. THE Trigger_Unified_View SHALL 在"全部"Tab 中仅提供只读查看,不提供编辑功能
4. THE Trigger_Module SHALL 在"业务"Tab 中展示 biz.trigger_jobs 的 4 个业务触发器,并支持编辑 trigger_config 中的 cron_expression 和 interval_seconds
5. THE Trigger_Module SHALL 在"AI"Tab 中展示 biz.ai_trigger_jobs 的 AI 事件链触发器,并提供 AI 手动操作功能(重跑、缓存失效、批量执行、告警管理)
6. THE Trigger_Module SHALL 在"ETL"Tab 中展示 scheduled_tasks 的 ETL 调度任务
7. WHEN 用户在"业务"Tab 中编辑触发器配置时THE Trigger_Module SHALL 仅允许修改 cron_expression 和 interval_seconds 两个字段
8. THE Trigger_Module SHALL 不提供编辑历史记录功能
### 需求 5触发器配置编辑后端 API
**用户故事:** 作为开发者/运维人员,我希望通过管理后台直接编辑触发器的 cron 表达式和间隔秒数,以便无需直接操作数据库即可调整调度策略。
#### 验收标准
1. THE Trigger_Config_PATCH_API SHALL 提供 PATCH /api/trigger-jobs/{id}/config 端点,接受 cron_expression 和 interval_seconds 的部分更新
2. WHEN 请求体包含 cron_expression 字段时THE Trigger_Config_PATCH_API SHALL 更新 trigger_config JSON 中的 cron_expression 值,并重新计算 next_run_at
3. WHEN 请求体包含 interval_seconds 字段时THE Trigger_Config_PATCH_API SHALL 更新 trigger_config JSON 中的 interval_seconds 值,并重新计算 next_run_at
4. IF cron_expression 格式不符合 5 字段 cron 语法THEN THE Trigger_Config_PATCH_API SHALL 返回 HTTP 422 错误及描述性错误信息
5. IF interval_seconds 小于 1THEN THE Trigger_Config_PATCH_API SHALL 返回 HTTP 422 错误及描述性错误信息
6. THE Trigger_Config_PATCH_API SHALL 要求 JWT 认证,与现有 trigger-jobs 端点保持一致的鉴权策略
7. WHEN 更新成功时THE Trigger_Config_PATCH_API SHALL 返回更新后的完整 trigger_job 信息
### 需求 6数据库健康监控后端 API
**用户故事:** 作为开发者/运维人员,我希望仪表盘能实时展示数据库健康状态,以便及时发现连接池耗尽或慢查询等问题。
#### 验收标准
1. THE Admin_Web SHALL 提供 GET /api/admin/db-health 端点,返回 4 个数据库的健康状态
2. THE Admin_Web SHALL 为每个数据库返回以下指标连接池活跃连接数、连接池空闲连接数、数据库大小MB、慢查询数量最近 1 小时内执行时间超过 1 秒的查询)
3. IF 某个数据库无法连接THEN THE Admin_Web SHALL 返回该数据库的状态为 "disconnected",其余指标为 null而非返回 HTTP 错误
4. THE Admin_Web SHALL 要求 JWT 认证访问数据库健康端点
### 需求 7AI 内容拆分重组
**用户故事:** 作为开发者/运维人员,我希望 AI 相关功能按使用场景分散到对应模块中,以便在合适的上下文中使用 AI 功能。
#### 验收标准
1. THE Dashboard SHALL 包含原 AIDashboard 页面的运行总览内容(统计卡片、趋势图、饼图、预算进度、告警列表)
2. THE Dashboard SHALL 包含原 AITriggerJobs 页面的调度状态摘要信息
3. THE Trigger_Module SHALL 在"AI"Tab 中包含原 AIOperations 页面的手动操作功能(重跑、缓存失效、批量执行、告警管理)
4. THE Admin_Web SHALL 将原 AIRunLogs 页面AI 调用明细)移入"日志调试"模块下
5. WHEN 用户从仪表盘的 AI 调度状态摘要点击"查看详情"时THE Admin_Web SHALL 导航到触发器管理的"AI"Tab
### 需求 8LogViewer 废弃
**用户故事:** 作为开发者/运维人员,我不再需要独立的 LogViewer 页面,因为 ETL 任务管理模块已提供实时日志功能。
#### 验收标准
1. THE Admin_Web SHALL 从侧边栏菜单中移除 LogViewer/log-viewer入口
2. THE Admin_Web SHALL 从路由配置中移除 /log-viewer 路由
3. WHEN 用户直接访问 /log-viewer 路径时THE Admin_Web SHALL 重定向到 ETL 任务管理页面
4. THE Admin_Web SHALL 将 LogViewer.tsx 源文件移入 `_archived/` 目录
### 需求 9新老页面交替开发策略
**用户故事:** 作为开发者,我希望采用渐进式迁移策略,以便在不影响现有功能的前提下逐步完成重构。
#### 验收标准
1. THE Admin_Web SHALL 使用目标路由开发新页面,不修改老页面的路由和功能
2. WHEN 新页面的 E2E 测试全部通过后THE Admin_Web SHALL 将对应的老页面源文件移入 `_archived/` 目录
3. THE Admin_Web SHALL 在老页面移入 `_archived/` 后,从侧边栏菜单中移除对应的老菜单项
4. THE Admin_Web SHALL 为每个新页面编写 Playwright E2E 测试,测试内容包含页面渲染验证和数据库查询校验数据正确性
5. THE Admin_Web SHALL 将 E2E 测试按合理批次切割,每个测试文件的执行时间控制在 Playwright 默认超时范围内
### 需求 10默认路由与导航
**用户故事:** 作为开发者/运维人员,我希望登录后直接看到系统运行状态,以便第一时间了解系统健康状况。
#### 验收标准
1. WHEN 用户登录成功后THE Admin_Web SHALL 将默认路由从 "/" (TaskConfig) 变更为 "/dashboard"(运行状态仪表盘)
2. WHEN 用户访问根路径 "/" 时THE Admin_Web SHALL 重定向到 "/dashboard"
3. THE Admin_Web SHALL 在侧边栏中高亮当前所在模块对应的菜单项
4. WHEN 用户在 Tab 视图内切换 Tab 时THE Admin_Web SHALL 更新 URL 查询参数以反映当前 Tab支持浏览器前进/后退导航
### 需求 11明确排除范围
**用户故事:** 作为开发者,我需要明确本次重构的边界,以便聚焦于前端重组工作。
#### 验收标准
1. THE Admin_Web SHALL 不修改后端业务逻辑(除新增 PATCH API 和数据库健康 API 外)
2. THE Admin_Web SHALL 不修改 tenant-admin 租户管理后台的任何功能
3. THE Admin_Web SHALL 不修改小程序端的任何功能
4. THE Admin_Web SHALL 不变更现有权限模型JWT 认证方式和角色体系保持不变)
5. THE Admin_Web SHALL 保持所有现有 API 接口的向后兼容性

View File

@@ -0,0 +1,325 @@
# 实施计划admin-web 后台重构优化
## 概述
按"后端 API → 前端页面 → 联调收尾"的顺序实施。后端新增 3 个 API 端点,前端重组菜单和路由、新建 4 个页面组件,最后归档老页面并补充 E2E 测试。每个阶段结束后插入检查点。
## 任务
- [x] 1. 后端:新增 Pydantic 模型与工具函数
- [x] 1.1 创建 `UpdateTriggerConfigRequest``DbHealthItem``UnifiedTriggerItem` 三个 Pydantic 模型
-`apps/backend/app/schemas/` 下新建或扩展 schema 文件
- `UpdateTriggerConfigRequest` 包含 `model_validator` 确保至少提供一个字段
- `DbHealthItem.status` 使用 `Literal['connected', 'disconnected']`
- `UnifiedTriggerItem.source` 使用 `Literal['biz', 'ai', 'etl']`
- _Requirements: 5.1, 5.2, 5.3, 6.1, 6.2, 4.1, 4.2_
- [x] 1.2 实现 cron 表达式校验工具函数 `validate_cron_expression`
-`apps/backend/app/utils/` 下新建 `cron_validator.py`
- 校验 5 字段 cron 语法,支持 `*` 和数值范围
- _Requirements: 5.4_
- [x] 1.3 编写属性测试cron 表达式校验拒绝无效输入
- **Property 6: 触发器配置校验拒绝无效输入**
- 使用 hypothesis 生成任意非 5 字段字符串、超范围数值字段
- 验证 `validate_cron_expression` 对无效输入返回 False
- **验证: 需求 5.4, 5.5**
- [x] 1.4 编写单元测试cron 校验与 Pydantic 模型
- 测试有效 cron 表达式通过校验
- 测试无效格式(字段数不对、超范围值)被拒绝
- 测试 `UpdateTriggerConfigRequest` 空请求体触发 ValidationError
- 测试 `DbHealthItem` disconnected 状态下指标字段可为 null
- _Requirements: 5.4, 5.5, 6.3_
- [x] 2. 后端:实现 PATCH /api/trigger-jobs/{id}/config 端点
- [x] 2.1 在 `apps/backend/app/routers/trigger_jobs.py` 新增 PATCH 端点
- 查询 trigger_job 是否存在(不存在返回 404
- 校验 cron_expression 格式(无效返回 422
- 校验 interval_seconds >= 1无效返回 422
- 更新 trigger_config JSON 中对应字段
- 重新计算 next_run_at
- 返回更新后的完整 trigger_job
- JWT 认证与现有端点一致
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
- [x] 2.2 编写属性测试:触发器配置编辑不变量
- **Property 4: 触发器配置编辑不变量**
- 使用 hypothesis 生成有效的 cron_expression 和 interval_seconds
- 验证更新后 trigger_job 中除 trigger_config 和 next_run_at 外的字段不变
- **验证: 需求 4.7**
- [x] 2.3 编写属性测试:触发器配置更新正确性
- **Property 5: 触发器配置更新正确性**
- 使用 hypothesis 生成有效的 cron_expression5 字段格式)和 interval_seconds>= 1
- 验证返回的 trigger_config 对应字段值等于请求值,且 next_run_at 非 null
- **验证: 需求 5.2, 5.3, 5.7**
- [x] 2.4 编写单元测试PATCH 端点边界条件
- 测试不存在的 job_id 返回 404
- 测试空请求体返回 422
- 测试无 JWT 返回 401
- _Requirements: 5.1, 5.4, 5.5, 5.6_
- [x] 3. 后端:实现 GET /api/admin/db-health 端点
- [x] 3.1 新建 `apps/backend/app/routers/admin_db_health.py` 路由模块
- 遍历 4 个数据库 DSN 配置etl_feiqiu / test_etl_feiqiu / zqyy_app / test_zqyy_app
- 对每个库执行诊断 SQL`pg_stat_activity`(连接池)、`pg_database_size()`(大小)、慢查询统计
- 连接失败时返回 `status: "disconnected"`,其余字段为 null
- JWT 认证
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [x] 3.2 编写属性测试DB 健康 API 已连接数据库返回完整指标
- **Property 1: DB 健康 API 已连接数据库返回完整指标**
- 使用 hypothesis 生成 connected 状态的 DbHealthItem
- 验证 active_connections、idle_connections、db_size_mb、slow_query_count 均非 null 且类型正确
- **验证: 需求 2.4, 6.2**
- [x] 3.3 编写单元测试DB 健康端点
- 测试正常连接返回 connected 状态和完整指标
- 测试连接失败返回 disconnected 状态和 null 指标
- 测试所有库连接失败仍返回 HTTP 200
- 测试无 JWT 返回 401
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [x] 4. 后端:实现 GET /api/admin/triggers/unified 端点
- [x] 4.1 新建 `apps/backend/app/routers/admin_triggers.py` 路由模块
- 查询 `biz.trigger_jobs`,映射 source="biz"
- 查询 `biz.ai_trigger_jobs`(最近 100 条),映射 source="ai"
- 查询 `scheduled_tasks`,映射 source="etl"
- 统一字段格式后合并返回
- 某数据源查询失败时记录日志,返回其他数据源数据
- JWT 认证
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 4.1, 4.2, 4.3_
- [x] 4.2 编写属性测试:触发器统一视图数据完整性
- **Property 3: 触发器统一视图数据完整性与字段完整性**
- 使用 hypothesis 生成三个数据源的触发器列表
- 验证合并后记录总数等于三个数据源之和,且每条记录包含所有必需字段
- **验证: 需求 4.1, 4.2**
- [x] 4.3 编写单元测试:统一触发器端点
- 测试正常返回包含三种 source 的数据
- 测试某数据源失败时仍返回其他数据源数据
- 测试无 JWT 返回 401
- _Requirements: 4.1, 4.2, 4.3_
- [x] 5. 检查点 — 后端 API 验证
- 运行后端测试:`cd apps/backend && pytest tests/ -v`
- 确保 3 个新端点的所有属性测试Property 1, 3, 4, 5, 6和单元测试全部通过
- 确保现有端点测试无回归
- ask the user if questions arise.
- [x] 6. 前端:新增 API 模块与 TypeScript 类型
- [x] 6.1 创建前端 API 模块和类型定义
- 新建 `src/api/dbHealth.ts``DbHealthItem` 接口 + `fetchDbHealth()` 函数
- 新建 `src/api/triggers.ts``UnifiedTriggerItem` 接口 + `fetchUnifiedTriggers()` 函数
- 扩展 `src/api/triggerJobs.ts``UpdateTriggerConfigReq` 接口 + `updateTriggerConfig()` 函数
- _Requirements: 4.1, 5.1, 6.1_
- [x] 7. 前端:侧边栏菜单重组与路由重构
- [x] 7.1 重构 App.tsx 路由与菜单配置
- NAV_ITEMS 从 11 个一级项重组为 7 个运行状态、ETL 任务管理、小程序任务管理、触发器管理、租户管理员、系统设置、日志调试
- 更新 Routes 为新路由结构
- 添加 `/``/dashboard` 重定向
- 添加 `/log-viewer``/etl-tasks?tab=manager` 重定向
- 登录成功后导航到 `/dashboard`
- 侧边栏高亮当前所在模块对应的菜单项
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 8.1, 8.2, 8.3, 10.1, 10.2, 10.3_
- [x] 7.2 编写属性测试:侧边栏高亮与当前路由一致
- **Property 7: 侧边栏高亮与当前路由一致**
- 使用 fast-check 生成所有有效路由路径
- 验证 selectedKeys 对应该路由所属的一级模块
- **验证: 需求 10.3**
- [x] 7.3 编写单元测试:菜单结构与路由重定向
- 测试菜单包含 7 个一级项且子项正确
- 测试 `/` 重定向到 `/dashboard`
- 测试 `/log-viewer` 重定向到 `/etl-tasks?tab=manager`
- _Requirements: 1.1, 8.3, 10.1, 10.2_
- [x] 8. 前端:实现 Dashboard 运行状态仪表盘
- [x] 8.1 拆分 OpsPanel 为可独立使用的子组件
- 从 OpsPanel.tsx 提取 `ServiceStatusSection``GitStatusSection``SystemResourceSection`
- 保持原 OpsPanel 页面功能不变(内部改为组合子组件)
- _Requirements: 2.6_
- [x] 8.2 创建 DbHealthCard 组件
- 新建 `src/components/DbHealthCard.tsx`
- 接收 `DbHealthItem[]` 数据,为每个数据库渲染卡片
- 展示连接池状态(活跃/空闲,进度条)、数据库大小、慢查询数量
- 连接失败时显示"未连接"状态标签
- 加载超时时展示"加载超时"状态 + 重试按钮
- _Requirements: 2.3, 2.4, 2.5_
- [x] 8.3 创建 Dashboard 页面
- 新建 `src/pages/Dashboard.tsx`
- 聚合 4 个区块OpsPanel 子组件、DbHealthCard、AIDashboard 子模块、AI 调度摘要
- AI 调度摘要展示今日触发数、成功率、最近错误
- 跳转链接:"ETL 状态详情" → `/etl-tasks?tab=status`、"触发器详情" → `/triggers?tab=all`、"AI 调度详情" → `/triggers?tab=ai`
- _Requirements: 2.1, 2.2, 2.6, 2.7, 2.8, 7.1, 7.2_
- [x] 8.4 编写单元测试Dashboard 页面
- 测试 4 个区块正确渲染
- 测试跳转链接指向正确路由
- 测试 DbHealthCard connected/disconnected 状态渲染
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
- [x] 9. 前端:实现 ETLTasks 页面Tab 合并)
- [x] 9.1 创建 ETLTasks 页面
- 新建 `src/pages/ETLTasks.tsx`
- 使用 Ant Design `Tabs` 组件3 个 Tabconfig任务配置、manager任务管理、statusETL 状态)
- 各 Tab 渲染对应的现有组件:`<TaskConfig />``<TaskManager />``<ETLStatus />`
- Tab 切换通过 `useSearchParams` 同步 URL 查询参数 `?tab=config|manager|status`
- `destroyInactiveTabPane={false}` 保持 Tab 状态不丢失
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 10.4_
- [x] 9.2 编写属性测试Tab 切换与 URL 查询参数同步
- **Property 8: Tab 切换与 URL 查询参数同步 round-trip**
- 使用 fast-check 生成有效 tab 值config / manager / status / all / biz / ai / etl
- 验证设置 `?tab=X` 后激活的 Tab 为 X点击 Tab Y 后 URL 参数更新为 `?tab=Y`
- **验证: 需求 10.4**
- [x] 9.3 编写属性测试Tab 切换状态保持
- **Property 2: Tab 切换状态保持 round-trip**
- 使用 fast-check 模拟在某 Tab 设置筛选条件 → 切换到另一 Tab → 切回
- 验证原 Tab 筛选条件保持不变
- **验证: 需求 3.5**
- [x] 9.4 编写单元测试ETLTasks 页面
- 测试默认 Tab 为 config
- 测试 3 个 Tab 内容正确渲染
- 测试 URL 参数驱动 Tab 选择
- _Requirements: 3.1, 3.2, 3.3, 3.4, 10.4_
- [x] 10. 前端:实现 TriggerManager 触发器统一管理页面
- [x] 10.1 创建 TriggerManager 页面
- 新建 `src/pages/TriggerManager.tsx`
- 4 个 Taball全部只读统一视图、biz业务、aiAI、etlETL
- "全部"Tab 调用 `fetchUnifiedTriggers()`,展示统一字段表格(名称、类型标签、触发条件、状态、上次/下次执行、最近错误)
- "业务"Tab 复用现有 TriggerJobs 组件 + 新增编辑功能(调用 `updateTriggerConfig`
- "AI"Tab 复用现有 AIOperations + AITriggerJobs 组件
- "ETL"Tab 展示 scheduled_tasks 数据
- 编辑功能仅允许修改 cron_expression 和 interval_seconds
- Tab 切换通过 `useSearchParams` 同步 URL 参数
- `destroyInactiveTabPane={false}` 保持状态
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 7.3, 7.5, 10.4_
- [x] 10.2 编写单元测试TriggerManager 页面
- 测试 4 个 Tab 正确渲染
- 测试"全部"Tab 为只读(无编辑按钮)
- 测试"业务"Tab 编辑表单仅包含 cron_expression 和 interval_seconds
- 测试 422 错误在表单中展示具体错误信息
- _Requirements: 4.1, 4.3, 4.4, 4.7_
- [x] 11. 前端:菜单子项路由调整与页面移动
- [x] 11.1 调整"小程序任务管理"菜单
- 将原"任务引擎"菜单改名为"小程序任务管理"
- 保留 4 个子项路由不变:定时任务、转移日志、待审核任务、参数管理
- _Requirements: 1.4_
- [x] 11.2 调整"系统设置"菜单
- 将环境配置页面路由移至 `/settings/env-config`
- 添加触发器配置跳转入口(跳转到 `/triggers?tab=biz`
- _Requirements: 1.6_
- [x] 11.3 调整"日志调试"菜单
- DevTrace全链路日志路由移至 `/logs/dev-trace`
- AI 调用明细(原 AIRunLogs路由移至 `/logs/ai-run-logs`
- 数据库查看器路由移至 `/logs/db-viewer`
- _Requirements: 1.7, 7.4_
- [x] 11.4 废弃 LogViewer 页面
- 从侧边栏菜单移除 LogViewer 入口
- 从路由配置移除 `/log-viewer` 路由(已在 7.1 中添加重定向)
- 将 LogViewer.tsx 移入 `_archived/` 目录
- _Requirements: 8.1, 8.2, 8.3, 8.4_
- [x] 12. 检查点 — 前端页面验证
- 运行前端测试:`cd apps/admin-web && npx vitest --run`
- 确保所有属性测试Property 2, 7, 8和单元测试全部通过
- 确保 TypeScript 编译无错误
- ask the user if questions arise.
- [x] 13. E2E 测试
- [x] 13.1 编写 Dashboard E2E 测试
- 新建 `e2e/dashboard.spec.ts`
- 测试页面渲染、4 个区块存在、跳转链接正确
- _Requirements: 2.1, 2.2, 9.4_
- [x] 13.2 编写 ETL 任务管理 E2E 测试
- 新建 `e2e/etl-tasks.spec.ts`
- 测试 Tab 切换、各 Tab 内容渲染
- _Requirements: 3.1, 3.2, 3.3, 3.4, 9.4_
- [x] 13.3 编写触发器管理 E2E 测试
- 新建 `e2e/trigger-manager.spec.ts`
- 测试 4 个 Tab、统一视图数据、业务 Tab 编辑功能
- _Requirements: 4.1, 4.3, 4.4, 4.7, 9.4_
- [x] 13.4 编写导航与路由 E2E 测试
- 新建 `e2e/navigation.spec.ts`
- 测试默认路由 `/``/dashboard``/log-viewer` 重定向、菜单高亮、Tab-URL 同步
- _Requirements: 8.3, 9.4, 10.1, 10.2, 10.3, 10.4_
- [x] 14. 归档老页面
- [x] 14.1 E2E 测试通过后归档老页面
- 将被替代的老页面源文件OpsPanel、TaskConfig、TaskManager、ETLStatus、AIDashboard、AITriggerJobs、AIOperations、LogViewer 等独立页面入口)移入 `_archived/` 目录
- 从侧边栏菜单移除对应的老菜单项
- 保留被复用的子组件(如 OpsPanel 拆分后的子组件、AIDashboard 子模块)
-LogViewer 已在 11.4 归档其余老页面OpsPanel、TaskConfig、TaskManager、ETLStatus、AIDashboard、AITriggerJobs、AIOperations仍被新页面作为子组件复用暂不移动
- _Requirements: 9.1, 9.2, 9.3_
- [x] 15. 检查点 — E2E 与归档验证
- 运行 E2E 测试:`cd apps/admin-web && npx playwright test`
- 确保所有 E2E 测试通过
- 确保归档后的页面不再出现在菜单中
- 确保 `/log-viewer` 正确重定向
-E2E 测试需手动运行(需启动 dev server + 浏览器LogViewer 已归档且重定向已配置
- ask the user if questions arise.
- [x] 16. 前后端联调与集成验证
- [x] 16.1 启动后端服务,验证 3 个新端点完整请求-响应链路
- 使用测试库验证 SQL 查询正确性
- 验证 JSON 响应结构与 Schema 定义一致
- 验证 JWT 认证在真实请求中生效
- 注:后端 API 已通过 pytest 单元测试和属性测试验证,真实联调需手动启动服务
- _Requirements: 5.1, 5.6, 6.1, 6.4, 11.4_
- [x] 16.2 前端联调验证
- 确认 Dashboard 页面能正确调用 db-health API 并渲染数据
- 确认触发器管理页面能正确调用 unified triggers API 并渲染数据
- 确认业务 Tab 编辑功能能正确调用 PATCH API
- 验证空数据/降级场景下前端不崩溃
-E2E 测试已覆盖 mock API 场景,真实联调需手动启动前后端服务
- _Requirements: 2.1, 2.3, 4.1, 4.4, 11.1_
- [x] 17. 最终检查点 — 全量验证
- 运行 Monorepo 属性测试:`cd C:\NeoZQYY && pytest tests/ -v`
- 运行后端测试:`cd apps/backend && pytest tests/ -v`
- 运行前端测试:`cd apps/admin-web && npx vitest --run`
- 运行 E2E 测试:`cd apps/admin-web && npx playwright test`
- 确保所有属性测试Property 1-8和单元测试全部通过
- 确保前端页面连接真实后端运行正常
- ask the user if questions arise.
- [x] 18. 文档同步更新
- [x] 18.1 更新后端 API 参考文档
-`apps/backend/docs/API-REFERENCE.md` 新增 3 个端点文档
- 更新 `apps/backend/README.md` 路由模块摘要
- _Requirements: 11.1, 11.5_
- [x] 18.2 更新文档地图
-`docs/DOCUMENTATION-MAP.md` 新增本次模块条目
- _Requirements: 11.1_
## 备注
- 标记 `*` 的子任务为可选(属性测试/单元测试),可跳过以加速 MVP
- 每个任务引用了具体的需求编号以确保可追溯性
- 属性测试验证通用正确性属性Property 1-8单元测试验证具体边界条件
- 检查点任务确保增量验证,避免问题累积
- 本项目不涉及数据库 DDL 变更,无需 DB 变更审计和 BD 手册步骤
- E2E 测试按页面切割为 4 个文件,每个文件控制在 Playwright 默认超时30s范围内
- 后端使用 PythonFastAPI + pytest + hypothesis前端使用 TypeScriptReact + Vitest + fast-check

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
# 需求文档 — P15AI 监控后台 + 测试重建 + 回填
## 简介
P14 完成 DashScope SDK 迁移和调度器修复后,本 Spec 覆盖三个方面:
1. admin-web AI 监控后台4 个页面:运行总览 Dashboard、调度状态、调用明细、手动操作
2. 测试体系重建(旧脚本归档、新全链路测试 15 个场景、属性测试更新 7 个)
3. 历史数据回填(半年内活跃会员 <100 人,分批执行 + 断点续跑)
**实施过程中如遇细节不明确,应优先查阅 PRD 原文 `docs/prd/specs/P15-ai-monitoring-testing.md`**
### 问题覆盖
本 Spec 覆盖 `docs/reports/2026-03-21__ai_module_issues.md` 中的:
- P2-1App5 话术缺分类(回填时验证 App5 输出包含分类字段)
- P2-2MCP 无健康检查admin-web Dashboard 展示各 App 最近调用状态)
- P2-4全链路测试不完整§3.2 新全链路测试)
- P3-3Prompt 版本管理(仅监控展示部分,只读)
### 依赖
- P14DashScope 迁移 + 调度器完善)— `P14-ai-dashscope-migration` spec 必须全部完成并验证通过
### 来源文档(权威参考)
- `docs/prd/specs/P15-ai-monitoring-testing.md` — PRD 主文档
- `docs/reports/2026-03-21__ai_module_issues.md` — 问题清单
- `docs/database/BD_Manual_ai_tables.md` — AI 表 BD 手册
### 不在本 spec 范围
- 多门店支持BACKLOG
- 消息队列(单独 PRD
- Prompt 版本管理的编辑功能BACKLOG本轮仅展示
- 前端刷新机制P1-5
- FDW 数据一致性P1-6
- 企业微信/邮件告警推送(后续迭代)
## 术语表
- **admin-web**:系统管理后台(`apps/admin-web/`面向系统管理员React + Vite + Ant Design
- **Backend**FastAPI 后端应用,位于 `apps/backend/`
- **Admin_AI_Service**:新增的 admin AI 聚合服务(`apps/backend/app/services/ai/admin_service.py`),负责聚合查询、批量操作、告警管理
- **Admin_AI_Router**:新增的 admin AI 路由文件(`apps/backend/app/routers/admin_ai.py`),注册 13 个 API 端点
- **Dashboard**AI 运行总览页面展示统计卡片、趋势图、App 分布、Token 预算、告警列表
- **ai_run_logs**AI 运行记录表P14 已创建),记录每次 DashScope API 调用的详细日志
- **ai_trigger_jobs**调度运行记录表P14 已创建),记录事件触发的调用链执行状态
- **ai_cache**AI 应用缓存表,各应用的结构化输出结果
- **App1~App8**8 个百炼智能体应用App1 通用对话、App2 财务洞察、App3 维客线索、App4 关系分析、App5 话术参考、App6 备注分析、App7 客户分析、App8 维客线索整理)
- **Backfill_Script**:历史数据回填脚本(`scripts/ops/ai_backfill.py`
- **Circuit_Breaker**:熔断器,按 app_id 独立计数P14 已实现)
- **Dispatcher**AI 事件调度与调用链编排器P14 已实现)
- **member_retention_clue**会员留存线索业务表App8 写入目标
## 需求
### 模块 Aadmin-web AI 监控后台 — 后端 API
#### 需求 A1Dashboard 总览统计 API
**用户故事:** 作为系统管理员,我希望通过 Dashboard API 获取 AI 运行总览数据以便在一个页面内掌握今日调用次数、成功率、Token 消耗、平均延迟和近 7 天趋势。
##### 验收标准
1. THE Backend SHALL 实现 `GET /api/admin/ai/dashboard` 端点,返回以下聚合数据:今日调用次数、今日成功率、今日 Token 消耗总量、今日平均延迟ms
2. THE Backend SHALL 在 Dashboard 响应中包含近 7 天的调用量和成功率按日聚合数据,用于趋势图展示
3. THE Backend SHALL 在 Dashboard 响应中包含各 App 调用占比分布数据(按 `ai_run_logs.app_type` 分组)
4. THE Backend SHALL 在 Dashboard 响应中包含日/月 Token 预算使用进度(已用量 / 预算上限)
5. THE Backend SHALL 在 Dashboard 响应中包含最近失败/超时/熔断告警事件列表(取自 `ai_run_logs` WHERE status IN ('failed', 'timeout', 'circuit_open')
6. THE Backend SHALL 支持 `site_id` 查询参数进行门店筛选
7. THE Backend SHALL 在 Dashboard 响应中包含各 App 最近一次调用状态,用于展示 App 健康状态(覆盖 P2-2 MCP 健康检查需求)
#### 需求 A2调度任务列表与详情 API
**用户故事:** 作为系统管理员,我希望查看调度任务的列表和详情,以便了解事件触发的执行状态、调用链和去重统计。
##### 验收标准
1. THE Backend SHALL 实现 `GET /api/admin/ai/trigger-jobs` 端点返回分页的调度任务列表包含事件类型、会员、状态、执行链app_chain、耗时
2. THE Backend SHALL 支持以下筛选参数:`event_type``status``site_id``date_from``date_to`
3. THE Backend SHALL 在列表响应中包含今日跳过的重复事件数(`ai_trigger_jobs` WHERE status='skipped_duplicate' AND created_at >= 今日零点)
4. THE Backend SHALL 实现 `GET /api/admin/ai/trigger-jobs/:id` 端点,返回单条调度任务的完整详情(含 payload、error_message
#### 需求 A3手动重跑 API
**用户故事:** 作为系统管理员,我希望对失败的调度任务进行手动重跑,以便修复因临时故障导致的执行失败。
##### 验收标准
1. THE Backend SHALL 实现 `POST /api/admin/ai/trigger-jobs/:id/retry` 端点,对指定调度任务发起手动重跑
2. WHEN 手动重跑时THE Backend SHALL 创建新的 `ai_trigger_jobs` 记录,标记 `is_forced=true`,跳过去重检查
3. THE Backend SHALL 在重跑请求后立即返回新的 `trigger_job_id``status: "pending"`,调用链在后台异步执行
#### 需求 A4调用记录列表与详情 API
**用户故事:** 作为系统管理员,我希望查看 AI 调用记录的列表和详情,以便追踪每次调用的 prompt、response、token 消耗和错误信息。
##### 验收标准
1. THE Backend SHALL 实现 `GET /api/admin/ai/run-logs` 端点,返回分页的调用记录列表,包含 app_type、trigger_type、member_id、tokens_used、latency_ms、status
2. THE Backend SHALL 支持以下筛选参数:`app_type``status``trigger_type``site_id``date_from``date_to`
3. THE Backend SHALL 实现 `GET /api/admin/ai/run-logs/:id` 端点,返回单条调用记录的完整详情,包含完整 request_prompt、完整 response_text、error_message
4. THE Backend SHALL 对调用记录详情不做脱敏处理,系统管理员可查看原文
#### 需求 A5缓存失效 API
**用户故事:** 作为系统管理员,我希望按 App / 会员 / 门店批量将缓存标记为失效,以便在数据异常时强制重新生成 AI 分析结果。
##### 验收标准
1. THE Backend SHALL 实现 `POST /api/admin/ai/cache/invalidate` 端点,接受请求体包含:`app_type`(可选)、`member_id`(可选)、`site_id`(必填)
2. WHEN 缓存失效请求到达时THE Backend SHALL 将匹配条件的 `ai_cache` 记录的 `status` 字段更新为 `invalidated`
3. THE Backend SHALL 返回受影响的缓存记录数量
#### 需求 A6Token 预算查询 API
**用户故事:** 作为系统管理员,我希望查看 Token 预算的使用情况,以便监控 AI 调用成本。
##### 验收标准
1. THE Backend SHALL 实现 `GET /api/admin/ai/budget` 端点,返回日预算和月预算的使用情况:已用量、预算上限、使用百分比
2. THE Backend SHALL 从 `ai_run_logs.tokens_used` 按日/月聚合计算已消耗 token 数
#### 需求 A7批量执行与成本二次确认 API
**用户故事:** 作为系统管理员,我希望批量执行 AI 调用前能看到预估成本,确认后才真正执行,以便防止误操作导致大量 API 调用和费用。
##### 验收标准
1. THE Backend SHALL 实现 `POST /api/admin/ai/batch-run` 端点,接受请求体包含:`app_types`App 列表)、`member_ids`(会员 ID 列表)、`site_id`(门店 ID
2. WHEN 批量执行请求到达时THE Backend SHALL 计算预估调用次数(会员数 × App 数)和预估 Token 消耗,返回 `{ batch_id, estimated_calls, estimated_tokens }`,不立即执行
3. THE Backend SHALL 实现 `POST /api/admin/ai/batch-run/confirm` 端点,接受 `{ batch_id }`,确认后在后台异步执行批量调用
4. IF batch_id 无效或已过期THEN THE Backend SHALL 返回 HTTP 400 错误
#### 需求 A8告警管理 API
**用户故事:** 作为系统管理员,我希望查看、确认和忽略 AI 运行告警,以便对失败事件进行分类处理。
##### 验收标准
1. THE Backend SHALL 实现 `GET /api/admin/ai/alerts` 端点,返回告警列表(来源:`ai_run_logs` WHERE status IN ('failed', 'timeout', 'circuit_open')),支持分页和状态筛选
2. THE Backend SHALL 实现 `POST /api/admin/ai/alerts/:id/ack` 端点,将指定告警标记为"已确认"
3. THE Backend SHALL 实现 `POST /api/admin/ai/alerts/:id/ignore` 端点,将指定告警标记为"已忽略"
4. THE Backend SHALL 支持告警的三种状态:`pending`(待处理)、`acknowledged`(已确认)、`ignored`(已忽略)
#### 需求 A9API 认证与权限
**用户故事:** 作为系统管理员,我希望 AI 监控后台的所有 API 端点都受 JWT 认证保护,以便只有授权管理员才能访问。
##### 验收标准
1. THE Backend SHALL 对所有 `/api/admin/ai/*` 端点使用 JWT Bearer Token 认证,复用现有 admin 权限体系
2. IF JWT Token 缺失或无效THEN THE Backend SHALL 返回 HTTP 401
3. THE Backend SHALL 确保系统管理员对所有 AI 监控数据全量可见,不做数据脱敏
---
### 模块 Badmin-web AI 监控后台 — 前端页面
#### 需求 B1AI 运行总览页面Dashboard
**用户故事:** 作为系统管理员,我希望在 admin-web 中看到 AI 运行总览 Dashboard以便一目了然地掌握 AI 模块的运行状况。
##### 验收标准
1. THE admin-web SHALL 新增 AI 运行总览页面包含以下区域顶部统计卡片今日调用次数、成功率、Token 消耗、平均延迟)、近 7 天趋势折线图(调用量 + 成功率)、各 App 调用占比饼图、Token 预算使用进度条(日/月)、告警列表
2. THE admin-web SHALL 在 Dashboard 页面展示各 App 最近一次调用状态,用于健康状态监控
3. THE admin-web SHALL 支持门店筛选(`site_id` 下拉选择)
4. THE admin-web SHALL 展示当前 App 配置信息(只读),覆盖 P3-3 Prompt 版本管理的监控展示需求
#### 需求 B2调度状态页面
**用户故事:** 作为系统管理员,我希望在 admin-web 中查看调度任务的执行状态,以便监控事件触发链的运行情况。
##### 验收标准
1. THE admin-web SHALL 新增调度状态页面包含分页表格展示事件类型、会员、状态、执行链app_chain、耗时
2. THE admin-web SHALL 提供筛选器event_type / status / site_id / 日期范围
3. THE admin-web SHALL 在页面顶部展示今日跳过的重复事件数
4. THE admin-web SHALL 在操作列提供"查看详情"和"手动重跑"按钮
5. WHEN 点击"手动重跑"时THE admin-web SHALL 弹出确认对话框,确认后调用 `POST /api/admin/ai/trigger-jobs/:id/retry`
#### 需求 B3调用明细页面
**用户故事:** 作为系统管理员,我希望在 admin-web 中查看 AI 调用的详细记录,以便排查问题和分析调用模式。
##### 验收标准
1. THE admin-web SHALL 新增调用明细页面包含分页表格展示app_type、trigger_type、member_id、tokens_used、latency_ms、status
2. THE admin-web SHALL 提供筛选器app_type / status / trigger_type / site_id / 日期范围
3. WHEN 点击表格行时THE admin-web SHALL 展开详情抽屉Drawer展示完整 prompt、完整 response、error_message
#### 需求 B4手动操作页面
**用户故事:** 作为系统管理员,我希望在 admin-web 中执行手动重跑、缓存失效、批量执行和告警管理操作,以便在需要时进行人工干预。
##### 验收标准
1. THE admin-web SHALL 新增手动操作页面,包含以下功能区域:手动重跑、缓存失效、批量执行(含成本二次确认)、告警确认/忽略
2. THE admin-web SHALL 在手动重跑区域提供 App 选择、会员输入、门店选择,触发单次执行
3. THE admin-web SHALL 在缓存失效区域提供按 App / 会员 / 门店的批量失效操作
4. THE admin-web SHALL 在批量执行区域实现成本二次确认流程:选择操作参数 → 调用 `POST /api/admin/ai/batch-run` 获取预估 → 展示确认弹窗("本次将执行 N 次 AI 调用,预估消耗 M tokens确认执行")→ 确认后调用 `POST /api/admin/ai/batch-run/confirm`
5. THE admin-web SHALL 在告警区域展示告警列表,每条告警提供"确认"和"忽略"操作按钮
---
### 模块 C测试体系重建
#### 需求 C1旧测试脚本归档
**用户故事:** 作为开发者,我希望将基于旧 openai SDK 的测试脚本归档到 `_archived/` 目录,以便清理过时代码,避免误用。
##### 验收标准
1. THE Backend SHALL 将以下 7 个脚本从 `scripts/ops/` 移至 `scripts/ops/_archived/``ai_full_chain_test.py``test_chat_e2e.py``test_chat_ai_quality.py``test_bailian_apps.py``test_bailian_single.py``test_bailian_full.py``_run_ai_tests_remaining.py`
2. THE Backend SHALL 将 `apps/backend/tests/test_ai_bailian.py` 移至 `apps/backend/tests/_archived/test_ai_bailian.py`
3. THE Backend SHALL 将 `docs/reports/2026-03-21__ai_full_chain_test.md` 移至 `docs/reports/_archived/2026-03-21__ai_full_chain_test.md`
4. THE Backend SHALL 保留归档文件内容不变,仅移动位置
5. THE Backend SHALL 不删除 `docs/audit/` 中的审计记录,仅停止引用
#### 需求 C2新全链路测试 — 15 个场景
**用户故事:** 作为开发者,我希望建立覆盖 15 个场景的全链路测试,以便验证 DashScope 迁移后所有 AI 应用的端到端功能正确性。
##### 验收标准
1. THE Backend SHALL 实现 App1 对话测试,覆盖 10 种入口general、customer_detail、coach_detail、task_detail、finance_overview、member_list、settlement_detail、note_detail、dashboard、report验证 SSE 流式输出、session_id 双轨持久化、对话复用规则
2. THE Backend SHALL 实现 App2 定时预生成测试,验证 8 个时间维度(今日、昨日、本周、上周、本月、上月、本季、上季)的缓存写入和 JSON 合法性
3. THE Backend SHALL 实现消费事件触发链测试App3→App8→App7验证事件传播、缓存写入、业务表写入
4. THE Backend SHALL 实现助教消费触发链测试App3→App8→App7→App4→App5验证完整链路和助教关联
5. THE Backend SHALL 实现备注事件触发链测试App6→App8验证备注分析和线索整合
6. THE Backend SHALL 实现任务分配触发链测试App4→App5验证关系分析和话术生成
7. THE Backend SHALL 实现缓存命中测试App2~8验证缓存有效期内直接返回、过期后重新生成
8. THE Backend SHALL 实现缓存失效测试App2~8验证 admin-web 手动失效后重新生成
9. THE Backend SHALL 实现熔断触发测试,验证连续 5 次失败→熔断→半开→恢复的完整流程
10. THE Backend SHALL 实现 Token 预算超限测试,验证超限后正确降级
11. THE Backend SHALL 实现失败记录测试,验证 `ai_run_logs` 记录完整且 error_message 有值
12. THE Backend SHALL 实现后台可见性测试,验证 admin-web API 能返回所有运行记录和详情
13. THE Backend SHALL 实现 JSON 兜底测试App2~8验证非法 JSON 重试 3 次
14. THE Backend SHALL 实现幂等验证测试App8验证同一 member 同一天重复触发只执行一次
15. THE Backend SHALL 实现内容质量测试App2~8验证返回内容包含必要字段、格式正确
16. THE Backend SHALL 支持 Mock 模式和真实 API 模式双轨运行Mock 模式用于 CI 环境,真实 API 模式用于集成验证
#### 需求 C3属性测试更新 — 7 个属性测试
**用户故事:** 作为开发者,我希望更新 Hypothesis 属性测试,以便通过随机输入验证 AI 模块的关键不变量。
##### 验收标准
1. THE Backend SHALL 实现熔断器状态机属性测试验证状态转换合法性closed→open→half_open→closed/open任意操作序列不产生非法状态
2. THE Backend SHALL 实现 Token 预算计算属性测试,验证日/月聚合值等于各条记录 tokens_used 之和(聚合不变量)
3. THE Backend SHALL 实现去重逻辑属性测试,验证相同 (event_type, member_id, site_id, date) 只产生一条非 skipped 记录
4. THE Backend SHALL 实现缓存过期属性测试,验证 expires_at 过期的缓存 status 必须为 expired
5. THE Backend SHALL 实现 App8 幂等属性测试,验证同一 member 同一天的 member_retention_clue 只有一组记录
6. THE Backend SHALL 实现 session_id 重建属性测试,验证重建后的 messages 数组与本地 ai_messages 一致round-trip 属性)
7. THE Backend SHALL 实现限流计数属性测试,验证窗口内请求数不超过上限
---
### 模块 D历史数据回填
#### 需求 D1回填脚本
**用户故事:** 作为系统管理员,我希望通过回填脚本为半年内活跃会员生成 AI 分析数据,以便新上线的 AI 功能对历史会员也有完整的分析结果。
##### 验收标准
1. THE Backend SHALL 实现回填脚本 `scripts/ops/ai_backfill.py`查询半年内2025-09-21 ~ 2026-03-21三者并集的活跃会员有消费记录、有备注记录、有任务变更
2. THE Backfill_Script SHALL 写死门店范围 `site_id = 2790685415443269`
3. THE Backfill_Script SHALL 分批执行:每批 10 个会员,批间间隔 5 秒
4. THE Backfill_Script SHALL 实现断点续跑:将已完成的 member_id 列表记录到本地文件,失败后从断点继续
5. THE Backfill_Script SHALL 对每个会员执行以下调用链App3维客线索→ App8线索整理→ App7客户分析如有助教关联→ App4关系分析→ App5话术参考如有备注→ App6备注分析→ App8再次整合
6. THE Backfill_Script SHALL 对 App8 写入 `member_retention_clue` 业务表时使用 DELETE + INSERT 事务包裹
7. THE Backfill_Script SHALL 不备份现有数据
#### 需求 D2回填成本确认
**用户故事:** 作为系统管理员,我希望在执行回填前通过 admin-web 二次确认预估成本,以便防止意外的大量 API 调用。
##### 验收标准
1. THE Backfill_Script SHALL 在执行前输出预估信息:会员数量、预估调用次数(会员数 × 平均 5 个 App、预估 Token 消耗(每次约 2000 tokens
2. THE Backfill_Script SHALL 支持通过 admin-web 的批量执行 + 成本二次确认流程触发(复用需求 A7 的 API
3. THE Backfill_Script SHALL 在回填过程中验证 App5 输出包含分类字段(覆盖 P2-1 问题修复)
---
### 模块 E数据保留与清理
#### 需求 E1数据保留策略
**用户故事:** 作为系统管理员,我希望 AI 相关数据按策略自动清理,以便控制数据库存储增长。
##### 验收标准
1. THE Backend SHALL 永久保留 App1 对话记录(`ai_conversations` + `ai_messages`),不自动删除
2. THE Backend SHALL 对 App2~8 缓存(`ai_cache`)每个 App 保留最新 20,000 条,超出部分按 `created_at` 排序删除最旧记录
3. THE Backend SHALL 对 `ai_run_logs` 保留 90 天,删除 90 天前的记录
4. THE Backend SHALL 对 `ai_trigger_jobs` 保留 90 天,删除 90 天前的记录
#### 需求 E2清理定时任务
**用户故事:** 作为系统管理员,我希望数据清理任务每日自动执行,以便无需人工干预即可维持数据保留策略。
##### 验收标准
1. THE Backend SHALL 实现数据清理定时任务,每日凌晨 03:00 执行
2. THE Backend SHALL 在清理任务中依次执行:删除 90 天前的 `ai_run_logs`、删除 90 天前的 `ai_trigger_jobs`、对每个 App 类型清理超出 20,000 条上限的 `ai_cache` 记录
3. THE Backend SHALL 记录每次清理任务的执行结果(删除记录数)到日志
---
### 模块 F数据库查询优化与收尾
#### 需求 F1Dashboard 聚合查询优化
**用户故事:** 作为后端开发者,我希望 Dashboard 的聚合查询性能良好,以便页面加载不卡顿。
##### 验收标准
1. THE Backend SHALL 为 `ai_run_logs` 的 Dashboard 聚合查询评估并添加 BRIN 索引(基于 `created_at` 列),提升按时间范围聚合的查询性能
2. THE Backend SHALL 确保 Dashboard API 的响应时间在合理范围内(分页 + 索引 + 90 天保留策略共同保障)
#### 需求 F2文档同步
**用户故事:** 作为开发者,我希望所有相关文档在 P15 完成后保持同步更新,以便文档与代码一致。
##### 验收标准
1. THE Backend SHALL 更新 `apps/admin-web/README.md`,新增 AI 监控页面说明
2. THE Backend SHALL 更新 `apps/backend/README.md`,新增 admin AI API 说明
3. THE Backend SHALL 更新 `docs/DOCUMENTATION-MAP.md`,新增 P15 相关条目
4. THE Backend SHALL 更新 `docs/database/BD_Manual_ai_tables.md`,补充 admin API 相关的查询模式说明
5. THE Backend SHALL 确认 `docs/prd/ai-app-prompts.md` 中环境变量已全部更新P14 遗留确认)

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