6 Commits
test ... dev

Author SHA1 Message Date
Neo
075caf067f 小程序迁移 静态页面完成!!! 2026-03-18 05:14:35 +08:00
Neo
72bb11b34f 1 2026-03-15 10:15:02 +08:00
Neo
2dd217522c 微信小程序页面迁移校验之前 P5任务处理之前 2026-03-09 01:24:20 +08:00
Neo
6e20987d2f 微信小程序页面迁移校验之前 P5任务处理之前 2026-03-09 01:19:21 +08:00
Neo
263bf96035 微信小程序页面迁移校验之前 P5任务处理之前 2026-03-09 01:18:47 +08:00
Neo
b25308c3f4 feat: P1-P3 全栈集成 — 数据库基础 + DWS 扩展 + 小程序鉴权 + 工程化体系
## P1 数据库基础
- zqyy_app: 创建 auth/biz schema、FDW 连接 etl_feiqiu
- etl_feiqiu: 创建 app schema RLS 视图、商品库存预警表
- 清理 assistant_abolish 残留数据

## P2 ETL/DWS 扩展
- 新增 DWS 助教订单贡献度表 (dws.assistant_order_contribution)
- 新增 assistant_order_contribution_task 任务及 RLS 视图
- member_consumption 增加充值字段、assistant_daily 增加处罚字段
- 更新 ODS/DWD/DWS 任务文档及业务规则文档
- 更新 consistency_checker、flow_runner、task_registry 等核心模块

## P3 小程序鉴权系统
- 新增 xcx_auth 路由/schema(微信登录 + JWT)
- 新增 wechat/role/matching/application 服务层
- zqyy_app 鉴权表迁移 + 角色权限种子数据
- auth/dependencies.py 支持小程序 JWT 鉴权

## 文档与审计
- 新增 DOCUMENTATION-MAP 文档导航
- 新增 7 份 BD_Manual 数据库变更文档
- 更新 DDL 基线快照(etl_feiqiu 6 schema + zqyy_app auth)
- 新增全栈集成审计记录、部署检查清单更新
- 新增 BACKLOG 路线图、FDW→Core 迁移计划

## Kiro 工程化
- 新增 5 个 Spec(P1/P2/P3/全栈集成/核心业务)
- 新增审计自动化脚本(agent_on_stop/build_audit_context/compliance_prescan)
- 新增 6 个 Hook(合规检查/会话日志/提交审计等)
- 新增 doc-map steering 文件

## 运维与测试
- 新增 ops 脚本:迁移验证/API 健康检查/ETL 监控/集成报告
- 新增属性测试:test_dws_contribution / test_auth_system
- 清理过期 export 报告文件
- 更新 .gitignore 排除规则
2026-02-26 08:03:53 +08:00
1289 changed files with 171225 additions and 16281448 deletions

41
.env
View File

@@ -46,6 +46,11 @@ TEST_APP_DB_DSN=postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test
TIMEZONE=Asia/Shanghai TIMEZONE=Asia/Shanghai
LOG_LEVEL=INFO LOG_LEVEL=INFO
# ------------------------------------------------------------------------------
# 营业日切点(统计日/周/月分割小时,默认 8 即 08:00
# ------------------------------------------------------------------------------
BUSINESS_DAY_START_HOUR=8
# ============================================================================== # ==============================================================================
# 统一输出路径配置export/ 目录) # 统一输出路径配置export/ 目录)
# ============================================================================== # ==============================================================================
@@ -105,4 +110,40 @@ BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
# CHANGE 2026-02-23 | 从 PRD 文档迁移至 .env禁止在文档中明文存放 # CHANGE 2026-02-23 | 从 PRD 文档迁移至 .env禁止在文档中明文存放
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
BAILIAN_API_KEY=sk-6def29cab3474cc797e52b82a46a5dba BAILIAN_API_KEY=sk-6def29cab3474cc797e52b82a46a5dba
BAILIAN_MODEL=qwen-plus
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
BAILIAN_TEST_APP_ID=541edb3d5fcd4c18b13cbad81bb5fb9d BAILIAN_TEST_APP_ID=541edb3d5fcd4c18b13cbad81bb5fb9d
# CHANGE 2026-03-05 | 8 个百炼 AI 应用 ID从百炼平台获取2026-03-05 更新)
BAILIAN_APP_ID_1_CHAT=979dabe6f22a43989632b8c662cac97c
BAILIAN_APP_ID_2_FINANCE=1dcdb5f39c3040b6af8ef79215b9b051
BAILIAN_APP_ID_3_CLUE=708bf45439cd48c7ab9a514d03482890
BAILIAN_APP_ID_4_ANALYSIS=ea7b1c374f574b9a925a2fb5789a9b90
BAILIAN_APP_ID_5_TACTICS=46f54e6053df4bb0b83be29366025cf6
BAILIAN_APP_ID_6_NOTE=025bb344146b4e4e8be30c444adab3b4
BAILIAN_APP_ID_7_CUSTOMER=df35e06991b24d49971c03c6428a9c87
BAILIAN_APP_ID_8_CONSOLIDATE=407dfb89283b4196934eec5fefe3ebc2
# ------------------------------------------------------------------------------
# 微信小程序
# CHANGE 2026-02-27 | 开发模式启用 mock 登录,跳过微信 code2Session
# 生产环境需设置真实 WX_APPID / WX_SECRET 并关闭 WX_DEV_MODE
# ------------------------------------------------------------------------------
WX_APPID=wx7c07793d82732921
WX_SECRET=wx7c07793d82732921
WX_DEV_MODE=true
# ------------------------------------------------------------------------------
# 管道限流配置RateLimiter 请求间隔)
# CHANGE 2026-03-06 | 从默认 5-20s 降至 0.1-2sODS_PAYMENT 46请求从582s→~48s
# ------------------------------------------------------------------------------
PIPELINE_RATE_MIN=0.1
PIPELINE_RATE_MAX=2.0
# ------------------------------------------------------------------------------
# 后端运维面板路径配置
# CHANGE 2026-03-06 | 显式锁定,避免 __file__ 推算在不同部署环境指向错误路径
# ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY
ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe

View File

@@ -52,6 +52,14 @@ TEST_APP_DB_DSN=postgresql://user:password@host:5432/test_zqyy_app
TIMEZONE=Asia/Shanghai TIMEZONE=Asia/Shanghai
LOG_LEVEL=INFO LOG_LEVEL=INFO
# ------------------------------------------------------------------------------
# 营业日切点(统计日/周/月分割小时,默认 8 即 08:00
# 日统计 = 当日 08:00 ~ 次日 08:00
# 月统计 = 当月1日 08:00 ~ 次月1日 08:00
# 周统计 = 周一 08:00 ~ 次周一 08:00
# ------------------------------------------------------------------------------
BUSINESS_DAY_START_HOUR=8
# ============================================================================== # ==============================================================================
# 统一输出路径配置export/ 目录) # 统一输出路径配置export/ 目录)
# ============================================================================== # ==============================================================================
@@ -98,8 +106,30 @@ BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS
# 阿里云百炼 AI 配置 # 阿里云百炼 AI 配置
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
BAILIAN_API_KEY= BAILIAN_API_KEY=
BAILIAN_MODEL=qwen-plus
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
BAILIAN_TEST_APP_ID= BAILIAN_TEST_APP_ID=
# 8 个百炼 AI 应用 ID从百炼平台获取
# 应用 1通用对话 | 应用 2财务洞察 | 应用 3客户数据维客线索分析
# 应用 4关系分析/任务建议 | 应用 5话术参考 | 应用 6备注分析
# 应用 7客户分析 | 应用 8维客线索整理
BAILIAN_APP_ID_1_CHAT=
BAILIAN_APP_ID_2_FINANCE=
BAILIAN_APP_ID_3_CLUE=
BAILIAN_APP_ID_4_ANALYSIS=
BAILIAN_APP_ID_5_TACTICS=
BAILIAN_APP_ID_6_NOTE=
BAILIAN_APP_ID_7_CUSTOMER=
BAILIAN_APP_ID_8_CONSOLIDATE=
# ------------------------------------------------------------------------------
# 管道限流配置RateLimiter 请求间隔,秒)
# 默认 0.1-2.0s,防止上游风控同时避免过度等待
# ------------------------------------------------------------------------------
PIPELINE_RATE_MIN=0.1
PIPELINE_RATE_MAX=2.0
# ╔════════════════════════════════════════════════════════════════════════════╗ # ╔════════════════════════════════════════════════════════════════════════════╗
# ║ [ETL] apps/etl/connectors/feiqiu/.env — ETL 专属配置 ║ # ║ [ETL] apps/etl/connectors/feiqiu/.env — ETL 专属配置 ║
# ╚════════════════════════════════════════════════════════════════════════════╝ # ╚════════════════════════════════════════════════════════════════════════════╝
@@ -234,7 +264,7 @@ DWD_FACT_UPSERT=true
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 任务列表配置 # 任务列表配置
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
RUN_TASKS=PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,ASSISTANT_ABOLISH,LEDGER RUN_TASKS=PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,LEDGER
# RUN_DWS_TASKS= # RUN_DWS_TASKS=
# RUN_INDEX_TASKS= # RUN_INDEX_TASKS=
INDEX_LOOKBACK_DAYS=60 INDEX_LOOKBACK_DAYS=60
@@ -282,10 +312,12 @@ INDEX_LOOKBACK_DAYS=60
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 微信小程序配置 # 微信小程序配置
# 代码读取 WX_APPID / WX_SECRET注意无下划线分隔
# WX_DEV_MODE=true 时启用 mock 登录端点,跳过微信 code2Session
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# WX_CALLBACK_TOKEN= # WX_APPID=
# WX_APP_ID= # WX_SECRET=
# WX_APP_SECRET= # WX_DEV_MODE=false
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# CORS逗号分隔 # CORS逗号分隔
@@ -293,6 +325,19 @@ INDEX_LOOKBACK_DAYS=60
# CORS_ORIGINS=http://localhost:5173 # CORS_ORIGINS=http://localhost:5173
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ETL 项目路径(子进程 cwd,缺省按 monorepo 相对路径推算 # ETL 项目路径(子进程 cwd
# CHANGE 2026-03-06 | 必须显式设置,禁止依赖 __file__ 推算
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu ETL_PROJECT_PATH=C:/NeoZQYY/apps/etl/connectors/feiqiu
# ------------------------------------------------------------------------------
# ETL 子进程 Python 可执行路径
# CHANGE 2026-03-06 | 必须显式设置,避免 PATH 歧义
# ------------------------------------------------------------------------------
ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe
# ------------------------------------------------------------------------------
# 运维面板服务器根目录
# CHANGE 2026-03-06 | 必须显式设置,消除 __file__ 推算风险
# ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY

40
.gitignore vendored
View File

@@ -8,28 +8,32 @@ __pycache__/
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
pytest-cache-files-*/ pytest-cache-files-*/
# logs/ logs/
*.log *.log
*.jsonl *.jsonl
# ===== 审计文件 =====
docs/audit/
# ===== 运行时产出 ===== # ===== 运行时产出 =====
#export/ export/
#reports/ reports/
# scripts/logs/ scripts/logs/
# ===== 环境配置(保留模板) ===== # ===== 环境配置(保留模板) =====
# .env .env
# .env.local .env.local
# !.env.template !.env.template
# ===== Node ===== # ===== Node =====
node_modules/ node_modules/
# ===== Python 虚拟环境 ===== # ===== Python 虚拟环境 =====
# .venv/ .venv/
# venv/ venv/
# ENV/ ENV/
# env/ env/
# ===== Python 构建产物 ===== # ===== Python 构建产物 =====
.Python .Python
@@ -53,6 +57,9 @@ dist/
.coverage .coverage
htmlcov/ htmlcov/
# ===== 测试 =====
tests/
# ===== infra 敏感文件 ===== # ===== infra 敏感文件 =====
infra/**/*.key infra/**/*.key
infra/**/*.pem infra/**/*.pem
@@ -74,3 +81,14 @@ infra/**/*.secret
# ===== Kiro 运行时状态 ===== # ===== Kiro 运行时状态 =====
.kiro/.audit_state.json .kiro/.audit_state.json
.kiro/.last_prompt_id.json .kiro/.last_prompt_id.json
.kiro/.git_snapshot.json
.kiro/.file_baseline.json
.kiro/.compliance_state.json
.kiro/.audit_context.json
# ===== 运维脚本运行时状态 =====
scripts/ops/.monitor_token
# ===== Kiro Powers含敏感 DSN =====
powers/

View File

@@ -1,4 +0,0 @@
{
"prompt_id": "P20260223-225610",
"at": "2026-02-23T22:56:10.365734+08:00"
}

View File

@@ -4,41 +4,180 @@ description: Run post-change audit + docs sync for NeoZQYY Monorepo; write audit
tools: ["read", "write", "shell"] tools: ["read", "write", "shell"]
--- ---
你是专职"审计收口/后处理写入"子代理。你的执行必须尽量不依赖主对话上下文优先使用本地仓库事实git、文件内容、prompt_log完成审计落盘。 你是专职"审计收口/后处理写入"子代理。
## 核心原则:从预构建上下文工作,禁止全盘扫描
你的唯一输入是 `.kiro/state/.audit_context.json`(由 `build_audit_context.py` 预构建)。
该文件已包含所有你需要的信息:
| 字段 | 来源 | 内容 |
|------|------|------|
| `changed_files` | audit-flagger | 全部变更文件列表 |
| `high_risk_files` | audit-flagger | 高风险文件子集 |
| `reasons` | audit-flagger | 风险分类标签 |
| `high_risk_diff` | git diff | 高风险文件的 diff已截断 |
| `diff_stat` | git diff --stat | 变更统计摘要 |
| `compliance.code_without_docs` | compliance-prescan | 缺少文档同步的代码文件及其应更新的文档 |
| `compliance.new_migration_sql` | compliance-prescan | 新增迁移 SQL 列表 |
| `compliance.has_bd_manual` | compliance-prescan | 是否已有 BD_Manual 文档 |
| `compliance.has_ddl_baseline` | compliance-prescan | 是否已更新 DDL 基线 |
| `compliance.api_changed` | compliance-prescan | 是否有接口相关文件变更 |
| `compliance.openapi_spec_stale` | compliance-prescan | OpenAPI spec 是否需要重新导出 |
| `session_diff` | agent-on-stop (file baseline) | 本次对话期间的精确变更:`added`/`modified`/`deleted` |
| `prompt_id` / `latest_prompt_log` | prompt-audit-log | Prompt-ID 与原文(溯源用) |
**禁止操作**
- ❌ 运行 `git status --porcelain`(已有 `changed_files`
- ❌ 运行 `git diff` 全量(已有 `high_risk_diff` + `diff_stat`
- ❌ 遍历目录寻找变更文件(已有分类好的列表)
- ❌ 运行 `change_compliance_prescan.py`(已有 `compliance` 数据)
**允许操作**
- ✅ 读取具体文件内容(如需更新某个 README 时读取其当前内容)
- ✅ 对单个文件运行 `git diff HEAD -- <file>`(仅当 context 中 diff 被截断时)
- ✅ 连接测试库验证迁移执行状态(仅当 `new_migration_sql` 非空时)
## 审计产物路径(统一根目录) ## 审计产物路径(统一根目录)
- 变更审计记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md` - 变更审计记录:`docs/audit/changes/<YYYY-MM-DD>__<slug>.md`
- 审计一览表:`docs/audit/audit_dashboard.md`(自动生成,勿手动编辑) - 审计一览表:`docs/audit/audit_dashboard.md`(自动生成,勿手动编辑)
- Prompt 日志:`docs/audit/prompt_logs/` - Prompt 日志:`docs/audit/prompt_logs/`
- 一览表刷新命令:`python scripts/audit/gen_audit_dashboard.py` - 一览表刷新命令:`python scripts/audit/gen_audit_dashboard.py`
- 所有审计产物统一写入项目根目录 `docs/audit/`,不要写入子模块(如 `apps/etl/connectors/feiqiu/docs/audit/`内部 - 所有审计产物统一写入项目根目录 `docs/audit/`,不要写入子模块内部
## 输入来源(不要询问主代理)
- 通过 `git status --porcelain``git diff` 获取本次未提交变更
- 通过 `docs/audit/prompt_logs/` 目录下的独立日志文件与 `.kiro/.last_prompt_id.json` 获取最新 Prompt-ID 与 prompt 原文(用于溯源)
- 通过项目实际文件内容判断是否"逻辑改动"
## 何时需要做"重型后处理" ## 何时需要做"重型后处理"
满足任一即执行审计收口(否则只输出"无逻辑改动/无需审计",并清除待审计标记) 根据 `audit_context.json` 中的 `audit_required``reasons` 判断
- 改动文件命中 ETL Connector 高风险路径:`apps/etl/connectors/feiqiu/` 下的 `api/``cli/``config/``database/``loaders/``models/``orchestration/``scd/``tasks/``utils/``quality/` - `audit_required: true` → 执行完整审计流程
- 改动文件命中后端 API`apps/backend/app/` - `audit_required: false` → 输出"无需审计",清除标记,退出
- 改动文件命中管理后台源码:`apps/admin-web/src/`
- 改动文件命中小程序源码:`apps/miniprogram/miniapp/``apps/miniprogram/miniprogram/`
- 改动文件命中共享包:`packages/shared/`
- 改动文件命中数据库定义:`db/` 下的 DDL / migration / seed 文件
- 根目录散文件(`pyproject.toml``.env*` 等)
- 发生 DB schema / migration / *.sql / *.prisma 变更
- 明确属于业务口径/资金精度舍入/API 契约/鉴权权限/调度游标 等逻辑改变
## 执行策略(尽量少写、但必须完整 ## 执行策略(从 context 驱动,不做冗余扫描
1) 判断是否逻辑改动
2) 若是逻辑改动: ### 步骤 1读取上下文
- 按需调用 skill 读取 `.kiro/state/.audit_context.json`,提取关键字段。
- steering-readme-maintainer同步 product/tech/structure-lite/README
- change-annotation-audit写 docs/audit/changes/... + AI_CHANGELOG + CHANGE 注释) ### 步骤 1b读取 Session 索引
- bd-manual-db-docs仅当 DB schema 变更) 读取 `docs/audit/session_logs/_session_index.json`,按 `startTime` 找到与 `audit_context.json``prompt_at` 最接近的 entry`is_sub` 的主对话)。提取:
3) 完成后把 `.kiro/.audit_state.json``audit_required` 置为 false或清空 reasons/changed_files/last_reminded_at - `description`:作为审计记录的「操作摘要」(比从 diff 推断更准确、更完整
4) 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表 - `summary.files_modified` / `summary.files_created`:交叉验证 `session_diff`
- executionId 前 8 位:作为 `session_id` 写入审计记录,建立双向链接
- `summary.sub_agents`:记录本次对话调用了哪些子代理
- `summary.errors`:标注执行中的异常
若索引不存在或无匹配 entry跳过此步骤不影响后续流程。
### 步骤 2审计落盘按需调用 skill
根据 `reasons` 判断需要哪些 skill
-`dir:backend` / `dir:etl` / `dir:shared` 等 → 调用 `steering-readme-maintainer`
- 含任意高风险标签 → 调用 `change-annotation-audit`(写 docs/audit/changes/ + AI_CHANGELOG + CHANGE 注释)
-`db-schema-change` → 调用 `bd-manual-db-docs`,并执行 DB 文档全量对账(见步骤 2b
所有审计记录中涉及日期时间的字段,必须精确到秒(格式:`YYYY-MM-DD HH:MM:SS`,时区 Asia/Shanghai。包括但不限于审计记录头部的"日期"、AI_CHANGELOG 条目的时间戳、CHANGE 标记注释中的日期。
`session_diff` 中有 `added``deleted` 文件,在审计记录中增加「本次对话文件变更」段落,分别列出新增和删除的文件。
若步骤 1b 成功获取了 Session 信息,在审计记录头部元数据中增加:
- `session_id`executionId 前 8 位(如 `f29acdea`
- `操作摘要`Session 索引中的 `description`LLM 生成的操作摘要)
- `session_path`Session 日志文件的相对路径(`output_dir` 字段值)
审计记录头部模板:
```markdown
# 变更审计记录:<标题>
| 字段 | 值 |
|------|-----|
| 日期 | YYYY-MM-DD HH:MM:SS |
| Prompt-ID | <从 audit_context> |
| Session-ID | <executionId 前 8 位> |
| Session 路径 | <output_dir 相对路径> |
## 操作摘要
<Session 索引中的 description或从 diff 推断的摘要>
```
### 步骤 2bDB 文档全量对账(当 reasons 含 db-schema-change 时)
`reasons``db-schema-change` 时,除了调用 `bd-manual-db-docs` skill 处理本次变更外,还必须执行全量对账:
1. 连接测试库(使用 pg power 的 `pg-etl-test` / `pg-app-test`),查询 `information_schema.tables``information_schema.columns` 获取所有表和字段的实际结构
2. 扫描 `docs/database/` 下现有文档,逐表对比:
- 文档中缺失的表 → 新建表结构文档
- 文档中字段与实际不一致类型、nullable、默认值等→ 更新文档
- 文档中存在但数据库已删除的表 → 在文档中标注已废弃
3. 输出对账摘要到审计记录中,列出:新增文档数、更新文档数、废弃标注数
4. 所有文档输出到 `docs/database/`,遵循现有目录结构和模板格式
注意全量对账使用测试库TEST_DB_DSN禁止连接正式库。
### 步骤 3文档校对补齐
遍历 `compliance.code_without_docs`,对每个缺失项:
- 读取对应代码文件当前内容(不需要 diff直接读文件
- 更新对应文档:
| 代码路径前缀 | 应同步更新的文档 |
|---|---|
| `apps/backend/app/routers/` | `apps/backend/docs/API-REFERENCE.md` + `docs/contracts/openapi/backend-api.json` |
| `apps/backend/app/services/` | `apps/backend/docs/API-REFERENCE.md` + `apps/backend/README.md` |
| `apps/backend/app/auth/` | `apps/backend/docs/API-REFERENCE.md` + `apps/backend/README.md` + `docs/contracts/openapi/backend-api.json` |
| `apps/backend/app/schemas/` | `docs/contracts/openapi/backend-api.json` |
| `apps/etl/connectors/feiqiu/tasks/` | `apps/etl/connectors/feiqiu/docs/etl_tasks/` |
| `apps/etl/connectors/feiqiu/loaders/` | `apps/etl/connectors/feiqiu/docs/etl_tasks/` |
| `apps/etl/connectors/feiqiu/scd/` | `apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md` |
| `apps/etl/connectors/feiqiu/orchestration/` | `apps/etl/connectors/feiqiu/docs/architecture/` |
| `apps/admin-web/src/` | `apps/admin-web/README.md` |
| `apps/miniprogram/` | `apps/miniprogram/README.md` |
| `packages/shared/` | `packages/shared/README.md` |
| `db/*/migrations/*.sql` | `docs/database/BD_Manual_*.md` + `apps/etl/connectors/feiqiu/docs/database/` + `docs/database/ddl/` |
### 步骤 4DDL/迁移检查
-`compliance.new_migration_sql` 非空:
- 连接测试库验证迁移是否已执行
- 在审计记录中标注执行状态
-`compliance.new_migration_sql` 非空且 `compliance.has_ddl_baseline` 为 false
- 在审计记录中标注 ⚠️ DDL 基线待合并
### 步骤 4bOpenAPI Spec 同步检查
-`compliance.api_changed` 为 true 且 `compliance.openapi_spec_stale` 为 true
- 在审计记录中标注 ⚠️ 接口代码已变更但 OpenAPI spec 未同步
- 运行 `python scripts/ops/_export_openapi.py` 重新导出 spec需后端可导入
- 若导出失败(后端未启动等),在审计记录中标注待手动导出
- 导出成功后提醒用户重连 OpenAPI Power 的 MCP server 以加载新 spec
-`compliance.api_changed` 为 true 且 `compliance.openapi_spec_stale` 为 false
- spec 已同步更新,无需额外操作
### 步骤 5改动注解Change Annotations
对本次审计涉及的所有变更文件,在审计记录(`docs/audit/changes/<YYYY-MM-DD>__<slug>.md`)中生成逐文件的改动注解段落。
注解内容包括:
- 文件路径
- 变更类型(新增 / 修改 / 删除)
- 原始原因:为什么要做这个改动(从 `latest_prompt_log` 和 diff 上下文推断用户意图)
- 思路分析:改动的技术思路和设计决策(从 diff 内容和代码结构推断)
- 修改结果:改动后的效果和影响范围
格式模板(写入审计记录的 `## 改动注解` 段落):
```markdown
## 改动注解
### `<文件路径>`
- 变更类型:新增 / 修改 / 删除
- 原始原因:<从 prompt log 和 diff 推断的改动动机>
- 思路分析:<技术思路、设计决策、为什么选择这种实现方式>
- 修改结果:<改动后的效果、影响范围、与其他模块的关联>
```
执行规则:
- 只对 `high_risk_files``session_diff.added` 中的文件写详细注解
- 对非高风险的 `session_diff.modified` 文件写简要一行注解即可
-`session_diff.deleted` 文件只记录删除原因
- 注解内容从 `high_risk_diff``latest_prompt_log`、文件内容综合推断,不要编造
- 若某文件的 diff 被截断,可对该单个文件运行 `git diff HEAD -- <file>` 获取完整 diff
- 注解语言使用简体中文
### 步骤 6收尾
-`.kiro/state/.audit_state.json``audit_required` 置为 false清空 `reasons`/`changed_files`/`last_reminded_at`
- 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表
## 输出(强制极短回执) ## 输出(强制极短回执)
你最终只允许输出 3 段信息: 你最终只允许输出 3 段信息:

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "Agent On Stop (Merged)",
"description": "合并 hook对话结束时检测变更含非 Kiro 外部变更)、记录 session log、合规预扫描、构建审计上下文、审计提醒。无变更时跳过。纯 Shell零 Token。",
"version": "1",
"when": {
"type": "agentStop"
},
"then": {
"type": "runCommand",
"command": "python C:/NeoZQYY/.kiro/scripts/agent_on_stop.py",
"timeout": 360
},
"workspaceFolderName": "NeoZQYY",
"shortName": "agent-on-stop"
}

View File

@@ -1,15 +0,0 @@
{
"enabled": true,
"name": "Audit Flagger (Prompt Submit)",
"description": "每次提交 prompt 时,基于 git status 判断是否存在高风险改动;若需要审计则写入 .kiro/.audit_state.json无 stdout。",
"version": "1",
"when": {
"type": "promptSubmit"
},
"then": {
"type": "runCommand",
"command": "python .kiro/scripts/audit_flagger.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "audit-flagger"
}

View File

@@ -1,15 +0,0 @@
{
"enabled": true,
"name": "Audit Reminder (Agent Stop, 15min)",
"description": "若检测到高风险改动且未审计,则在 agentStop 以 stderr+非0 形式提醒15 分钟限频;不写 stdout。",
"version": "1",
"when": {
"type": "agentStop"
},
"then": {
"type": "runCommand",
"command": "python .kiro/scripts/audit_reminder.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "audit-reminder"
}

View File

@@ -0,0 +1,16 @@
{
"enabled": true,
"name": "CWD Guard for Shell",
"description": "在 AI 执行 shell 命令前,检查是否在运行 Python 脚本。如果是,提醒 AI 确认 cwd 是否正确(仓库根 C:\\NeoZQYY避免相对路径解析到错误位置。",
"version": "1",
"when": {
"type": "preToolUse",
"toolTypes": [
"shell"
]
},
"then": {
"type": "askAgent",
"prompt": "如果即将执行的命令包含 `python` 且涉及 scripts/ops/、.kiro/scripts/、apps/etl/connectors/feiqiu/scripts/ 下的脚本请确认1) cwd 参数是否设置为仓库根目录 C:\\NeoZQYY2) 脚本是否已有 ensure_repo_root() 校验。如果 cwd 不对且脚本无校验,请修正 cwd 后再执行。对于非 Python 命令或不涉及上述目录的命令,直接放行。"
}
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "每日经营数据报告",
"description": "手动触发后执行 daily_revenue_report.py统计 3月1日至当天的每日经营数据实收、充值、团购结算、到店人次、新会员、充值人数等输出到 docs/reports/daily-revenue-latest.md",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行 python C:\\NeoZQYY\\scripts\\ops\\daily_revenue_report.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "daily-revenue-report"
}

View File

@@ -1,15 +0,0 @@
{
"enabled": true,
"name": "Data Flow Structure Analysis",
"description": "手动触发数据流结构分析:先执行 Python 脚本采集 API JSON、DB 表结构、三层字段映射和 BD_manual 业务描述,再由报告生成器输出带锚点链接、业务描述、多示例值、白名单折叠和字段差异报告的 Markdown 文档。",
"version": "4.0.0",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行数据流结构分析,按以下步骤完成。若发现已完成或有历史任务痕迹则清空,重新执行:\n\n第一阶段数据采集\n1. 运行 `python scripts/ops/analyze_dataflow.py` 完成数据采集(如需指定日期范围,加 --date-from / --date-to 参数)\n2. 确认采集结果已落盘,包括:\n - json_trees/(含 samples 多示例值)\n - db_schemas/\n - field_mappings/(三层映射 + 锚点)\n - bd_descriptions/BD_manual 业务描述)\n - collection_manifest.json含 json_field_count、date_from、date_to\n\n第二阶段报告生成\n3. 运行 `python scripts/ops/gen_dataflow_report.py` 生成 Markdown 报告\n4. 报告包含以下增强内容:\n - 报告头含 API 请求日期范围date_from ~ date_to和 JSON 数据总量\n - 总览表含 API JSON 字段数列\n - 1.1 API↔ODS↔DWD 字段对比差异报告(白名单字段折叠汇总,不展开详细表格行)\n - 2.3 覆盖率表含业务描述列\n - API 源字段表含业务描述列 + 多示例值(枚举值解释)\n - ODS 表结构含业务描述列 + 上下游双向映射锚点链接\n - DWD 表结构含业务描述列 + ODS 来源锚点链接\n5. 输出文件路径和关键统计摘要\n\n白名单规则v4\n- ETL 元数据列source_file, source_endpoint, fetched_at, payload, content_hash\n- DWD 维表 SCD2 管理列valid_from, valid_to, is_current, etl_loaded_at, etl_batch_id\n- API siteProfile 嵌套对象字段\n- 白名单字段仍正常参与检查和统计,仅在报告中折叠显示并注明原因\n\n注意当前仅分析飞球feiqiu连接器。未来新增连接器时应自动发现并纳入分析范围。"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "dataflow-analyze"
}

View File

@@ -1,15 +0,0 @@
{
"enabled": true,
"name": "Manual: DB 文档全量同步",
"description": "按需触发:对比 Postgres 实际 schema 与 docs/database/ 下的文档,自动补全或更新缺失/过时的表结构说明,并输出变更摘要。",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行一次按需的数据库文档全量同步。\n\n步骤\n1) 检查当前 Postgres schema使用环境中可用的工具/命令,例如 pg_dump --schema-only 或查询 information_schema。\n2) 与 docs/database 下现有文档进行对比。\n3) 更新缺失或过时的 schema/表结构文档。\n4) 输出对账摘要:哪些文档被修改了、修改原因。输出路径遵循.env路径定义。\n\n注意如果需要执行 shell 命令,请通过 agent 的 shell 工具调用。"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "db-docs-sync"
}

View File

@@ -1,15 +0,0 @@
{
"enabled": true,
"name": "ETL Data Consistency Check",
"description": "手动触发 ETL 全链路数据一致性黑盒检查:获取最近一次成功的 ETL 任务,对 API→ODS→DWD→DWS/INDEX 逐表逐字段进行实际数据比对,输出详细的数据差异报告。",
"version": "1.0.0",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行 ETL 全链路数据一致性黑盒检查,按以下步骤完成,若发现已完成或有历史任务痕迹则清空,重新执行:\n\n1. 运行 `python scripts/ops/etl_consistency_check.py`\n2. 脚本会自动:\n a. 从 LOG_ROOT 找到最近一次成功的 ETL 日志,解析本次执行的任务列表\n b. 从 FETCH_ROOT 读取本次 ETL 落盘的 API JSON 文件\n c. 连接数据库PG_DSN对本次任务涉及的每张表逐字段比对\n - API JSON vs ODS字段完整性、值采样比对随机 5 条记录的关键字段)\n - ODS vs DWD字段映射正确性、值转换验证采样比对\n - DWD vs DWS/INDEX聚合逻辑验证行数、关键指标抽查\n d. 输出 Markdown 报告到 ETL_REPORT_ROOT\n3. 检查报告输出,汇总关键发现\n\n报告结构\n- 1. ETL 执行概览(任务列表、成功/失败/跳过统计)\n- 2. API↔ODS 数据一致性(逐表逐字段值比对)\n- 3. ODS↔DWD 数据一致性(映射验证 + 值采样)\n- 4. DWD↔DWS 数据一致性(聚合逻辑验证)\n- 5. 异常汇总与建议\n\n注意使用正式库 PG_DSN 连接(只读模式),不修改任何数据。"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "etl-data-consistency"
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "ETL FULL TEST",
"description": "一键执行 ETL 全流程前后端联调:启动服务 → Playwright 浏览器提交任务 → 实时监控 → 性能报告 → 黑盒一致性测试 → 服务清理。详细步骤参考 .kiro/specs/[ETL]-fullstack-integration/tasks.md",
"version": "1.1.0",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行 ETL 全栈联调运维任务。先读取 `.kiro/specs/[ETL]-fullstack-integration/tasks.md` 获取完整步骤细节,然后严格按以下 6 大步骤依次执行。全程使用 Playwright 浏览器模拟真实用户操作,不直接调用 API。\n\n## 步骤 1服务启动与健康检查\n- 用 controlPwshProcess 启动后端uvicorn app.main:app --host 0.0.0.0 --port 8000cwd=apps/backend/\n- 用 controlPwshProcess 启动前端pnpm devcwd=apps/admin-web/\n- 等待服务就绪,验证 http://localhost:8000/docs 和 http://localhost:5173 可访问\n- Playwright 打开 http://localhost:5173登录用户名 admin密码 admin123\n- 验证登录成功后跳转到任务配置页,侧边栏菜单正常渲染\n\n## 步骤 2浏览器操作 - 任务配置与提交\n- 在任务配置页(/)依次操作:\n - Flow 选择 api_fullAPI → ODS → DWD → DWS → INDEX\n - 处理模式选择 full_window\n - 时间窗口模式设为【自定义】,开始 2025-7-01结束为当前时间\n - 窗口切分【按天】,切分天数 30\n - 勾选 force_full强制全量\n - 任务选择区域全选 is_common=True 的常用任务(共 41 个)\n- 确认 CLI 命令预览区显示完整参数\n- 点击【直接执行】按钮SendOutlined 图标),触发 POST /api/execution/run\n- 确认提交成功提示,记录 execution_id\n\n## 步骤 3执行监控与 DEBUG\n- 导航到【任务管理】页面(/task-manager\n- 在【队列】Tab 确认任务状态为 running\n- 点击 running 任务行,打开 WebSocket 实时日志流抽屉\n- 按需以 30秒~20分钟 弹性间隔检查页面状态\n- 检测日志中的 ERROR / CRITICAL / Traceback / Exception / WARNING 关键字\n- 连续 20 分钟无新日志输出则报超时警告\n- 任务完成success/failed/cancelled时停止监控\n- 收集所有 ERROR 和 WARNING 日志行及上下文,分析错误类型\n- 如果任务失败切换到【历史】Tab 查看完整执行详情\n\n## 步骤 4性能计时与报告生成\n- 在【历史】Tab 点击已完成任务查看执行详情\n- 通过 GET /api/execution/{id}/logs 获取完整日志\n- 从日志提取每个窗口切片30天的开始/结束时间,计算耗时\n- 识别 ODS / DWD / DWS / INDEX 各阶段耗时,标注 Top-5 瓶颈\n- 生成综合联调报告到 {SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md\n- 报告包含执行概要、性能报告各切片耗时对比、Top-5、DEBUG 报告\n\n## 步骤 5黑盒数据一致性测试\n- 运行全链路检查器uv run python scripts/ops/etl_consistency_check.pycwd=C:\\\\NeoZQYY\n - 脚本自动从 LOG_ROOT 找最近 ETL 日志,从 FETCH_ROOT 读 API JSON\n - 连接数据库PG_DSN逐表逐字段比对API vs ODS、ODS vs DWD、DWD vs DWS\n - 白名单ETL_META_COLS、SCD2_COLS 排除API 空字符串 vs DB None 视为等价\n - 报告输出到 ETL_REPORT_ROOT\n- 检查 FlowRunner 内置一致性报告ETL_REPORT_ROOT 下已自动生成)\n- 对比两份报告结论是否一致\n- 将黑盒测试结果摘要追加到步骤 4 的综合报告中(通过/失败统计、白名单差异、失败表清单)\n\n## 步骤 6服务清理\n- 关闭 Playwright 浏览器实例\n- 停止 uvicorn 后端进程controlPwshProcess stop\n- 停止 pnpm dev 前端进程controlPwshProcess stop\n- 报告联调完成状态\n\n## 环境与规范要求\n- 环境变量从根 .env 加载load_dotenv缺失必须报错禁止静默回退\n- 数据库使用测试库PG_DSN 指向 test_etl_feiqiu\n- 报告路径遵循 export-paths 规范,从环境变量读取\n- 需要的环境变量PG_DSN、FETCH_ROOT、LOG_ROOT、ETL_REPORT_ROOT、SYSTEM_LOG_ROOT"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "etl-fullstack-integration"
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "ETL Unified Analysis",
"description": "手动触发 ETL 统一分析:合并数据流结构分析和数据一致性检查为一个流程。支持 --mode structure|consistency|full默认 full支持 --source api|etl-log默认 api 主动采集最近 60 天)。",
"version": "1.0.0",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行 ETL 统一分析,按以下步骤完成。若发现已完成或有历史任务痕迹则清空,重新执行:\n\n运行 `python scripts/ops/etl_unified_analysis.py`\n\n默认行为full 模式):\n1. 第一阶段:数据流结构分析\n - 运行 analyze_dataflow.py 采集 API JSON、DB 表结构、三层字段映射、BD_manual 业务描述(默认最近 60 天)\n - 运行 gen_dataflow_report.py 生成结构分析报告\n2. 第二阶段ETL 数据一致性检查\n - 运行 etl_consistency_check.py 对 API→ODS→DWD→DWS 逐表逐字段比对\n - 每张表展示数据截止日期create_time/createtime/fetched_at 的 MAX 值)\n3. 第三阶段:报告合并\n - 将两份报告合并为一份统一报告,输出到 ETL_REPORT_ROOT\n\n可选参数\n- `--mode structure` 仅执行结构分析\n- `--mode consistency` 仅执行一致性检查\n- `--source etl-log` 切换为读 ETL 落盘 JSON而非主动调 API\n- `--date-from YYYY-MM-DD` 指定起始日期\n- `--date-to YYYY-MM-DD` 指定截止日期\n- `--limit N` 每端点最大记录数\n- `--tables t1,t2` 指定分析的表\n\n白名单规则继承 v5\n- ETL 元数据列source_file, source_endpoint, fetched_at, payload, content_hash\n- DWD 维表 SCD2 管理列valid_from, valid_to, is_current, etl_loaded_at, etl_batch_id\n- API siteProfile 嵌套对象字段\n- 时间格式等价:同一时刻的不同格式表示视为内容相同\n- 白名单字段仍正常参与检查和统计,仅在报告中折叠显示并注明原因\n\n注意\n- 当前仅分析飞球feiqiu连接器\n- 数据库使用测试库TEST_DB_DSN只读模式"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "etl-unified-analysis"
}

View File

@@ -0,0 +1,14 @@
{
"enabled": true,
"name": "字段消失扫描",
"description": "手动触发 DWD 表字段消失扫描检测字段值从某天起突然全部为空的异常≥3天且≥20条连续空记录。输出终端报告 + CSV。",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "runCommand",
"command": "python scripts/ops/field_disappearance_scan.py",
"timeout": 300
}
}

View File

@@ -0,0 +1,13 @@
{
"enabled": true,
"name": "H5 原型截图",
"description": "手动触发:启动 HTTP 服务器 → 运行 screenshot_h5_pages.py 批量截取 docs/h5_ui/pages/ 下所有 H5 原型页面iPhone 15 Pro Max, 430×932, DPR:3输出到 docs/h5_ui/screenshots/。完成后关闭服务器。",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "执行 H5 原型页面批量截图流程:\n1. 启动 HTTP 服务器:`python -m http.server 8765 --directory docs/h5_ui/pages`(用 controlPwshProcess 后台启动cwd 为 C:\\NeoZQYY\n2. 等待 2 秒确认服务器就绪\n3. 运行截图脚本:`python C:\\NeoZQYY\\scripts\\ops\\screenshot_h5_pages.py`cwd 为 C:\\NeoZQYYtimeout 180s\n4. 检查输出:列出 docs/h5_ui/screenshots/*.png 的文件名和大小,确认数量和关键交互态截图大小合理\n5. 停止 HTTP 服务器controlPwshProcess stop\n6. 简要汇报结果:总截图数、像素尺寸验证(应为 1290×N、异常文件如有"
}
}

View File

@@ -0,0 +1,16 @@
{
"enabled": false,
"name": "Pre-Change Research Guard",
"description": "在写操作执行前检查:是否已完成逻辑改动前置调研(审计历史、文档阅读、上下文摘要)。若未完成则阻止写入,先完成调研流程。",
"version": "1",
"when": {
"type": "preToolUse",
"toolTypes": [
"write"
]
},
"then": {
"type": "askAgent",
"prompt": "你即将执行写操作。请确认:\n\n1. 本次写操作是否涉及逻辑改动ETL/业务规则/API/数据模型/前端交互)?\n2. 如果涉及逻辑改动,你是否已通过 context-gatherer 子代理完成前置调研,并向用户输出了上下文摘要且获得确认?\n\n若属于例外情况纯格式/注释/文档纯文字/配置文件/.kiro 目录/用户明确跳过/新建不涉及已有逻辑),可直接继续。\n若未完成前置调研必须先停止写操作使用 context-gatherer 子代理完成调研流程后再继续。"
}
}

View File

@@ -1,15 +0,0 @@
{
"enabled": true,
"name": "Prompt Audit Log (Shell)",
"description": "每次提交 prompt 时,用本地 Shell 在 docs/audit/prompt_logs/ 生成独立日志文件(按时间戳命名);不触发 LLM避免上下文膨胀。",
"version": "3",
"when": {
"type": "promptSubmit"
},
"then": {
"type": "runCommand",
"command": "python .kiro/scripts/prompt_audit_log.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "prompt-audit-log"
}

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "Prompt On Submit (Merged)",
"description": "合并 hook每次提交 prompt 时执行风险标记 + prompt 日志记录 + git 快照。纯 Shell零 Token。",
"version": "1",
"when": {
"type": "promptSubmit"
},
"then": {
"type": "runCommand",
"command": "python C:/NeoZQYY/.kiro/scripts/prompt_on_submit.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "prompt-on-submit"
}

View File

@@ -1,14 +1,14 @@
{ {
"enabled": true, "enabled": true,
"name": "Manual: Run /audit (via audit-writer subagent)", "name": "Manual: Run /audit (via audit-writer subagent)",
"description": "按需触发:启动 audit-writer 子代理执行变更影响审查+文档同步+审计落盘,完成后自动刷新审计一览表,并仅回传极短回执。", "description": "按需触发:读取 agent-on-stop 预构建的审计上下文 + Session 索引,启动 audit-writer 子代理执行审计落盘+文档校对+DB文档全量对账+Session关联。上下文过期时自动重建。",
"version": "2", "version": "11",
"when": { "when": {
"type": "userTriggered" "type": "userTriggered"
}, },
"then": { "then": {
"type": "askAgent", "type": "askAgent",
"prompt": "立刻启动名为 audit-writer 的子代理来执行「后处理写入/审计收口」流程。\n\n约束\n- 子代理自行使用 git status/diff 与 .kiro/.last_prompt_id.json 中最新 Prompt-ID 作为溯源;不要依赖主对话上下文。\n- 子代理必须按需调用 skillsteering-readme-maintainer、change-annotation-audit、bd-manual-db-docs仅在满足触发条件时。\n- 所有审计产物统一写入项目根目录 docs/audit/(变更记录写 docs/audit/changes/prompt 日志写 docs/audit/prompt_logs/,不写入子模块内部。\n- 子代理结束后,必须把 .kiro/.audit_state.json 中 audit_required 置为 false(或清空文件),以停止后续提醒。\n- 审计落盘完成后,必须执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表docs/audit/audit_dashboard.md。\n- 你的最终回复必须是「极短回执」,只包含:\n 1) 是否完成yes/no\n 2) 写了哪些文件(文件列表)\n 3) 如果失败下一步怎么做1~2 条)" "prompt": "执行 /audit 审计流程:\n\n**第零步:获取当前时间**:运行 `python -c \"from datetime import datetime, timezone, timedelta; print(datetime.now(timezone(timedelta(hours=8))).isoformat())\"` 获取当前北京时间,记为 `now`。后续所有「超过 30 分钟」的判断以此 `now` 为基准。\n\n**前置检查**:读取 `.kiro/state/.audit_context.json`,检查 `built_at` 时间戳。若文件不存在或 `built_at` 距 `now` 超过 30 分钟,先运行 `python .kiro/scripts/agent_on_stop.py --force-rebuild` 重建上下文,再重新读取。\n\n**Session 索引读取**:读取 `docs/audit/session_logs/_session_index.json`,找到与本次对话时间最接近的 entry按 `startTime` 匹配),提取其 `description`LLM 操作摘要)和 `summary`(结构化摘要)。这些信息将用于:\n- 作为审计记录头部的「操作摘要」来源(比从 diff 推断更准确)\n- 交叉验证 audit_context.json 中的 session_difffiles_modified/created\n- 记录本次审计关联的 session executionId建立双向链接\n\n**主流程**:启动名为 audit-writer 的子代理,传入以下指令:\n\n> 读取 `.kiro/state/.audit_context.json` 作为主输入,同时参考 Session 索引中匹配的 entry。不要自行运行 git status/diff/扫描文件。audit_context.json 已包含:变更文件列表、高风险文件 diff、合规检查清单文档缺失/迁移状态/DDL 基线/接口变更/OpenAPI spec 状态、本次对话精确变更session_diff: added/modified/deleted、Prompt-ID 溯源。按 audit-writer.md 中定义的执行策略完成审计落盘+文档校对补齐。\n\n约束\n- 子代理禁止重复运行 git status --porcelain 或 git diff 全量扫描,所有信息已在 .audit_context.json 中预备好。\n- 子代理需要读取具体文件内容时(如更新文档),可以直接读取对应文件,但不要做全仓库遍历。\n- 子代理必须按需调用 skillsteering-readme-maintainer、change-annotation-audit、bd-manual-db-docs仅在满足触发条件时。\n- 子代理必须根据 compliance.code_without_docs 自动补齐缺失的文档同步。\n- 当 reasons 含 db-schema-change 时,子代理必须执行 DB 文档全量对账连接测试库TEST_DB_DSN查询 information_schema与 docs/database/ 下现有文档全量对比,补全或更新所有缺失/过时的表结构说明(不仅限于本次变更涉及的表),输出对账摘要。\n- 子代理应参考 session_diff 中的 added/modified/deleted 列表,精确定位本次对话的变更范围。\n- **Session 关联**在审计记录docs/audit/changes/*.md头部增加 `session_id` 字段executionId 前 8 位),并将 Session 索引中的 description 作为「操作摘要」写入审计记录。这建立了审计记录 ↔ Session 日志的双向链接。\n- 子代理必须为所有变更文件生成改动注解(步骤 5写入审计记录的「改动注解」段落包含变更类型、原始原因、思路分析、修改结果。高风险文件写详细注解普通修改写简要一行删除文件只记录原因。\n- 若 compliance.api_changed=true 且 compliance.openapi_spec_stale=true运行 `python scripts/ops/_export_openapi.py` 重新导出 OpenAPI spec导出失败则在审计记录标注待手动导出导出成功则提醒用户重连 OpenAPI Power MCP server。\n- 所有审计产物统一写入 docs/audit/,不写入子模块内部。\n- 完成后把 .kiro/state/.audit_state.json 中 audit_required 置为 false。\n- 执行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表。\n- **文档地图更新**:审计完成后,自动更新 `docs/DOCUMENTATION-MAP.md`\n - 检查本次审计涉及的文档变更(从审计记录中识别)\n - 扫描 `docs/` 目录和各模块内部文档的变化(新增、修改、删除)\n - 特别关注数据库文档(`docs/database/`)是否有新增的 BD_Manual 文件\n - 根据发现的文档变更,更新文档地图中的相应条目\n - 确保文档地图的结构完整,所有重要文档都有记录\n- 最终回复必须是极短回执done/files_written/next_step。"
}, },
"workspaceFolderName": "NeoZQYY", "workspaceFolderName": "NeoZQYY",
"shortName": "audit" "shortName": "audit"

View File

@@ -0,0 +1,15 @@
{
"enabled": true,
"name": "Session description maker",
"description": "手动触发:为缺少 description 的 session log 调用百炼千问 API 生成摘要写入双索引。askAgent 模式可看到实时输出。",
"version": "1",
"when": {
"type": "userTriggered"
},
"then": {
"type": "askAgent",
"prompt": "请在后台运行以下命令并展示实时输出python -B C:/NeoZQYY/scripts/ops/batch_generate_summaries.py"
},
"workspaceFolderName": "NeoZQYY",
"shortName": "session-summary"
}

View File

@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
"""cwd 校验工具 — .kiro/scripts/ 下所有脚本共享。
用法:
from _ensure_root import ensure_repo_root
ensure_repo_root()
委托给 neozqyy_shared.repo_root共享包未安装时 fallback。
"""
from __future__ import annotations
import os
import warnings
from pathlib import Path
def ensure_repo_root() -> Path:
"""校验 cwd 是否为仓库根目录,不是则自动切换。"""
try:
from neozqyy_shared.repo_root import ensure_repo_root as _shared
return _shared()
except ImportError:
pass
# fallback
cwd = Path.cwd()
if (cwd / "pyproject.toml").is_file() and (cwd / ".kiro").is_dir():
return cwd
root = Path(__file__).resolve().parents[2]
if (root / "pyproject.toml").is_file() and (root / ".kiro").is_dir():
os.chdir(root)
warnings.warn(
f"cwd 不是仓库根目录,已自动切换: {cwd}{root}",
stacklevel=2,
)
return root
raise RuntimeError(
f"无法定位仓库根目录。当前 cwd={cwd},推断 root={root}"
f"请在仓库根目录下运行脚本。"
)

View File

@@ -0,0 +1,650 @@
#!/usr/bin/env python3
"""agent_on_stop — agentStop 合并 hook 脚本v3含 LLM 摘要生成)。
合并原 audit_reminder + change_compliance_prescan + build_audit_context + session_extract
1. 全量会话记录提取 → docs/audit/session_logs/(无论是否有代码变更)
2. 为刚提取的 session 调用百炼 API 生成 description → 写入双索引
3. 扫描工作区 → 与 promptSubmit 基线对比 → 精确检测本次对话变更
4. 若无任何文件变更 → 跳过审查,静默退出
5. 合规预扫描 → .kiro/state/.compliance_state.json
6. 构建审计上下文 → .kiro/state/.audit_context.json
7. 审计提醒15 分钟限频)→ stderr
变更检测基于文件 mtime+size 基线对比,不依赖 git commit 历史。
所有功能块用 try/except 隔离,单个失败不影响其他。
"""
import hashlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
# 同目录导入文件基线模块 + cwd 校验
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from file_baseline import scan_workspace, load_baseline, diff_baselines, total_changes
from _ensure_root import ensure_repo_root
TZ_TAIPEI = timezone(timedelta(hours=8))
MIN_INTERVAL = timedelta(minutes=15)
# 路径常量
STATE_PATH = os.path.join(".kiro", "state", ".audit_state.json")
COMPLIANCE_PATH = os.path.join(".kiro", "state", ".compliance_state.json")
CONTEXT_PATH = os.path.join(".kiro", "state", ".audit_context.json")
PROMPT_ID_PATH = os.path.join(".kiro", "state", ".last_prompt_id.json")
# 噪声路径(用于过滤变更列表中的非业务文件)
NOISE_PATTERNS = [
re.compile(r"^docs/audit/"),
re.compile(r"^\.kiro/"),
re.compile(r"^\.hypothesis/"),
re.compile(r"^tmp/"),
re.compile(r"\.png$"),
re.compile(r"\.jpg$"),
]
# 高风险路径
HIGH_RISK_PATTERNS = [
re.compile(r"^apps/etl/connectors/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"),
re.compile(r"^apps/backend/app/"),
re.compile(r"^apps/admin-web/src/"),
re.compile(r"^apps/miniprogram/"),
re.compile(r"^packages/shared/"),
re.compile(r"^db/"),
]
# 文档映射(合规检查用)
DOC_MAP = {
"apps/backend/app/routers/": ["apps/backend/docs/API-REFERENCE.md", "docs/contracts/openapi/backend-api.json"],
"apps/backend/app/services/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/backend/app/auth/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md", "docs/contracts/openapi/backend-api.json"],
"apps/backend/app/schemas/": ["docs/contracts/openapi/backend-api.json"],
"apps/backend/app/main.py": ["docs/contracts/openapi/backend-api.json"],
"apps/etl/connectors/feiqiu/tasks/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/loaders/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/scd/": ["apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md"],
"apps/etl/connectors/feiqiu/orchestration/": ["apps/etl/connectors/feiqiu/docs/architecture/"],
"apps/admin-web/src/": ["apps/admin-web/README.md"],
"apps/miniprogram/": ["apps/miniprogram/README.md"],
"packages/shared/": ["packages/shared/README.md"],
}
# 接口变更检测模式routers / auth / schemas / main.py
API_CHANGE_PATTERNS = [
re.compile(r"^apps/backend/app/routers/"),
re.compile(r"^apps/backend/app/auth/"),
re.compile(r"^apps/backend/app/schemas/"),
re.compile(r"^apps/backend/app/main\.py$"),
]
MIGRATION_PATTERNS = [
re.compile(r"^db/etl_feiqiu/migrations/.*\.sql$"),
re.compile(r"^db/zqyy_app/migrations/.*\.sql$"),
re.compile(r"^db/fdw/.*\.sql$"),
]
BD_MANUAL_PATTERN = re.compile(r"^docs/database/BD_Manual_.*\.md$")
DDL_BASELINE_DIR = "docs/database/ddl/"
AUDIT_CHANGES_DIR = "docs/audit/changes/"
def now_taipei():
return datetime.now(TZ_TAIPEI)
def sha1hex(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()
def is_noise(f: str) -> bool:
return any(p.search(f) for p in NOISE_PATTERNS)
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def write_json(path, data):
os.makedirs(os.path.dirname(path) or os.path.join(".kiro", "state"), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def git_diff_stat():
try:
r = subprocess.run(
["git", "diff", "--stat", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def git_diff_files(files, max_total=30000, max_per_file=15000):
"""获取文件的实际 diff 内容。对已跟踪文件用 git diff HEAD对新文件直接读取内容。"""
if not files:
return ""
all_diff = []
total_len = 0
for f in files:
if total_len >= max_total:
all_diff.append(f"\n[TRUNCATED: diff exceeds {max_total // 1000}KB]")
break
try:
# 先尝试 git diff HEAD
r = subprocess.run(
["git", "diff", "HEAD", "--", f],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
chunk = ""
if r.returncode == 0 and r.stdout.strip():
chunk = r.stdout.strip()
elif os.path.isfile(f):
# untracked 新文件:直接读取内容作为 diff
try:
with open(f, "r", encoding="utf-8", errors="replace") as fh:
file_content = fh.read(max_per_file + 100)
chunk = f"--- /dev/null\n+++ b/{f}\n@@ -0,0 +1 @@\n" + file_content
except Exception:
continue
if chunk:
if len(chunk) > max_per_file:
chunk = chunk[:max_per_file] + f"\n[TRUNCATED: {f} diff too long]"
all_diff.append(chunk)
total_len += len(chunk)
except Exception:
continue
return "\n".join(all_diff)
def get_latest_prompt_log():
log_dir = os.path.join("docs", "audit", "prompt_logs")
if not os.path.isdir(log_dir):
return ""
try:
files = sorted(
[f for f in os.listdir(log_dir) if f.startswith("prompt_log_")],
reverse=True
)
if not files:
return ""
with open(os.path.join(log_dir, files[0]), "r", encoding="utf-8") as f:
content = f.read()
return content[:3000] + "\n[TRUNCATED]" if len(content) > 3000 else content
except Exception:
return ""
# ── 步骤 1基于文件基线检测变更 ──
def detect_changes_via_baseline():
"""扫描当前工作区,与 promptSubmit 基线对比,返回精确的变更列表。
返回 (all_changed_files, external_files, diff_result, no_change)
- all_changed_files: 本次对话期间所有变更文件added + modified
- external_files: 暂时等于 all_changed_files后续可通过 Kiro 写入日志细化)
- diff_result: 完整的 diff 结果 {added, modified, deleted}
- no_change: 是否无任何变更
"""
before = load_baseline()
after = scan_workspace(".")
if not before:
# 没有基线(首次运行或基线丢失),无法对比,回退到全部文件
return [], [], {"added": [], "modified": [], "deleted": []}, True
diff = diff_baselines(before, after)
count = total_changes(diff)
if count == 0:
return [], [], diff, True
# 所有变更文件 = added + modifieddeleted 的文件已不存在,不参与风险判定)
all_changed = sorted(set(diff["added"] + diff["modified"]))
# 过滤噪声
real_files = [f for f in all_changed if not is_noise(f)]
if not real_files:
return [], [], diff, True
# 外部变更:目前所有基线检测到的变更都记录,
# 因为 Kiro 的写入也会改变 mtime所以这里的"外部"含义是
# "本次对话期间发生的所有变更",包括 Kiro 和非 Kiro 的。
# 精确区分需要 Kiro 运行时提供写入文件列表,目前不可用。
external_files = [] # 不再误报外部变更
return real_files, external_files, diff, False
# ── 步骤 3合规预扫描 ──
def do_compliance_prescan(all_files):
result = {
"new_migration_sql": [],
"new_or_modified_sql": [],
"code_without_docs": [],
"new_files": [],
"has_bd_manual": False,
"has_audit_record": False,
"has_ddl_baseline": False,
"api_changed": False,
"openapi_spec_stale": False,
}
code_files = []
doc_files = set()
for f in all_files:
if is_noise(f):
continue
for mp in MIGRATION_PATTERNS:
if mp.search(f):
result["new_migration_sql"].append(f)
break
if f.endswith(".sql"):
result["new_or_modified_sql"].append(f)
if BD_MANUAL_PATTERN.search(f):
result["has_bd_manual"] = True
if f.startswith(AUDIT_CHANGES_DIR):
result["has_audit_record"] = True
if f.startswith(DDL_BASELINE_DIR):
result["has_ddl_baseline"] = True
if f.endswith(".md") or "/docs/" in f:
doc_files.add(f)
if f.endswith((".py", ".ts", ".tsx", ".js", ".jsx")):
code_files.append(f)
# 检测接口相关文件变更
for ap in API_CHANGE_PATTERNS:
if ap.search(f):
result["api_changed"] = True
break
# 接口变更但 openapi spec 未同步更新 → 标记过期
if result["api_changed"] and "docs/contracts/openapi/backend-api.json" not in all_files:
result["openapi_spec_stale"] = True
for cf in code_files:
expected_docs = []
for prefix, docs in DOC_MAP.items():
if cf.startswith(prefix):
expected_docs.extend(docs)
if expected_docs:
has_doc = False
for ed in expected_docs:
if ed in doc_files:
has_doc = True
break
if ed.endswith("/") and any(d.startswith(ed) for d in doc_files):
has_doc = True
break
if not has_doc:
result["code_without_docs"].append({
"file": cf,
"expected_docs": expected_docs,
})
needs_check = bool(
result["new_migration_sql"]
or result["code_without_docs"]
or result["openapi_spec_stale"]
)
now = now_taipei()
write_json(COMPLIANCE_PATH, {
"needs_check": needs_check,
"scanned_at": now.isoformat(),
**result,
})
return result
# ── 步骤 4构建审计上下文 ──
def do_build_audit_context(all_files, diff_result, compliance):
now = now_taipei()
audit_state = safe_read_json(STATE_PATH)
prompt_info = safe_read_json(PROMPT_ID_PATH)
# 使用 audit_state 中的 changed_files来自 git status 的风险文件)
# 与本次对话的 baseline diff 合并
git_changed = audit_state.get("changed_files", [])
session_changed = all_files # 本次对话期间变更的文件
# 合并两个来源,去重
all_changed = sorted(set(git_changed + session_changed))
high_risk_files = [
f for f in all_changed
if any(p.search(f) for p in HIGH_RISK_PATTERNS)
]
diff_stat = git_diff_stat()
high_risk_diff = git_diff_files(high_risk_files)
prompt_log = get_latest_prompt_log()
context = {
"built_at": now.isoformat(),
"prompt_id": prompt_info.get("prompt_id", "unknown"),
"prompt_at": prompt_info.get("at", ""),
"audit_required": audit_state.get("audit_required", False),
"db_docs_required": audit_state.get("db_docs_required", False),
"reasons": audit_state.get("reasons", []),
"changed_files": all_changed[:100],
"high_risk_files": high_risk_files,
"session_diff": {
"added": diff_result.get("added", [])[:50],
"modified": diff_result.get("modified", [])[:50],
"deleted": diff_result.get("deleted", [])[:50],
},
"compliance": {
"code_without_docs": compliance.get("code_without_docs", []),
"new_migration_sql": compliance.get("new_migration_sql", []),
"has_bd_manual": compliance.get("has_bd_manual", False),
"has_audit_record": compliance.get("has_audit_record", False),
"has_ddl_baseline": compliance.get("has_ddl_baseline", False),
"api_changed": compliance.get("api_changed", False),
"openapi_spec_stale": compliance.get("openapi_spec_stale", False),
},
"diff_stat": diff_stat,
"high_risk_diff": high_risk_diff,
"latest_prompt_log": prompt_log,
}
write_json(CONTEXT_PATH, context)
# ── 步骤 5审计提醒15 分钟限频) ──
def do_audit_reminder(real_files):
state = safe_read_json(STATE_PATH)
if not state.get("audit_required"):
return
# 无变更时不提醒
if not real_files:
return
now = now_taipei()
last_str = state.get("last_reminded_at")
if last_str:
try:
last = datetime.fromisoformat(last_str)
if (now - last) < MIN_INTERVAL:
return
except Exception:
pass
state["last_reminded_at"] = now.isoformat()
write_json(STATE_PATH, state)
reasons = state.get("reasons", [])
reason_text = ", ".join(reasons) if reasons else "high-risk paths changed"
# 仅信息性提醒exit(0) 避免 agent 将其视为错误并自行执行审计
# 审计留痕统一由用户手动触发 /audit 完成
sys.stderr.write(
f"[AUDIT REMINDER] Pending audit ({reason_text}), "
f"{len(real_files)} files changed this session. "
f"Run /audit to sync. (15min rate limit)\n"
)
sys.exit(0)
# ── 步骤 6全量会话记录提取 ──
def do_full_session_extract():
"""从 Kiro globalStorage 提取当前 execution 的全量对话记录。
调用 scripts/ops/extract_kiro_session.py 的核心逻辑。
仅提取最新一条未索引的 execution避免重复。
"""
# 动态导入提取器(避免启动时 import 开销)
scripts_ops = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "scripts", "ops")
scripts_ops = os.path.normpath(scripts_ops)
if scripts_ops not in sys.path:
sys.path.insert(0, scripts_ops)
try:
from extract_kiro_session import extract_latest
except ImportError:
return # 提取器不存在则静默跳过
# globalStorage 路径:从环境变量或默认位置
global_storage = os.environ.get(
"KIRO_GLOBAL_STORAGE",
os.path.join(os.environ.get("APPDATA", ""), "Kiro", "User", "globalStorage")
)
workspace_path = os.getcwd()
extract_latest(global_storage, workspace_path)
def _extract_summary_content(md_content: str) -> str:
"""从 session log markdown 中提取适合生成摘要的内容。
策略:如果"用户输入"包含 CONTEXT TRANSFER跨轮续接
则替换为简短标注,避免历史背景干扰本轮摘要生成。
"""
import re
# 检测用户输入中是否包含 context transfer
ct_pattern = re.compile(r"## 2\. 用户输入\s*\n```\s*\n.*?CONTEXT TRANSFER", re.DOTALL)
if ct_pattern.search(md_content):
# 替换"用户输入"section 为简短标注
# 匹配从 "## 2. 用户输入" 到下一个 "## 3." 之间的内容
md_content = re.sub(
r"(## 2\. 用户输入)\s*\n```[\s\S]*?```\s*\n(?=## 3\.)",
r"\1\n\n[本轮为 Context Transfer 续接,用户输入为历史多轮摘要,已省略。请基于执行摘要和对话记录中的实际工具调用判断本轮工作。]\n\n",
md_content,
)
return md_content
# ── 步骤 7为最新 session 生成 LLM 摘要 ──
_SUMMARY_SYSTEM_PROMPT = """你是一个专业的技术对话分析师。你的任务是为 AI 编程助手的一轮执行execution生成简洁的中文摘要。
背景一个对话chatSession包含多轮执行execution。每轮执行 = 用户发一条消息 → AI 完成响应。你收到的是单轮执行的完整记录。
摘要规则:
1. 只描述本轮执行实际完成的工作,不要描述历史背景
2. 列出完成的功能点/任务(一轮可能完成多个)
3. 包含关键技术细节文件路径、模块名、数据库表、API 端点等
4. bug 修复要说明原因和方案
5. 不写过程性描述("用户说..."),只写结果
6. 内容太短或无实质内容的,写"无实质内容"
7. 不限字数,信息完整优先,避免截断失真
重要:
- "执行摘要"(📋)是最可靠的信息源,优先基于它判断本轮做了什么
- 如果"用户输入"包含 CONTEXT TRANSFER那是之前多轮的历史摘要不是本轮工作
- 对话记录中的实际工具调用和文件变更才是本轮的真实操作
请直接输出摘要,不要添加任何前缀或解释。"""
def do_generate_description():
"""为缺少 description 的主对话 entry 调用百炼 API 生成摘要,写入双索引。"""
from dotenv import load_dotenv
load_dotenv()
api_key = os.environ.get("BAILIAN_API_KEY", "")
if not api_key:
return
model = os.environ.get("BAILIAN_MODEL", "qwen-plus")
base_url = os.environ.get("BAILIAN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
scripts_ops = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "scripts", "ops")
scripts_ops = os.path.normpath(scripts_ops)
if scripts_ops not in sys.path:
sys.path.insert(0, scripts_ops)
try:
from extract_kiro_session import load_index, save_index, load_full_index, save_full_index
except ImportError:
return
index = load_index()
entries = index.get("entries", {})
if not entries:
return
# 收集所有缺少 description 的主对话 entry
targets = []
for eid, ent in entries.items():
if ent.get("is_sub"):
continue
if not ent.get("description"):
targets.append((eid, ent))
if not targets:
return
# agent_on_stop 场景下限制处理数量,避免超时
# 批量处理积压用独立脚本 batch_generate_summaries.py
MAX_PER_RUN = 10
if len(targets) > MAX_PER_RUN:
# 优先处理最新的(按 startTime 降序)
targets.sort(key=lambda t: t[1].get("startTime", ""), reverse=True)
targets = targets[:MAX_PER_RUN]
try:
from openai import OpenAI
client = OpenAI(api_key=api_key, base_url=base_url)
except Exception:
return
full_index = load_full_index()
full_entries = full_index.get("entries", {})
generated = 0
for target_eid, target_entry in targets:
out_dir = target_entry.get("output_dir", "")
if not out_dir or not os.path.isdir(out_dir):
continue
# 找到该 entry 对应的 main_*.md 文件
main_files = sorted(
f for f in os.listdir(out_dir)
if f.startswith("main_") and f.endswith(".md")
and target_eid[:8] in f # 按 executionId 短码匹配
)
if not main_files:
# 回退:取目录下所有 main 文件
main_files = sorted(
f for f in os.listdir(out_dir)
if f.startswith("main_") and f.endswith(".md")
)
if not main_files:
continue
content_parts = []
for mf in main_files:
try:
with open(os.path.join(out_dir, mf), "r", encoding="utf-8") as fh:
content_parts.append(fh.read())
except Exception:
continue
if not content_parts:
continue
content = "\n\n---\n\n".join(content_parts)
content = _extract_summary_content(content)
if len(content) > 60000:
content = content[:60000] + "\n\n[TRUNCATED]"
try:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": _SUMMARY_SYSTEM_PROMPT},
{"role": "user", "content": f"请为以下单轮执行记录生成摘要:\n\n{content}"},
],
max_tokens=4096,
)
description = resp.choices[0].message.content.strip()
except Exception:
continue # 单条失败不影响其他
if not description:
continue
# 写入双索引(内存中)
entries[target_eid]["description"] = description
if target_eid in full_entries:
full_entries[target_eid]["description"] = description
generated += 1
# 批量保存
if generated > 0:
save_index(index)
save_full_index(full_index)
def main():
ensure_repo_root()
now = now_taipei()
force_rebuild = "--force-rebuild" in sys.argv
# 全量会话记录提取(无论是否有文件变更,每次对话都要记录)
try:
do_full_session_extract()
except Exception:
pass
# 步骤 1基于文件基线检测变更
real_files, external_files, diff_result, no_change = detect_changes_via_baseline()
# 无任何文件变更 → 跳过所有审查(除非 --force-rebuild
if no_change and not force_rebuild:
return
# --force-rebuild 且无变更时,仍需基于 git status 重建 context
if no_change and force_rebuild:
try:
compliance = do_compliance_prescan(real_files or [])
except Exception:
compliance = {}
try:
do_build_audit_context(real_files or [], diff_result, compliance)
except Exception:
pass
return
# 步骤 2合规预扫描基于本次对话变更的文件
compliance = {}
try:
compliance = do_compliance_prescan(real_files)
except Exception:
pass
# 步骤 4构建审计上下文
try:
do_build_audit_context(real_files, diff_result, compliance)
except Exception:
pass
# 步骤 7审计提醒信息性exit(0),不触发 agent 自行审计)
try:
do_audit_reminder(real_files)
except SystemExit:
pass # exit(0) 信息性退出,不需要 re-raise
except Exception:
pass
if __name__ == "__main__":
try:
main()
except SystemExit as e:
sys.exit(e.code)
except Exception:
pass

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""audit_flagger — 判断 git 工作区是否存在高风险改动,写入 .kiro/.audit_state.json """audit_flagger — 判断 git 工作区是否存在高风险改动,写入 .kiro/state/.audit_state.json
替代原 PowerShell 版本,避免 Windows PowerShell 5.1 解析器 bug。 替代原 PowerShell 版本,避免 Windows PowerShell 5.1 解析器 bug。
""" """
@@ -37,7 +37,7 @@ DB_PATTERNS = [
re.compile(r"\.prisma$"), re.compile(r"\.prisma$"),
] ]
STATE_PATH = os.path.join(".kiro", ".audit_state.json") STATE_PATH = os.path.join(".kiro", "state", ".audit_state.json")
def now_taipei(): def now_taipei():
@@ -78,7 +78,7 @@ def is_noise(f: str) -> bool:
def write_state(state: dict): def write_state(state: dict):
os.makedirs(".kiro", exist_ok=True) os.makedirs(os.path.join(".kiro", "state"), exist_ok=True)
with open(STATE_PATH, "w", encoding="utf-8") as fh: with open(STATE_PATH, "w", encoding="utf-8") as fh:
json.dump(state, fh, indent=2, ensure_ascii=False) json.dump(state, fh, indent=2, ensure_ascii=False)

View File

@@ -11,7 +11,7 @@ import sys
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8)) TZ_TAIPEI = timezone(timedelta(hours=8))
STATE_PATH = os.path.join(".kiro", ".audit_state.json") STATE_PATH = os.path.join(".kiro", "state", ".audit_state.json")
MIN_INTERVAL = timedelta(minutes=15) MIN_INTERVAL = timedelta(minutes=15)
@@ -30,7 +30,7 @@ def load_state():
def save_state(state): def save_state(state):
os.makedirs(".kiro", exist_ok=True) os.makedirs(os.path.join(".kiro", "state"), exist_ok=True)
with open(STATE_PATH, "w", encoding="utf-8") as f: with open(STATE_PATH, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False) json.dump(state, f, indent=2, ensure_ascii=False)

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""build_audit_context — 合并所有前置 hook 产出,生成统一审计上下文快照。
读取:
- .kiro/state/.audit_state.jsonaudit-flagger 产出:风险判定、变更文件列表)
- .kiro/state/.compliance_state.jsonchange-compliance 产出:文档缺失、迁移状态)
- .kiro/state/.last_prompt_id.jsonprompt-audit-log 产出Prompt ID 溯源)
- git diff --stat HEAD变更统计摘要
- git diff HEAD仅高风险文件的 diff截断到合理长度
输出:.kiro/state/.audit_context.jsonaudit-writer 子代理的唯一输入)
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
CONTEXT_PATH = os.path.join(".kiro", "state", ".audit_context.json")
# 高风险路径(只对这些文件取 diff避免 diff 过大)
HIGH_RISK_PATTERNS = [
re.compile(r"^apps/etl/connectors/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"),
re.compile(r"^apps/backend/app/"),
re.compile(r"^apps/admin-web/src/"),
re.compile(r"^apps/miniprogram/"),
re.compile(r"^packages/shared/"),
re.compile(r"^db/"),
]
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def git_diff_stat():
try:
r = subprocess.run(
["git", "diff", "--stat", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def git_diff_files(files, max_total=30000):
"""获取指定文件的 git diff截断到 max_total 字符"""
if not files:
return ""
# 分批取 diff避免命令行过长
all_diff = []
total_len = 0
for f in files:
if total_len >= max_total:
all_diff.append(f"\n[TRUNCATED: diff exceeds {max_total // 1000}KB limit]")
break
try:
r = subprocess.run(
["git", "diff", "HEAD", "--", f],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
if r.returncode == 0 and r.stdout.strip():
chunk = r.stdout.strip()
# 单文件 diff 截断
if len(chunk) > 5000:
chunk = chunk[:5000] + f"\n[TRUNCATED: {f} diff too long]"
all_diff.append(chunk)
total_len += len(chunk)
except Exception:
continue
return "\n".join(all_diff)
def get_latest_prompt_log():
"""获取最新的 prompt log 文件内容(用于溯源)"""
log_dir = os.path.join("docs", "audit", "prompt_logs")
if not os.path.isdir(log_dir):
return ""
try:
files = sorted(
[f for f in os.listdir(log_dir) if f.startswith("prompt_log_")],
reverse=True
)
if not files:
return ""
latest = os.path.join(log_dir, files[0])
with open(latest, "r", encoding="utf-8") as f:
content = f.read()
# 截断过长内容
if len(content) > 3000:
content = content[:3000] + "\n[TRUNCATED]"
return content
except Exception:
return ""
def main():
now = datetime.now(TZ_TAIPEI)
# 读取前置 hook 产出
audit_state = safe_read_json(os.path.join(".kiro", "state", ".audit_state.json"))
compliance = safe_read_json(os.path.join(".kiro", "state", ".compliance_state.json"))
prompt_id_info = safe_read_json(os.path.join(".kiro", "state", ".last_prompt_id.json"))
# 从 audit_state 提取高风险文件
changed_files = audit_state.get("changed_files", [])
high_risk_files = [
f for f in changed_files
if any(p.search(f) for p in HIGH_RISK_PATTERNS)
]
# 获取 diff仅高风险文件
diff_stat = git_diff_stat()
high_risk_diff = git_diff_files(high_risk_files)
# 获取最新 prompt log
prompt_log = get_latest_prompt_log()
# 构建统一上下文
context = {
"built_at": now.isoformat(),
"prompt_id": prompt_id_info.get("prompt_id", "unknown"),
"prompt_at": prompt_id_info.get("at", ""),
# 来自 audit-flagger
"audit_required": audit_state.get("audit_required", False),
"db_docs_required": audit_state.get("db_docs_required", False),
"reasons": audit_state.get("reasons", []),
"changed_files": changed_files,
"high_risk_files": high_risk_files,
# 来自 change-compliance-prescan
"compliance": {
"code_without_docs": compliance.get("code_without_docs", []),
"new_migration_sql": compliance.get("new_migration_sql", []),
"has_bd_manual": compliance.get("has_bd_manual", False),
"has_audit_record": compliance.get("has_audit_record", False),
"has_ddl_baseline": compliance.get("has_ddl_baseline", False),
},
# git 摘要
"diff_stat": diff_stat,
"high_risk_diff": high_risk_diff,
# prompt 溯源
"latest_prompt_log": prompt_log,
}
os.makedirs(os.path.join(".kiro", "state"), exist_ok=True)
with open(CONTEXT_PATH, "w", encoding="utf-8") as f:
json.dump(context, f, indent=2, ensure_ascii=False)
# 输出摘要到 stdout
print(f"audit_context built: {len(changed_files)} files, "
f"{len(high_risk_files)} high-risk, "
f"{len(compliance.get('code_without_docs', []))} docs missing")
if __name__ == "__main__":
try:
main()
except Exception as e:
sys.stderr.write(f"build_audit_context failed: {e}\n")
sys.exit(1)

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""change_compliance_prescan — 预扫描变更文件,输出需要合规审查的项目。
在 agentStop 时由 askAgent hook 调用,为 LLM 提供精简的审查清单,
避免 LLM 自行扫描文件浪费 Token。
输出到 stdout供 askAgent 读取):
- 若无需审查:输出 "NO_CHECK_NEEDED"
- 若需审查:输出结构化 JSON 清单
"""
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
TZ_TAIPEI = timezone(timedelta(hours=8))
STATE_PATH = os.path.join(".kiro", "state", ".audit_state.json")
# doc-map 中定义的文档对应关系
DOC_MAP = {
# 代码路径前缀 → 应同步更新的文档
"apps/backend/app/routers/": ["apps/backend/docs/API-REFERENCE.md"],
"apps/backend/app/services/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/backend/app/auth/": ["apps/backend/docs/API-REFERENCE.md", "apps/backend/README.md"],
"apps/etl/connectors/feiqiu/tasks/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/loaders/": ["apps/etl/connectors/feiqiu/docs/etl_tasks/"],
"apps/etl/connectors/feiqiu/scd/": ["apps/etl/connectors/feiqiu/docs/business-rules/scd2_rules.md"],
"apps/etl/connectors/feiqiu/orchestration/": ["apps/etl/connectors/feiqiu/docs/architecture/"],
"apps/admin-web/src/": ["apps/admin-web/README.md"],
"apps/miniprogram/": ["apps/miniprogram/README.md"],
"packages/shared/": ["packages/shared/README.md"],
}
# DDL 基线文件doc-map 中定义)
DDL_BASELINE_DIR = "docs/database/ddl/"
# 迁移脚本路径
MIGRATION_PATTERNS = [
re.compile(r"^db/etl_feiqiu/migrations/.*\.sql$"),
re.compile(r"^db/zqyy_app/migrations/.*\.sql$"),
re.compile(r"^db/fdw/.*\.sql$"),
]
# DB 文档路径
BD_MANUAL_PATTERN = re.compile(r"^docs/database/BD_Manual_.*\.md$")
# 审计记录路径
AUDIT_CHANGES_DIR = "docs/audit/changes/"
# 噪声路径(不参与合规检查)
NOISE = [
re.compile(r"^docs/audit/"),
re.compile(r"^\.kiro/"),
re.compile(r"^\.hypothesis/"),
re.compile(r"^tmp/"),
re.compile(r"\.png$"),
re.compile(r"\.jpg$"),
]
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def get_changed_files():
"""从 audit_state 或 git status 获取变更文件"""
state = safe_read_json(STATE_PATH)
files = state.get("changed_files", [])
if files:
return files
# 回退到 git status
try:
r = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, timeout=10
)
if r.returncode != 0:
return []
result = []
for line in r.stdout.splitlines():
if len(line) < 4:
continue
path = line[3:].strip().strip('"').replace("\\", "/")
if " -> " in path:
path = path.split(" -> ")[-1]
if path:
result.append(path)
return sorted(set(result))
except Exception:
return []
def is_noise(f):
return any(p.search(f) for p in NOISE)
def classify_files(files):
"""将变更文件分类,输出审查清单"""
result = {
"new_migration_sql": [], # 新增的迁移 SQL
"new_or_modified_sql": [], # 所有 SQL 变更
"code_without_docs": [], # 有代码改动但缺少对应文档改动
"new_files": [], # 新增文件(需检查目录规范)
"has_bd_manual": False, # 是否有 BD_Manual 文档变更
"has_audit_record": False, # 是否有审计记录变更
"has_ddl_baseline": False, # 是否有 DDL 基线变更
}
code_files = []
doc_files = set()
for f in files:
if is_noise(f):
continue
# 迁移 SQL
for mp in MIGRATION_PATTERNS:
if mp.search(f):
result["new_migration_sql"].append(f)
break
# SQL 文件
if f.endswith(".sql"):
result["new_or_modified_sql"].append(f)
# BD_Manual
if BD_MANUAL_PATTERN.search(f):
result["has_bd_manual"] = True
# 审计记录
if f.startswith(AUDIT_CHANGES_DIR):
result["has_audit_record"] = True
# DDL 基线
if f.startswith(DDL_BASELINE_DIR):
result["has_ddl_baseline"] = True
# 文档文件
if f.endswith(".md") or "/docs/" in f:
doc_files.add(f)
# 代码文件(非文档、非配置)
if f.endswith((".py", ".ts", ".tsx", ".js", ".jsx")):
code_files.append(f)
# 检查代码文件是否有对应文档变更
for cf in code_files:
expected_docs = []
for prefix, docs in DOC_MAP.items():
if cf.startswith(prefix):
expected_docs.extend(docs)
if expected_docs:
# 检查是否有任一对应文档在变更列表中
has_doc = False
for ed in expected_docs:
if ed in doc_files:
has_doc = True
break
# 目录级匹配
if ed.endswith("/"):
if any(d.startswith(ed) for d in doc_files):
has_doc = True
break
if not has_doc:
result["code_without_docs"].append({
"file": cf,
"expected_docs": expected_docs,
})
return result
COMPLIANCE_STATE_PATH = os.path.join(".kiro", "state", ".compliance_state.json")
def save_compliance_state(result, needs_check):
"""持久化合规检查结果,供 audit-writer 子代理读取"""
os.makedirs(os.path.join(".kiro", "state"), exist_ok=True)
now = datetime.now(TZ_TAIPEI)
state = {
"needs_check": needs_check,
"scanned_at": now.isoformat(),
**result,
}
with open(COMPLIANCE_STATE_PATH, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, ensure_ascii=False)
def main():
files = get_changed_files()
if not files:
save_compliance_state({"new_migration_sql": [], "new_or_modified_sql": [],
"code_without_docs": [], "new_files": [],
"has_bd_manual": False, "has_audit_record": False,
"has_ddl_baseline": False}, False)
print("NO_CHECK_NEEDED")
return
# 过滤噪声
real_files = [f for f in files if not is_noise(f)]
if not real_files:
save_compliance_state({"new_migration_sql": [], "new_or_modified_sql": [],
"code_without_docs": [], "new_files": [],
"has_bd_manual": False, "has_audit_record": False,
"has_ddl_baseline": False}, False)
print("NO_CHECK_NEEDED")
return
result = classify_files(files)
# 判断是否需要审查
needs_check = (
result["new_migration_sql"]
or result["code_without_docs"]
or (result["new_migration_sql"] and not result["has_ddl_baseline"])
)
# 始终持久化结果
save_compliance_state(result, needs_check)
if not needs_check:
print("NO_CHECK_NEEDED")
return
# 输出精简 JSON 供 LLM 审查
print(json.dumps(result, indent=2, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except Exception as e:
# 出错时不阻塞,输出无需检查
print("NO_CHECK_NEEDED")

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""file_baseline — 基于文件 mtime+size 的独立基线快照系统。
不依赖 git commit 历史,通过扫描工作区文件的 (mtime, size) 指纹,
在 promptSubmit 和 agentStop 之间精确检测"本次对话期间"的文件变更。
用法:
from file_baseline import scan_workspace, diff_baselines, save_baseline, load_baseline
"""
import json
import os
import re
from typing import TypedDict
BASELINE_PATH = os.path.join(".kiro", "state", ".file_baseline.json")
# 扫描时排除的目录(与 .gitignore 对齐 + 额外排除)
EXCLUDE_DIRS = {
".git", ".venv", "venv", "ENV", "env",
"node_modules", "__pycache__", ".hypothesis", ".pytest_cache",
".idea", ".vscode", ".specstory",
"build", "dist", "eggs", ".eggs",
"export", "reports", "tmp",
"htmlcov", ".coverage",
# Kiro 运行时状态不参与业务变更检测
".kiro",
}
# 扫描时排除的文件后缀
EXCLUDE_SUFFIXES = {
".pyc", ".pyo", ".pyd", ".so", ".egg", ".whl",
".log", ".jsonl", ".lnk",
".swp", ".swo",
}
# 扫描时排除的文件名模式
EXCLUDE_NAMES = {
".DS_Store", "Thumbs.db", "desktop.ini",
}
# 业务目录白名单(只扫描这些顶层目录 + 根目录散文件)
# 这样可以避免扫描 .vite/deps 等深层缓存目录
SCAN_ROOTS = [
"apps",
"packages",
"db",
"docs",
"scripts",
"tests",
]
class FileEntry(TypedDict):
mtime: float
size: int
class DiffResult(TypedDict):
added: list[str]
modified: list[str]
deleted: list[str]
def _should_exclude_dir(dirname: str) -> bool:
"""判断目录是否应排除"""
return dirname in EXCLUDE_DIRS or dirname.startswith(".")
def _should_exclude_file(filename: str) -> bool:
"""判断文件是否应排除"""
if filename in EXCLUDE_NAMES:
return True
_, ext = os.path.splitext(filename)
if ext.lower() in EXCLUDE_SUFFIXES:
return True
return False
def scan_workspace(root: str = ".") -> dict[str, FileEntry]:
"""扫描工作区,返回 {相对路径: {mtime, size}} 字典。
只扫描 SCAN_ROOTS 中的目录 + 根目录下的散文件,
跳过 EXCLUDE_DIRS / EXCLUDE_SUFFIXES / EXCLUDE_NAMES。
"""
result: dict[str, FileEntry] = {}
# 1. 根目录散文件pyproject.toml, .env 等)
try:
for entry in os.scandir(root):
if entry.is_file(follow_symlinks=False):
if _should_exclude_file(entry.name):
continue
try:
st = entry.stat(follow_symlinks=False)
rel = entry.name.replace("\\", "/")
result[rel] = {"mtime": st.st_mtime, "size": st.st_size}
except OSError:
continue
except OSError:
pass
# 2. 业务目录递归扫描
for scan_root in SCAN_ROOTS:
top = os.path.join(root, scan_root)
if not os.path.isdir(top):
continue
for dirpath, dirnames, filenames in os.walk(top):
# 原地修改 dirnames 以跳过排除目录
dirnames[:] = [
d for d in dirnames
if not _should_exclude_dir(d)
]
for fname in filenames:
if _should_exclude_file(fname):
continue
full = os.path.join(dirpath, fname)
try:
st = os.stat(full)
rel = os.path.relpath(full, root).replace("\\", "/")
result[rel] = {"mtime": st.st_mtime, "size": st.st_size}
except OSError:
continue
return result
def diff_baselines(
before: dict[str, FileEntry],
after: dict[str, FileEntry],
) -> DiffResult:
"""对比两次快照,返回 added/modified/deleted 列表。"""
before_keys = set(before.keys())
after_keys = set(after.keys())
added = sorted(after_keys - before_keys)
deleted = sorted(before_keys - after_keys)
modified = []
for path in sorted(before_keys & after_keys):
b = before[path]
a = after[path]
# mtime 或 size 任一变化即视为修改
if b["mtime"] != a["mtime"] or b["size"] != a["size"]:
modified.append(path)
return {"added": added, "modified": modified, "deleted": deleted}
def save_baseline(data: dict[str, FileEntry], path: str = BASELINE_PATH):
"""保存基线快照到 JSON 文件。"""
os.makedirs(os.path.dirname(path) or ".kiro", exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False)
def load_baseline(path: str = BASELINE_PATH) -> dict[str, FileEntry]:
"""加载基线快照,文件不存在返回空字典。"""
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def total_changes(diff: DiffResult) -> int:
"""变更文件总数"""
return len(diff["added"]) + len(diff["modified"]) + len(diff["deleted"])

View File

@@ -46,9 +46,9 @@ def main():
f.write(entry) f.write(entry)
# 保存 last prompt id 供 /audit 溯源 # 保存 last prompt id 供 /audit 溯源
os.makedirs(".kiro", exist_ok=True) os.makedirs(os.path.join(".kiro", "state"), exist_ok=True)
last_prompt = {"prompt_id": prompt_id, "at": now.isoformat()} last_prompt = {"prompt_id": prompt_id, "at": now.isoformat()}
with open(os.path.join(".kiro", ".last_prompt_id.json"), "w", encoding="utf-8") as f: with open(os.path.join(".kiro", "state", ".last_prompt_id.json"), "w", encoding="utf-8") as f:
json.dump(last_prompt, f, indent=2, ensure_ascii=False) json.dump(last_prompt, f, indent=2, ensure_ascii=False)

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""prompt_on_submit — promptSubmit 合并 hook 脚本v2文件基线模式
合并原 audit_flagger + prompt_audit_log 的功能:
1. 扫描工作区文件 → 保存基线快照 → .kiro/state/.file_baseline.json
2. 基于基线文件列表做风险判定 → .kiro/state/.audit_state.json
3. 记录 prompt 日志 → docs/audit/prompt_logs/
变更检测不再依赖 git status解决不常 commit 导致的误判问题)。
风险判定仍基于 git status因为需要知道哪些文件相对于 commit 有变化)。
所有功能块用 try/except 隔离,单个失败不影响其他。
"""
import hashlib
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timezone, timedelta
# 同目录导入文件基线模块 + cwd 校验
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from file_baseline import scan_workspace, save_baseline
from _ensure_root import ensure_repo_root
TZ_TAIPEI = timezone(timedelta(hours=8))
# ── 风险规则(来自 audit_flagger ──
RISK_RULES = [
(re.compile(r"^apps/etl/connectors/feiqiu/(api|cli|config|database|loaders|models|orchestration|scd|tasks|utils|quality)/"), "etl"),
(re.compile(r"^apps/backend/app/"), "backend"),
(re.compile(r"^apps/admin-web/src/"), "admin-web"),
(re.compile(r"^apps/miniprogram/(miniapp|miniprogram)/"), "miniprogram"),
(re.compile(r"^packages/shared/"), "shared"),
(re.compile(r"^db/"), "db"),
]
NOISE_PATTERNS = [
re.compile(r"^docs/audit/"),
re.compile(r"^\.kiro/"),
re.compile(r"^tmp/"),
re.compile(r"^\.hypothesis/"),
]
DB_PATTERNS = [
re.compile(r"^db/"),
re.compile(r"/migrations/"),
re.compile(r"\.sql$"),
re.compile(r"\.prisma$"),
]
STATE_PATH = os.path.join(".kiro", "state", ".audit_state.json")
PROMPT_ID_PATH = os.path.join(".kiro", "state", ".last_prompt_id.json")
def now_taipei():
return datetime.now(TZ_TAIPEI)
def sha1hex(s: str) -> str:
return hashlib.sha1(s.encode("utf-8")).hexdigest()
def get_git_changed_files() -> list[str]:
"""通过 git status 获取变更文件(仅用于风险判定,不用于变更检测)"""
try:
r = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10
)
if r.returncode != 0:
return []
except Exception:
return []
files = []
for line in r.stdout.splitlines():
if len(line) < 4:
continue
path = line[3:].strip()
if " -> " in path:
path = path.split(" -> ")[-1]
path = path.strip().strip('"').replace("\\", "/")
if path:
files.append(path)
return files
def is_noise(f: str) -> bool:
return any(p.search(f) for p in NOISE_PATTERNS)
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def write_json(path, data):
os.makedirs(os.path.dirname(path) or os.path.join(".kiro", "state"), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
# ── 功能块 1风险标记基于 git status判定哪些文件需要审计 ──
def do_audit_flag(git_files, now):
files = sorted(set(f for f in git_files if not is_noise(f)))
if not files:
write_json(STATE_PATH, {
"audit_required": False,
"db_docs_required": False,
"reasons": [],
"changed_files": [],
"change_fingerprint": "",
"marked_at": now.isoformat(),
"last_reminded_at": None,
})
return
reasons = []
audit_required = False
db_docs_required = False
for f in files:
for pattern, label in RISK_RULES:
if pattern.search(f):
audit_required = True
tag = f"dir:{label}"
if tag not in reasons:
reasons.append(tag)
if "/" not in f:
audit_required = True
if "root-file" not in reasons:
reasons.append("root-file")
if any(p.search(f) for p in DB_PATTERNS):
db_docs_required = True
if "db-schema-change" not in reasons:
reasons.append("db-schema-change")
fp = sha1hex("\n".join(files))
# 保留已有 last_reminded_at
last_reminded = None
existing = safe_read_json(STATE_PATH)
if existing.get("change_fingerprint") == fp:
last_reminded = existing.get("last_reminded_at")
write_json(STATE_PATH, {
"audit_required": audit_required,
"db_docs_required": db_docs_required,
"reasons": reasons,
"changed_files": files[:50],
"change_fingerprint": fp,
"marked_at": now.isoformat(),
"last_reminded_at": last_reminded,
})
# ── 功能块 2Prompt 日志 ──
def do_prompt_log(now):
prompt_id = f"P{now.strftime('%Y%m%d-%H%M%S')}"
prompt_raw = os.environ.get("USER_PROMPT", "")
if len(prompt_raw) > 20000:
prompt_raw = prompt_raw[:5000] + "\n[TRUNCATED: prompt too long]"
summary = " ".join(prompt_raw.split()).strip()
if len(summary) > 120:
summary = summary[:120] + ""
if not summary:
summary = "(empty prompt)"
log_dir = os.path.join("docs", "audit", "prompt_logs")
os.makedirs(log_dir, exist_ok=True)
filename = f"prompt_log_{now.strftime('%Y%m%d_%H%M%S')}.md"
entry = f"""- [{prompt_id}] {now.strftime('%Y-%m-%d %H:%M:%S %z')}
- summary: {summary}
- prompt:
```text
{prompt_raw}
```
"""
with open(os.path.join(log_dir, filename), "w", encoding="utf-8") as f:
f.write(entry)
write_json(PROMPT_ID_PATH, {"prompt_id": prompt_id, "at": now.isoformat()})
# ── 功能块 3文件基线快照替代 git snapshot ──
def do_file_baseline():
"""扫描工作区文件 mtime+size保存为基线快照。
agentStop 时再扫一次对比,即可精确检测本次对话期间的变更。
"""
baseline = scan_workspace(".")
save_baseline(baseline)
def main():
ensure_repo_root()
now = now_taipei()
# 功能块 3文件基线快照最先执行记录对话开始时的文件状态
try:
do_file_baseline()
except Exception:
pass
# 功能块 1风险标记仍用 git status因为需要知道未提交的变更
try:
git_files = get_git_changed_files()
do_audit_flag(git_files, now)
except Exception:
pass
# 功能块 2Prompt 日志
try:
do_prompt_log(now)
except Exception:
pass
if __name__ == "__main__":
try:
main()
except Exception:
pass

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""session_log — agentStop 时记录本次对话的完整日志。
收集来源:
- 环境变量 AGENT_OUTPUTKiro 注入的 agent 输出)
- 环境变量 USER_PROMPT最近一次用户输入
- .kiro/state/.last_prompt_id.jsonPrompt ID 溯源)
- .kiro/state/.audit_state.json变更文件列表
- git diff --stat变更统计
输出docs/audit/session_logs/session_<timestamp>.md
"""
import json
import os
import subprocess
import sys
from datetime import datetime, timezone, timedelta
# cwd 校验
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from _ensure_root import ensure_repo_root
TZ_TAIPEI = timezone(timedelta(hours=8))
LOG_DIR = os.path.join("docs", "audit", "session_logs")
STATE_PATH = os.path.join(".kiro", "state", ".audit_state.json")
PROMPT_ID_PATH = os.path.join(".kiro", "state", ".last_prompt_id.json")
def now_taipei():
return datetime.now(TZ_TAIPEI)
def safe_read_json(path):
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return {}
def git_diff_stat():
try:
r = subprocess.run(
["git", "diff", "--stat", "HEAD"],
capture_output=True, text=True, timeout=10
)
return r.stdout.strip() if r.returncode == 0 else "(git diff failed)"
except Exception:
return "(git not available)"
def git_status_short():
try:
r = subprocess.run(
["git", "status", "--short"],
capture_output=True, text=True, timeout=10
)
return r.stdout.strip() if r.returncode == 0 else ""
except Exception:
return ""
def main():
ensure_repo_root()
now = now_taipei()
ts = now.strftime("%Y%m%d_%H%M%S")
timestamp_display = now.strftime("%Y-%m-%d %H:%M:%S %z")
# 收集数据
agent_output = os.environ.get("AGENT_OUTPUT", "")
user_prompt = os.environ.get("USER_PROMPT", "")
prompt_info = safe_read_json(PROMPT_ID_PATH)
audit_state = safe_read_json(STATE_PATH)
prompt_id = prompt_info.get("prompt_id", "unknown")
# 截断超长内容,避免日志文件过大
max_len = 50000
if len(agent_output) > max_len:
agent_output = agent_output[:max_len] + "\n\n[TRUNCATED: output exceeds 50KB]"
if len(user_prompt) > 10000:
user_prompt = user_prompt[:10000] + "\n\n[TRUNCATED: prompt exceeds 10KB]"
diff_stat = git_diff_stat()
status_short = git_status_short()
changed_files = audit_state.get("changed_files", [])
os.makedirs(LOG_DIR, exist_ok=True)
filename = f"session_{ts}.md"
filepath = os.path.join(LOG_DIR, filename)
content = f"""# Session Log — {timestamp_display}
- Prompt-ID: `{prompt_id}`
- Audit Required: `{audit_state.get('audit_required', 'N/A')}`
- Reasons: {', '.join(audit_state.get('reasons', [])) or 'none'}
## User Input
```text
{user_prompt or '(not captured)'}
```
## Agent Output
```text
{agent_output or '(not captured)'}
```
## Changed Files ({len(changed_files)})
```
{chr(10).join(changed_files[:80]) if changed_files else '(none)'}
```
## Git Diff Stat
```
{diff_stat}
```
## Git Status
```
{status_short or '(clean)'}
```
"""
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
if __name__ == "__main__":
try:
main()
except Exception:
pass

View File

@@ -1,5 +1,15 @@
{ {
"mcpServers": { "mcpServers": {
"weixin-devtools-mcp": {
"command": "npx",
"args": ["-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"],
"env": {
"WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat",
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
},
"disabled": true,
"autoApprove": ["*"]
},
"git": { "git": {
"command": "uvx", "command": "uvx",
"args": [ "args": [
@@ -7,7 +17,7 @@
"--repository", "--repository",
"C:\\NeoZQYY" "C:\\NeoZQYY"
], ],
"disabled": false, "disabled": true,
"autoApprove": [ "autoApprove": [
"all", "all",
"*" "*"
@@ -40,7 +50,7 @@
"env": { "env": {
"DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_etl_feiqiu" "DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_etl_feiqiu"
}, },
"disabled": false, "disabled": true,
"autoApprove": [ "autoApprove": [
"all", "all",
"*" "*"
@@ -70,7 +80,7 @@
"env": { "env": {
"DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_zqyy_app" "DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_zqyy_app"
}, },
"disabled": false, "disabled": true,
"autoApprove": [ "autoApprove": [
"all", "all",
"*" "*"

View File

@@ -1,6 +1,6 @@
# Schema 变更日志Schema Change Log # Schema 变更日志Schema Change Log
- 日期Asia/ShanghaiYYYY-MM-DD - 日期Asia/ShanghaiYYYY-MM-DD HH:MM:SS精确到秒
- Prompt-ID - Prompt-ID
- 原始原因Prompt 摘录/原文): - 原始原因Prompt 摘录/原文):
- 直接原因(必要性 + 方案简介): - 直接原因(必要性 + 方案简介):

View File

@@ -19,4 +19,4 @@
- 例如:状态机枚举范围、唯一性、跨字段一致性约束(如有) - 例如:状态机枚举范围、唯一性、跨字段一致性约束(如有)
## 变更历史Change History ## 变更历史Change History
- YYYY-MM-DD | Prompt-ID | 直接原因 | 变更摘要 - YYYY-MM-DD HH:MM:SS | Prompt-ID | 直接原因 | 变更摘要

View File

@@ -1,6 +1,6 @@
# 变更审计记录Change Audit Record # 变更审计记录Change Audit Record
- 日期/时间Asia/Shanghai - 日期/时间Asia/Shanghai,精确到秒,格式 YYYY-MM-DD HH:MM:SS
- Prompt-ID - Prompt-ID
- 原始原因Prompt 原文或 ≤5 行摘录): - 原始原因Prompt 原文或 ≤5 行摘录):
- 直接原因(必要性 + 修改方案简介): - 直接原因(必要性 + 修改方案简介):

View File

@@ -1,20 +1,22 @@
# 文件内 AI_CHANGELOG 与 CHANGE 标记模板 # 文件内 AI_CHANGELOG 与 CHANGE 标记模板
## 通用 AI_CHANGELOG建议放在文件头部或“变更记录”小节 > 所有时间戳精确到秒,格式:`YYYY-MM-DD HH:MM:SS`,时区 Asia/Shanghai。
- 2026-02-13 | Prompt: P20260213-101530摘录...| Direct cause... | Summary... | Verify...
## 通用 AI_CHANGELOG建议放在文件头部或"变更记录"小节)
- 2026-02-13 10:15:30 | Prompt: P20260213-101530摘录...| Direct cause... | Summary... | Verify...
--- ---
## Markdown / 文档(放在文档末尾或变更记录小节) ## Markdown / 文档(放在文档末尾或"变更记录"小节)
### AI_CHANGELOG ### AI_CHANGELOG
- YYYY-MM-DD | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify... - YYYY-MM-DD HH:MM:SS | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify...
--- ---
## JS/TS块注释 ## JS/TS块注释
/* /*
AI_CHANGELOG AI_CHANGELOG
- YYYY-MM-DD | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify... - YYYY-MM-DD HH:MM:SS | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify...
*/ */
// [CHANGE P...] intent: ... // [CHANGE P...] intent: ...
@@ -27,7 +29,7 @@ AI_CHANGELOG
## Pythondocstring/块注释) ## Pythondocstring/块注释)
""" """
AI_CHANGELOG AI_CHANGELOG
- YYYY-MM-DD | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify... - YYYY-MM-DD HH:MM:SS | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify...
""" """
# [CHANGE P...] intent: ... # [CHANGE P...] intent: ...
@@ -40,7 +42,7 @@ AI_CHANGELOG
## SQL块注释 + 行注释) ## SQL块注释 + 行注释)
/* /*
AI_CHANGELOG AI_CHANGELOG
- YYYY-MM-DD | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify... - YYYY-MM-DD HH:MM:SS | Prompt: P...(摘录:...| Direct cause... | Summary... | Verify...
*/ */
-- [CHANGE P...] intent: ... -- [CHANGE P...] intent: ...
-- assumptions: ... -- assumptions: ...

View File

@@ -0,0 +1,431 @@
# 设计文档小程序数据库基础设施层miniapp-db-foundation
## 概述
本设计实现 P1 基础设施层的三大核心能力:
1. **业务库 Schema 划分**:在 `test_zqyy_app` 中创建 `auth`(认证)和 `biz`(业务)两个 Schema配合权限管理
2. **ETL 库 RLS 视图层**:在 `test_etl_feiqiu.app` Schema 中为 35 张 DWD/DWS 表创建行级安全视图,通过 `site_id` 隔离多门店数据
3. **FDW 跨库映射**:通过 `postgres_fdw` 将 ETL 库的 RLS 视图映射为业务库的只读外部表
**环境变量驱动**:所有数据库名称通过 `.env` 环境变量引用,不硬编码。迁移脚本中使用占位符,验证脚本从 `PG_DSN` / `APP_DB_DSN` 解析连接信息。
| 环境变量 | 用途 | 示例值 |
|---------|------|--------|
| `PG_DSN` | ETL 库连接字符串 | `postgresql://user:pass@host:5432/test_etl_feiqiu` |
| `APP_DB_DSN` | 业务库连接字符串 | `postgresql://user:pass@host:5432/test_zqyy_app` |
整体数据流向:
```
ETL 库PG_DSN 业务库APP_DB_DSN
┌─────────────────────┐ ┌─────────────────────┐
│ dwd.dim_member │ │ auth (用户认证) │
│ dwd.dim_assistant │ │ biz (业务数据) │
│ dws.dws_* │ │ public (系统管理) │
│ dws.cfg_* │ │ │
│ │ │ │ fdw_etl │
│ ▼ │ postgres_fdw │ ├ v_dim_member │
│ app.v_dim_member │ ◄──────────────► │ ├ v_dim_assistant │
│ app.v_dws_* │ IMPORT SCHEMA │ └ v_dws_* │
│ (RLS: site_id 过滤) │ │ (外部表,只读) │
└─────────────────────┘ └─────────────────────┘
```
## 架构
### 分层架构
```mermaid
graph TB
subgraph "业务库APP_DB_DSN"
AUTH["auth Schema<br/>用户认证、权限、映射"]
BIZ["biz Schema<br/>业务数据"]
PUBLIC["public Schema<br/>系统管理表(保留)"]
FDW["fdw_etl Schema<br/>FDW 外部表(只读)"]
end
subgraph "ETL 库PG_DSN"
APP["app Schema<br/>RLS 视图层"]
DWD["dwd Schema<br/>明细层11 张表)"]
DWS["dws Schema<br/>汇总层24 张表)"]
end
FDW -->|"postgres_fdw<br/>IMPORT FOREIGN SCHEMA"| APP
APP -->|"WHERE site_id = current_setting(...)"| DWD
APP -->|"WHERE site_id = current_setting(...)"| DWS
```
### 执行顺序
迁移脚本必须按以下顺序执行:
1. **ETL 库**(通过 `PG_DSN` 连接):创建 `app` Schema → 创建 `app_reader` 角色 → 创建 RLS 视图 → 授权
2. **业务库**(通过 `APP_DB_DSN` 连接):创建 `auth`/`biz` Schema → 安装 `postgres_fdw` → 创建外部服务器 → 用户映射 → 导入外部表 → 授权
## 组件与接口
### 组件 1Schema 管理(业务库)
**职责**:在业务库(`APP_DB_DSN`)中创建 `auth``biz` Schema配置权限。
**SQL 接口**
```sql
-- 创建 Schema
CREATE SCHEMA IF NOT EXISTS auth;
CREATE SCHEMA IF NOT EXISTS biz;
-- 授权 app_user
GRANT USAGE ON SCHEMA auth TO app_user;
GRANT USAGE ON SCHEMA biz TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA auth TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA biz TO app_user;
-- 未来新表自动授权
ALTER DEFAULT PRIVILEGES IN SCHEMA auth
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA biz
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
```
### 组件 2RLS 视图层ETL 库)
**职责**:在 ETL 库(`PG_DSN`)的 `app` Schema 中为每张源表创建带 `site_id` 过滤的视图。
**视图命名规则**`app.v_<源表名>`,例如 `app.v_dim_member``app.v_dws_member_consumption_summary`
**视图模板**
```sql
CREATE OR REPLACE VIEW app.v_<> AS
SELECT * FROM <schema>.<>
WHERE site_id = current_setting('app.current_site_id')::bigint;
```
**DWD 层视图清单11 张)**
| 视图名 | 源表 |
|--------|------|
| `app.v_dim_member` | `dwd.dim_member` |
| `app.v_dim_assistant` | `dwd.dim_assistant` |
| `app.v_dim_member_card_account` | `dwd.dim_member_card_account` |
| `app.v_dim_table` | `dwd.dim_table` |
| `app.v_dwd_settlement_head` | `dwd.dwd_settlement_head` |
| `app.v_dwd_table_fee_log` | `dwd.dwd_table_fee_log` |
| `app.v_dwd_assistant_service_log` | `dwd.dwd_assistant_service_log` |
| `app.v_dwd_recharge_order` | `dwd.dwd_recharge_order` |
| `app.v_dwd_store_goods_sale` | `dwd.dwd_store_goods_sale` |
| `app.v_dim_staff` | `dwd.dim_staff` |
| `app.v_dim_staff_ex` | `dwd.dim_staff_ex` |
**DWS 层视图清单24 张)**
| 视图名 | 源表 |
|--------|------|
| `app.v_dws_member_consumption_summary` | `dws.dws_member_consumption_summary` |
| `app.v_dws_member_visit_detail` | `dws.dws_member_visit_detail` |
| `app.v_dws_member_winback_index` | `dws.dws_member_winback_index` |
| `app.v_dws_member_newconv_index` | `dws.dws_member_newconv_index` |
| `app.v_dws_member_recall_index` | `dws.dws_member_recall_index` |
| `app.v_dws_member_assistant_relation_index` | `dws.dws_member_assistant_relation_index` |
| `app.v_dws_member_assistant_intimacy` | `dws.dws_member_assistant_intimacy` |
| `app.v_dws_assistant_daily_detail` | `dws.dws_assistant_daily_detail` |
| `app.v_dws_assistant_monthly_summary` | `dws.dws_assistant_monthly_summary` |
| `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_finance_analysis` | `dws.dws_assistant_finance_analysis` |
| `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_recharge_summary` | `dws.dws_finance_recharge_summary` |
| `app.v_dws_finance_discount_detail` | `dws.dws_finance_discount_detail` |
| `app.v_dws_finance_expense_summary` | `dws.dws_finance_expense_summary` |
| `app.v_dws_platform_settlement` | `dws.dws_platform_settlement` |
| `app.v_dws_assistant_recharge_commission` | `dws.dws_assistant_recharge_commission` |
| `app.v_cfg_performance_tier` | `dws.cfg_performance_tier` |
| `app.v_cfg_assistant_level_price` | `dws.cfg_assistant_level_price` |
| `app.v_cfg_bonus_rules` | `dws.cfg_bonus_rules` |
| `app.v_cfg_index_parameters` | `dws.cfg_index_parameters` |
| `app.v_dws_order_summary` | `dws.dws_order_summary` |
**P2 预留(注释标记,暂不创建)**
- `dws.dws_member_spending_power_index` → 待 P2 完成后补充
- `dws.dws_assistant_order_contribution` → 待 P2 完成后补充
**`cfg_*` 表特殊处理**:配置表(`cfg_performance_tier``cfg_assistant_level_price``cfg_bonus_rules``cfg_index_parameters`)可能不含 `site_id` 列。对于不含 `site_id` 的配置表,视图直接 `SELECT *` 不加过滤条件。
**权限配置**
```sql
-- 创建只读角色(如不存在)
DO $$ BEGIN
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app_reader') THEN
CREATE ROLE app_reader LOGIN;
END IF;
END $$;
GRANT USAGE ON SCHEMA app TO app_reader;
GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_reader;
ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO app_reader;
```
### 组件 3FDW 跨库映射(业务库)
**职责**:通过 `postgres_fdw` 将 ETL 库 `app` Schema 的视图映射为业务库 `fdw_etl` Schema 的外部表。
**实现方式**:使用 `IMPORT FOREIGN SCHEMA` 批量导入,而非逐表定义外部表。这与现有 `db/fdw/setup_fdw_test.sql` 的模式一致。
**环境感知**:迁移脚本中的 `host``dbname``port``password` 等连接参数使用占位符 `'***'`,部署时根据环境替换。项目已有 `db/fdw/setup_fdw_test.sql`(测试环境)和 `db/fdw/setup_fdw.sql`(生产环境)的分环境模式,本次迁移脚本遵循相同模式——提供测试环境和生产环境两个版本。
```sql
-- 安装扩展
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
-- 创建外部服务器
-- host / dbname / port 按环境替换,从 PG_DSN 解析 ETL 库连接信息
CREATE SERVER IF NOT EXISTS etl_feiqiu_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '***', dbname '***', port '***');
-- 用户映射(密码按环境替换)
CREATE USER MAPPING IF NOT EXISTS FOR app_user
SERVER etl_feiqiu_server
OPTIONS (user 'app_reader', password '***');
-- 创建目标 Schema
CREATE SCHEMA IF NOT EXISTS fdw_etl;
-- 批量导入
IMPORT FOREIGN SCHEMA app
FROM SERVER etl_feiqiu_server
INTO fdw_etl;
-- 授权
GRANT USAGE ON SCHEMA fdw_etl TO app_user;
GRANT SELECT ON ALL TABLES IN SCHEMA fdw_etl TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA fdw_etl GRANT SELECT ON TABLES TO app_user;
```
**设计决策**
1. 使用 `IMPORT FOREIGN SCHEMA` 而非逐表 `CREATE FOREIGN TABLE`——自动匹配列定义,避免手动维护列类型不一致的风险
2. 新增 RLS 视图后只需重新执行 `IMPORT` 即可同步
3. 与现有 `db/fdw/setup_fdw_test.sql` 保持一致
4. 服务器名使用通用名 `etl_feiqiu_server`(不含环境前缀),通过连接参数区分环境
### 组件 4验证脚本
**职责**:自动化检查所有数据库对象是否正确创建。
**文件位置**`scripts/ops/validate_p1_db_foundation.py`
**接口**
```python
def validate_p1_db_foundation() -> dict:
"""
返回验证结果字典:
{
"schemas": {"auth": bool, "biz": bool, "app": bool, "fdw_etl": bool},
"rls_views": {"app.v_dim_member": bool, ...},
"fdw_tables": {"fdw_etl.v_dim_member": bool, ...},
"rls_filtering": bool,
"permissions": {"app_user": bool, "app_reader": bool},
"errors": [str, ...]
}
"""
```
**环境变量依赖**(强制从 `.env` 加载,缺失时 `RuntimeError` 终止):
- `PG_DSN`ETL 库连接字符串(从中解析 host、port、dbname
- `APP_DB_DSN`:业务库连接字符串(从中解析 host、port、dbname
- 脚本通过 `load_dotenv()` 加载根 `.env`,禁止硬编码任何数据库名称或连接参数
## 数据模型
### Schema 拓扑
```mermaid
erDiagram
test_zqyy_app_auth {
string schema_name "auth"
string purpose "用户认证、权限、映射"
}
test_zqyy_app_biz {
string schema_name "biz"
string purpose "业务数据任务、备注、AI、Excel"
}
test_zqyy_app_fdw_etl {
string schema_name "fdw_etl"
string purpose "FDW 外部表(只读)"
}
test_zqyy_app_public {
string schema_name "public"
string purpose "系统管理表(保留)"
}
test_etl_feiqiu_app {
string schema_name "app"
string purpose "RLS 视图层"
}
test_etl_feiqiu_dwd {
string schema_name "dwd"
string purpose "明细层11 张表)"
}
test_etl_feiqiu_dws {
string schema_name "dws"
string purpose "汇总层24 张表)"
}
test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dwd : "视图引用"
test_etl_feiqiu_app ||--o{ test_etl_feiqiu_dws : "视图引用"
test_zqyy_app_fdw_etl ||--|| test_etl_feiqiu_app : "postgres_fdw 映射"
```
### RLS 视图数据流
对于含 `site_id` 的表:
```
源表数据(全量)→ RLS 视图site_id 过滤)→ FDW 外部表(只读访问)
```
对于不含 `site_id` 的配置表(`cfg_*`
```
源表数据(全量)→ 直通视图(无过滤)→ FDW 外部表(只读访问)
```
### 迁移脚本清单
| 序号 | 目标库 | 文件名 | 内容 |
|------|--------|--------|------|
| 1 | ETL 库(`PG_DSN` | `YYYY-MM-DD__p1_create_app_schema_rls_views.sql` | 创建 app Schema + 全部 RLS 视图 + app_reader 权限 |
| 2 | 业务库(`APP_DB_DSN` | `YYYY-MM-DD__p1_create_auth_biz_schemas.sql` | 创建 auth/biz Schema + app_user 权限 |
| 3 | 业务库(`APP_DB_DSN` | `YYYY-MM-DD__p1_setup_fdw_etl.sql` | FDW 扩展 + 外部服务器 + 用户映射 + 导入外部表 |
## 正确性属性Correctness Properties
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1默认权限自动授予
*For any*`auth``biz` Schema 中新创建的表,`app_user` 角色都应自动获得 SELECT、INSERT、UPDATE、DELETE 权限,无需额外手动授权。
**Validates: Requirements 1.5**
### Property 2public Schema 不变量
*For any* 迁移脚本执行前后,`test_zqyy_app.public` Schema 中的表集合应保持不变——迁移不应删除、重命名或修改 `public` 中的现有表。
**Validates: Requirements 1.6**
### Property 3RLS 视图定义包含 site_id 过滤
*For any* `test_etl_feiqiu.app` Schema 中的 RLS 视图(含 `site_id` 列的源表对应的视图),其视图定义 SQL 中都应包含 `current_setting('app.current_site_id')` 过滤条件。
**Validates: Requirements 2.4**
### Property 4RLS 过滤正确性
*For any*`site_id` 列的 RLS 视图和任意有效的 `site_id` 值,设置 `app.current_site_id` 后查询该视图,返回结果中所有行的 `site_id` 都应等于设置的值。
**Validates: Requirements 2.5**
### Property 5未设置 site_id 时 RLS 视图拒绝访问
*For any*`site_id` 过滤的 RLS 视图,在未设置 `app.current_site_id` 的会话中执行查询,应抛出错误而非返回数据。
**Validates: Requirements 2.6**
### Property 6FDW 外部表完整性与数据一致性
*For any* `test_etl_feiqiu.app` Schema 中的视图,`test_zqyy_app.fdw_etl` Schema 中都应存在对应的可查询外部表,且在相同 `site_id` 条件下,外部表返回的数据与 RLS 视图返回的数据一致。
**Validates: Requirements 3.4, 3.5, 3.6**
### Property 7迁移脚本结构合规性
*For any* `db/etl_feiqiu/migrations/``db/zqyy_app/migrations/` 中本次新增的迁移脚本文件,文件名应匹配 `YYYY-MM-DD__*.sql` 模式,且文件内容中应包含回滚语句(以注释形式)。
**Validates: Requirements 4.3, 4.4**
### Property 8迁移脚本幂等性
*For any* 本次新增的迁移脚本,连续执行两次的结果应与执行一次相同——第二次执行不应产生错误。
**Validates: Requirements 4.5**
### Property 9环境变量缺失时验证脚本报错
*For any* 必需环境变量(`PG_DSN``APP_DB_DSN`)的缺失组合,验证脚本应立即抛出错误终止,而非静默使用默认值或空字符串。
**Validates: Requirements 5.8**
## 错误处理
### 迁移脚本错误处理
| 场景 | 处理方式 |
|------|---------|
| Schema 已存在 | `CREATE SCHEMA IF NOT EXISTS` 幂等跳过 |
| 视图已存在 | `CREATE OR REPLACE VIEW` 覆盖更新 |
| 角色不存在 | `DO $$ ... IF NOT EXISTS ... END $$` 条件创建 |
| 源表不存在P2 待建表) | 以注释形式预留,不创建视图 |
| FDW 服务器已存在 | `CREATE SERVER IF NOT EXISTS` 幂等跳过 |
| 用户映射已存在 | `CREATE USER MAPPING IF NOT EXISTS` 幂等跳过 |
| `IMPORT FOREIGN SCHEMA` 表已存在 | 先 `DROP SCHEMA fdw_etl CASCADE` 再重新导入(脚本中提供选项) |
### 验证脚本错误处理
| 场景 | 处理方式 |
|------|---------|
| 环境变量缺失 | `RuntimeError` 立即终止,输出缺失变量名 |
| 数据库连接失败 | 捕获 `psycopg2.OperationalError`,输出连接参数(脱敏)和错误信息 |
| Schema/视图/外部表不存在 | 记录为失败项,继续检查其余项目 |
| RLS 过滤验证无数据 | 标记为 SKIP无法验证不标记为失败 |
| 权限查询失败 | 记录具体错误,继续检查 |
### `current_setting` 未设置时的行为
PostgreSQL 中 `current_setting('app.current_site_id')` 在未设置时会抛出 `ERROR: unrecognized configuration parameter "app.current_site_id"`。这是期望行为(需求 2.6),确保不会意外返回全量数据。
如果需要更友好的错误信息,可以使用 `current_setting('app.current_site_id', true)` 返回 NULL然后在视图中用 `CASE` 处理。但当前设计选择让 PostgreSQL 原生报错,因为:
1. 更安全——不可能绕过
2. 后端代码必须显式设置 `SET app.current_site_id = ...`,这是一个强制约束
## 测试策略
### 属性测试Property-Based Testing
使用 Python `hypothesis` 框架,测试目录:`tests/`Monorepo 级属性测试目录)。
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
标注格式:`# Feature: miniapp-db-foundation, Property N: <属性标题>`
**属性测试清单**
| 属性 | 测试方法 | 生成器 |
|------|---------|--------|
| P1 默认权限 | 生成随机表名,在 auth/biz 中创建表,验证 app_user 权限 | `hypothesis.strategies.text` 生成合法 SQL 标识符 |
| P3 视图定义过滤 | 遍历所有 app schema 视图,检查定义 SQL | 无需生成器,遍历所有视图 |
| P4 RLS 过滤正确性 | 生成随机 site_id设置后查询视图验证结果 | `hypothesis.strategies.integers` 生成 site_id |
| P5 未设置 site_id 报错 | 遍历所有 RLS 视图,在新会话中查询 | 无需生成器,遍历所有视图 |
| P7 脚本结构合规 | 遍历所有新增迁移脚本,验证命名和内容 | 无需生成器,遍历文件 |
| P8 幂等性 | 对每个迁移脚本执行两次,验证无错误 | 无需生成器 |
| P9 环境变量缺失 | 生成环境变量缺失组合,验证报错 | `hypothesis.strategies.sampled_from` 生成缺失组合 |
**注意**P2public schema 不变量)和 P6FDW 数据一致性)需要真实数据库环境,作为集成测试在验证脚本中实现,而非 hypothesis 属性测试。
### 单元测试
单元测试聚焦于验证脚本的逻辑正确性:
- 验证脚本在 Schema 缺失时正确报告失败
- 验证脚本在权限不足时正确报告
- 验证脚本的输出格式正确JSON 结构)
- 环境变量缺失时的错误消息包含变量名
### 集成测试
集成测试通过验证脚本 `scripts/ops/validate_p1_db_foundation.py` 实现,覆盖:
- 全部 Schema 存在性检查
- 全部 RLS 视图存在性和过滤正确性
- 全部 FDW 外部表存在性和可查询性
- 权限配置完整性
- FDW 数据与 RLS 视图数据一致性

View File

@@ -0,0 +1,88 @@
# 需求文档小程序数据库基础设施层miniapp-db-foundation
## 简介
P1 基础设施层是整个小程序系统的第一个 SPEC无前置依赖是所有后续 SPEC 的硬依赖。本 SPEC 负责在业务库 `test_zqyy_app` 中建立清晰的 Schema 划分(`auth` + `biz`),在 ETL 库 `test_etl_feiqiu` 中为数据依赖矩阵列出的所有 DWD/DWS 表创建 RLS 视图(按 `site_id` 隔离),并通过 `postgres_fdw` 将 RLS 视图映射为业务库的外部表,使后端无需直连 ETL 库即可读取汇总/维度数据。
## 术语表
- **Schema_Manager**:负责在 PostgreSQL 数据库中创建和管理 Schema、权限配置的迁移脚本系统
- **RLS_View_Layer**:在 `test_etl_feiqiu.app` Schema 中创建的一组视图,通过 `current_setting('app.current_site_id')``site_id` 过滤数据,实现行级安全隔离
- **FDW_Bridge**:通过 `postgres_fdw` 扩展在 `test_zqyy_app.fdw_etl` Schema 中创建的外部表集合,只读映射 ETL 库 `app` Schema 的 RLS 视图
- **Migration_Script**:存放在 `db/zqyy_app/migrations/``db/etl_feiqiu/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
- **Validation_Script**:用于验证数据库对象是否正确创建、权限是否配置正确、数据是否可查询的 Python 脚本
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **app_reader**ETL 库侧的只读角色,供 FDW 用户映射使用
- **app_user**:业务库侧的应用连接角色,通过 FDW 读取 ETL 数据
## 需求
### 需求 1业务库 Schema 划分与权限配置
**用户故事:** 作为后端开发者,我需要 `test_zqyy_app` 中有清晰的 Schema 划分(`auth` + `biz`),以便按功能组织业务表。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_zqyy_app` 中创建 `auth` Schema
2. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_zqyy_app` 中创建 `biz` Schema
3. WHEN `auth` Schema 创建完成, THE Schema_Manager SHALL 授予 `app_user` 角色对 `auth` Schema 的 USAGE 权限和对其中所有表的 SELECT、INSERT、UPDATE、DELETE 权限
4. WHEN `biz` Schema 创建完成, THE Schema_Manager SHALL 授予 `app_user` 角色对 `biz` Schema 的 USAGE 权限和对其中所有表的 SELECT、INSERT、UPDATE、DELETE 权限
5. WHEN 新表在 `auth``biz` Schema 中创建, THE Schema_Manager SHALL 通过 ALTER DEFAULT PRIVILEGES 自动授予 `app_user` 角色相应权限
6. THE Migration_Script SHALL 保留 `public` Schema 中现有的系统管理表(`admin_users``roles``permissions` 等)不受影响
### 需求 2ETL 库 RLS 视图层创建
**用户故事:** 作为系统管理员,我需要 RLS 视图按 `site_id` 隔离数据,以便多门店数据安全。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Schema_Manager SHALL 在 `test_etl_feiqiu` 中创建 `app` Schema如不存在
2. WHEN `app` Schema 创建完成, THE RLS_View_Layer SHALL 为数据依赖矩阵中列出的每张 DWD 表创建对应的 RLS 视图(共 11 张:`dim_member``dim_assistant``dim_member_card_account``dim_table``dwd_settlement_head``dwd_table_fee_log``dwd_assistant_service_log``dwd_recharge_order``dwd_store_goods_sale``dim_staff``dim_staff_ex`
3. WHEN `app` Schema 创建完成, THE RLS_View_Layer SHALL 为数据依赖矩阵中列出的每张 DWS 表创建对应的 RLS 视图(共 24 张,包含 `dws_*``cfg_*` 表)
4. THE RLS_View_Layer 中每个视图 SHALL 包含 `WHERE site_id = current_setting('app.current_site_id')::bigint` 过滤条件
5. WHEN 设置 `app.current_site_id` 为某门店 ID 后查询 RLS 视图, THE RLS_View_Layer SHALL 仅返回该门店的数据
6. WHEN 未设置 `app.current_site_id` 时查询 RLS 视图, THE RLS_View_Layer SHALL 抛出错误而非返回全部数据
7. THE Schema_Manager SHALL 授予 `app_reader` 角色对 `app` Schema 的 USAGE 权限和对其中所有视图的 SELECT 权限
8. THE RLS_View_Layer SHALL 为 P2 待建表(`dws_member_spending_power_index``dws_assistant_order_contribution`)在迁移脚本中以注释形式预留位置
### 需求 3FDW 外部表映射
**用户故事:** 作为后端开发者,我需要通过 FDW 从 `test_zqyy_app` 读取 ETL 库的 DWS/DWD 数据,以便小程序页面展示 ETL 计算结果。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE FDW_Bridge SHALL 在 `test_zqyy_app` 中安装 `postgres_fdw` 扩展
2. WHEN `postgres_fdw` 扩展安装完成, THE FDW_Bridge SHALL 创建指向 `test_etl_feiqiu` 的外部服务器 `test_etl_feiqiu_server`
3. WHEN 外部服务器创建完成, THE FDW_Bridge SHALL 创建 `app_user``app_reader` 的用户映射
4. WHEN 用户映射创建完成, THE FDW_Bridge SHALL 在 `fdw_etl` Schema 中通过 `IMPORT FOREIGN SCHEMA app` 导入所有外部表
5. WHEN 外部表导入完成, THE FDW_Bridge SHALL 对 `fdw_etl` Schema 中的每张外部表执行 `SELECT` 查询验证可读性
6. WHEN 外部表查询成功, THE FDW_Bridge 返回的数据 SHALL 与 ETL 库 `app` Schema 中对应 RLS 视图的数据一致
7. THE FDW_Bridge SHALL 授予 `app_user` 角色对 `fdw_etl` Schema 的 USAGE 权限和对其中所有外部表的 SELECT 权限
### 需求 4迁移脚本管理
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放、可回滚。
#### 验收标准
1. THE Migration_Script SHALL 将 ETL 库变更RLS 视图创建、`app` Schema 权限)存放在 `db/etl_feiqiu/migrations/` 目录中
2. THE Migration_Script SHALL 将业务库变更Schema 创建、FDW 配置)存放在 `db/zqyy_app/migrations/` 目录中
3. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
5. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
6. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL非 ORM
### 需求 5端到端验证
**用户故事:** 作为后端开发者,我需要一个自动化验证脚本,确认所有数据库对象正确创建且数据可访问。
#### 验收标准
1. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_zqyy_app``auth``biz` Schema 是否存在
2. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_etl_feiqiu.app` Schema 中所有 RLS 视图是否存在
3. WHEN Validation_Script 执行时, THE Validation_Script SHALL 检查 `test_zqyy_app.fdw_etl` 中所有外部表是否存在
4. WHEN Validation_Script 执行时, THE Validation_Script SHALL 对每张外部表执行 `SELECT count(*)` 验证可查询性
5. WHEN Validation_Script 执行时, THE Validation_Script SHALL 设置 `app.current_site_id` 后验证 RLS 视图正确过滤数据
6. WHEN Validation_Script 执行时, THE Validation_Script SHALL 验证 `app_user``app_reader` 角色的权限配置正确
7. WHEN 验证发现异常, THE Validation_Script SHALL 输出具体的失败项和错误信息
8. THE Validation_Script SHALL 从 `.env` 加载数据库连接参数(`PG_DSN``APP_DB_DSN`),缺失时立即报错终止

View File

@@ -0,0 +1,108 @@
# 实现计划小程序数据库基础设施层miniapp-db-foundation
## 概述
按照"ETL 库先行 → 业务库跟进 → FDW 桥接 → 验证收尾"的顺序,将设计拆分为可增量执行的编码任务。每个迁移脚本使用幂等语法,所有连接参数通过环境变量驱动。
## 任务
- [x] 1. ETL 库:创建 app Schema 与 RLS 视图
- [x] 1.1 编写迁移脚本 `db/etl_feiqiu/migrations/YYYY-MM-DD__p1_create_app_schema_rls_views.sql`
- 创建 `app` Schema`CREATE SCHEMA IF NOT EXISTS app`
- 创建 `app_reader` 角色(条件创建,`DO $$ ... IF NOT EXISTS ... END $$`
- 为 11 张 DWD 表创建 RLS 视图(`CREATE OR REPLACE VIEW app.v_<表名> AS SELECT * FROM dwd.<表名> WHERE site_id = current_setting('app.current_site_id')::bigint`
- 为 24 张 DWS 表创建 RLS 视图(同上模式;`cfg_*` 配置表若无 `site_id` 列则直接 `SELECT *` 不加过滤)
- 在脚本末尾以注释形式预留 P2 待建表位置(`dws_member_spending_power_index``dws_assistant_order_contribution`
- 授予 `app_reader``app` Schema 的 USAGE + SELECT 权限 + ALTER DEFAULT PRIVILEGES
- 包含回滚语句(注释形式)
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.7, 2.8, 4.1, 4.3, 4.4, 4.5, 4.6_
- [x] 1.2 编写属性测试RLS 视图定义包含 site_id 过滤
- **Property 3: RLS 视图定义包含 site_id 过滤**
- 遍历 `app` Schema 所有视图,查询 `pg_views.definition`,验证含 `site_id` 列的源表对应视图包含 `current_setting` 过滤条件
- **Validates: Requirements 2.4**
- [x] 1.3 编写属性测试:未设置 site_id 时 RLS 视图拒绝访问
- **Property 5: 未设置 site_id 时 RLS 视图拒绝访问**
- 遍历所有含 `site_id` 过滤的 RLS 视图,在新会话(未设置 `app.current_site_id`)中执行 `SELECT`,验证抛出错误
- **Validates: Requirements 2.6**
- [x] 2. 业务库:创建 auth/biz Schema 与权限
- [x] 2.1 编写迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p1_create_auth_biz_schemas.sql`
- 创建 `auth` Schema`CREATE SCHEMA IF NOT EXISTS auth`
- 创建 `biz` Schema`CREATE SCHEMA IF NOT EXISTS biz`
- 授予 `app_user``auth`/`biz` 的 USAGE + CRUD 权限
- 设置 ALTER DEFAULT PRIVILEGES 自动授权未来新表
- 包含回滚语句(注释形式)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 2.2 编写属性测试:默认权限自动授予
- **Property 1: 默认权限自动授予**
- 使用 hypothesis 生成随机合法表名,在 `auth`/`biz` 中创建临时表,验证 `app_user` 自动获得 SELECT/INSERT/UPDATE/DELETE 权限,测试后清理
- **Validates: Requirements 1.5**
- [x] 3. 业务库:配置 FDW 跨库映射
- [x] 3.1 编写迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p1_setup_fdw_etl.sql`
- 安装 `postgres_fdw` 扩展
- 创建外部服务器 `etl_feiqiu_server`host/dbname/port 使用占位符 `'***'`,按环境替换)
- 创建 `app_user``app_reader` 用户映射
- 创建 `fdw_etl` Schema
- 执行 `IMPORT FOREIGN SCHEMA app FROM SERVER etl_feiqiu_server INTO fdw_etl`
- 授予 `app_user``fdw_etl` 的 USAGE + SELECT 权限 + ALTER DEFAULT PRIVILEGES
- 包含回滚语句(注释形式)
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.7, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 3.2 编写属性测试FDW 外部表完整性与数据一致性
- **Property 6: FDW 外部表完整性与数据一致性**
- 遍历 ETL 库 `app` Schema 所有视图,验证 `fdw_etl` 中存在对应外部表且可查询;在相同 `site_id` 下对比数据一致性
- **Validates: Requirements 3.4, 3.5, 3.6**
- [x] 4. Checkpoint — 迁移脚本验证
- 确保三个迁移脚本均可在测试环境中成功执行按顺序ETL 库 → 业务库 Schema → 业务库 FDW
- 验证重复执行不报错(幂等性)
- 如有问题请告知用户
- [x] 5. 编写端到端验证脚本
- [x] 5.1 创建 `scripts/ops/validate_p1_db_foundation.py`
- 通过 `load_dotenv()` 加载根 `.env`,读取 `PG_DSN``APP_DB_DSN`,缺失时 `RuntimeError` 终止
- 检查业务库中 `auth``biz` Schema 存在性
- 检查 ETL 库中 `app` Schema 及所有 RLS 视图存在性(对照设计文档中的 35 张视图清单)
- 检查业务库中 `fdw_etl` Schema 及所有外部表存在性
- 对每张外部表执行 `SELECT count(*)` 验证可查询性
- 设置 `app.current_site_id` 后验证 RLS 视图过滤正确性
- 验证 `app_user``app_reader` 角色权限配置
- 输出结构化验证结果(通过/失败/跳过),失败项附带错误信息
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
- [x] 5.2 编写属性测试RLS 过滤正确性
- **Property 4: RLS 过滤正确性**
- 使用 hypothesis 生成随机 `site_id`,设置 `app.current_site_id` 后查询含 `site_id` 的 RLS 视图,验证返回结果中所有行的 `site_id` 等于设置值
- **Validates: Requirements 2.5**
- [x] 5.3 编写属性测试:环境变量缺失报错
- **Property 9: 环境变量缺失时验证脚本报错**
- 使用 hypothesis 生成 `PG_DSN`/`APP_DB_DSN` 的缺失组合,验证脚本抛出 `RuntimeError`
- **Validates: Requirements 5.8**
- [x] 5.4 编写属性测试:迁移脚本结构合规性
- **Property 7: 迁移脚本结构合规性**
- 遍历本次新增的迁移脚本文件,验证文件名匹配 `YYYY-MM-DD__*.sql` 模式,且内容包含回滚语句注释
- **Validates: Requirements 4.3, 4.4**
- [x] 5.5 编写属性测试:迁移脚本幂等性
- **Property 8: 迁移脚本幂等性**
- 对每个迁移脚本连续执行两次,验证第二次执行无错误
- **Validates: Requirements 4.5**
- [x] 6. Final checkpoint — 全量验证
- 运行验证脚本 `python scripts/ops/validate_p1_db_foundation.py`,确认所有检查项通过
- 运行属性测试 `cd C:\NeoZQYY && pytest tests/ -v -k p1`,确认所有属性测试通过
- 如有问题请告知用户
## 说明
- 标记 `*` 的子任务为可选,可跳过以加速 MVP
- 每个任务引用具体的需求编号,确保可追溯
- Checkpoint 确保增量验证
- 属性测试使用 Python hypothesis 框架,测试文件放在根目录 `tests/`
- 迁移脚本中的数据库连接参数host/dbname/port/password均使用占位符按环境替换

View File

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

View File

@@ -0,0 +1,562 @@
# 设计文档ETL DWS 层扩展 — 小程序数据支撑
## 概述
本设计覆盖三个独立但相关的 DWS 层扩展模块:
1. **助教订单流水四项统计**:新建 `AssistantOrderContributionTask`,计算每名助教每日的订单总流水、订单净流水、时效贡献流水、时效净贡献。算法核心在于"时效贡献流水"的台费分摊和酒水食品均分逻辑。
2. **会员消费汇总扩展**:修改现有 `MemberConsumptionTask`,新增 30/60/90 天充值窗口统计和次均消费字段。
3. **定档折算惩罚**:修改现有 `AssistantDailyTask`,新增时间重叠检测和惩罚计算逻辑。
三个模块共享同一套 RLS 视图 + FDW 映射基础设施。
### 设计决策
1. **助教订单流水独立建表**:四项统计粒度为 `(site_id, assistant_id, stat_date)`,与现有 `dws_assistant_daily_detail` 粒度相同但语义不同daily_detail 聚焦服务时长/金额contribution 聚焦订单级流水分摊),独立建表避免字段膨胀。`stat_date` 为营业日(以 `BUSINESS_DAY_START_HOUR` 08:00 为日切点)。
2. **时效贡献流水计算为纯函数**:核心分摊算法(`compute_time_weighted_revenue`)设计为静态方法,输入为结构化的订单数据,输出为每名助教的贡献值。不依赖数据库,便于属性测试。
3. **惩罚检测在 transform 阶段完成**:定档折算惩罚的时间重叠检测和计算在 `AssistantDailyTask.transform` 中完成,不新建独立任务,因为惩罚字段与日度明细同粒度。
4. **充值统计复用现有 extract 模式**:在 `MemberConsumptionTask` 中新增一个 `_extract_recharge_stats` 方法,与现有的 `_extract_consumption_stats` 并行提取,在 transform 阶段合并。
## 架构
```mermaid
graph TD
subgraph 数据来源DWD
SH[dwd_settlement_head<br/>结算主表]
TF[dwd_table_fee_log<br/>台费明细]
ASL[dwd_assistant_service_log<br/>助教服务记录]
RO[dwd_recharge_order<br/>充值订单]
end
subgraph 新建任务
AOC[AssistantOrderContributionTask<br/>助教订单流水统计]
end
subgraph 修改任务
MCT[MemberConsumptionTask<br/>+充值窗口 +次均消费]
ADT[AssistantDailyTask<br/>+惩罚检测 +惩罚计算]
end
subgraph 输出DWS
T1[dws_assistant_order_contribution<br/>新建]
T2[dws_member_consumption_summary<br/>扩展字段]
T3[dws_assistant_daily_detail<br/>扩展字段]
end
subgraph 基础设施
RLS[app schema RLS 视图]
FDW[fdw_etl 外部表映射]
end
SH --> AOC
TF --> AOC
ASL --> AOC
AOC --> T1
RO --> MCT
SH --> MCT
MCT --> T2
ASL --> ADT
TF --> ADT
ADT --> T3
T1 --> RLS
T2 --> RLS
T3 --> RLS
RLS --> FDW
```
### 任务依赖关系
```
DWD_LOAD_FROM_ODS
├── DWS_ASSISTANT_DAILY (扩展:+惩罚检测计算)
├── DWS_MEMBER_CONSUMPTION (扩展:+充值窗口+次均消费)
└── DWS_ASSISTANT_ORDER_CONTRIBUTION (新建:四项统计)
```
`DWS_ASSISTANT_ORDER_CONTRIBUTION` 依赖 `DWD_LOAD_FROM_ODS`(需要最新的结算、台费、服务记录数据)。
## 组件与接口
### AssistantOrderContributionTask新建
继承 `BaseDwsTask`,实现四项统计计算:
```python
class AssistantOrderContributionTask(BaseDwsTask):
DATE_COL = "stat_date"
def get_task_code(self) -> str:
return "DWS_ASSISTANT_ORDER_CONTRIBUTION"
def get_target_table(self) -> str:
return "dws_assistant_order_contribution"
def get_primary_keys(self) -> List[str]:
return ["site_id", "assistant_id", "stat_date"]
# --- ETL 主流程 ---
def extract(self, context: TaskContext) -> Dict[str, Any]: ...
def transform(self, extracted, context) -> List[Dict[str, Any]]: ...
# load() 使用 BaseDwsTask 默认实现
# --- 数据提取 ---
def _extract_order_data(self, site_id, start_date, end_date) -> List[Dict]: ...
# --- 核心计算(纯函数,可独立测试) ---
@staticmethod
def compute_order_gross_revenue(order: OrderData) -> Decimal:
"""订单总流水 = 台费 + 酒水食品 + 所有助教服务费"""
...
@staticmethod
def compute_order_net_revenue(order: OrderData) -> Decimal:
"""订单净流水 = 订单总流水 - 所有助教服务分成"""
...
@staticmethod
def compute_time_weighted_revenue(
order: OrderData, assistant_id: int
) -> Decimal:
"""时效贡献流水 = 台费按时长分摊 + 个人服务费 + 酒水食品按时长比例"""
...
@staticmethod
def compute_time_weighted_net_revenue(
time_weighted_revenue: Decimal, assistant_commission: Decimal
) -> Decimal:
"""时效净贡献 = 时效贡献流水 - 个人服务分成"""
...
```
### 核心数据结构
```python
@dataclass
class TableUsage:
"""台桌使用信息"""
table_id: int
table_area: str # 区域名称A/B/C/S/TV/M1-M7 等)
usage_seconds: int # 台桌使用时长(秒)
table_fee: Decimal # 台费/房费
@dataclass
class AssistantService:
"""助教服务记录"""
assistant_id: int
table_id: int
service_seconds: int # 服务时长(秒)
ledger_amount: Decimal # 服务流水(助教收费)
commission: Decimal # 助教分成
skill_id: int
course_type: str # BASE / BONUS / ROOM
@dataclass
class OrderData:
"""订单聚合数据(一个结算单的完整信息)"""
order_settle_id: int
site_id: int
total_table_fee: Decimal # 台费总额
total_goods_amount: Decimal # 酒水食品总额
tables: List[TableUsage] # 台桌列表
assistants: List[AssistantService] # 助教服务列表
```
### 四项统计算法详解
#### 1. 订单总流水order_gross_revenue
```
order_gross_revenue = total_table_fee + total_goods_amount + SUM(所有助教的 ledger_amount)
```
每个参与助教获得相同的 order_gross_revenue 值。
#### 2. 订单净流水order_net_revenue
```
order_net_revenue = order_gross_revenue - SUM(所有助教的 commission)
```
每个参与助教获得相同的 order_net_revenue 值。
#### 3. 时效贡献流水time_weighted_revenue— 核心算法
这是最复杂的计算,按以下步骤进行:
**步骤 1确定每张台桌的有效计费时长**
```
对于每张台桌 t
助教总服务时长 = SUM(该台桌所有助教的 service_seconds)
有效计费时长 = MAX(助教总服务时长, 台桌使用时长)
每小时单价 = table_fee / (有效计费时长 / 3600)
```
**步骤 2按助教在台桌的服务时长分摊台费**
```
对于每个助教 a 在台桌 t
台费分摊 = 每小时单价 × (助教在该台桌的服务时长 / 3600)
特殊情况:当助教总服务时长 < 台桌使用时长时
按比例缩放:台费分摊 = (table_fee × 台桌使用时长对应比例) / 该台桌助教人数中按时长占比分配
即:台费分摊 = (table_fee / 台桌使用时长 × MIN(助教总服务时长, 台桌使用时长))
× (助教个人时长 / 助教总服务时长)
```
更精确的公式(统一处理两种情况):
```
对于台桌 t
billable_seconds = MAX(SUM(助教服务时长), 台桌使用时长)
对于助教 a
台费分摊_a = table_fee_t × (service_seconds_a / billable_seconds)
```
> 注意:当 `SUM(助教服务时长) > 台桌使用时长` 时,`billable_seconds = SUM(助教服务时长)`
> 此时各助教按自己的时长占比分摊台费,总和 = table_fee。
> 当 `SUM(助教服务时长) < 台桌使用时长` 时,`billable_seconds = 台桌使用时长`
> 此时各助教分摊的台费总和 < table_fee未被助教覆盖的时段不分配给任何人
**步骤 3助教个人服务费直接计入**
```
个人服务费 = 助教的 ledger_amount
```
**步骤 4酒水食品按助教总时长比例均分**
```
助教总时长 = SUM(所有助教在所有台桌的 service_seconds)
对于助教 a
酒水食品分摊 = total_goods_amount × (助教 a 的总服务时长 / 助教总时长)
```
**合成:**
```
time_weighted_revenue_a = SUM(各台桌台费分摊_a) + 个人服务费_a + 酒水食品分摊_a
```
#### 4. 时效净贡献time_weighted_net_revenue
```
time_weighted_net_revenue_a = time_weighted_revenue_a - commission_a
```
#### 5. 超休/打赏课特殊处理
当助教为超休/打赏课类型(`course_type = BONUS`)时,该助教不参与订单级分摊:
```
order_gross_revenue = ledger_amount个人服务流水
order_net_revenue = ledger_amount - commission
time_weighted_revenue = ledger_amount
time_weighted_net_revenue = ledger_amount - commission
```
### MemberConsumptionTask 扩展
在现有任务中新增:
```python
# extract 阶段新增
def _extract_recharge_stats(self, site_id: int, stat_date: date) -> Dict[int, Dict]:
"""从 dwd_recharge_order 提取 30/60/90 天充值统计"""
...
# transform 阶段新增字段
record['recharge_count_30d'] = recharge.get('count_30d', 0)
record['recharge_count_60d'] = recharge.get('count_60d', 0)
record['recharge_count_90d'] = recharge.get('count_90d', 0)
record['recharge_amount_30d'] = recharge.get('amount_30d', Decimal('0'))
record['recharge_amount_60d'] = recharge.get('amount_60d', Decimal('0'))
record['recharge_amount_90d'] = recharge.get('amount_90d', Decimal('0'))
record['avg_ticket_amount'] = (
record['total_consume_amount'] / max(record['total_visit_count'], 1)
)
```
### AssistantDailyTask 扩展 — 惩罚检测
在现有任务的 transform 阶段新增惩罚检测逻辑:
```python
# 惩罚检测核心逻辑
@staticmethod
def detect_overlap_violations(
service_records: List[Dict],
penalty_areas: Set[str]
) -> Dict[Tuple[int, date], List[Dict]]:
"""
检测同一台桌同一时间段超过 2 名助教挂台的违规。
penalty_areas: 指定区域集合,如 {'A','B','C','S','TV','M1','M2',...,'M7'}
返回: {(assistant_id, stat_date): [violation_info, ...]}
"""
...
@staticmethod
def compute_penalty_minutes(
actual_minutes: Decimal,
per_hour_contribution: Decimal,
threshold: Decimal = Decimal('24')
) -> Decimal:
"""
计算惩罚分钟数。
per_hour_contribution >= threshold: 返回 0
per_hour_contribution < threshold:
返回 actual_minutes × (1 - per_hour_contribution / threshold)
"""
...
```
**惩罚区域定义:**
- 大厅A、B、C、S、TV
- 麻将房M1、M2、M3、M4、M5、M6、M7
**时间重叠检测算法:**
1.`(台桌ID, 服务日期)` 分组所有服务记录
2. 对每组内的服务记录,检查时间段是否有重叠(任意两个助教的 `[start_time, end_time]` 有交集)
3. 若同一时间段内助教数 > 2标记为违规
4. 对违规记录计算 `per_hour_contribution = 台费每小时单价 / 该时段助教人数`
5. 根据 `per_hour_contribution` 与 24 元阈值比较,计算 `penalty_minutes`
## 数据模型
### dws.dws_assistant_order_contribution新建
```sql
CREATE TABLE dws.dws_assistant_order_contribution (
contribution_id BIGSERIAL PRIMARY KEY,
site_id INTEGER NOT NULL,
tenant_id INTEGER NOT NULL,
assistant_id BIGINT NOT NULL,
assistant_nickname VARCHAR(100),
stat_date DATE NOT NULL,
-- 四项统计
order_gross_revenue NUMERIC(14,2) DEFAULT 0,
order_net_revenue NUMERIC(14,2) DEFAULT 0,
time_weighted_revenue NUMERIC(14,2) DEFAULT 0,
time_weighted_net_revenue NUMERIC(14,2) DEFAULT 0,
-- 辅助字段
order_count INTEGER DEFAULT 0,
total_service_seconds INTEGER DEFAULT 0,
-- 元数据
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_aoc_site_assistant_date
ON dws.dws_assistant_order_contribution (site_id, assistant_id, stat_date);
CREATE INDEX idx_aoc_stat_date
ON dws.dws_assistant_order_contribution (site_id, stat_date);
```
### dws_member_consumption_summary 扩展字段
```sql
ALTER TABLE dws.dws_member_consumption_summary
ADD COLUMN IF NOT EXISTS recharge_count_30d INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_count_60d INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_count_90d INTEGER DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_amount_30d NUMERIC(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_amount_60d NUMERIC(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS recharge_amount_90d NUMERIC(14,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS avg_ticket_amount NUMERIC(14,2) DEFAULT 0;
```
### dws_assistant_daily_detail 扩展字段
```sql
ALTER TABLE dws.dws_assistant_daily_detail
ADD COLUMN IF NOT EXISTS penalty_minutes NUMERIC(10,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS penalty_reason TEXT,
ADD COLUMN IF NOT EXISTS is_exempt BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS per_hour_contribution NUMERIC(14,2);
```
### RLS 视图app schema
```sql
-- 新建:助教订单流水统计
CREATE OR REPLACE VIEW app.v_dws_assistant_order_contribution AS
SELECT * FROM dws.dws_assistant_order_contribution
WHERE site_id = current_setting('app.current_site_id')::bigint;
-- 已有视图无需修改dws_member_consumption_summary 和 dws_assistant_daily_detail
-- 的 RLS 视图使用 SELECT *,新增字段自动包含
```
### FDW 映射fdw_etl schema
`test_zqyy_app.fdw_etl` 中新建外部表:
```sql
CREATE FOREIGN TABLE fdw_etl.dws_assistant_order_contribution (
contribution_id BIGINT,
site_id INTEGER,
tenant_id INTEGER,
assistant_id BIGINT,
assistant_nickname VARCHAR(100),
stat_date DATE,
order_gross_revenue NUMERIC(14,2),
order_net_revenue NUMERIC(14,2),
time_weighted_revenue NUMERIC(14,2),
time_weighted_net_revenue NUMERIC(14,2),
order_count INTEGER,
total_service_seconds INTEGER,
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE
) SERVER etl_server
OPTIONS (schema_name 'app', table_name 'v_dws_assistant_order_contribution');
```
对于扩展字段的表(`dws_member_consumption_summary``dws_assistant_daily_detail`),需要 `DROP` 并重建 FDW 外部表定义以包含新字段。
## 正确性属性
*正确性属性Correctness Property是系统在所有合法执行路径上都应成立的行为特征——本质上是对"系统应该做什么"的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
以下属性基于需求文档中的验收标准推导。四项统计的核心计算函数(`compute_order_gross_revenue``compute_time_weighted_revenue` 等)和惩罚计算函数(`compute_penalty_minutes`)设计为纯静态方法,不依赖数据库,可直接用于属性测试。
### Property 1: 订单级统计不变量 — gross/net 各助教相等
*For any* 订单数据(包含任意数量的台桌、助教服务和酒水食品),所有参与该订单的助教应获得相同的 `order_gross_revenue` 值,且获得相同的 `order_net_revenue` 值。
推导:`order_gross_revenue``order_net_revenue` 是订单级聚合值,不按助教个人拆分,因此所有参与助教共享同一个值。
**Validates: Requirements 2.2, 2.3, 10.1, 10.2**
### Property 2: 时效贡献流水之和约束
*For any* 订单数据,所有参与助教的 `time_weighted_revenue` 之和应满足:
- 当所有台桌的助教总服务时长 ≥ 台桌使用时长时,之和 = `order_gross_revenue`
- 当存在台桌的助教总服务时长 < 台桌使用时长时,之和 ≤ `order_gross_revenue`
且在所有情况下,之和 ≥ 0。
推导:台费按时长比例分摊,当助教完全覆盖台桌时长时分摊总和等于台费;酒水食品按时长比例均分总和等于酒水总额;助教服务费直接计入。因此总和 = 台费分摊总和 + 酒水分摊总和 + 服务费总和 ≤ order_gross_revenue。
**Validates: Requirements 2.4, 10.3**
### Property 3: 时效净贡献减法关系
*For any* 助教和订单数据,该助教的 `time_weighted_net_revenue` 应等于 `time_weighted_revenue - commission`(该助教个人的服务分成)。
推导:这是定义性等式,直接从需求 2.5 得出。
**Validates: Requirements 2.5, 10.4**
### Property 4: 惩罚分钟数分段公式
*For any* 非负的 `actual_minutes` 和非负的 `per_hour_contribution`
-`per_hour_contribution >= 24` 时,`penalty_minutes = 0`
-`per_hour_contribution < 24` 时,`penalty_minutes = actual_minutes × (1 - per_hour_contribution / 24)`
且在所有情况下,`0 ≤ penalty_minutes ≤ actual_minutes`
推导:直接从需求 6.3/6.4 的分段公式得出。上界 `actual_minutes``per_hour_contribution = 0` 时取到。
**Validates: Requirements 6.3, 6.4, 10.5, 10.6**
### Property 5: 次均消费公式
*For any* 非负的 `total_consume_amount` 和非负整数 `total_visit_count``avg_ticket_amount` 应等于 `total_consume_amount / MAX(total_visit_count, 1)`
推导:直接从需求 3.4 得出。`MAX(total_visit_count, 1)` 防止除零。
**Validates: Requirements 3.4, 10.7**
### Property 6: 重叠检测正确性
*For any* 一组助教服务记录,若在指定区域的同一台桌上存在 3 名或以上助教的服务时间段有重叠,则 `detect_overlap_violations` 应返回非空的违规列表。
推导:需求 6.1 要求检测"同一台桌同一时间段超过 2 名助教挂台"。我们可以生成随机的服务记录(包含时间段重叠和不重叠的情况),验证检测函数的正确性。
**Validates: Requirements 6.1**
## 错误处理
| 场景 | 处理方式 |
|------|----------|
| 订单无助教服务记录 | 跳过该订单,不生成统计记录 |
| 台桌使用时长为 0 | 台费分摊设为 0避免除零 |
| 助教总服务时长为 0 | 酒水食品分摊设为 0避免除零 |
| 会员无充值记录 | 充值次数/金额设为 0 |
| 会员无消费记录 | avg_ticket_amount 设为 0 |
| 助教当日无违规 | penalty_minutes = 0penalty_reason = NULL |
| 服务记录缺少时间段信息 | 跳过该记录的重叠检测,日志 WARNING |
| per_hour_contribution 为负数 | 视为 0 处理(防御性编程) |
| FDW 映射创建失败 | 事务回滚,报错终止 |
| 数据库写入失败 | 事务回滚,抛出异常由调度器处理 |
> **注意:所有数据库操作均在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行。**
## 测试策略
### 属性测试hypothesis
属性测试位于 `tests/` 目录Monorepo 级),使用 `hypothesis` 库。
每个属性测试对应设计文档中的一个 Property最少运行 100 次迭代。
测试文件:`tests/test_dws_contribution_properties.py`
```python
# Feature: 02-etl-dws-miniapp-extensions, Property 1: 订单级统计不变量
@given(order_data=order_data_strategy())
@settings(max_examples=200)
def test_gross_net_equal_across_assistants(order_data):
"""所有参与助教的 order_gross_revenue 和 order_net_revenue 应分别相等"""
gross = AssistantOrderContributionTask.compute_order_gross_revenue(order_data)
net = AssistantOrderContributionTask.compute_order_net_revenue(order_data)
# 每个助教获得相同的 gross 和 net
for assistant in order_data.assistants:
assert assistant_gross == gross
assert assistant_net == net
```
```python
# Feature: 02-etl-dws-miniapp-extensions, Property 4: 惩罚分钟数分段公式
@given(
actual_minutes=st.decimals(min_value=0, max_value=600, places=2),
per_hour_contribution=st.decimals(min_value=0, max_value=200, places=2),
)
@settings(max_examples=200)
def test_penalty_minutes_formula(actual_minutes, per_hour_contribution):
"""惩罚分钟数应符合分段公式且在 [0, actual_minutes] 范围内"""
result = AssistantDailyTask.compute_penalty_minutes(
actual_minutes, per_hour_contribution
)
if per_hour_contribution >= 24:
assert result == 0
else:
expected = actual_minutes * (1 - per_hour_contribution / 24)
assert abs(result - expected) < Decimal('0.01')
assert 0 <= result <= actual_minutes
```
属性测试库:`hypothesis`(已在项目依赖中)
### 单元测试
单元测试位于 `apps/etl/connectors/feiqiu/tests/unit/`,使用 FakeDB/FakeAPI 工具。
重点覆盖:
- PRD 示例数据验算:使用 PRD 中的具体订单示例3 名助教、2 张台桌、酒水 600 元)验证四项统计的精确数值
- 超休/打赏课边界:验证超休助教的四项统计等于个人流水
- 零值边界:无台费、无酒水、无助教服务的极端情况
- 惩罚计算边界per_hour_contribution 恰好等于 24 元的临界值
- 充值窗口:验证 30/60/90 天窗口的正确切分
- 豁免逻辑is_exempt = TRUE 时跳过惩罚
### 测试配置
- 属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_assistant_order_contribution.py -v`
- 每个属性测试标注 `@settings(max_examples=200)`
- 每个属性测试注释引用设计文档 Property 编号

View File

@@ -0,0 +1,157 @@
# 需求文档ETL DWS 层扩展 — 小程序数据支撑
## 简介
本 Spec 覆盖 P2 任务中 T4T11 的 ETL DWS 层扩展,为小程序提供三类核心数据支撑:
1. 助教订单流水四项统计(`dws_assistant_order_contribution`
2. 会员消费汇总扩展(充值窗口 + 次均消费)
3. 定档折算惩罚检测与计算
同时包含新表的 RLS 视图创建、FDW 映射同步,以及影子跑数验证。
> SPI 消费力指数T1T3已在独立 Spec `.kiro/specs/spi-spending-power-index/` 中完成,本文档不再重复。
## 术语表
- **AssistantOrderContributionTask**:助教订单流水统计 ETL 任务,粒度 `(site_id, assistant_id, stat_date)`
- **MemberConsumptionTask**:会员消费汇总 ETL 任务,粒度 `(site_id, member_id, stat_date)`
- **AssistantDailyTask**:助教日度业绩明细 ETL 任务,粒度 `(site_id, assistant_id, stat_date)`
- **dws_assistant_order_contribution**:助教订单流水四项统计结果表
- **dws_member_consumption_summary**:会员消费汇总表(已有,需扩展字段)
- **dws_assistant_daily_detail**:助教日度业绩明细表(已有,需扩展字段)
- **order_gross_revenue**:订单总流水 — 助教参与订单的全部流水(台费 + 酒水食品 + 助教服务费)
- **order_net_revenue**:订单净流水 — 订单总流水 - 该订单所有助教的服务分成总额
- **time_weighted_revenue**:时效贡献流水 — 按助教个人服务时长折算的订单金额贡献
- **time_weighted_net_revenue**:时效净贡献 — 时效贡献流水 - 该助教个人的服务分成
- **penalty_minutes**:定档折算惩罚分钟数 — 因违规被扣减的定档业绩时长
- **per_hour_contribution**:单人每小时贡献流水 — 台费/房费每小时实收单价 ÷ 本次基础课助教人数
- **RLS 视图**:行级安全视图,位于 ETL 库 `app` schema`site_id` 隔离数据
- **FDW 映射**:外部数据包装器映射,将 ETL 库表映射到业务库 `fdw_etl` schema
- **settle_type**结算类型1=台桌结账3=商城订单5=充值订单
- **BaseDwsTask**DWS 层任务基类,提供 delete-before-insert 幂等机制
- **delete-before-insert**:幂等更新策略,先按条件删除旧记录再批量插入新记录
## 需求
### 需求 1助教订单流水统计表创建T4
**用户故事:** 作为 ETL 开发者,我需要创建助教订单流水四项统计表,以便存储每名助教每日的订单流水贡献数据。
#### 验收标准
1. THE 开发者 SHALL 创建 `dws.dws_assistant_order_contribution` 表,主键为 `(site_id, assistant_id, stat_date)`
2. THE dws_assistant_order_contribution 表 SHALL 包含四项统计字段:`order_gross_revenue`(订单总流水)、`order_net_revenue`(订单净流水)、`time_weighted_revenue`(时效贡献流水)、`time_weighted_net_revenue`(时效净贡献),精度为 `NUMERIC(14,2)`
3. THE dws_assistant_order_contribution 表 SHALL 包含辅助字段:`order_count`(参与订单数)、`total_service_seconds`(总服务时长秒数)、`assistant_nickname`(助教昵称)
4. THE dws_assistant_order_contribution 表 SHALL 包含元数据字段:`tenant_id``created_at``updated_at`
5. THE 开发者 SHALL 编写迁移脚本 `db/etl_feiqiu/migrations/<日期>__create_dws_assistant_order_contribution.sql`,在测试库 `test_etl_feiqiu` 中执行建表
6. WHEN DDL 在测试库执行成功后THE 开发者 SHALL 运行 `gen_consolidated_ddl.py` 导出最新 DDL
### 需求 2助教订单流水四项统计计算T5
**用户故事:** 作为产品经理,我需要助教订单流水四项统计(订单总流水/订单净流水/时效贡献流水/时效净贡献),以便评估助教个人能力。
#### 验收标准
1. THE AssistantOrderContributionTask SHALL 从 `dwd.dwd_settlement_head``dwd.dwd_table_fee_log``dwd.dwd_assistant_service_log` 提取订单、台费和助教服务数据
2. WHEN 计算 order_gross_revenue 时THE AssistantOrderContributionTask SHALL 将助教参与订单的全部流水(台费 + 酒水食品 + 所有助教服务费)累加,每个参与助教获得相同的订单总流水值
3. WHEN 计算 order_net_revenue 时THE AssistantOrderContributionTask SHALL 从订单总流水中减去该订单所有助教的服务分成总额,每个参与助教获得相同的订单净流水值
4. WHEN 计算 time_weighted_revenue 时THE AssistantOrderContributionTask SHALL 按以下步骤折算个人贡献:
- 确定每张台桌的有效计费时长:取 MAX(该台桌所有助教服务时长之和, 台桌使用时长)
- 按助教在该台桌的服务时长占比分摊台费
- 助教个人服务费直接计入
- 酒水食品按助教个人服务总时长占所有助教服务总时长的比例均分
5. WHEN 计算 time_weighted_net_revenue 时THE AssistantOrderContributionTask SHALL 从该助教的时效贡献流水中减去该助教个人的服务分成
6. WHEN 助教为超休/打赏课类型时THE AssistantOrderContributionTask SHALL 将四项统计均设为该助教个人的服务流水和分成(不参与订单级分摊)
7. THE AssistantOrderContributionTask SHALL 以任务代码 `DWS_ASSISTANT_ORDER_CONTRIBUTION` 注册到 task_registry
8. THE AssistantOrderContributionTask SHALL 采用 delete-before-insert 策略按日期窗口幂等更新
### 需求 3会员消费汇总扩展T6
**用户故事:** 作为产品经理,我需要客户 30/60/90 天充值次数和金额、次均消费,以便在客户看板中展示。
#### 验收标准
1. THE 开发者 SHALL 在 `dws.dws_member_consumption_summary` 表中新增以下字段:`recharge_count_30d``recharge_count_60d``recharge_count_90d`充值次数INTEGER`recharge_amount_30d``recharge_amount_60d``recharge_amount_90d`充值金额NUMERIC(14,2))、`avg_ticket_amount`次均消费额度NUMERIC(14,2)
2. THE 开发者 SHALL 编写 ALTER TABLE 迁移脚本在测试库 `test_etl_feiqiu` 中执行字段扩展
3. THE 充值数据 SHALL 从 `dwd.dwd_recharge_order` 提取,按 `member_id` 和时间窗口聚合
4. THE avg_ticket_amount SHALL 按公式 `total_consume_amount / MAX(total_visit_count, 1)` 计算
### 需求 4会员消费汇总任务修改T7
**用户故事:** 作为 ETL 开发者,我需要修改 MemberConsumptionTask 以填充新增的充值窗口和次均消费字段。
#### 验收标准
1. THE MemberConsumptionTask SHALL 在 extract 阶段新增充值订单提取逻辑,从 `dwd.dwd_recharge_order` 按 30/60/90 天窗口聚合充值次数和金额
2. THE MemberConsumptionTask SHALL 在 transform 阶段将充值统计和次均消费填充到输出记录中
3. WHEN 会员无充值记录时THE MemberConsumptionTask SHALL 将充值次数设为 0、充值金额设为 0.00
4. WHEN 会员无消费记录时THE MemberConsumptionTask SHALL 将 avg_ticket_amount 设为 0.00
### 需求 5助教日度明细表扩展 — 定档折算惩罚字段T8
**用户故事:** 作为 ETL 开发者,我需要在助教日度明细表中新增定档折算惩罚相关字段,以便存储惩罚检测和计算结果。
#### 验收标准
1. THE 开发者 SHALL 在 `dws.dws_assistant_daily_detail` 表中新增以下字段:`penalty_minutes` (NUMERIC(10,2))、`penalty_reason` (TEXT)、`is_exempt` (BOOLEAN DEFAULT FALSE)、`per_hour_contribution` (NUMERIC(14,2))
2. THE 开发者 SHALL 编写 ALTER TABLE 迁移脚本在测试库 `test_etl_feiqiu` 中执行字段扩展
3. WHEN 助教当日无惩罚时THE penalty_minutes SHALL 为 0penalty_reason SHALL 为 NULL
### 需求 6定档折算惩罚检测与计算逻辑T9
**用户故事:** 作为产品经理,我需要定档折算惩罚数据,以便在绩效页面展示折算详情,防止助教利用低价订单冲档位。
#### 验收标准
1. THE AssistantDailyTask SHALL 检测规则 2 违规:在指定区域(大厅 A/B/C/S/TV 和麻将房 M1M7同一台桌同一时间段超过 2 名助教挂台(课程时间段有重叠即算)
2. WHEN 检测到违规时THE AssistantDailyTask SHALL 计算单人每小时贡献流水:台费/房费每小时实收单价 ÷ 本次基础课助教人数
3. WHEN per_hour_contribution >= 24 元时THE AssistantDailyTask SHALL 按满额计入定档业绩时长penalty_minutes = 0
4. WHEN per_hour_contribution < 24 元时THE AssistantDailyTask SHALL 按比例折算:`penalty_minutes = 实际服务分钟数 × (1 - per_hour_contribution / 24)`
5. WHEN 订单标记为 is_exempt = TRUE 时THE AssistantDailyTask SHALL 跳过惩罚计算penalty_minutes 设为 0
6. THE 定档折算惩罚 SHALL 仅影响定档业绩时长统计,不影响实际工资时长
7. THE AssistantDailyTask SHALL 每日自动计算惩罚,计算频率与现有日度任务一致
### 需求 7RLS 视图创建T10
**用户故事:** 作为 ETL 开发者,我需要为新表创建 RLS 视图,以便通过 FDW 安全地向业务库暴露数据。
#### 验收标准
1. THE 开发者 SHALL 在 ETL 库 `app` schema 中为 `dws_assistant_order_contribution` 创建 RLS 视图,按 `site_id` 过滤
2. THE 开发者 SHALL 更新已有 RLS 视图以包含 `dws_member_consumption_summary``dws_assistant_daily_detail` 的新增字段
3. THE RLS 视图 SHALL 使用 `current_setting('app.current_site_id')::INTEGER` 进行行级过滤
### 需求 8FDW 映射同步T10
**用户故事:** 作为后端开发者,我需要在业务库中通过 FDW 访问新建和扩展的 ETL 表,以便小程序后端读取数据。
#### 验收标准
1. THE 开发者 SHALL 在 `test_zqyy_app.fdw_etl` schema 中创建 `dws_assistant_order_contribution` 的外部表映射
2. THE 开发者 SHALL 更新 `dws_member_consumption_summary``dws_assistant_daily_detail` 的 FDW 映射以包含新增字段
3. THE FDW 映射 SHALL 通过 `app` schema 的 RLS 视图访问数据,而非直接访问 `dws` schema
### 需求 9影子跑数验证T11
**用户故事:** 作为 ETL 开发者,我需要通过影子跑数验证新增统计的正确性,以便确保数据质量。
#### 验收标准
1. THE 开发者 SHALL 编写验证脚本,对照 PRD 示例数据验算四项统计的计算结果
2. THE 验证脚本 SHALL 检查 `dws_assistant_order_contribution` 中四项统计数值的一致性order_gross_revenue 各助教相等、order_net_revenue 各助教相等、time_weighted_revenue 各助教之和加上误差容忍度等于订单总流水
3. THE 验证脚本 SHALL 检查 `dws_member_consumption_summary` 新增字段有值且充值金额与 `dwd_recharge_order` 源数据一致
4. THE 验证脚本 SHALL 检查定档折算惩罚字段在符合惩罚条件的记录上正确填充
### 需求 10算法正确性测试
**用户故事:** 作为 ETL 开发者我需要通过属性测试hypothesis验证四项统计和惩罚计算的正确性。
#### 验收标准
1. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 order_gross_revenue 值相等
2. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 order_net_revenue 值相等
3. THE 属性测试 SHALL 验证:对于任意订单,所有参与助教的 time_weighted_revenue 之和应在订单总流水的合理误差范围内±0.01 元)
4. THE 属性测试 SHALL 验证对于任意助教time_weighted_net_revenue = time_weighted_revenue - 该助教个人服务分成
5. THE 属性测试 SHALL 验证:对于任意 per_hour_contribution >= 24 的记录penalty_minutes 为 0
6. THE 属性测试 SHALL 验证:对于任意 per_hour_contribution < 24 且 per_hour_contribution >= 0 的记录penalty_minutes = 实际分钟数 × (1 - per_hour_contribution / 24)
7. THE 属性测试 SHALL 验证对于任意会员avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)

View File

@@ -0,0 +1,152 @@
# 实现计划ETL DWS 层扩展 — 小程序数据支撑
## 概述
基于设计文档将实现拆分为DDL 建表/扩展 → 助教订单流水统计任务 → 会员消费汇总扩展 → 定档折算惩罚 → RLS 视图 + FDW 映射 → 影子跑数验证 六个阶段。所有数据库操作在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行。
## 任务
- [x] 1. DDL 建表与字段扩展
- [x] 1.1 编写迁移脚本创建 `dws.dws_assistant_order_contribution`
- 新建 `db/etl_feiqiu/migrations/<日期>__create_dws_assistant_order_contribution.sql`
- 包含表定义、唯一索引 `idx_aoc_site_assistant_date`、查询索引 `idx_aoc_stat_date`
- 字段参照设计文档数据模型章节
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
- [x] 1.2 编写迁移脚本扩展 `dws_member_consumption_summary` 字段
- 新建 `db/etl_feiqiu/migrations/<日期>__alter_member_consumption_add_recharge_fields.sql`
- ALTER TABLE 添加 `recharge_count_30d/60d/90d``recharge_amount_30d/60d/90d``avg_ticket_amount`
- _Requirements: 3.1, 3.2_
- [x] 1.3 编写迁移脚本扩展 `dws_assistant_daily_detail` 字段
- 新建 `db/etl_feiqiu/migrations/<日期>__alter_assistant_daily_add_penalty_fields.sql`
- ALTER TABLE 添加 `penalty_minutes``penalty_reason``is_exempt``per_hour_contribution`
- _Requirements: 5.1, 5.2_
- [x] 1.4 在测试库 `test_etl_feiqiu` 执行全部迁移脚本
- 通过 `PG_DSN`(指向测试库)连接执行 SQL
- _Requirements: 1.5, 3.2, 5.2_
- [x] 1.5 运行 `gen_consolidated_ddl.py` 导出最新 DDL
- 执行 `python scripts/ops/gen_consolidated_ddl.py`
- 验证 `docs/database/ddl/etl_feiqiu__dws.sql` 已包含新表和扩展字段
- _Requirements: 1.6_
- [x] 2. 实现助教订单流水统计任务
- [x] 2.1 创建数据结构和 `AssistantOrderContributionTask` 骨架
- 新建 `apps/etl/connectors/feiqiu/tasks/dws/assistant_order_contribution_task.py`
- 定义 `TableUsage``AssistantService``OrderData` dataclass
- 定义 `AssistantOrderContributionTask` 类继承 `BaseDwsTask`
- 实现 `get_task_code``get_target_table``get_primary_keys`
- _Requirements: 1.1, 2.7_
- [x] 2.2 实现四项统计核心计算(纯函数)
- 实现 `compute_order_gross_revenue` 静态方法
- 实现 `compute_order_net_revenue` 静态方法
- 实现 `compute_time_weighted_revenue` 静态方法(含台费分摊、酒水均分逻辑)
- 实现 `compute_time_weighted_net_revenue` 静态方法
- 处理超休/打赏课特殊情况
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 2.3 编写属性测试:订单级统计不变量
- **Property 1: 订单级统计不变量 — gross/net 各助教相等**
- **Validates: Requirements 2.2, 2.3, 10.1, 10.2**
- [x] 2.4 编写属性测试:时效贡献流水之和约束
- **Property 2: 时效贡献流水之和 ≤ order_gross_revenue**
- **Validates: Requirements 2.4, 10.3**
- [x] 2.5 编写属性测试:时效净贡献减法关系
- **Property 3: time_weighted_net_revenue = time_weighted_revenue - commission**
- **Validates: Requirements 2.5, 10.4**
- [x] 2.6 实现 `extract` 方法
-`dwd_settlement_head``dwd_table_fee_log``dwd_assistant_service_log` 提取数据
-`order_settle_id` 聚合为 `OrderData` 结构
- _Requirements: 2.1_
- [x] 2.7 实现 `transform` 方法
- 遍历订单,调用四项统计计算函数
-`(assistant_id, stat_date)` 聚合日度统计
- _Requirements: 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 2.8 注册任务到 task_registry 并导出模块
-`tasks/dws/__init__.py` 中导出 `AssistantOrderContributionTask`
-`orchestration/task_registry.py` 中注册 `DWS_ASSISTANT_ORDER_CONTRIBUTION``layer="DWS"``depends_on=["DWD_LOAD_FROM_ODS"]`
- _Requirements: 2.7, 2.8_
- [x] 3. 检查点 — 确保助教订单流水统计测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 确保所有属性测试通过,如有问题请询问用户。
- [x] 4. 扩展会员消费汇总任务
- [x] 4.1 在 `MemberConsumptionTask` 中新增充值统计提取
- 新增 `_extract_recharge_stats` 方法,从 `dwd.dwd_recharge_order` 按 30/60/90 天窗口聚合
-`extract` 方法中调用并返回充值统计数据
- _Requirements: 4.1, 3.3_
- [x] 4.2 在 `MemberConsumptionTask.transform` 中填充新字段
- 合并充值统计到输出记录
- 计算 `avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)`
- 处理无充值/无消费的边界情况
- _Requirements: 4.2, 4.3, 4.4, 3.4_
- [x] 4.3 编写属性测试:次均消费公式
- **Property 5: avg_ticket_amount = total_consume_amount / MAX(total_visit_count, 1)**
- **Validates: Requirements 3.4, 10.7**
- [x] 5. 实现定档折算惩罚检测与计算
- [x] 5.1 实现时间重叠检测逻辑
-`AssistantDailyTask` 中新增 `detect_overlap_violations` 静态方法
- 定义惩罚区域集合(大厅 A/B/C/S/TV + 麻将房 M1M7
-`(table_id, service_date)` 分组,检测时间段重叠且助教数 > 2
- _Requirements: 6.1_
- [x] 5.2 实现惩罚分钟数计算
-`AssistantDailyTask` 中新增 `compute_penalty_minutes` 静态方法
- 计算 `per_hour_contribution = 台费每小时单价 / 助教人数`
- 按分段公式计算 `penalty_minutes`
- 处理 `is_exempt = TRUE` 豁免逻辑
- _Requirements: 6.2, 6.3, 6.4, 6.5_
- [x] 5.3 集成惩罚逻辑到 `AssistantDailyTask.transform`
- 在现有聚合逻辑后调用重叠检测和惩罚计算
-`penalty_minutes``penalty_reason``is_exempt``per_hour_contribution` 填充到输出记录
- _Requirements: 6.6, 6.7_
- [x] 5.4 编写属性测试:惩罚分钟数分段公式
- **Property 4: 惩罚分钟数符合分段公式且在 [0, actual_minutes] 范围内**
- **Validates: Requirements 6.3, 6.4, 10.5, 10.6**
- [x] 5.5 编写属性测试:重叠检测正确性
- **Property 6: 3+ 助教时间重叠时应检测到违规**
- **Validates: Requirements 6.1**
- [x] 6. 检查点 — 确保惩罚计算和消费汇总测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/ -k "contribution or penalty or consumption" -v`
- 确保所有测试通过,如有问题请询问用户。
- [x] 7. RLS 视图与 FDW 映射
- [x] 7.1 创建 `dws_assistant_order_contribution` 的 RLS 视图
- 在测试库 `test_etl_feiqiu``app` schema 中创建 `v_dws_assistant_order_contribution` 视图
- 使用 `current_setting('app.current_site_id')::bigint` 过滤
- 授予 `app_reader` 角色 SELECT 权限
- _Requirements: 7.1, 7.3_
- [x] 7.2 验证已有 RLS 视图自动包含新增字段
- 确认 `app.v_dws_member_consumption_summary``app.v_dws_assistant_daily_detail` 使用 `SELECT *`,新增字段自动包含
- _Requirements: 7.2_
- [x] 7.3 创建/更新 FDW 外部表映射
- 在测试库 `test_zqyy_app``fdw_etl` schema 中创建 `dws_assistant_order_contribution` 外部表
- 重建 `dws_member_consumption_summary``dws_assistant_daily_detail` 的 FDW 外部表以包含新字段
- FDW 映射通过 `app` schema RLS 视图访问
- _Requirements: 8.1, 8.2, 8.3_
- [x] 8. 影子跑数验证
- [x] 8.1 编写验证脚本
- 新建 `apps/etl/connectors/feiqiu/scripts/verify_dws_extensions.py`
- 验证四项统计:对照 PRD 示例数据验算,检查 gross/net 各助教相等
- 验证充值窗口:检查新增字段有值,充值金额与源数据一致
- 验证惩罚字段:检查符合条件的记录正确填充
- _Requirements: 9.1, 9.2, 9.3, 9.4_
- [x] 8.2 编写数据库手册文档
- 新建 `docs/database/BD_Manual_dws_assistant_order_contribution.md`
- 包含表结构、字段说明、索引、验证 SQL至少 3 条)、兼容性说明、回滚策略
- 更新 `docs/database/``dws_member_consumption_summary``dws_assistant_daily_detail` 的文档
- _Requirements: 1.1_
- [x] 9. 最终检查点 — 确保所有测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_dws_contribution_properties.py -v`
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/ -k "contribution or penalty or consumption" -v`
- 确保所有测试通过,如有问题请询问用户。
## 备注
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务引用具体需求编号以确保可追溯
- 所有数据库操作在测试库(`test_etl_feiqiu` / `test_zqyy_app`)中进行
- 检查点确保增量验证
- 属性测试验证全称正确性属性,单元测试验证具体示例和边界情况

View File

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

View File

@@ -0,0 +1,859 @@
# 设计文档小程序用户认证系统miniapp-auth-system
## 概述
本设计在 P1miniapp-db-foundation已建立的 `auth` Schema 基础上,实现完整的小程序用户认证链路:
1. **微信登录**:小程序端发送 `code` → 后端调用微信 `code2Session` → 获取 `openid` → 创建/查找用户 → 签发 JWT
2. **用户申请**新用户填写球房ID + 手机号 + 申请身份 → 系统自动匹配助教/员工 → 管理员审核
3. **权限控制**:基于 `user_site_roles` + `role_permissions` 的 RBAC 模型,权限中间件拦截无权请求
4. **多店铺支持**:一个用户可关联多个 `site_id`,切换店铺时重新签发 JWT
**环境变量依赖**
| 环境变量 | 用途 | 来源 |
|---------|------|------|
| `APP_DB_DSN` / `DB_HOST` 等 | 业务库连接 | 根 `.env` |
| `PG_DSN` / `ETL_DB_HOST` 等 | ETL 库连接FDW 匹配) | 根 `.env` |
| `JWT_SECRET_KEY` | JWT 签名密钥 | `.env.local` |
| `WX_APPID` | 微信小程序 AppID | `.env.local` |
| `WX_SECRET` | 微信小程序 AppSecret | `.env.local` |
**整体认证流程**
```
小程序端 FastAPI 后端 微信服务器
│ │ │
│── wx.login() ──► │ │
│ 获取 code │ │
│ │ │
│── POST /api/xcx/login ──► │ │
│ {code} │── GET code2Session ──────────► │
│ │◄── {openid, session_key} ──── │
│ │ │
│ │── 查找/创建 auth.users ──► │
│ │── 签发 JWT ──► │
│◄── {jwt, status} ─────── │ │
│ │ │
│ [status=pending] │ │
│── POST /api/xcx/apply ──► │ │
│ {site_code, phone, ...} │── 创建 user_applications ──► │
│◄── {application_id} ───── │ │
```
## 架构
### 分层架构
```mermaid
graph TB
subgraph "小程序端"
MP["微信小程序<br/>wx.login() / wx.request()"]
end
subgraph "FastAPI 后端apps/backend/"
subgraph "路由层"
XCX_AUTH["routers/xcx_auth.py<br/>微信登录 + 申请"]
XCX_USER["routers/xcx_user.py<br/>用户状态 + 店铺切换"]
ADMIN_APP["routers/admin_applications.py<br/>管理端审核"]
end
subgraph "中间件层"
PERM_MW["middleware/permission.py<br/>权限中间件"]
end
subgraph "服务层"
WX_SVC["services/wechat.py<br/>code2Session 调用"]
APP_SVC["services/application.py<br/>申请 CRUD + 审核"]
MATCH_SVC["services/matching.py<br/>人员匹配"]
ROLE_SVC["services/role.py<br/>角色权限查询"]
end
subgraph "认证层(已有 + 扩展)"
JWT["auth/jwt.py<br/>JWT 签发/验证(扩展)"]
DEPS["auth/dependencies.py<br/>依赖注入(扩展)"]
end
DB["database.py<br/>数据库连接"]
end
subgraph "数据库"
AUTH_SCHEMA["auth Schema<br/>users / applications / roles / ..."]
FDW_ETL["fdw_etl Schema<br/>v_dim_assistant / v_dim_staff"]
end
subgraph "外部服务"
WX_API["微信 API<br/>code2Session"]
end
MP --> XCX_AUTH
MP --> XCX_USER
XCX_AUTH --> PERM_MW
XCX_USER --> PERM_MW
ADMIN_APP --> PERM_MW
PERM_MW --> JWT
PERM_MW --> DEPS
XCX_AUTH --> WX_SVC
XCX_AUTH --> APP_SVC
ADMIN_APP --> APP_SVC
ADMIN_APP --> MATCH_SVC
XCX_USER --> ROLE_SVC
WX_SVC --> WX_API
APP_SVC --> DB
MATCH_SVC --> DB
ROLE_SVC --> DB
DB --> AUTH_SCHEMA
DB --> FDW_ETL
```
### 请求处理流程
```mermaid
sequenceDiagram
participant MP as 小程序
participant MW as Permission Middleware
participant R as Router
participant S as Service
participant DB as PostgreSQL
MP->>R: POST /api/xcx/login {code}
R->>S: wechat.code2session(code)
S-->>R: openid
R->>DB: SELECT FROM auth.users WHERE wx_openid = ?
alt 用户不存在
R->>DB: INSERT INTO auth.users
end
R->>R: jwt.create_token_pair(user_id, site_id)
R-->>MP: {access_token, refresh_token, status}
Note over MP,DB: 后续请求(需认证)
MP->>MW: GET /api/xcx/... (Bearer token)
MW->>MW: decode_access_token(token)
MW->>DB: SELECT permissions FROM auth.user_site_roles JOIN ...
alt 权限不足
MW-->>MP: 403 Forbidden
else 权限通过
MW->>R: 放行
R->>S: 业务逻辑
S->>DB: 数据操作
R-->>MP: 200 OK
end
```
## 组件与接口
### 组件 1微信认证服务services/wechat.py
**职责**:封装微信 `code2Session` API 调用。
```python
import httpx
from app.config import get
WX_APPID: str = get("WX_APPID", "")
WX_SECRET: str = get("WX_SECRET", "")
CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session"
async def code2session(code: str) -> dict:
"""
调用微信 code2Session 接口。
返回:
{"openid": str, "session_key": str, "unionid": str | None}
异常:
WeChatAuthError: 微信接口返回错误码时抛出
"""
...
class WeChatAuthError(Exception):
"""微信认证错误,包含 errcode 和 errmsg。"""
def __init__(self, errcode: int, errmsg: str): ...
```
**设计决策**
- 使用 `httpx.AsyncClient` 异步调用微信 API与 FastAPI 异步模型一致
- `WX_APPID` / `WX_SECRET` 从环境变量读取,缺失时在调用时报错(而非启动时,因为非所有端点都需要微信认证)
### 组件 2申请服务services/application.py
**职责**:处理用户申请的创建、查询、审核。
```python
async def create_application(
user_id: int,
site_code: str,
applied_role_text: str,
phone: str,
employee_number: str | None = None,
nickname: str | None = None,
) -> dict:
"""
创建用户申请。
1. 查找 site_code → site_id 映射
2. 检查是否有 pending 申请(有则 409
3. 插入 user_applications 记录
4. 更新 users.nickname如提供
"""
...
async def approve_application(
application_id: int,
reviewer_id: int,
role_id: int,
binding: dict | None = None, # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
review_note: str | None = None,
) -> dict:
"""
批准申请。
1. 检查申请状态为 pending否则 409
2. 更新 user_applications.status = 'approved'
3. 创建 user_site_roles 记录
4. 创建 user_assistant_binding 记录(如有 binding
5. 更新 users.status = 'approved'(如果是首次通过)
6. 记录 reviewer_id 和 reviewed_at
"""
...
async def reject_application(
application_id: int,
reviewer_id: int,
review_note: str,
) -> dict:
"""
拒绝申请。
1. 检查申请状态为 pending否则 409
2. 更新 user_applications.status = 'rejected'
3. 记录 reviewer_id、review_note、reviewed_at
"""
...
async def get_user_applications(user_id: int) -> list[dict]:
"""查询用户的所有申请记录。"""
...
```
### 组件 3人员匹配服务services/matching.py
**职责**:根据申请信息在 FDW 外部表中查找候选匹配。
```python
async def find_candidates(
site_id: int,
phone: str,
employee_number: str | None = None,
) -> list[dict]:
"""
在助教表和员工表中查找匹配候选。
查询逻辑:
1. fdw_etl.v_dim_assistant: WHERE site_id = ? AND mobile = ?
2. fdw_etl.v_dim_staff + v_dim_staff_ex: WHERE site_id = ? AND (mobile = ? OR job_num = ?)
3. 合并结果,每条包含 source_type / name / mobile / job_num
注意:查询 FDW 外部表前需设置 app.current_site_idRLS 隔离)。
但 fdw_etl 中的外部表映射的是 app schema 的 RLS 视图,
所以需要在 ETL 库连接上设置 site_id。
实际上,我们直接在业务库通过 fdw_etl 查询,
FDW 会透传 session 变量到远端。
返回:
[{"source_type": "assistant"|"staff", "id": int, "name": str, "mobile": str, "job_num": str | None}]
"""
...
```
**设计决策**
- FDW 查询需要在业务库连接上设置 `app.current_site_id`,因为 FDW 外部表映射的是 ETL 库 `app` Schema 的 RLS 视图
- 匹配查询使用业务库连接(`get_connection()`),通过 `SET LOCAL app.current_site_id` 设置隔离
- 如果 `site_code` 无法映射到 `site_id`,直接返回空列表
### 组件 4角色权限服务services/role.py
**职责**:查询用户在指定店铺下的角色和权限。
```python
async def get_user_permissions(user_id: int, site_id: int) -> list[str]:
"""
获取用户在指定 site_id 下的权限 code 列表。
SQL: SELECT DISTINCT p.code
FROM auth.user_site_roles usr
JOIN auth.role_permissions rp ON usr.role_id = rp.role_id
JOIN auth.permissions p ON rp.permission_id = p.id
WHERE usr.user_id = ? AND usr.site_id = ?
"""
...
async def get_user_sites(user_id: int) -> list[dict]:
"""
获取用户关联的所有店铺及对应角色。
返回: [{"site_id": int, "site_name": str, "roles": [{"code": str, "name": str}]}]
"""
...
async def check_user_has_site_role(user_id: int, site_id: int) -> bool:
"""检查用户在指定 site_id 下是否有任何角色绑定。"""
...
```
### 组件 5权限中间件middleware/permission.py
**职责**:基于 JWT 中的 `user_id` + `site_id` 检查用户权限。
```python
from functools import wraps
from fastapi import Depends, HTTPException, status
from app.auth.dependencies import get_current_user, CurrentUser
def require_permission(*permission_codes: str):
"""
权限装饰器/依赖,用于路由端点。
用法:
@router.get("/finance")
async def get_finance(
user: CurrentUser = Depends(require_permission("view_board_finance"))
):
...
逻辑:
1. 从 JWT 提取 user_id + site_id
2. 查询 auth.users.status非 approved 则 403
3. 查询 user_site_roles + role_permissions 获取权限列表
4. 检查是否包含所需权限,不包含则 403
"""
...
def require_approved():
"""
仅检查用户状态为 approved 的依赖(不检查具体权限)。
用于通用的已认证端点。
"""
...
```
**设计决策**
- 使用 FastAPI 依赖注入模式而非全局中间件,更灵活且可按端点配置
- `pending` 用户只能访问申请提交和状态查询端点,其他端点需要 `approved` 状态
- 权限检查结果可考虑短期缓存(当前版本不缓存,每次查库)
### 组件 6JWT 服务扩展auth/jwt.py 扩展)
**职责**:扩展现有 JWT 服务,支持微信登录场景。
**扩展内容**
```python
# 新增创建受限令牌pending 用户)
def create_limited_token_pair(user_id: int) -> dict[str, str]:
"""
为 pending 用户签发受限令牌。
payload 不含 site_id 和 roles仅包含 user_id + type + limited=True。
"""
...
# 扩展create_access_token payload 增加 roles 字段
def create_access_token(user_id: int, site_id: int, roles: list[str] | None = None) -> str:
"""
生成 access_token。
payload: sub=user_id, site_id, roles, type=access, exp
"""
...
```
**设计决策**
- 保持向后兼容:现有 `create_access_token(user_id, site_id)` 调用不受影响(`roles` 默认 `None`
- `pending` 用户的受限令牌通过 `limited=True` 标记区分,权限中间件据此拦截
### 组件 7路由端点
#### 7.1 小程序认证路由routers/xcx_auth.py
| 方法 | 路径 | 说明 | 认证要求 |
|------|------|------|---------|
| POST | `/api/xcx/login` | 微信登录 | 无(公开) |
| POST | `/api/xcx/apply` | 提交申请 | JWT含 pending |
| GET | `/api/xcx/me` | 查询自身状态 | JWT含 pending |
| GET | `/api/xcx/me/sites` | 查询关联店铺 | JWTapproved |
| POST | `/api/xcx/switch-site` | 切换店铺 | JWTapproved |
| POST | `/api/xcx/refresh` | 刷新令牌 | refresh_token |
#### 7.2 管理端审核路由routers/admin_applications.py
| 方法 | 路径 | 说明 | 认证要求 |
|------|------|------|---------|
| GET | `/api/admin/applications` | 查询申请列表 | JWT + site_admin/tenant_admin |
| GET | `/api/admin/applications/{id}` | 查询申请详情 + 候选匹配 | JWT + site_admin/tenant_admin |
| POST | `/api/admin/applications/{id}/approve` | 批准申请 | JWT + site_admin/tenant_admin |
| POST | `/api/admin/applications/{id}/reject` | 拒绝申请 | JWT + site_admin/tenant_admin |
### 组件 8Pydantic 模型schemas/xcx_auth.py
```python
from pydantic import BaseModel, Field
import re
class WxLoginRequest(BaseModel):
code: str = Field(..., min_length=1, description="微信临时登录凭证")
class WxLoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user_status: str # pending / approved / rejected / disabled
user_id: int
class ApplicationRequest(BaseModel):
site_code: str = Field(..., pattern=r"^[A-Za-z]{2}\d{3}$", description="球房ID")
applied_role_text: str = Field(..., min_length=1, max_length=100)
phone: str = Field(..., pattern=r"^\d{11}$", description="手机号")
employee_number: str | None = Field(None, max_length=50)
nickname: str | None = Field(None, max_length=50)
class ApplicationResponse(BaseModel):
id: int
site_code: str
applied_role_text: str
status: str
review_note: str | None = None
created_at: str
reviewed_at: str | None = None
class UserStatusResponse(BaseModel):
user_id: int
status: str
nickname: str | None
applications: list[ApplicationResponse]
class SiteInfo(BaseModel):
site_id: int
site_name: str
roles: list[dict]
class SwitchSiteRequest(BaseModel):
site_id: int
class MatchCandidate(BaseModel):
source_type: str # assistant / staff
id: int
name: str
mobile: str | None
job_num: str | None
class ApproveRequest(BaseModel):
role_id: int
binding: dict | None = None # {"assistant_id": ..., "staff_id": ..., "binding_type": ...}
review_note: str | None = None
class RejectRequest(BaseModel):
review_note: str = Field(..., min_length=1)
```
## 数据模型
### ER 图
```mermaid
erDiagram
users {
serial id PK
varchar wx_openid UK
varchar wx_union_id
varchar wx_avatar_url
varchar nickname
varchar phone
varchar status "pending/approved/rejected/disabled"
timestamptz created_at
timestamptz updated_at
}
user_applications {
serial id PK
int user_id FK
varchar site_code
int site_id "可空,映射后填入"
varchar applied_role_text
varchar employee_number "可选"
varchar phone
varchar status "pending/approved/rejected"
int reviewer_id
text review_note
timestamptz created_at
timestamptz reviewed_at
}
site_code_mapping {
serial id PK
varchar site_code UK "2字母+3数字"
bigint site_id UK
varchar site_name
int tenant_id
timestamptz created_at
}
roles {
serial id PK
varchar code UK
varchar name
text description
timestamptz created_at
}
permissions {
serial id PK
varchar code UK
varchar name
text description
timestamptz created_at
}
role_permissions {
int role_id FK
int permission_id FK
}
user_site_roles {
serial id PK
int user_id FK
bigint site_id
int role_id FK
timestamptz created_at
}
user_assistant_binding {
serial id PK
int user_id FK
bigint site_id
bigint assistant_id "可空"
bigint staff_id "可空"
varchar binding_type "assistant/staff/manager"
timestamptz created_at
}
users ||--o{ user_applications : "提交申请"
users ||--o{ user_site_roles : "店铺角色"
users ||--o{ user_assistant_binding : "人员绑定"
roles ||--o{ user_site_roles : "角色分配"
roles ||--o{ role_permissions : "角色权限"
permissions ||--o{ role_permissions : "权限定义"
site_code_mapping ||--o{ user_applications : "球房映射"
```
### 表 DDL 概要
所有表在 `auth` Schema 下,迁移脚本位于 `db/zqyy_app/migrations/`
**关键约束**
- `users.wx_openid` UNIQUE — 一个微信用户对应一条记录
- `site_code_mapping.site_code` UNIQUE — 球房ID 唯一
- `site_code_mapping.site_id` UNIQUE — site_id 唯一映射
- `user_site_roles (user_id, site_id, role_id)` UNIQUE — 防止重复分配
- `role_permissions (role_id, permission_id)` 联合主键
**索引**
- `users`: `ix_users_wx_openid` (wx_openid)
- `user_applications`: `ix_user_applications_user_id` (user_id), `ix_user_applications_status` (status)
- `user_site_roles`: `ix_user_site_roles_user_site` (user_id, site_id)
- `site_code_mapping`: `ix_site_code_mapping_site_code` (site_code)
### 迁移脚本清单
| 序号 | 文件名 | 内容 |
|------|--------|------|
| 1 | `YYYY-MM-DD__p3_create_auth_tables.sql` | 创建 users / user_applications / site_code_mapping / roles / permissions / role_permissions / user_site_roles / user_assistant_binding |
| 2 | `YYYY-MM-DD__p3_seed_roles_permissions.sql` | 种子数据:权限列表 + 默认角色 + 角色权限映射 |
## 正确性属性Correctness Properties
*属性是系统在所有有效执行中都应保持为真的特征或行为——本质上是关于系统应该做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1迁移脚本幂等性
*For any* 本次新增的迁移脚本DDL + 种子数据),连续执行两次的结果应与执行一次相同——第二次执行不应产生错误,且数据库状态不变。
**Validates: Requirements 1.9, 2.4, 11.5**
### Property 2登录创建/查找用户正确性
*For any* 有效的微信 `openid`,调用登录逻辑后:若该 `openid` 已存在于 `auth.users` 中,应返回已有用户的 `user_id`若不存在应创建新用户status=`pending`)并返回新 `user_id`。无论哪种情况,返回的 JWT 中 `sub` 应等于该 `user_id`
**Validates: Requirements 3.2, 3.3**
### Property 3disabled 用户登录拒绝
*For any* `auth.users` 中 status 为 `disabled` 的用户,通过其 `openid` 登录时应返回 403 错误,不签发 JWT。
**Validates: Requirements 3.5**
### Property 4申请创建正确性
*For any* 有效的申请数据(合法 `site_code` 格式、11 位手机号、非空 `applied_role_text`),提交申请后 `auth.user_applications` 中应新增一条 status=`pending` 的记录。若 `site_code``site_code_mapping` 中有映射,记录的 `site_id` 应等于映射值;若无映射,`site_id` 为 NULL 但申请仍成功。若提供了 `nickname``auth.users` 中该用户的 `nickname` 应更新。
**Validates: Requirements 4.1, 4.2, 4.3, 4.4**
### Property 5手机号格式验证
*For any* 非 11 位纯数字的字符串作为 `phone` 提交申请,系统应返回 422 错误,`auth.user_applications` 中不应新增记录。
**Validates: Requirements 4.5**
### Property 6重复申请拒绝
*For any* 已有一条 status=`pending` 申请的用户,再次提交申请时应返回 409 错误,`auth.user_applications` 中不应新增记录。
**Validates: Requirements 4.6**
### Property 7人员匹配合并正确性
*For any* 有效的 `site_id``phone` 组合,匹配服务返回的候选列表应满足:(1) 每条候选的 `source_type``assistant``staff`(2) 助教来源的候选来自 `v_dim_assistant` 表中 `site_id``mobile` 匹配的记录;(3) 员工来源的候选来自 `v_dim_staff` 表中 `site_id``mobile`(或 `job_num`)匹配的记录;(4) 列表是两个来源结果的并集,无遗漏。
**Validates: Requirements 5.1, 5.2, 5.3, 5.4**
### Property 8审核操作正确性
*For any* status=`pending` 的申请:(1) 批准操作后,申请 status 变为 `approved``auth.user_site_roles` 中新增角色记录,`auth.users.status` 变为 `approved``reviewer_id``reviewed_at` 非空;(2) 若提供了 binding 信息,`auth.user_assistant_binding` 中新增绑定记录;(3) 拒绝操作后,申请 status 变为 `rejected``review_note` 非空,`reviewer_id``reviewed_at` 非空。
**Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
### Property 9非 pending 申请审核拒绝
*For any* status 不是 `pending` 的申请(`approved` / `rejected`),对其执行批准或拒绝操作应返回 409 错误,申请状态不变。
**Validates: Requirements 6.6**
### Property 10用户状态查询完整性
*For any* 用户,查询自身状态应返回:(1) 用户的 `status` 字段;(2) 该用户所有申请记录的完整列表。若用户 status 为 `approved`,还应返回已关联的店铺列表和对应角色。
**Validates: Requirements 7.1, 7.2**
### Property 11多店铺角色独立分配
*For any* 用户和多个不同的 `site_id`,系统应允许为该用户在每个 `site_id` 下独立分配不同的角色,且 `auth.user_site_roles` 中的记录互不干扰。
**Validates: Requirements 8.1**
### Property 12店铺切换令牌正确性
*For any* 拥有多店铺绑定的 approved 用户,切换到目标 `site_id` 后签发的新 JWT 中 `site_id` 应等于目标值,`roles` 应等于该用户在目标 `site_id` 下的角色列表。若用户在目标 `site_id` 下无角色绑定,切换应失败。
**Validates: Requirements 8.2, 10.4**
### Property 13权限中间件拦截正确性
*For any* 用户、`site_id` 和所需权限 code 的组合:(1) 若用户 status 非 `approved`,返回 403(2) 若用户在该 `site_id` 下的权限列表不包含所需权限,返回 403(3) 若用户在该 `site_id` 下拥有所需权限且 status 为 `approved`,放行。
**Validates: Requirements 8.3, 9.1, 9.2, 9.3**
### Property 14JWT payload 结构与状态一致性
*For any* 通过登录签发的 JWT(1) 解码后应包含 `sub`user_id`type``exp` 字段;(2) 若用户 status 为 `approved`payload 应包含 `site_id``roles`(3) 若用户 status 为 `pending`payload 应包含 `limited=True`,不含 `site_id``roles`
**Validates: Requirements 10.1, 10.2, 10.3**
### Property 15JWT 过期/无效令牌拒绝
*For any* 过期的 JWT 或被篡改的 JWT 字符串,权限中间件应返回 401 错误,不放行请求。
**Validates: Requirements 9.4**
## 错误处理
### API 错误码规范
| HTTP 状态码 | 场景 | 响应体 |
|------------|------|--------|
| 401 | JWT 无效/过期、微信 code2Session 失败 | `{"detail": "具体错误描述"}` |
| 403 | 用户 disabled、权限不足、用户未 approved | `{"detail": "具体错误描述"}` |
| 404 | 申请不存在 | `{"detail": "申请不存在"}` |
| 409 | 重复提交 pending 申请、审核非 pending 申请 | `{"detail": "具体冲突描述"}` |
| 422 | 请求体校验失败手机号格式、site_code 格式等) | Pydantic 标准错误格式 |
| 500 | 数据库连接失败、微信 API 超时 | `{"detail": "服务器内部错误"}` |
### 微信 API 错误处理
| 微信 errcode | 含义 | 处理方式 |
|-------------|------|---------|
| 0 | 成功 | 正常流程 |
| 40029 | code 无效 | 返回 401提示"登录凭证无效,请重新登录" |
| 45011 | 频率限制 | 返回 429提示"请求过于频繁" |
| 40226 | 高风险用户 | 返回 403提示"账号存在风险" |
| 其他 | 未知错误 | 返回 401记录日志提示"微信登录失败" |
### 数据库错误处理
| 场景 | 处理方式 |
|------|---------|
| 连接失败 | 捕获 `psycopg2.OperationalError`,返回 500 |
| 唯一约束冲突wx_openid | 并发创建时捕获 `UniqueViolation`,改为查询已有记录 |
| 外键约束失败 | 返回 422提示具体的关联数据不存在 |
| FDW 查询失败 | 捕获异常,匹配服务返回空列表,记录日志 |
### 环境变量缺失处理
| 变量 | 缺失时行为 |
|------|-----------|
| `WX_APPID` / `WX_SECRET` | 微信登录端点返回 500日志记录"微信配置缺失" |
| `JWT_SECRET_KEY` | 应用启动时警告空字符串不安全JWT 签发/验证使用空密钥(仅开发环境) |
| `DB_HOST` 等数据库参数 | 数据库连接失败,返回 500 |
## 测试策略
### DDL 测试库落库与文档同步
DDL 变更必须经过以下流程:
1. **测试库执行**:在 `test_zqyy_app` 中执行迁移脚本,验证无错误
2. **幂等性验证**:连续执行两次,第二次无错误
3. **数据库手册更新**:创建/更新 `docs/database/BD_Manual_auth_tables.md`,格式参照现有 `BD_Manual_auth_biz_schemas.md`
4. **DDL 基线刷新**:运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 `docs/database/ddl/zqyy_app__auth.sql`
### 小程序认证前端页面
#### 页面清单
| 页面 | 路径 | 说明 | H5 原型 |
|------|------|------|---------|
| login | `pages/login/login` | 微信登录页(自动调用 wx.login | `docs/h5_ui/pages/login.html` |
| apply | `pages/apply/apply` | 申请表单页球房ID + 手机号 + 身份 + 编号 + 昵称) | `docs/h5_ui/pages/apply.html` |
| reviewing | `pages/reviewing/reviewing` | 审核等待页(显示状态 + 申请摘要) | `docs/h5_ui/pages/reviewing.html` |
| no-permission | `pages/no-permission/no-permission` | 无权限/已禁用页 | `docs/h5_ui/pages/no-permission.html` |
#### 认证路由流程
```
app.ts onLaunch()
├── wx.login() → 获取 code
├── POST /api/xcx/login {code}
│ │
│ ├── 返回 user_status = "approved"
│ │ └── 跳转主页task-list 或 home
│ │
│ ├── 返回 user_status = "pending"
│ │ ├── 查询 /api/xcx/me → 有 pending 申请
│ │ │ └── 跳转 reviewing 页面
│ │ └── 查询 /api/xcx/me → 无 pending 申请
│ │ └── 跳转 apply 页面
│ │
│ ├── 返回 user_status = "rejected"
│ │ └── 跳转 reviewing 页面(显示拒绝原因 + 重新申请按钮)
│ │
│ └── 返回 403disabled
│ └── 跳转 no-permission 页面
└── 登录失败(网络错误等)
└── 显示错误提示,提供重试按钮
```
#### app.ts 全局状态管理
```typescript
// globalData 扩展
interface IAppOption {
globalData: {
userInfo?: {
userId: number;
status: string; // pending / approved / rejected / disabled
nickname?: string;
};
token?: string;
refreshToken?: string;
currentSiteId?: number;
sites?: Array<{ siteId: number; siteName: string; roles: string[] }>;
};
}
```
#### 请求封装utils/request.ts
```typescript
/**
* 统一请求封装:
* 1. 自动附加 Authorization: Bearer <token>
* 2. 401 时自动尝试 refresh_token 刷新
* 3. 刷新失败时跳转 login 页面
*/
function request(options: RequestOptions): Promise<any> { ... }
```
### 开发模式联调
#### Mock 登录端点
后端在 `WX_DEV_MODE=true` 时注册 `POST /api/xcx/dev-login`
```python
@router.post("/api/xcx/dev-login")
async def dev_login(openid: str, status: str = "approved"):
"""
开发模式 mock 登录。
直接根据 openid 查找/创建用户,跳过微信 code2Session。
可通过 status 参数模拟不同用户状态。
仅在 WX_DEV_MODE=true 时可用。
"""
...
```
#### 微信开发者工具联调步骤
联调指南文档位于 `apps/miniprogram/doc/auth-integration-guide.md`,包含:
1. 微信开发者工具项目导入配置appid、不校验合法域名
2. 后端启动命令(`cd apps/backend && uvicorn app.main:app --reload`
3. 小程序请求域名配置(开发环境指向 `http://localhost:8000`
4. 测试流程:登录 → 申请 → 管理端审核 → 重新登录验证
5. Mock 模式使用说明
### 属性测试Property-Based Testing
使用 Python `hypothesis` 框架,测试目录:`tests/`Monorepo 级属性测试目录)。
每个属性测试至少运行 100 次迭代。每个测试用注释标注对应的设计属性编号。
标注格式:`# Feature: miniapp-auth-system, Property N: <属性标题>`
**属性测试清单**
| 属性 | 测试文件 | 测试方法 | 生成器 |
|------|---------|---------|--------|
| P2 登录创建/查找用户 | `tests/test_auth_system_properties.py` | 生成随机 openid模拟登录验证用户创建/查找逻辑 | `hypothesis.strategies.text` 生成 openid |
| P4 申请创建正确性 | `tests/test_auth_system_properties.py` | 生成随机合法申请数据,验证申请记录创建 | 自定义 strategy 生成 site_code2字母+3数字、phone11位数字 |
| P5 手机号格式验证 | `tests/test_auth_system_properties.py` | 生成随机非法手机号,验证 422 拒绝 | `hypothesis.strategies.text` 过滤非 11 位数字 |
| P6 重复申请拒绝 | `tests/test_auth_system_properties.py` | 生成随机用户+申请,提交两次,验证第二次 409 | 复用申请数据生成器 |
| P7 人员匹配合并 | `tests/test_auth_system_properties.py` | 生成随机助教/员工数据,验证匹配结果合并 | 自定义 strategy 生成匹配数据 |
| P8 审核操作正确性 | `tests/test_auth_system_properties.py` | 生成随机 pending 申请,执行批准/拒绝,验证状态流转 | 自定义 strategy 生成审核数据 |
| P9 非 pending 审核拒绝 | `tests/test_auth_system_properties.py` | 生成随机非 pending 申请,验证 409 | `hypothesis.strategies.sampled_from(["approved", "rejected"])` |
| P11 多店铺角色独立 | `tests/test_auth_system_properties.py` | 生成随机用户+多个 site_id验证角色独立分配 | `hypothesis.strategies.lists` 生成 site_id 列表 |
| P12 店铺切换令牌 | `tests/test_auth_system_properties.py` | 生成多店铺用户,切换店铺,验证 JWT 内容 | 复用多店铺生成器 |
| P13 权限中间件拦截 | `tests/test_auth_system_properties.py` | 生成随机用户+权限组合,验证中间件判断 | 自定义 strategy 生成权限矩阵 |
| P14 JWT payload 结构 | `tests/test_auth_system_properties.py` | 生成随机用户(不同 status签发 JWT验证 payload | `hypothesis.strategies.sampled_from(["pending", "approved"])` |
| P15 JWT 过期/无效拒绝 | `tests/test_auth_system_properties.py` | 生成随机过期/篡改 JWT验证 401 | 自定义 strategy 生成无效 JWT |
**注意**P1迁移幂等性、P3disabled 登录拒绝、P10状态查询完整性作为集成测试在后端测试目录实现因为它们需要真实数据库环境或涉及具体的数据库状态验证。
### 单元测试
单元测试位于 `apps/backend/tests/`,聚焦于:
- `test_xcx_auth_router.py`微信登录路由测试mock 微信 API
- `test_application_service.py`:申请服务的边界情况
- `test_matching_service.py`匹配逻辑的边界情况空结果、FDW 异常)
- `test_permission_middleware.py`:权限中间件的各种组合
- `test_jwt_extended.py`:扩展 JWT 的 limited token 逻辑
### 集成测试
集成测试通过验证脚本实现,覆盖:
- 迁移脚本幂等性验证(执行两次无错误)
- 种子数据完整性验证(权限和角色数量正确)
- 完整认证流程:登录 → 申请 → 审核 → 权限验证

View File

@@ -0,0 +1,196 @@
# 需求文档小程序用户认证系统miniapp-auth-system
## 简介
本 SPEC 实现小程序用户认证系统,涵盖微信登录、用户申请审核、人员匹配、多店铺权限管理等完整认证链路。系统基于 P1miniapp-db-foundation已建立的 `auth` Schema 和 FDW 映射,在 `test_zqyy_app.auth` 中创建用户、申请、角色、绑定等业务表,并在 FastAPI 后端实现对应的 API 端点和权限中间件。
## 术语表
- **Auth_System**:小程序用户认证系统,负责微信登录、用户管理、申请审核、权限控制的完整后端服务
- **WeChat_Auth_Service**:微信认证服务模块,负责调用微信 `code2Session` 接口换取 `openid``session_key`
- **Application_Service**:用户申请服务模块,负责处理用户提交的入驻申请、状态流转和审核操作
- **Matching_Service**人员匹配服务模块负责根据球房ID和手机号/编号在助教表和员工表中查找候选匹配
- **Permission_Middleware**:权限中间件,基于用户的 `site_id` + `role` 拦截无权请求
- **JWT_Service**JWT 令牌服务,负责签发和刷新 access_token / refresh_token已有实现本 SPEC 扩展)
- **site_code**球房ID格式为 2 字母 + 3 数字(如 `AB123`),与 `site_id` 一一映射
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **user_status**:用户状态枚举,取值为 `pending`(审核中)/ `approved`(已通过)/ `rejected`(已拒绝)/ `disabled`(已禁用)
- **binding_type**:绑定类型枚举,取值为 `assistant`(助教)/ `staff`(员工)/ `manager`(管理员)
- **FDW**`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库数据
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
- **BD_Manual**:数据库手册文档,存放在 `docs/database/` 中,记录表结构变更、兼容性影响、回滚策略和验证 SQL
- **DDL_Baseline**DDL 基线文件,存放在 `docs/database/ddl/` 中,由 `gen_consolidated_ddl.py` 自动生成
- **Miniprogram_Auth_Pages**:小程序认证相关前端页面,包括登录页、申请表单页、审核等待页、无权限页
- **Dev_Login**:开发模式下的 mock 登录端点,绕过微信 code2Session 调用,用于联调测试
## 需求
### 需求 1认证数据表创建
**用户故事:** 作为后端开发者,我需要在 `auth` Schema 中创建用户认证相关的数据表,以便支撑完整的认证和权限管理功能。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `users` 表,包含 `id`SERIAL PK`wx_openid`UNIQUE`wx_union_id``wx_avatar_url``nickname``phone``status`(默认 `pending`)、`created_at``updated_at` 字段
2. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_applications` 表,包含 `id`SERIAL PK`user_id`FK → users`site_code``applied_role_text``employee_number`(可选)、`phone``status`(默认 `pending`)、`reviewer_id``review_note``created_at``reviewed_at` 字段
3. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `site_code_mapping` 表,包含 `id`SERIAL PK`site_code`UNIQUE格式 2 字母 + 3 数字)、`site_id`BIGINT UNIQUE`site_name``tenant_id``created_at` 字段
4. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_site_roles` 表,包含 `id`SERIAL PK`user_id`FK → users`site_id`BIGINT`role_id`FK → roles`created_at` 字段,并对 `(user_id, site_id, role_id)` 建立唯一约束
5. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `user_assistant_binding` 表,包含 `id`SERIAL PK`user_id`FK → users`site_id`BIGINT`assistant_id`BIGINT可选`staff_id`BIGINT可选`binding_type``created_at` 字段
6. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `roles` 表,包含 `id`SERIAL PK`code`UNIQUE`name``description``created_at` 字段
7. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `permissions` 表,包含 `id`SERIAL PK`code`UNIQUE`name``description``created_at` 字段
8. WHEN Migration_Script 执行完成, THE Auth_System SHALL 在 `auth` Schema 中创建 `role_permissions` 表,包含 `role_id`FK → roles`permission_id`FK → permissions字段并以 `(role_id, permission_id)` 为联合主键
9. THE Migration_Script SHALL 使用 `IF NOT EXISTS` / `OR REPLACE` 等幂等语法,确保重复执行不会报错
10. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
### 需求 2种子数据预置
**用户故事:** 作为系统管理员,我需要系统预置固定的权限列表和默认角色,以便审核时可直接分配。
#### 验收标准
1. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.permissions` 表中插入 5 条固定权限记录:`view_tasks``view_board``view_board_finance``view_board_customer``view_board_coach`
2. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.roles` 表中插入默认角色(至少包含 `coach`(助教)、`staff`(员工)、`site_admin`(店铺管理员)、`tenant_admin`(租户管理员))
3. WHEN 种子数据脚本执行完成, THE Auth_System SHALL 在 `auth.role_permissions` 表中为每个默认角色分配对应的权限组合
4. THE 种子数据脚本 SHALL 使用 `ON CONFLICT DO NOTHING` 语法,确保重复执行不会产生重复数据
### 需求 3微信登录
**用户故事:** 作为球房工作人员,我需要通过微信登录小程序,以便快速进入系统。
#### 验收标准
1. WHEN 小程序端发送微信临时登录凭证(`code`, THE WeChat_Auth_Service SHALL 调用微信 `code2Session` 接口换取 `openid``session_key`
2. WHEN `code2Session` 返回有效 `openid` 且该 `openid` 已存在于 `auth.users` 表中, THE Auth_System SHALL 返回该用户的 JWT 令牌对access_token + refresh_token和用户状态信息
3. WHEN `code2Session` 返回有效 `openid` 且该 `openid` 不存在于 `auth.users` 表中, THE Auth_System SHALL 创建新用户记录status 为 `pending`),返回 JWT 令牌对和 `pending` 状态标识
4. IF `code2Session` 接口调用失败或返回错误码, THEN THE WeChat_Auth_Service SHALL 返回 HTTP 401 错误,包含具体的错误描述
5. WHEN 用户状态为 `disabled`, THE Auth_System SHALL 返回 HTTP 403 错误,拒绝登录
### 需求 4用户申请提交
**用户故事:** 作为球房工作人员我需要在首次登录后填写申请表单球房ID、申请身份、手机号、编号、昵称以便管理员审核我的身份。
#### 验收标准
1. WHEN 用户提交申请表单(包含 `site_code``applied_role_text``phone`,可选 `employee_number`, THE Application_Service SHALL 在 `auth.user_applications` 表中创建一条 status 为 `pending` 的申请记录
2. WHEN 用户提交的 `site_code``auth.site_code_mapping` 中存在映射, THE Application_Service SHALL 将申请记录关联到对应的 `site_id`
3. WHEN 用户提交的 `site_code``auth.site_code_mapping` 中不存在映射, THE Application_Service SHALL 仍然接受申请,申请记录中保留 `site_code` 文本,管理端显示"未找到关联信息"
4. WHEN 用户提交申请时提供了 `nickname`, THE Auth_System SHALL 更新 `auth.users` 表中该用户的 `nickname` 字段
5. IF 用户提交的 `phone` 为空或格式无效(非 11 位数字), THEN THE Application_Service SHALL 返回 HTTP 422 错误,包含具体的校验失败信息
6. WHEN 用户已有一条 `pending` 状态的申请, THE Application_Service SHALL 拒绝重复提交,返回 HTTP 409 错误
### 需求 5人员匹配
**用户故事:** 作为系统我需要根据球房ID和手机号自动建议用户与助教/员工的对应关系,以便管理员快速审核。
#### 验收标准
1. WHEN 管理员查看某条申请详情时, THE Matching_Service SHALL 根据申请中的 `site_id``phone``fdw_etl.v_dim_assistant` 中按 `site_id` + `mobile` 匹配助教记录
2. WHEN 管理员查看某条申请详情时, THE Matching_Service SHALL 根据申请中的 `site_id``phone``fdw_etl.v_dim_staff``fdw_etl.v_dim_staff_ex` 中按 `site_id` + `mobile` 匹配员工记录
3. WHEN 申请中包含 `employee_number`, THE Matching_Service SHALL 额外按 `job_num` 字段匹配员工记录
4. THE Matching_Service SHALL 将助教匹配结果和员工匹配结果合并为统一的候选列表返回,每条候选包含来源类型(`assistant` / `staff`)、姓名、手机号、编号
5. WHEN 助教表和员工表均无匹配结果, THE Matching_Service SHALL 返回空候选列表,管理端显示"未找到关联信息"
6. WHEN 申请的 `site_code` 无法映射到 `site_id`, THE Matching_Service SHALL 跳过匹配,返回空候选列表
### 需求 6申请审核
**用户故事:** 作为租户管理员,我需要审核用户申请,将用户关联到对应的助教/员工,并分配身份权限。
#### 验收标准
1. WHEN 管理员批准申请并选择了候选匹配对象, THE Application_Service SHALL 将申请状态更新为 `approved`,在 `auth.user_assistant_binding` 中创建绑定记录,在 `auth.user_site_roles` 中分配角色
2. WHEN 管理员批准申请但无候选匹配(手动审核), THE Application_Service SHALL 将申请状态更新为 `approved`,仅在 `auth.user_site_roles` 中分配角色,不创建绑定记录
3. WHEN 管理员拒绝申请, THE Application_Service SHALL 将申请状态更新为 `rejected`,记录 `review_note`(拒绝原因)
4. WHEN 申请审核通过后, THE Auth_System SHALL 将 `auth.users` 表中该用户的 `status` 更新为 `approved`
5. WHEN 审核操作完成, THE Application_Service SHALL 记录 `reviewer_id``reviewed_at` 时间戳
6. IF 审核目标申请的状态不是 `pending`, THEN THE Application_Service SHALL 返回 HTTP 409 错误,拒绝重复审核
### 需求 7用户状态查询
**用户故事:** 作为用户,我需要看到自己的申请状态(审核中/通过/拒绝),以便了解审核进度。
#### 验收标准
1. WHEN 用户查询自身状态, THE Auth_System SHALL 返回用户的 `status`、所有申请记录列表(含每条申请的 `site_code``applied_role_text``status``review_note`
2. WHEN 用户状态为 `approved`, THE Auth_System SHALL 同时返回用户已关联的店铺列表和对应角色
### 需求 8多店铺支持与店铺切换
**用户故事:** 作为用户,我可以同时属于多个店铺(连锁场景),切换店铺后数据正确隔离。
#### 验收标准
1. THE Auth_System SHALL 允许一个用户通过多次申请关联到多个不同的 `site_id`,每个 `site_id` 独立分配角色
2. WHEN 用户切换当前店铺, THE JWT_Service SHALL 签发包含新 `site_id` 的 JWT 令牌对
3. WHEN 用户携带某 `site_id` 的 JWT 访问 API, THE Permission_Middleware SHALL 仅允许访问该 `site_id` 下用户拥有权限的资源
### 需求 9权限中间件
**用户故事:** 作为系统,我需要权限中间件正确拦截无权请求,确保数据安全。
#### 验收标准
1. WHEN 用户携带有效 JWT 访问受保护端点, THE Permission_Middleware SHALL 从 JWT 中提取 `user_id``site_id`,查询 `auth.user_site_roles``auth.role_permissions` 获取用户在该店铺下的权限列表
2. WHEN 用户的权限列表不包含端点所需的权限 code, THE Permission_Middleware SHALL 返回 HTTP 403 错误
3. WHEN 用户的 `status` 不是 `approved`, THE Permission_Middleware SHALL 返回 HTTP 403 错误,拒绝访问受保护端点
4. WHEN JWT 令牌过期或无效, THE Permission_Middleware SHALL 返回 HTTP 401 错误
### 需求 10JWT 令牌扩展
**用户故事:** 作为后端开发者,我需要扩展现有 JWT 服务以支持微信登录场景和多店铺切换。
#### 验收标准
1. THE JWT_Service SHALL 在 JWT payload 中包含 `user_id`sub`site_id``roles`(角色 code 列表)、`type`access/refresh`exp` 字段
2. WHEN 用户通过微信登录且状态为 `approved`, THE JWT_Service SHALL 使用用户默认店铺(第一个关联的 site_id签发令牌
3. WHEN 用户通过微信登录且状态为 `pending`, THE JWT_Service SHALL 签发不含 `site_id``roles` 的受限令牌,仅允许访问申请提交和状态查询端点
4. WHEN 用户请求切换店铺, THE JWT_Service SHALL 验证用户在目标 `site_id` 下有角色绑定后签发新令牌
### 需求 11迁移脚本管理
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放。
#### 验收标准
1. THE Migration_Script SHALL 将所有认证相关表的 DDL 存放在 `db/zqyy_app/migrations/` 目录中
2. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`
3. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL非 ORM
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
5. THE Migration_Script SHALL 使用幂等语法(`IF NOT EXISTS``ON CONFLICT DO NOTHING`),确保重复执行不会报错
### 需求 12DDL 测试库落库与文档同步
**用户故事:** 作为后端开发者,我需要所有 DDL 变更在测试库(`test_zqyy_app`)中实际执行验证,并同步更新数据库手册和 DDL 基线,确保文档与实际 Schema 一致。
#### 验收标准
1. WHEN 迁移脚本编写完成, THE Auth_System SHALL 在 `test_zqyy_app` 测试库中执行迁移脚本,验证无错误
2. WHEN 迁移脚本执行成功, THE Auth_System SHALL 创建或更新 `docs/database/BD_Manual_auth_tables.md` 数据库手册,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
3. WHEN 迁移脚本执行成功, THE Auth_System SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 DDL 基线文件 `docs/database/ddl/zqyy_app__auth.sql`
4. WHEN 种子数据脚本执行成功, THE Auth_System SHALL 在数据库手册中记录种子数据内容(角色、权限、角色-权限映射)
### 需求 13小程序认证前端页面
**用户故事:** 作为球房工作人员,我需要在小程序中看到登录页、申请表单页、审核状态页,以便完成从微信登录到正式使用的完整流程。
#### 验收标准
1. WHEN 用户首次打开小程序, THE Auth_System SHALL 展示登录页面,调用 `wx.login()` 获取 code 并发送到后端 `/api/xcx/login`
2. WHEN 后端返回 `user_status=pending` 且用户无 pending 申请, THE Auth_System SHALL 跳转到申请表单页面包含球房ID`site_code`)、申请身份、手机号、编号(选填)、昵称输入框
3. WHEN 用户提交申请表单, THE Auth_System SHALL 调用 `/api/xcx/apply` 提交申请,成功后跳转到审核等待页面
4. WHEN 用户状态为 `pending` 且已有 pending 申请, THE Auth_System SHALL 展示审核等待页面,显示"审核中"状态和申请信息摘要
5. WHEN 用户状态为 `rejected`, THE Auth_System SHALL 在审核等待页面显示拒绝原因,并提供"重新申请"按钮
6. WHEN 用户状态为 `approved`, THE Auth_System SHALL 跳转到小程序主页(任务列表)
7. WHEN 用户状态为 `disabled`, THE Auth_System SHALL 展示无权限页面,提示账号已被禁用
8. THE Auth_System SHALL 在小程序 `app.ts``onLaunch` 中实现自动登录逻辑,根据用户状态路由到对应页面
9. WHEN 用户拥有多个店铺, THE Auth_System SHALL 在主页提供店铺切换入口
### 需求 14前后端联调验证
**用户故事:** 作为开发者,我需要在微信开发者工具中验证完整的认证流程(登录→申请→审核→进入主页),确保前后端接口对接正确。
#### 验收标准
1. THE Auth_System SHALL 提供联调验证脚本或文档,说明如何在微信开发者工具中测试完整认证流程
2. THE Auth_System SHALL 在后端提供开发模式下的 mock 登录端点(`POST /api/xcx/dev-login`),接受任意 openid 直接返回 JWT绕过微信 code2Session 调用
3. WHEN 开发模式启用时, THE Auth_System SHALL 允许通过环境变量 `WX_DEV_MODE=true` 切换到 mock 模式
4. THE Auth_System SHALL 在 `apps/miniprogram/doc/` 中提供联调指南文档,包含微信开发者工具配置、后端启动步骤、测试账号说明

View File

@@ -0,0 +1,288 @@
# 实现计划小程序用户认证系统miniapp-auth-system
## 概述
基于已批准的需求和设计文档,将小程序用户认证系统拆分为增量式编码任务。每个任务构建在前一个任务之上,最终完成完整的认证链路。后端使用 Python + FastAPI数据库使用 PostgreSQL 纯 SQL属性测试使用 hypothesis。
## 任务
- [x] 1. 创建认证数据表和种子数据
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p3_create_auth_tables.sql`
-`auth` Schema 下创建 `users``user_applications``site_code_mapping``roles``permissions``role_permissions``user_site_roles``user_assistant_binding` 共 8 张表
- 包含所有字段定义、约束、索引、外键
- 使用 `IF NOT EXISTS` 幂等语法
- 包含回滚语句(注释形式)
- _Requirements: 1.1-1.10_
- [x] 1.2 创建种子数据脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p3_seed_roles_permissions.sql`
- 插入 5 条固定权限:`view_tasks``view_board``view_board_finance``view_board_customer``view_board_coach`
- 插入默认角色:`coach``staff``site_admin``tenant_admin`
- 插入角色-权限映射
- 使用 `ON CONFLICT DO NOTHING` 幂等语法
- _Requirements: 2.1-2.4_
- [x] 1.3 在测试库执行迁移脚本并验证
-`test_zqyy_app` 中执行建表脚本和种子数据脚本
- 验证幂等性:连续执行两次无错误
- 验证表结构、约束、索引正确
- 验证种子数据完整5 权限、4 角色、角色-权限映射)
- _Requirements: 12.1_
- [x] 1.4 更新数据库手册和 DDL 基线
- 创建 `docs/database/BD_Manual_auth_tables.md`,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
- 运行 `python scripts/ops/gen_consolidated_ddl.py` 刷新 DDL 基线
- 在数据库手册中记录种子数据内容
- _Requirements: 12.2, 12.3, 12.4_
- [x] 1.5 编写迁移脚本幂等性属性测试
- **Property 1: 迁移脚本幂等性**
- **Validates: Requirements 1.9, 2.4, 11.5**
- [x] 2. 扩展 JWT 服务和认证依赖
- [x] 2.1 扩展 `apps/backend/app/auth/jwt.py`
- 新增 `create_limited_token_pair(user_id)` 函数pending 用户受限令牌)
- 扩展 `create_access_token` 支持 `roles` 参数
- 保持向后兼容
- _Requirements: 10.1, 10.2, 10.3_
- [x] 2.2 扩展 `apps/backend/app/auth/dependencies.py`
- 扩展 `CurrentUser` 数据类,增加 `roles``status``limited` 字段
- 新增 `get_current_user_or_limited` 依赖(允许 pending 用户)
- _Requirements: 10.3, 9.1_
- [x] 2.3 编写 JWT payload 结构属性测试
- **Property 14: JWT payload 结构与状态一致性**
- **Validates: Requirements 10.1, 10.2, 10.3**
- [x] 2.4 编写 JWT 过期/无效拒绝属性测试
- **Property 15: JWT 过期/无效令牌拒绝**
- **Validates: Requirements 9.4**
- [x] 3. 检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
- [x] 4. 实现微信认证服务
- [x] 4.1 创建 `apps/backend/app/services/wechat.py`
- 实现 `code2session(code)` 异步函数
- 使用 `httpx.AsyncClient` 调用微信 API
- 从环境变量读取 `WX_APPID` / `WX_SECRET`
- 定义 `WeChatAuthError` 异常类
- _Requirements: 3.1, 3.4_
- [x] 4.2 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_auth.py`
- 定义 `WxLoginRequest``WxLoginResponse``ApplicationRequest``ApplicationResponse``UserStatusResponse``SiteInfo``SwitchSiteRequest``MatchCandidate``ApproveRequest``RejectRequest`
- `site_code` 使用正则校验 `^[A-Za-z]{2}\d{3}$`
- `phone` 使用正则校验 `^\d{11}$`
- _Requirements: 4.5_
- [x] 4.3 创建小程序认证路由 `apps/backend/app/routers/xcx_auth.py`
- 实现 `POST /api/xcx/login`:微信登录(查找/创建用户 + 签发 JWT
- 实现 `POST /api/xcx/apply`:提交申请
- 实现 `GET /api/xcx/me`:查询自身状态
- 实现 `GET /api/xcx/me/sites`:查询关联店铺
- 实现 `POST /api/xcx/switch-site`:切换店铺
- 实现 `POST /api/xcx/refresh`:刷新令牌
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 3.2, 3.3, 3.5, 4.1-4.6, 7.1, 7.2, 8.2_
- [x] 4.4 编写登录创建/查找用户属性测试
- **Property 2: 登录创建/查找用户正确性**
- **Validates: Requirements 3.2, 3.3**
- [x] 4.5 编写申请创建正确性属性测试
- **Property 4: 申请创建正确性**
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4**
- [x] 4.6 编写手机号格式验证属性测试
- **Property 5: 手机号格式验证**
- **Validates: Requirements 4.5**
- [x] 4.7 编写重复申请拒绝属性测试
- **Property 6: 重复申请拒绝**
- **Validates: Requirements 4.6**
- [x] 5. 实现申请服务和人员匹配
- [x] 5.1 创建申请服务 `apps/backend/app/services/application.py`
- 实现 `create_application()`:创建申请 + site_code 映射查找
- 实现 `approve_application()`:批准 + 创建绑定/角色
- 实现 `reject_application()`:拒绝 + 记录原因
- 实现 `get_user_applications()`:查询用户申请列表
- _Requirements: 4.1-4.4, 6.1-6.6_
- [x] 5.2 创建人员匹配服务 `apps/backend/app/services/matching.py`
- 实现 `find_candidates(site_id, phone, employee_number)`
- 通过 FDW 查询 `fdw_etl.v_dim_assistant``fdw_etl.v_dim_staff` / `v_dim_staff_ex`
- 设置 `app.current_site_id` 进行 RLS 隔离
- 合并助教和员工匹配结果
- _Requirements: 5.1-5.6_
- [x] 5.3 创建角色权限服务 `apps/backend/app/services/role.py`
- 实现 `get_user_permissions(user_id, site_id)`
- 实现 `get_user_sites(user_id)`
- 实现 `check_user_has_site_role(user_id, site_id)`
- _Requirements: 8.1, 9.1_
- [x] 5.4 编写人员匹配合并属性测试
- **Property 7: 人员匹配合并正确性**
- **Validates: Requirements 5.1, 5.2, 5.3, 5.4**
- [x] 5.5 编写审核操作正确性属性测试
- **Property 8: 审核操作正确性**
- **Validates: Requirements 6.1, 6.2, 6.3, 6.4, 6.5**
- [x] 5.6 编写非 pending 审核拒绝属性测试
- **Property 9: 非 pending 申请审核拒绝**
- **Validates: Requirements 6.6**
- [x] 6. 检查点 - 确保所有测试通过
- 确保所有测试通过,如有问题请向用户确认。
- [x] 7. 实现权限中间件和管理端路由
- [x] 7.1 创建权限中间件 `apps/backend/app/middleware/permission.py`
- 实现 `require_permission(*permission_codes)` 依赖
- 实现 `require_approved()` 依赖
- 检查用户 status + 权限列表
- _Requirements: 9.1-9.4_
- [x] 7.2 创建管理端审核路由 `apps/backend/app/routers/admin_applications.py`
- 实现 `GET /api/admin/applications`:查询申请列表
- 实现 `GET /api/admin/applications/{id}`:查询申请详情 + 候选匹配
- 实现 `POST /api/admin/applications/{id}/approve`:批准申请
- 实现 `POST /api/admin/applications/{id}/reject`:拒绝申请
-`apps/backend/app/main.py` 中注册路由
- _Requirements: 6.1-6.6, 5.1-5.6_
- [x] 7.3 编写权限中间件拦截属性测试
- **Property 13: 权限中间件拦截正确性**
- **Validates: Requirements 8.3, 9.1, 9.2, 9.3**
- [x] 7.4 编写多店铺角色独立分配属性测试
- **Property 11: 多店铺角色独立分配**
- **Validates: Requirements 8.1**
- [x] 7.5 编写店铺切换令牌属性测试
- **Property 12: 店铺切换令牌正确性**
- **Validates: Requirements 8.2, 10.4**
- [x] 8. 集成与端到端验证
- [x] 8.1 更新 `apps/backend/app/config.py` 新增微信配置项
- 新增 `WX_APPID``WX_SECRET``WX_DEV_MODE` 配置读取
- _Requirements: 3.1, 14.3_
- [x] 8.2 更新 `apps/backend/app/main.py` 注册所有新路由
- 确保 `xcx_auth``admin_applications` 路由已注册
- 验证无路由冲突
- _Requirements: 全部_
- [x] 8.3 实现开发模式 mock 登录端点
-`routers/xcx_auth.py` 中新增 `POST /api/xcx/dev-login`
- 仅在 `WX_DEV_MODE=true` 时注册
- 接受 `openid` 和可选 `status` 参数,直接查找/创建用户并返回 JWT
- _Requirements: 14.2, 14.3_
- [x] 8.4 编写用户状态查询完整性属性测试
- **Property 10: 用户状态查询完整性**
- **Validates: Requirements 7.1, 7.2**
- [x] 8.5 编写 disabled 用户登录拒绝属性测试
- **Property 3: disabled 用户登录拒绝**
- **Validates: Requirements 3.5**
- [x] 9. 小程序认证前端页面
- [x] 9.1 实现请求封装工具 `apps/miniprogram/miniprogram/utils/request.ts`
- 统一请求封装:自动附加 Authorization header
- 401 时自动尝试 refresh_token 刷新
- 刷新失败时跳转 login 页面
- 后端 base URL 从配置读取(开发环境 `http://localhost:8000`
- _Requirements: 13.8_
- [x] 9.2 实现登录页 `apps/miniprogram/miniprogram/pages/login/`
- 调用 `wx.login()` 获取 code
- 发送 code 到 `POST /api/xcx/login`
- 根据返回的 `user_status` 路由到对应页面
- 存储 token 到 globalData 和 Storage
- 参考 H5 原型 `docs/h5_ui/pages/login.html`
- _Requirements: 13.1, 13.6, 13.7, 13.8_
- [x] 9.3 实现申请表单页 `apps/miniprogram/miniprogram/pages/apply/`
- 表单字段球房IDsite_code、申请身份、手机号、编号选填、昵称
- 前端校验site_code 格式2字母+3数字、手机号11位数字
- 提交到 `POST /api/xcx/apply`
- 成功后跳转 reviewing 页面
- 参考 H5 原型 `docs/h5_ui/pages/apply.html`
- _Requirements: 13.2, 13.3_
- [x] 9.4 实现审核等待页 `apps/miniprogram/miniprogram/pages/reviewing/`
- 显示当前申请状态(审核中/已拒绝)
- 显示申请信息摘要球房ID、申请身份、手机号
- 拒绝时显示拒绝原因 + "重新申请"按钮
- 支持下拉刷新查询最新状态
- 参考 H5 原型 `docs/h5_ui/pages/reviewing.html`
- _Requirements: 13.4, 13.5_
- [x] 9.5 实现无权限页 `apps/miniprogram/miniprogram/pages/no-permission/`
- 显示账号已禁用提示
- 参考 H5 原型 `docs/h5_ui/pages/no-permission.html`
- _Requirements: 13.7_
- [x] 9.6 更新 `app.ts``app.json`
-`app.json` 中注册新页面login、apply、reviewing、no-permission
-`app.ts``onLaunch` 中实现自动登录逻辑
- 根据用户状态路由到对应页面
- 扩展 globalData 类型定义token、userInfo、currentSiteId、sites
- _Requirements: 13.8_
- [x] 10. 前后端联调验证
- [x] 10.1 编写联调指南文档 `apps/miniprogram/doc/auth-integration-guide.md`
- 微信开发者工具项目导入配置说明
- 后端启动步骤(含 `WX_DEV_MODE=true` 配置)
- 测试流程mock 登录 → 申请 → 管理端审核 → 重新登录验证
- 常见问题排查
- _Requirements: 14.1, 14.4_
- [x] 10.2 在微信开发者工具中执行联调验证
- 验证登录流程wx.login → 后端 → JWT 返回
- 验证申请流程:表单提交 → 后端创建申请 → 审核等待页展示
- 验证状态路由pending/approved/rejected/disabled 各状态正确跳转
- 验证 token 刷新access_token 过期后自动刷新
- _Requirements: 14.1_
- [x] 11. 属性测试全量运行100 次迭代)— ✅ 15/15 全部通过
- 前面各任务中的属性测试仅用 5 次迭代快速验证逻辑正确性
- 本任务集中对所有属性测试执行 100 次迭代,确保健壮性
- 运行脚本:`scripts/ops/_run_auth_pbt_full.py`
- 结果报告:`export/reports/auth_pbt_full_20260227_034401.md`
- 总耗时 375s15 个属性测试全部通过100 次迭代/每个)
- [x] 11.1 P1 迁移脚本幂等性 — ✅ 25.0s
- [x] 11.2 P2 登录创建/查找用户 — ✅ 49.8s
- [x] 11.3 P3 disabled 用户登录拒绝 — ✅ 37.9s
- [x] 11.4 P4 申请创建正确性 — ✅ 21.3s
- [x] 11.5 P5 手机号格式验证 — ✅ 2.5s
- [x] 11.6 P6 重复申请拒绝 — ✅ 24.7s
- [x] 11.7 P7 人员匹配合并正确性 — ✅ 14.9s
- [x] 11.8 P8 审核操作正确性 — ✅ 22.7s
- [x] 11.9 P9 非 pending 审核拒绝 — ✅ 18.8s
- [x] 11.10 P10 用户状态查询完整性 — ✅ 28.6s
- [x] 11.11 P11 多店铺角色独立分配 — ✅ 46.6s
- [x] 11.12 P12 店铺切换令牌正确性 — ✅ 45.3s
- [x] 11.13 P13 权限中间件拦截正确性 — ✅ 11.9s
- [x] 11.14 P14 JWT payload 结构一致性 — ✅ 4.6s
- [x] 11.15 P15 JWT 过期/无效拒绝 — ✅ 3.2s
- [x] 12. 最终检查点
- 任务 1-12 全部完成
- 15 个属性测试在 100 次迭代下全部通过(报告见 `export/reports/auth_pbt_full_20260227_034401.md`
- 小程序 4 个认证页面login/apply/reviewing/no-permission已创建
- app.ts / app.json 已更新为认证感知版本
- 联调指南文档已编写
## 备注
- 标记 `*` 的任务为可选,可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点确保增量验证
- **属性测试策略**:开发阶段各任务中属性测试用 5 次迭代快速验证;任务 11 集中用 100 次迭代全量运行,逐个报告进度
- 单元测试验证具体例子和边界情况
- 所有数据库操作在测试库 `test_zqyy_app` 进行
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录
- 属性测试放在 `tests/` 目录Monorepo 级)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,278 @@
# 需求文档小程序核心业务模块miniapp-core-business
## 简介
本 SPEC 实现小程序的核心业务逻辑涵盖助教任务系统生成、分配、状态流转、完成检测、备注系统CRUD、星星评分、类型区分、以及后台触发器/轮询调度框架。系统基于 P1miniapp-db-foundation的数据库基础设施、P2etl-dws-miniapp-extensions的 DWS 指数数据、P3miniapp-auth-system的用户认证体系`test_zqyy_app.biz` Schema 中创建任务、备注、触发器等业务表,并在 FastAPI 后端实现对应的 API 端点和后台调度逻辑。
## 术语表
- **Task_Generator**:任务生成器,每日 4:00 后运行,基于 WBI/NCI/RS 指数为每个助教分配 4 种类型任务的后台服务
- **Task_Manager**:任务管理服务,负责任务 CRUD、置顶、放弃、状态流转的后端模块
- **Task_Expiry_Checker**:任务有效期轮询器,每小时检查 `expires_at` 并将过期任务标记为无效
- **Recall_Completion_Detector**召回完成检测器ETL 数据更新后检查助教是否为匹配客户提供了服务
- **Note_Reclassifier**:备注回溯重分类器,召回完成时回溯检查是否有普通备注需重分类为回访备注
- **Note_Service**:备注服务模块,负责备注 CRUD、星星评分存储与读取
- **Trigger_Scheduler**:触发器调度框架,支持 cron/interval/event 三种触发方式的统一调度引擎
- **coach_tasks**:助教任务表,位于 `biz` Schema存储任务分配、状态、有效期等信息
- **coach_task_history**:任务变更历史表,记录任务关闭/新建的追溯链
- **notes**:统一备注表,位于 `biz` Schema通过 `type` 字段区分普通备注/回访备注/放弃原因
- **trigger_jobs**:触发器配置表,位于 `biz` Schema存储轮询/事件触发器的配置与执行状态
- **task_type**:任务类型枚举,取值为 `high_priority_recall`(高优先召回)/ `priority_recall`(优先召回)/ `follow_up_visit`(客户回访)/ `relationship_building`(关系构建)
- **task_status**:任务状态枚举,取值为 `active`(有效)/ `inactive`(无效)/ `completed`(已完成)/ `abandoned`(已放弃)
- **note_type**:备注类型枚举,取值为 `normal`(普通备注)/ `follow_up`(回访备注)/ `abandon_reason`(放弃原因)
- **priority_score**:优先级分数,取 `max(WBI, NCI)` 的快照值,用于任务排序
- **expires_at**:有效期时间戳,默认 NULL无限期填充后表示任务将在该时间点过期
- **FDW**`postgres_fdw` 外部数据包装器,通过 `fdw_etl` Schema 读取 ETL 库指数数据
- **Migration_Script**:存放在 `db/zqyy_app/migrations/` 中的纯 SQL 迁移脚本,以日期前缀命名
- **site_id**:门店标识符,类型为 `BIGINT`,用于多门店数据隔离
- **member_retention_clue**:维客线索表,位于 `public` Schema存储助教为客户记录的维护线索大类 + 摘要 + 详情),独立于 ETL 数据。当前已有基础表结构和 CRUD API`/api/retention-clue`),若不足以支撑本 SPEC 的任务系统需求,可对其 DDL、Pydantic 模型及路由进行扩展或修改
## 需求
### 需求 1业务数据表创建
**用户故事:** 作为后端开发者,我需要在 `biz` Schema 中创建任务、备注、触发器等业务表,以便支撑核心业务功能。
#### 验收标准
1. WHEN Migration_Script 执行完成, THE Task_Manager SHALL 在 `biz` Schema 中创建 `coach_tasks` 表,包含 `id`BIGSERIAL PK`site_id`BIGINT NOT NULL`assistant_id`BIGINT NOT NULL`member_id`BIGINT NOT NULL`task_type`VARCHAR NOT NULL`status`VARCHAR NOT NULL DEFAULT 'active')、`priority_score`NUMERIC(5,2))、`expires_at`TIMESTAMPTZ可空`is_pinned`BOOLEAN DEFAULT FALSE`abandon_reason`TEXT可空`completed_at`TIMESTAMPTZ可空`completed_task_type`VARCHAR可空`parent_task_id`BIGINT可空FK → coach_tasks`created_at`TIMESTAMPTZ DEFAULT NOW())、`updated_at`TIMESTAMPTZ DEFAULT NOW())字段
2. WHEN Migration_Script 执行完成, THE Task_Manager SHALL 在 `biz` Schema 中创建 `coach_task_history` 表,包含 `id`BIGSERIAL PK`task_id`BIGINT FK → coach_tasks`action`VARCHAR NOT NULL`old_status`VARCHAR`new_status`VARCHAR`old_task_type`VARCHAR`new_task_type`VARCHAR`detail`JSONB`created_at`TIMESTAMPTZ DEFAULT NOW())字段
3. WHEN Migration_Script 执行完成, THE Note_Service SHALL 在 `biz` Schema 中创建 `notes` 表,包含 `id`BIGSERIAL PK`site_id`BIGINT NOT NULL`user_id`INTEGER NOT NULL`target_type`VARCHAR NOT NULL`target_id`BIGINT NOT NULL`type`VARCHAR NOT NULL DEFAULT 'normal')、`content`TEXT NOT NULL`rating_service_willingness`SMALLINT可空CHECK 1-5`rating_revisit_likelihood`SMALLINT可空CHECK 1-5`task_id`BIGINT可空FK → coach_tasks`ai_score`SMALLINT可空`ai_analysis`TEXT可空`created_at`TIMESTAMPTZ DEFAULT NOW())、`updated_at`TIMESTAMPTZ DEFAULT NOW())字段
4. WHEN Migration_Script 执行完成, THE Trigger_Scheduler SHALL 在 `biz` Schema 中创建 `trigger_jobs` 表,包含 `id`SERIAL PK`job_type`VARCHAR NOT NULL`job_name`VARCHAR NOT NULL UNIQUE`trigger_condition`VARCHAR NOT NULL`trigger_config`JSONB NOT NULL`last_run_at`TIMESTAMPTZ可空`next_run_at`TIMESTAMPTZ可空`status`VARCHAR NOT NULL DEFAULT 'enabled')、`created_at`TIMESTAMPTZ DEFAULT NOW())字段
5. THE Migration_Script SHALL 对 `coach_tasks` 表创建唯一索引 `idx_coach_tasks_site_assistant_member_type``(site_id, assistant_id, member_id, task_type)` 上,仅对 `status = 'active'` 的记录生效(部分唯一索引)
6. THE Migration_Script SHALL 对 `coach_tasks` 表创建索引 `idx_coach_tasks_assistant_status``(site_id, assistant_id, status)` 上,用于助教任务列表查询
7. THE Migration_Script SHALL 对 `notes` 表创建索引 `idx_notes_target``(site_id, target_type, target_id)` 上,用于按目标查询备注
8. THE Migration_Script SHALL 使用 `IF NOT EXISTS` 幂等语法,确保重复执行不会报错
9. THE Migration_Script SHALL 在脚本中包含回滚语句(以注释形式)
### 需求 2触发器种子数据预置
**用户故事:** 作为系统管理员,我需要系统预置核心触发器配置,以便后台调度任务自动运行。
#### 验收标准
1. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `task_generator` 记录trigger_condition='cron'trigger_config 包含 cron 表达式 '0 4 * * *'
2. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `task_expiry_check` 记录trigger_condition='interval'trigger_config 包含间隔秒数 3600
3. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `recall_completion_check` 记录trigger_condition='event'trigger_config 包含事件名 'etl_data_updated'
4. WHEN 种子数据脚本执行完成, THE Trigger_Scheduler SHALL 在 `biz.trigger_jobs` 表中插入 `note_reclassify_backfill` 记录trigger_condition='event'trigger_config 包含事件名 'recall_completed'
5. THE 种子数据脚本 SHALL 使用 `ON CONFLICT (job_name) DO NOTHING` 语法,确保重复执行不会产生重复数据
### 需求 3任务生成器
**用户故事:** 作为助教,我每天打开小程序能看到系统为我分配的任务列表,按优先级排序。
#### 验收标准
1. WHEN Task_Generator 运行时, THE Task_Generator SHALL 通过 FDW 读取 `fdw_etl` 中的 `dws_member_winback_index`WBI`dws_member_newconv_index`NCI指数数据计算 `priority_score = max(WBI, NCI)`
2. WHEN `priority_score > 7`, THE Task_Generator SHALL 为该客户-助教对生成 `high_priority_recall`(高优先召回)类型任务
3. WHEN `priority_score > 5``priority_score <= 7`, THE Task_Generator SHALL 为该客户-助教对生成 `priority_recall`(优先召回)类型任务
4. WHEN 助教完成某客户的召回任务后该客户无回访备注, THE Task_Generator SHALL 为该客户-助教对生成 `follow_up_visit`(客户回访)类型任务
5. WHEN 客户-助教对的 RS 指数 < 6通过 FDW 读取 `dws_member_assistant_relation_index`, THE Task_Generator SHALL 为该客户-助教对生成 `relationship_building`(关系构建)类型任务
6. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id, task_type)``status = 'active'` 的任务, THE Task_Generator SHALL 跳过该任务不做任何操作
7. WHEN Task_Generator 生成任务时发现已存在相同 `(site_id, assistant_id, member_id)``task_type` 不同且 `status = 'active'` 的任务, THE Task_Generator SHALL 将旧任务状态设为 `inactive`,创建新任务,并在 `coach_task_history` 中记录变更
8. THE Task_Generator SHALL 按优先级从高到低的顺序处理任务类型:`high_priority_recall`0> `priority_recall`0> `follow_up_visit`1> `relationship_building`2高优先级任务覆盖低优先级任务
9. THE Task_Generator SHALL 通过 `auth.user_assistant_binding` 确定助教与小程序用户的映射关系,仅为已绑定的助教生成任务
10. THE Task_Generator SHALL 在 `trigger_jobs` 中更新 `last_run_at``next_run_at` 时间戳
### 需求 448 小时回访滞留机制
**用户故事:** 作为系统,回访任务至少保留 48 小时,到期后自动失效。
#### 验收标准
1. WHEN Task_Generator 生成 `follow_up_visit` 类型任务时, THE Task_Generator SHALL 将 `expires_at` 设为 NULL无限期有效`status` 设为 `active`
2. WHEN Task_Generator 检测到某 `follow_up_visit` 任务的触发条件不再满足(指数变化), THE Task_Generator SHALL 将该任务的 `expires_at` 填充为 `created_at + 48 小时``status` 保持 `active`
3. WHEN Task_Expiry_Checker 轮询检查时发现某任务的 `expires_at` 不为 NULL 且当前时间超过 `expires_at`, THE Task_Expiry_Checker SHALL 将该任务 `status` 设为 `inactive`
4. WHEN 新的 `follow_up_visit` 任务生成时发现同一 `(site_id, assistant_id, member_id)` 已存在一个有 `expires_at``follow_up_visit` 任务, THE Task_Generator SHALL 将旧任务标记为 `inactive`,创建新的 `active` 任务(`expires_at` 为 NULL
5. THE Task_Expiry_Checker SHALL 每小时运行一次,由 `trigger_jobs` 中的 `task_expiry_check` 配置驱动
### 需求 5任务类型变更与状态流转
**用户故事:** 作为系统,当客户指数变化导致任务类型变更时,系统正确关闭旧任务并创建新任务。
#### 验收标准
1. WHEN 任务类型从 `priority_recall` 变更为 `high_priority_recall`, THE Task_Generator SHALL 将旧 `priority_recall` 任务标记为 `inactive``expires_at` 保持 NULL创建新的 `high_priority_recall` 任务
2. WHEN 任务类型从 `follow_up_visit` 变更为 `high_priority_recall``priority_recall`, THE Task_Generator SHALL 将旧 `follow_up_visit` 任务标记为 `active` 并填充 `expires_at = created_at + 48 小时`,创建新的召回任务
3. WHEN 任务类型从召回类型变回 `follow_up_visit`, THE Task_Generator SHALL 检查是否存在有 `expires_at` 的旧 `follow_up_visit` 任务,若存在则将旧任务标记为 `inactive`,创建新的 `follow_up_visit` 任务
4. THE Task_Manager SHALL 在每次状态变更时在 `coach_task_history` 中记录 `action``old_status``new_status``old_task_type``new_task_type`
### 需求 6召回完成检测
**用户故事:** 作为助教,我完成召回任务后(客户到店被服务),系统自动标记任务完成。
#### 验收标准
1. WHEN ETL 数据更新后, THE Recall_Completion_Detector SHALL 通过 FDW 读取 `fdw_etl.dwd_assistant_service_log` 中的新增服务记录
2. WHEN 发现某助教为某客户提供了服务, THE Recall_Completion_Detector SHALL 查找该 `(site_id, assistant_id, member_id)` 下所有 `status = 'active'` 的任务
3. WHEN 匹配到活跃任务, THE Recall_Completion_Detector SHALL 将任务 `status` 设为 `completed`,记录 `completed_at` 为服务时间,记录 `completed_task_type` 为完成时的任务类型
4. WHEN 召回完成后, THE Recall_Completion_Detector SHALL 触发 `note_reclassify_backfill` 事件,通知 Note_Reclassifier 执行备注回溯
5. THE Recall_Completion_Detector SHALL 由 `trigger_jobs` 中的 `recall_completion_check` 配置驱动,在 ETL 数据更新事件后触发
### 需求 7备注回溯重分类
**用户故事:** 作为系统,当 ETL 数据延迟导致召回完成晚于备注提交时,需要回溯重分类备注。
#### 验收标准
1. WHEN 召回完成事件触发后, THE Note_Reclassifier SHALL 查找该 `(site_id, assistant_id, member_id)` 在召回服务结束时间之后提交的第一条 `type = 'normal'` 的备注
2. WHEN 找到符合条件的普通备注, THE Note_Reclassifier SHALL 将该备注的 `type``normal` 更新为 `follow_up`
3. WHEN 备注重分类完成后, THE Note_Reclassifier SHALL 触发 AI 应用 6 对该备注进行含金量评分(评分逻辑由 P5 AI 集成层实现,本 SPEC 仅定义触发接口)
4. WHEN AI 应用 6 返回评分 >= 6, THE Note_Reclassifier SHALL 生成一条 `follow_up_visit` 任务并标记为 `completed`(回溯完成)
5. WHEN AI 应用 6 返回评分 < 6, THE Note_Reclassifier SHALL 生成一条 `follow_up_visit` 任务,`status``active`(回访未完成,需助教重新备注)
### 需求 8任务 CRUD API
**用户故事:** 作为助教,我可以查看任务列表、置顶/放弃任务、取消置顶/取消放弃。
#### 验收标准
1. WHEN 助教请求任务列表, THE Task_Manager SHALL 返回该助教在当前 `site_id` 下所有 `status = 'active'` 的任务,按 `is_pinned DESC, priority_score DESC, created_at ASC` 排序
2. WHEN 助教请求任务列表, THE Task_Manager SHALL 在每条任务中包含客户基本信息(通过 FDW 读取 `dim_member`、RS 指数(通过 FDW 读取 `dws_member_assistant_relation_index`)、爱心 icon 档位(💖>8.5 / 🧡>7 / 💛>5 / 💙<5
3. WHEN 助教置顶某任务, THE Task_Manager SHALL 将该任务的 `is_pinned` 设为 TRUE并在 `coach_task_history` 中记录
4. WHEN 助教放弃某任务, THE Task_Manager SHALL 将该任务 `status` 设为 `abandoned`,记录 `abandon_reason`(必填),并在 `coach_task_history` 中记录
5. WHEN 助教取消置顶某任务, THE Task_Manager SHALL 将该任务的 `is_pinned` 设为 FALSE
6. WHEN 助教取消放弃某任务, THE Task_Manager SHALL 将该任务 `status` 恢复为 `active`,清空 `abandon_reason`
7. IF 助教放弃任务时未提供 `abandon_reason`, THEN THE Task_Manager SHALL 返回 HTTP 422 错误
8. THE Task_Manager SHALL 通过 Permission_Middleware 验证用户身份,仅允许操作自己的任务
### 需求 9备注 CRUD API
**用户故事:** 作为助教,我给客户添加备注后,系统正确存储备注内容和星星评分。
#### 验收标准
1. WHEN 助教创建备注时, THE Note_Service SHALL 在 `biz.notes` 表中创建记录,包含 `site_id``user_id``target_type`'member')、`target_id`member_id`type``content`、可选的 `rating_service_willingness`1-5、可选的 `rating_revisit_likelihood`1-5、可选的 `task_id`
2. WHEN 备注关联的任务类型为 `follow_up_visit`, THE Note_Service SHALL 将备注 `type` 自动设为 `follow_up`
3. WHEN 备注关联的任务类型不是 `follow_up_visit`, THE Note_Service SHALL 将备注 `type` 设为 `normal`
4. WHEN 备注创建成功且 `type = 'follow_up'`, THE Note_Service SHALL 触发 AI 应用 6 备注分析接口(由 P5 实现),传入备注内容和客户信息
5. WHEN AI 应用 6 返回评分 >= 6 且备注关联的 `follow_up_visit` 任务 `status = 'active'`, THE Note_Service SHALL 将该任务标记为 `completed`
6. WHEN 助教查询某客户的备注列表, THE Note_Service SHALL 返回该客户在当前 `site_id` 下的所有备注,按 `created_at DESC` 排序,包含星星评分和 AI 评分
7. WHEN 助教删除备注, THE Note_Service SHALL 执行软删除或硬删除(根据业务需要),删除前需二次确认(前端实现)
8. IF 星星评分值不在 1-5 范围内, THEN THE Note_Service SHALL 返回 HTTP 422 错误
9. THE Note_Service 的星星评分 SHALL 不参与回访完成判定(完成判定仅看 AI 应用 6 评分 >= 6不参与 AI 应用 6 分析,仅作辅助数据存储
### 需求 10触发器调度框架
**用户故事:** 作为系统,我需要一个统一的触发器调度框架,支持定时、间隔、事件驱动三种触发方式。
#### 验收标准
1. THE Trigger_Scheduler SHALL 支持 `cron` 类型触发器,按 cron 表达式计算下次运行时间
2. THE Trigger_Scheduler SHALL 支持 `interval` 类型触发器,按固定间隔秒数计算下次运行时间
3. THE Trigger_Scheduler SHALL 支持 `event` 类型触发器,在指定事件发生时立即执行
4. WHEN 触发器执行完成, THE Trigger_Scheduler SHALL 更新 `trigger_jobs` 表中的 `last_run_at``next_run_at`
5. WHEN 触发器 `status = 'disabled'`, THE Trigger_Scheduler SHALL 跳过该触发器不执行
6. THE Trigger_Scheduler SHALL 提供 `fire_event(event_name, payload)` 方法,用于触发事件驱动型任务
7. IF 触发器执行过程中发生错误, THEN THE Trigger_Scheduler SHALL 记录错误日志但不中断其他触发器的执行
### 需求 11迁移脚本管理
**用户故事:** 作为后端开发者,我需要所有数据库变更都有对应的迁移脚本,以便变更可追溯、可重放。
#### 验收标准
1. THE Migration_Script SHALL 将所有业务表的 DDL 存放在 `db/zqyy_app/migrations/` 目录中
2. THE Migration_Script SHALL 使用日期前缀命名(格式:`YYYY-MM-DD__<描述>.sql`
3. THE Migration_Script SHALL 使用 UTF-8 编码,纯 SQL非 ORM
4. THE Migration_Script SHALL 在每个脚本中包含回滚语句(以注释形式)
5. THE Migration_Script SHALL 使用幂等语法(`IF NOT EXISTS``ON CONFLICT DO NOTHING`),确保重复执行不会报错
### 需求 12DDL 测试库落库与文档同步
**用户故事:** 作为后端开发者,我需要所有 DDL 变更在测试库中实际执行验证,并同步更新数据库手册和 DDL 基线。
#### 验收标准
1. WHEN 迁移脚本编写完成, THE Task_Manager SHALL 在 `test_zqyy_app` 测试库中执行迁移脚本,验证无错误
2. WHEN 迁移脚本执行成功, THE Task_Manager SHALL 创建或更新 `docs/database/BD_Manual_biz_tables.md` 数据库手册,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
3. WHEN 迁移脚本执行成功, THE Task_Manager SHALL 运行 `python scripts/ops/gen_consolidated_ddl.py` 重新生成 DDL 基线文件
4. WHEN 种子数据脚本执行成功, THE Task_Manager SHALL 在数据库手册中记录种子数据内容(触发器配置)
### 需求 13小程序前端页面原型还原强制
**用户故事:** 作为产品经理,我需要小程序前端页面严格忠于 `docs/h5_ui/pages/` 中的 H5 原型图结构和视觉细节,确保最终实现与设计稿高度一致。
#### 原型图索引
| 原型文件 | 对应小程序页面 | 说明 |
|---------|--------------|------|
| `docs/h5_ui/pages/task-list.html` | `pages/task-list/task-list` | 任务列表页(首页),含业绩进度卡片、置顶/一般/已放弃三区域 |
| `docs/h5_ui/pages/task-detail.html` | `pages/task-detail/task-detail` | 任务详情页 - 高优先召回theme-red Banner |
| `docs/h5_ui/pages/task-detail-priority.html` | `pages/task-detail/task-detail` | 任务详情页 - 优先召回theme-orange Banner |
| `docs/h5_ui/pages/task-detail-relationship.html` | `pages/task-detail/task-detail` | 任务详情页 - 关系构建theme-pink Banner |
| `docs/h5_ui/pages/task-detail-callback.html` | `pages/task-detail/task-detail` | 任务详情页 - 客户回访theme-teal Banner |
| `docs/h5_ui/pages/notes.html` | `pages/notes/notes` | 备注记录页 |
| `docs/h5_ui/pages/customer-detail.html` | `pages/customer-detail/customer-detail` | 客户详情页 |
#### 验收标准
##### 13.A 结构还原(强制)
1. WHEN 实现任务列表页时, THE 小程序页面 SHALL 严格还原原型图中的以下结构层次:顶部用户信息区(头像 + 姓名 + 角色标签 + 门店名)→ 业绩进度卡片5 段档位进度条 + 课时数据含红戳 + 奖金激励 + 预计收入)→ 任务列表区(📌 置顶区 / 一般任务区 / 已放弃区三个分区,每个分区有标签 + 计数)
2. WHEN 实现任务卡片时, THE 每张任务卡片 SHALL 包含原型图中的全部元素:左侧 4px 彩色边框(高优先=红、优先=橙、关系构建=粉、客户回访=青)、任务类型标签(渐变色圆角矩形)、客户姓名、爱心 icon💖/🧡/💛/💙)、备注指示器(📝)、描述行(最近到店 + 余额、AI 建议行(含 AI 机器人 icon、右侧箭头
3. WHEN 实现任务详情页时, THE 页面 SHALL 严格还原原型图中的以下模块顺序:通栏 Banner导航栏 + 客户信息 + 放弃按钮)→ 维客线索卡片(客户基础/消费习惯/玩法偏好/重要反馈,每条含大类标签 + 摘要 + 详情 + 来源标注)→ 与我的关系卡片(爱心档位标签 + 进度条 + RS 分数 + 描述 + 近期服务记录列表)→ 任务建议卡片(建议执行 + 话术参考含复制按钮)→ 我给 TA 的备注卡片(备注列表含星星评分 + 删除按钮)→ 底部操作栏(问问助手 + 备注两个按钮)
4. WHEN 实现备注弹窗时, THE 弹窗 SHALL 包含原型图中的全部元素:标题行(添加备注 + 展开评价按钮)、可折叠的星星评分区(再次服务意愿 1-5 星 + 再来店可能性 1-5 星,各含文字提示)、文本输入区、保存按钮
5. WHEN 实现长按上下文菜单时, THE 菜单 SHALL 还原原型图中的交互:遮罩层 + 圆角菜单面板(置顶/取消置顶、备注、放弃/取消放弃等选项)
6. WHEN 实现备注记录页时, THE 页面 SHALL 还原原型图中的列表结构:每条备注含内容文本 + 底部标签(助教/客户类型标签 + 时间戳)
##### 13.B 视觉还原(强制)
1. THE 小程序页面 SHALL 使用与原型图一致的 TDesign 色彩体系primary=#0052d9、success=#00a870、warning=#ed7b2f、error=#e34d59,灰阶色板 gray-1(#f3f3f3) 至 gray-13(#242424)
2. THE 任务详情页 Banner SHALL 根据任务类型使用不同主题色:高优先召回=theme-red、优先召回=theme-orange、关系构建=theme-pink、客户回访=theme-teal与原型图中的渐变背景一致
3. THE 维客线索大类标签 SHALL 使用原型图中的配色方案:客户基础=primary/10 底色 + primary 文字、消费习惯=success/10 底色 + success 文字、玩法偏好=purple-500/10 底色 + purple-600 文字、重要反馈=error/10 底色 + error 文字
4. THE 星星评分组件 SHALL 还原原型图中的视觉效果:填充星/空心星 SVG、支持半星显示用于展示 AI 评分映射)
5. THE 业绩进度卡片 SHALL 还原原型图中的 5 段档位进度条按比例宽度0-100 占 45.45%、100-130/130-160/160-190/190-220 各占 13.64%)、红戳动画(盖戳效果)、奖金金额突出样式
##### 13.C WXML/WXSS 技术规范(强制)
1. THE 小程序页面 SHALL 使用 WXML 语法而非 HTML 语法:`<view>` 替代 `<div>``<text>` 替代 `<span>`/`<p>``<image>` 替代 `<img>``<navigator>` 替代 `<a>`,禁止使用 HTML 标签
2. THE 小程序样式 SHALL 使用 WXSS 语法:使用 `rpx` 单位替代 `px`750rpx = 屏幕宽度)、使用 `@import` 导入公共样式、禁止使用 `rem`/`em`/`vw`/`vh` 等 CSS 单位
3. THE 小程序页面 SHALL 使用 `wx:for` 替代 JavaScript 循环渲染、`wx:if`/`wx:elif`/`wx:else` 替代条件渲染、`bind:tap` 替代 `onclick``data-*` + `e.currentTarget.dataset` 替代 DOM 操作
4. THE 小程序页面 SHALL 禁止使用以下 Web 特性:`document.*``window.*``localStorage`(用 `wx.setStorageSync`)、`fetch`/`XMLHttpRequest`(用 `wx.request`、CSS `position: fixed``bottom: 0` 底部栏(用小程序安全区域适配)
5. THE 小程序样式 SHALL 仅使用小程序支持的 CSS 选择器:`.class``#id``element``element, element``::after``::before`,禁止使用 `>`(子选择器)、`+`(相邻兄弟)、`~`(通用兄弟)、`[attr]`(属性选择器)等不支持的选择器
6. THE 小程序页面 SHALL 使用 `<block>` 标签作为无渲染包裹容器(替代 HTML 的 `<template>` 或 React 的 `<Fragment>``<block>` 不会生成真实 DOM 节点
##### 13.D TDesign 组件使用规范(强制)
1. THE 小程序页面 SHALL 优先使用 TDesign 组件库中的组件,组件引入路径格式为 `tdesign-miniprogram/{组件名}/{组件名}`,在页面 `.json``usingComponents` 中注册
2. THE 以下 UI 元素 SHALL 使用对应的 TDesign 组件:导航栏→`t-navbar`、底部标签栏→`t-tab-bar`、对话框→`t-dialog`、轻提示→`t-toast`、弹出层→`t-popup`、空状态→`t-empty`、加载→`t-loading`、骨架屏→`t-skeleton`、标签→`t-tag`、搜索框→`t-search`
3. THE TDesign 组件样式覆盖 SHALL 使用以下 4 种方式之一:`style`/`custom-style` 属性、解除样式隔离(`addGlobalClass`)、外部样式类(`t-class`、CSS 变量(`--td-*`),禁止直接修改 `node_modules` 中的组件源码
4. THE 小程序 `app.json` SHALL 移除 `"style": "v2"` 配置项,避免 TDesign 组件样式错乱
5. WHEN 原型图中的 UI 元素无法用 TDesign 组件直接实现时(如自定义进度条、红戳动画、话术气泡等), THE 开发者 SHALL 使用原生 WXML + WXSS 自定义实现,但视觉效果必须与原型图一致
##### 13.E 原型图参考流程(强制)
1. WHEN 开始实现任何小程序页面前, THE 开发者 SHALL 首先阅读对应的 `docs/h5_ui/pages/*.html` 原型文件,提取页面结构、组件层次、样式细节、交互行为
2. WHEN 原型图中使用 Tailwind CSS 类名时, THE 开发者 SHALL 将其转换为等效的 WXSS 样式(如 `px-4``padding: 0 32rpx``rounded-xl``border-radius: 24rpx``text-sm``font-size: 28rpx`
3. WHEN 原型图中使用 `<iframe>` 嵌套页面时, THE 开发者 SHALL 理解这是原型展示方式,实际小程序中使用 `wx.navigateTo` 页面跳转
4. WHEN 原型图中使用 `onclick`/`history.back()` 等 Web API 时, THE 开发者 SHALL 转换为小程序等效 API`bind:tap` + `wx.navigateBack()`
5. THE 开发者 SHALL 在实现前加载 `wechat-miniprogram` Power 的相关 steering 文件(`view-layer.md``tdesign.md``builtin-components.md`),确保使用正确的小程序语法和 TDesign 组件规范
6. THE 开发者 SHALL 在实现前阅读项目内的 H5 转小程序避坑指南 `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`该文档基于本项目已转换页面的实际踩坑经验整理涵盖标签映射、rpx 换算、事件系统、TDesign 覆盖方式、高频踩坑清单及新页面开发 Checklist所有条目具有强制参考效力
### 需求 14任务系统属性测试
**用户故事:** 作为后端开发者,我需要通过属性测试验证任务系统核心逻辑的正确性。
#### 验收标准
1. THE 属性测试 SHALL 验证:对于任意 `(site_id, assistant_id, member_id, task_type)` 组合,`status = 'active'` 的任务最多只有一条(唯一性不变量)
2. THE 属性测试 SHALL 验证:对于任意任务类型变更操作,旧任务被标记为 `inactive` 且新任务被创建为 `active`(状态机正确性)
3. THE 属性测试 SHALL 验证:对于任意 `follow_up_visit` 任务,当 `expires_at` 不为 NULL 且当前时间超过 `expires_at` 时,轮询后 `status` 变为 `inactive`(有效期机制)
4. THE 属性测试 SHALL 验证:对于任意任务放弃操作,`abandon_reason` 不为空字符串(放弃原因必填)
5. THE 属性测试 SHALL 验证:对于任意备注创建操作,`rating_service_willingness``rating_revisit_likelihood` 的值在 NULL 或 1-5 范围内(评分范围约束)
6. THE 属性测试 SHALL 验证:对于任意召回完成事件,`completed_task_type` 记录了完成时的任务类型快照(完成类型快照不变量)
7. THE 属性测试 SHALL 验证:对于任意备注回溯操作,重分类后的备注 `type``normal` 变为 `follow_up`(回溯分类正确性)
---
## 附录:原型还原强制规则摘要
> 以下规则适用于本 SPEC 及所有后续小程序页面开发 SPEC具有全局约束力。
1. **原型图是唯一视觉真相**`docs/h5_ui/pages/*.html` 中的结构、层次、元素、配色、间距、交互行为是小程序页面实现的唯一参考标准。任何偏离原型图的实现都需要明确的产品确认。
2. **WXML ≠ HTML**:严禁在小程序中使用 HTML 标签div/span/p/a/img 等必须使用小程序原生标签view/text/image/navigator 等)。
3. **WXSS ≠ CSS**:使用 rpx 单位、仅支持有限选择器、无 DOM/BOM API、样式隔离机制不同。Tailwind CSS 类名必须手动转换为 WXSS。
4. **TDesign 优先**:凡 TDesign 组件库能覆盖的 UI 元素,必须使用 TDesign 组件;自定义实现仅限 TDesign 无法覆盖的场景。
5. **Power 文档优先**:实现前必须加载 `wechat-miniprogram` Power 的相关 steering 文件,确保语法和组件用法正确。
6. **项目踩坑指南必读**:实现前必须阅读 `apps/miniprogram/doc/h5-to-miniprogram-pitfalls.md`,该文档是基于本项目实际转换经验的避坑手册,涵盖 WXML/WXSS 差异、事件系统、TDesign 用法、rpx 换算规则及新页面开发 Checklist。

View File

@@ -0,0 +1,239 @@
# 实现计划小程序核心业务模块miniapp-core-business
## 概述
基于已批准的需求和设计文档,将小程序核心业务模块拆分为增量式编码任务。按照"DDL 建表 → 触发器调度框架 → 任务生成器 → 任务管理 → 有效期轮询 → 召回检测 → 备注系统 → 路由集成"的顺序实现。后端使用 Python + FastAPI数据库使用 PostgreSQL 纯 SQL属性测试使用 hypothesis。所有数据库操作在测试库 `test_zqyy_app` 中进行。
## 任务
- [x] 1. 创建业务数据表和种子数据
- [x] 1.1 创建迁移脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p4_create_biz_tables.sql`
-`biz` Schema 下创建 `coach_tasks``coach_task_history``notes``trigger_jobs` 共 4 张表
- 包含所有字段定义、约束、CHECK 约束(评分 1-5、外键coach_task_history → coach_tasks、notes → coach_tasks、coach_tasks → coach_tasks 自引用)
- 创建部分唯一索引 `idx_coach_tasks_site_assistant_member_type`(仅 status='active'
- 创建查询索引 `idx_coach_tasks_assistant_status``idx_notes_target`
- 使用 `IF NOT EXISTS` 幂等语法
- 包含回滚语句(注释形式)
- _Requirements: 1.1-1.9_
- [x] 1.2 创建种子数据脚本 `db/zqyy_app/migrations/YYYY-MM-DD__p4_seed_trigger_jobs.sql`
- 插入 4 条触发器配置:`task_generator`cron, 0 4 * * *)、`task_expiry_check`interval, 3600s`recall_completion_check`event, etl_data_updated`note_reclassify_backfill`event, recall_completed
- 使用 `ON CONFLICT (job_name) DO NOTHING` 幂等语法
- _Requirements: 2.1-2.5_
- [x] 1.3 在测试库执行迁移脚本并验证
-`test_zqyy_app` 中执行建表脚本和种子数据脚本
- 验证幂等性:连续执行两次无错误
- 验证表结构、约束、索引正确
- 验证种子数据完整4 条触发器配置)
- _Requirements: 11.1-11.5, 12.1_
- [x] 1.4 更新数据库手册和 DDL 基线
- 创建 `docs/database/BD_Manual_biz_tables.md`,包含变更说明、兼容性影响、回滚策略、验证 SQL至少 3 条)
- 运行 `python scripts/ops/gen_consolidated_ddl.py` 刷新 DDL 基线
- 在数据库手册中记录种子数据内容(触发器配置)
- _Requirements: 12.2, 12.3, 12.4_
- [x] 1.5 编写迁移脚本幂等性属性测试
- **Property 13: 迁移脚本幂等性**
- 对 DDL 脚本和种子数据脚本连续执行两次,验证第二次执行无错误且数据库状态不变
- **Validates: Requirements 1.8, 2.5, 11.4, 11.5**
- [x] 2. 检查点 - 确保 DDL 和种子数据正确
- 确保迁移脚本在测试库中执行成功,幂等性验证通过,如有问题请向用户确认。
- [x] 3. 实现 Pydantic 模型和纯函数核心逻辑
- [x] 3.1 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_tasks.py`
- 定义 `TaskListItem`(含 member_name、member_phone、rs_score、heart_icon
- 定义 `AbandonRequest`reason 必填min_length=1
- _Requirements: 8.1, 8.2, 8.4, 8.7_
- [x] 3.2 创建 Pydantic 模型 `apps/backend/app/schemas/xcx_notes.py`
- 定义 `NoteCreateRequest`(含 target_type、target_id、content、task_id、rating_service_willingness、rating_revisit_likelihood评分 ge=1 le=5
- 定义 `NoteOut`(含 type、content、评分、ai_score、ai_analysis
- _Requirements: 9.1, 9.8_
- [x] 3.3 创建任务生成器核心纯函数 `apps/backend/app/services/task_generator.py`
- 定义 `TaskPriority` 枚举、`TASK_TYPE_PRIORITY` 映射
- 定义 `IndexData` 数据类
- 实现 `determine_task_type(index_data)` 纯函数:根据 WBI/NCI/RS 指数确定任务类型
- 实现 `should_replace_task(existing_type, new_type)` 纯函数:判断是否应替换现有任务
- 实现 `compute_heart_icon(rs_score)` 纯函数:根据 RS 指数计算爱心 icon 档位
- _Requirements: 3.1-3.5, 3.8, 8.2_
- [x] 3.4 编写任务类型确定正确性属性测试
- **Property 1: 任务类型确定正确性**
- 生成随机 WBI/NCI/RS 值Decimal, 0-10, 2 位小数),验证 `determine_task_type()` 返回值符合优先级规则
- **Validates: Requirements 3.1, 3.2, 3.3, 3.5**
- [x] 3.5 编写星星评分范围约束属性测试
- **Property 9: 星星评分范围约束**
- 生成随机整数(-100 到 100验证 Pydantic 模型对 1-5 范围外的值拒绝ValidationError
- **Validates: Requirements 9.8, 14.5**
- [x] 3.6 编写爱心 icon 档位计算属性测试
- **Property 11: 爱心 icon 档位计算**
- 生成随机 RS 值Decimal, 0-10, 1 位小数),验证 `compute_heart_icon()` 返回正确 icon
- **Validates: Requirements 8.2**
- [x] 4. 实现触发器调度框架
- [x] 4.1 创建 `apps/backend/app/services/trigger_scheduler.py`
- 实现 `_JOB_REGISTRY` 注册表和 `register_job(job_type, handler)` 函数
- 实现 `fire_event(event_name, payload)` 方法:查找 event 类型触发器并执行
- 实现 `check_scheduled_jobs()` 方法:检查 cron/interval 到期 job 并执行
- 实现 `_calculate_next_run(trigger_condition, trigger_config)` 方法:计算下次运行时间
- 每个 job 独立事务,失败不影响其他触发器
- _Requirements: 10.1-10.7_
- [x] 4.2 编写触发器 next_run_at 计算属性测试
- **Property 12: 触发器 next_run_at 计算**
- 生成随机 cron/interval 配置和当前时间,验证 cron 类型 next_run_at > 当前时间interval 类型 next_run_at = 当前时间 + interval_seconds
- **Validates: Requirements 10.1, 10.2**
- [x] 5. 实现任务生成器完整流程
- [x] 5.1 实现 `TaskGenerator.run()` 主流程
- 通过 `auth.user_assistant_binding` 获取所有已绑定助教
- 对每个助教,通过 FDW 读取 WBI/NCI/RS 指数(`SET LOCAL app.current_site_id`
- 调用 `determine_task_type()` 确定任务类型
- 检查已存在的 active 任务:相同 task_type → 跳过;不同 task_type → 关闭旧任务 + 创建新任务 + 记录 history
- 处理 `follow_up_visit` 的 48 小时滞留机制expires_at 填充)
- 更新 `trigger_jobs` 时间戳
- _Requirements: 3.1-3.10, 4.1-4.5, 5.1-5.4_
- [x] 5.2 编写活跃任务唯一性不变量属性测试
- **Property 2: 活跃任务唯一性不变量**
- 生成随机 (site_id, assistant_id, member_id, task_type) 组合,模拟插入操作,验证 active 任务最多一条
- **Validates: Requirements 1.5, 3.6, 14.1**
- [x] 5.3 编写任务类型变更状态机属性测试
- **Property 3: 任务类型变更状态机**
- 生成随机现有任务 + 新任务类型,执行变更,验证旧任务 inactive + 新任务 active + history 记录
- **Validates: Requirements 3.7, 5.1, 5.4, 14.2**
- [x] 5.4 编写 48 小时滞留机制属性测试
- **Property 4: 48 小时滞留机制**
- 生成随机 follow_up_visit 任务 + 时间偏移,验证 expires_at 填充和过期逻辑
- **Validates: Requirements 4.1, 4.2, 4.3, 4.4, 14.3**
- [x] 6. 检查点 - 确保任务生成器测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v -k "property_1 or property_2 or property_3 or property_4"`
- 确保所有属性测试通过,如有问题请向用户确认。
- [x] 7. 实现任务管理服务
- [x] 7.1 创建 `apps/backend/app/services/task_manager.py`
- 实现 `get_task_list(user_id, site_id)` 异步方法:查询活跃任务 + FDW 读取客户信息和 RS 指数 + 爱心 icon 计算 + 排序
- 实现 `pin_task(task_id, user_id, site_id)` 异步方法:验证归属 + 设置 is_pinned=TRUE + 记录 history
- 实现 `unpin_task(task_id, user_id, site_id)` 异步方法:验证归属 + 设置 is_pinned=FALSE
- 实现 `abandon_task(task_id, user_id, site_id, reason)` 异步方法:验证 reason 非空 + 设置 abandoned + 记录 history
- 实现 `cancel_abandon(task_id, user_id, site_id)` 异步方法:恢复 active + 清空 abandon_reason + 记录 history
- 实现 `_record_history()` 内部方法
- _Requirements: 8.1-8.8_
- [x] 7.2 编写放弃与取消放弃往返属性测试
- **Property 5: 放弃与取消放弃往返**
- 生成随机 active 任务 + 非空放弃原因,执行放弃→取消放弃,验证状态恢复;空原因应返回 422
- **Validates: Requirements 8.4, 8.6, 8.7, 14.4**
- [x] 7.3 编写任务列表排序正确性属性测试
- **Property 10: 任务列表排序正确性**
- 生成随机任务列表(不同 is_pinned/priority_score/created_at验证排序为 is_pinned DESC, priority_score DESC, created_at ASC
- **Validates: Requirements 8.1**
- [x] 7.4 编写状态变更历史完整性属性测试
- **Property 15: 状态变更历史完整性**
- 生成随机状态变更操作序列(置顶/放弃/取消放弃),验证 history 记录数量和内容正确
- **Validates: Requirements 5.4, 8.3**
- [x] 8. 实现有效期轮询器
- [x] 8.1 创建 `apps/backend/app/services/task_expiry.py`
- 实现 `run()` 方法:查询 expires_at 不为 NULL 且已过期的 active 任务,标记为 inactive记录 history
- _Requirements: 4.3, 4.5_
- [x] 9. 实现召回完成检测器
- [x] 9.1 创建 `apps/backend/app/services/recall_detector.py`
- 实现 `run(payload)` 方法:通过 FDW 读取新增服务记录,匹配 active 任务标记 completed记录 completed_at 和 completed_task_type 快照,触发 `recall_completed` 事件
- _Requirements: 6.1-6.5_
- [x] 9.2 编写召回完成检测与类型快照属性测试
- **Property 6: 召回完成检测与类型快照**
- 生成随机 active 任务 + 服务记录,执行完成检测,验证 completed_task_type 记录了完成时的 task_type 快照
- **Validates: Requirements 6.2, 6.3, 14.6**
- [x] 10. 检查点 - 确保任务管理和召回检测测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v -k "property_5 or property_6 or property_10 or property_15"`
- 确保所有属性测试通过,如有问题请向用户确认。
- [-] 11. 实现备注系统
- [x] 11.1 创建备注服务 `apps/backend/app/services/note_service.py`
- 实现 `create_note()` 异步方法:验证评分范围 + 确定 note type关联 follow_up_visit 任务 → follow_up否则 normal+ INSERT + 触发 AI 应用 6 接口(占位)+ 若 ai_score >= 6 标记任务 completed
- 实现 `get_notes()` 异步方法:按 created_at DESC 排序,包含评分和 AI 评分
- 实现 `delete_note()` 异步方法:验证归属后硬删除
- _Requirements: 9.1-9.9_
- [x] 11.2 创建备注回溯重分类器 `apps/backend/app/services/note_reclassifier.py`
- 实现 `run(payload)` 方法:查找 service_time 之后的第一条 normal 备注 → 更新为 follow_up → 触发 AI 应用 6 接口(占位)→ 根据 ai_score 生成 follow_up_visit 任务
- 实现 `ai_analyze_note(note_id)` 占位函数(返回 NoneP5 实现后替换)
- _Requirements: 7.1-7.5_
- [x] 11.3 编写备注回溯重分类属性测试
- **Property 7: 备注回溯重分类**
- 生成随机备注列表 + service_time执行回溯验证符合条件的 normal 备注 type 变为 follow_up
- **Validates: Requirements 7.1, 7.2, 14.7**
- [x] 11.4 编写备注类型自动设置属性测试
- **Property 8: 备注类型自动设置**
- 生成随机 task_type + 备注创建,验证关联 follow_up_visit → type=follow_up其他 → type=normal
- **Validates: Requirements 9.2, 9.3**
- [x] 11.5 编写 AI 评分驱动的任务完成判定属性测试
- **Property 14: AI 评分驱动的任务完成判定**
- 生成随机 ai_score + 任务状态,验证 ai_score >= 6 且 active → completedai_score < 6 → 保持 active
- **Validates: Requirements 7.4, 7.5, 9.5**
- [x] 12. 实现 API 路由层
- [x] 12.1 创建小程序任务路由 `apps/backend/app/routers/xcx_tasks.py`
- 实现 `GET /api/xcx/tasks`获取任务列表require_approved
- 实现 `POST /api/xcx/tasks/{id}/pin`:置顶任务
- 实现 `POST /api/xcx/tasks/{id}/unpin`:取消置顶
- 实现 `POST /api/xcx/tasks/{id}/abandon`放弃任务AbandonRequest 校验)
- 实现 `POST /api/xcx/tasks/{id}/cancel-abandon`:取消放弃
- _Requirements: 8.1-8.8_
- [x] 12.2 创建小程序备注路由 `apps/backend/app/routers/xcx_notes.py`
- 实现 `POST /api/xcx/notes`创建备注NoteCreateRequest 校验)
- 实现 `GET /api/xcx/notes`查询备注列表query: target_type, target_id
- 实现 `DELETE /api/xcx/notes/{id}`:删除备注
- _Requirements: 9.1-9.9_
- [x] 12.3 在 `apps/backend/app/main.py` 中注册新路由
- 注册 `xcx_tasks``xcx_notes` 路由
- 验证无路由冲突
- _Requirements: 全部_
- [x] 12.4 注册触发器 job handler
- 在应用启动时调用 `register_job()` 注册 `task_generator``task_expiry_check``recall_completion_check``note_reclassify_backfill` 四个 handler
- _Requirements: 10.1-10.6_
- [x] 13. 检查点 - 确保所有测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_core_business_properties.py -v`
- 26/26 全部通过16.81s
- [x] 14. 最终检查点 - 全量验证
- 运行全部属性测试26/26 通过16.81s
- 验证迁移脚本幂等性Property 133 个测试)通过
- 验证种子数据完整性4 条触发器配置全部存在
- 验证表结构coach_tasks / coach_task_history / notes / trigger_jobs 全部存在
- 验证部分唯一索引idx_coach_tasks_site_assistant_member_type 存在
## 备注
- 标记 `*` 的子任务为可选(属性测试),可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点确保增量验证
- 属性测试验证通用正确性属性hypothesis最少 200 次迭代)
- 所有数据库操作在测试库 `test_zqyy_app` 进行
- 迁移脚本放在 `db/zqyy_app/migrations/` 目录
- 属性测试放在 `tests/test_core_business_properties.py`Monorepo 级)
- AI 应用 6 接口为占位实现(返回 None由 P5 AI 集成层替换
- 维客线索功能由独立模块 `routers/member_retention_clue.py` 处理,不在本 SPEC 范围内
- FDW 查询需在事务中 `SET LOCAL app.current_site_id` 设置 RLS 隔离

View File

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

View File

@@ -0,0 +1,878 @@
# 设计文档P5 AI 集成层miniapp-ai-integration
## 概述
本设计文档描述 P5-A 阶段 AI 集成层的技术架构与实现方案。系统在现有 FastAPI 后端(`apps/backend/`)中新增 AI 模块,通过阿里云百炼 API通义千问为 8 个 AI 应用提供统一的调用能力。
核心交付物:
- 3 张新表(`biz.ai_conversations``biz.ai_messages``biz.ai_cache`
- 百炼 API 统一封装层(流式 + 非流式)
- 应用 1 SSE 流式对话端点
- 应用 2 财务洞察Prompt 完整)+ 应用 8 维客线索整理Prompt 完整)
- 应用 3/4/5/6/7 触发机制与调用骨架
- 事件调度与调用链编排
- AI 缓存读写 API
设计原则:
- **统一封装**:所有 AI 调用经 `BailianClient` 统一出口,便于重试、计量、日志
- **事件驱动**:复用现有 `trigger_scheduler.fire_event()` 机制,扩展支持串行调用链
- **骨架优先**P5-A 只实现管道和框架Prompt 细化留给 P5-B 阶段
- **site_id 隔离**:所有表和查询强制 site_id 过滤
## 架构
### 系统架构图
```mermaid
graph TB
subgraph 微信小程序
MP_CHAT[对话页面]
MP_PAGES[其他页面<br/>财务看板/任务详情/客户详情]
end
subgraph FastAPI 后端
subgraph AI 模块 - apps/backend/app/ai/
SSE[SSE 端点<br/>/api/ai/chat/stream]
CACHE_API[缓存 API<br/>/api/ai/cache]
HISTORY_API[历史对话 API<br/>/api/ai/conversations]
DISPATCHER[AI Event Dispatcher<br/>调用链编排]
BAILIAN[BailianClient<br/>百炼 API 封装]
end
subgraph 现有服务
NOTE_SVC[note_service<br/>备注服务]
TRIGGER[trigger_scheduler<br/>触发器调度]
TASK_GEN[task_generator<br/>任务生成]
end
end
subgraph 外部服务
BAILIAN_API[阿里云百炼 API<br/>通义千问]
end
subgraph PostgreSQL - zqyy_app
AI_CONV[biz.ai_conversations]
AI_MSG[biz.ai_messages]
AI_CACHE_T[biz.ai_cache]
CLUE_T[member_retention_clue]
end
MP_CHAT -->|SSE| SSE
MP_PAGES -->|REST| CACHE_API
MP_CHAT -->|REST| HISTORY_API
SSE --> BAILIAN
DISPATCHER --> BAILIAN
BAILIAN -->|HTTP/SSE| BAILIAN_API
NOTE_SVC -->|备注提交事件| DISPATCHER
TRIGGER -->|消费/任务分配事件| DISPATCHER
DISPATCHER -->|写入| AI_CONV
DISPATCHER -->|写入| AI_MSG
DISPATCHER -->|写入| AI_CACHE_T
DISPATCHER -->|全量替换 AI 线索| CLUE_T
SSE -->|写入| AI_CONV
SSE -->|写入| AI_MSG
```
### 事件调用链
```mermaid
sequenceDiagram
participant E as 业务事件
participant D as AI Dispatcher
participant A3 as App3 线索
participant A8 as App8 整理
participant A7 as App7 客户分析
participant A4 as App4 关系分析
participant A5 as App5 话术
Note over E,A5: 消费事件链(无助教)
E->>D: consumption_event(member_id, site_id)
D->>A3: 调用(串行)
A3-->>D: 线索结果 → ai_cache
D->>A8: 调用(串行)
A8-->>D: 整合线索 → ai_cache + member_retention_clue
D->>A7: 调用(串行)
A7-->>D: 客户分析 → ai_cache
Note over E,A5: 消费事件链(有助教)
E->>D: consumption_event(member_id, assistant_id, site_id)
D->>A3: 调用
A3-->>D: 线索结果
D->>A8: 调用
A8-->>D: 整合线索
D->>A7: 调用
D->>A4: 调用A8 完成后)
A4-->>D: 关系分析
D->>A5: 调用A4 完成后)
A5-->>D: 话术参考
Note over E,A5: 备注事件链
E->>D: note_event(member_id, note_id, site_id)
D->>+A6: 调用
Note right of A6: App6 备注分析
A6-->>-D: 线索 + 评分
D->>A8: 调用
A8-->>D: 整合线索
Note over E,A5: 任务分配事件链
E->>D: task_assign_event(assistant_id, member_id, site_id)
D->>A4: 调用(读已有 A8 缓存)
A4-->>D: 关系分析
D->>A5: 调用
A5-->>D: 话术参考
```
### 模块目录结构
```
apps/backend/app/ai/
├── __init__.py
├── bailian_client.py # 百炼 API 统一封装
├── dispatcher.py # AI 事件调度与调用链编排
├── cache_service.py # AI 缓存读写服务
├── conversation_service.py # 对话记录持久化服务
├── apps/
│ ├── __init__.py
│ ├── app1_chat.py # 应用 1 通用对话
│ ├── app2_finance.py # 应用 2 财务洞察
│ ├── app3_clue.py # 应用 3 客户数据维客线索
│ ├── app4_analysis.py # 应用 4 关系分析
│ ├── app5_tactics.py # 应用 5 话术参考
│ ├── app6_note.py # 应用 6 备注分析
│ ├── app7_customer.py # 应用 7 客户分析
│ └── app8_consolidation.py # 应用 8 维客线索整理
├── prompts/
│ ├── __init__.py
│ ├── app2_finance_prompt.py # 应用 2 完整 Prompt
│ └── app8_consolidation_prompt.py # 应用 8 完整 Prompt
└── schemas.py # Pydantic 模型
apps/backend/app/routers/
├── xcx_ai_chat.py # SSE 对话路由
└── xcx_ai_cache.py # 缓存查询路由
```
## 组件与接口
### 1. BailianClient百炼 API 统一封装)
文件:`apps/backend/app/ai/bailian_client.py`
技术方案(基于百炼官方文档):
- **流式调用**:使用 OpenAI 兼容接口(百炼支持 OpenAI SDK 协议),`stream=True` 返回 SSE 事件流
- **非流式调用**`stream=False`,返回完整 JSON 响应
- **JSON 输出模式**:通过 System Prompt 约束 + `response_format={"type": "json_object"}` 参数(百炼兼容 OpenAI 的 JSON mode
- **重试策略**:指数退避,最多 3 次,基础间隔 1s → 2s → 4s
- **SDK 选择**:使用 `openai` Python SDK百炼兼容 OpenAI 协议),`base_url` 指向百炼端点
```python
class BailianClient:
"""百炼 API 统一封装层。"""
def __init__(self, api_key: str, base_url: str, model: str):
"""
Args:
api_key: 百炼 API Key从 BAILIAN_API_KEY 环境变量读取)
base_url: 百炼 API 端点(从 BAILIAN_BASE_URL 环境变量读取)
model: 模型标识(如 qwen-plus
"""
async def chat_stream(
self,
messages: list[dict],
*,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式调用,逐 chunk 返回文本。用于应用 1 SSE。"""
async def chat_json(
self,
messages: list[dict],
*,
temperature: float = 0.3,
max_tokens: int = 4000,
) -> tuple[dict, int]:
"""非流式调用,返回解析后的 JSON dict 和 tokens_used。
用于应用 2-8。
Raises:
BailianJsonParseError: JSON 解析失败时抛出
BailianApiError: API 调用失败(重试耗尽后)
"""
def _inject_current_time(self, messages: list[dict]) -> list[dict]:
"""在首条消息的 content JSON 中注入 current_time 字段。"""
async def _call_with_retry(self, **kwargs) -> Any:
"""带指数退避的重试封装。"""
```
环境变量(新增到 `.env` / `.env.template`
```
BAILIAN_API_KEY=sk-xxx
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
BAILIAN_MODEL=qwen-plus
```
### 2. AI Event Dispatcher事件调度器
文件:`apps/backend/app/ai/dispatcher.py`
调度器负责根据业务事件编排 AI 应用调用链。与现有 `trigger_scheduler` 的关系:
- `trigger_scheduler.fire_event()` 触发业务事件 → 调用 `ai_dispatcher` 对应的 handler
- `ai_dispatcher` 内部管理串行调用链的执行顺序
```python
class AIDispatcher:
"""AI 应用调用链编排器。"""
async def handle_consumption_event(
self,
member_id: int,
site_id: int,
settle_id: int,
assistant_id: int | None = None,
) -> None:
"""消费事件链App3 → App8 → App7+ App4 → App5 如有助教)。"""
async def handle_note_event(
self,
member_id: int,
site_id: int,
note_id: int,
note_content: str,
noted_by_name: str,
) -> None:
"""备注事件链App6 → App8。"""
async def handle_task_assign_event(
self,
assistant_id: int,
member_id: int,
site_id: int,
task_type: str,
) -> None:
"""任务分配事件链App4 → App5读已有 App8 缓存)。"""
async def _run_chain(
self,
chain: list[Callable],
context: dict,
) -> None:
"""串行执行调用链,某步失败记录日志后继续。"""
```
容错策略:
- 调用链中某个应用失败 → 记录错误日志 + 写入 `ai_conversations`(标记失败)
- 后续应用使用已有缓存继续执行,不阻塞整条链
- 整条链在后台异步执行,不阻塞业务请求
### 3. AI Cache Service缓存读写服务
文件:`apps/backend/app/ai/cache_service.py`
```python
class AICacheService:
"""AI 缓存读写服务。"""
def get_latest(
self,
cache_type: str,
site_id: int,
target_id: str,
) -> dict | None:
"""查询最新缓存记录。"""
def get_history(
self,
cache_type: str,
site_id: int,
target_id: str,
limit: int = 2,
) -> list[dict]:
"""查询历史缓存记录(按 created_at DESC用于 Prompt reference。"""
def write_cache(
self,
cache_type: str,
site_id: int,
target_id: str,
result_json: dict,
triggered_by: str | None = None,
score: int | None = None,
expires_at: datetime | None = None,
) -> int:
"""写入缓存记录,返回 id。写入后异步清理超限记录。"""
def _cleanup_excess(
self,
cache_type: str,
site_id: int,
target_id: str,
max_count: int = 500,
) -> int:
"""清理超限记录,保留最近 max_count 条,返回删除数量。"""
```
### 4. Conversation Service对话记录持久化
文件:`apps/backend/app/ai/conversation_service.py`
```python
class ConversationService:
"""AI 对话记录持久化服务。"""
def create_conversation(
self,
user_id: int | str,
nickname: str,
app_id: str,
site_id: int,
source_page: str | None = None,
source_context: dict | None = None,
) -> int:
"""创建对话记录,返回 conversation_id。
系统自动调用时 user_id 为 'system'"""
def add_message(
self,
conversation_id: int,
role: str,
content: str,
tokens_used: int | None = None,
) -> int:
"""添加消息记录,返回 message_id。"""
def get_conversations(
self,
user_id: int,
site_id: int,
page: int = 1,
page_size: int = 20,
) -> list[dict]:
"""查询用户历史对话列表,按时间倒序,懒加载。"""
def get_messages(
self,
conversation_id: int,
) -> list[dict]:
"""查询对话的所有消息。"""
```
### 5. Clue Writer维客线索写入器
文件:集成在 `apps/backend/app/ai/apps/app8_consolidation.py`
```python
class ClueWriter:
"""维客线索全量替换写入器。"""
def replace_ai_clues(
self,
member_id: int,
site_id: int,
clues: list[dict],
) -> int:
"""全量替换该客户的 AI 来源线索。
1. DELETE FROM member_retention_clue
WHERE member_id = %s AND site_id = %s
AND source IN ('ai_consumption', 'ai_note')
2. INSERT 新线索(人工线索 source='manual' 不受影响)
字段映射:
- category → category
- emoji + summary → summary"📅 偏好周末下午时段消费"
- detail → detail
- providers → recorded_by_name
- source: 纯 App3 → ai_consumption纯 App6 → ai_note混合 → ai_consumption
- recorded_by_assistant_id: NULL系统触发
返回写入的线索数量。
"""
```
### 6. API 端点
#### 6.1 SSE 对话端点
路由文件:`apps/backend/app/routers/xcx_ai_chat.py`
```
POST /api/ai/chat/stream
Content-Type: application/json
Accept: text/event-stream
Request Body:
{
"message": "string",
"source_page": "string",
"page_context": {},
"screen_content": "string" // 页面可见内容文本化
}
SSE Events:
data: {"type": "chunk", "content": "..."}
data: {"type": "done", "conversation_id": 123, "tokens_used": 456}
data: {"type": "error", "message": "..."}
认证JWT Token从 xcx_auth 获取)
隔离:从 JWT 中提取 user_id、site_id、nickname、role
```
#### 6.2 历史对话 API
```
GET /api/ai/conversations?page=1&page_size=20
→ [{ id, app_id, source_page, created_at, first_message_preview }]
GET /api/ai/conversations/{conversation_id}/messages
→ [{ id, role, content, tokens_used, created_at }]
```
#### 6.3 缓存查询 API
路由文件:`apps/backend/app/routers/xcx_ai_cache.py`
```
GET /api/ai/cache/{cache_type}?target_id=xxx
→ { id, cache_type, target_id, result_json, score, created_at }
认证JWT Token
隔离site_id 从 JWT 提取,强制过滤
```
### 7. 各应用骨架接口
每个应用实现统一的调用接口:
```python
# 应用基类模式(非继承,约定接口)
async def run(
context: dict, # 包含 member_id, site_id, 及应用特定参数
bailian: BailianClient,
cache_svc: AICacheService,
conv_svc: ConversationService,
) -> dict:
"""
执行 AI 应用调用。
1. 构建 Promptbuild_prompt
2. 调用百炼 APIbailian.chat_json
3. 写入 ai_conversations + ai_messages
4. 写入 ai_cache
5. 返回结果 dict
"""
```
骨架应用App3/4/5/6/7`build_prompt` 函数留接口:
```python
def build_prompt(context: dict) -> list[dict]:
"""构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段。
P5-B 阶段:由对应页面 spec 补充完整 Prompt。
"""
# TODO: P5-B 细化
return [
{"role": "system", "content": json.dumps({
"task": "...",
"current_time": "", # BailianClient 自动注入
# 以下字段待细化
"data": {},
"reference": {},
})},
]
```
## 数据模型
### DDLbiz.ai_conversations
```sql
CREATE TABLE IF NOT EXISTS biz.ai_conversations (
id BIGSERIAL PRIMARY KEY,
user_id VARCHAR(50) NOT NULL, -- 用户 ID 或 'system'
nickname VARCHAR(100) NOT NULL DEFAULT '',
app_id VARCHAR(30) NOT NULL, -- app1_chat / app2_finance / ... / app8_consolidation
site_id BIGINT NOT NULL,
source_page VARCHAR(100), -- 来源页面标识
source_context JSONB, -- 页面上下文 JSON
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE biz.ai_conversations IS 'AI 对话记录:每次 AI 调用(用户主动或系统自动)创建一条';
COMMENT ON COLUMN biz.ai_conversations.app_id IS '应用标识app1_chat / app2_finance / app3_clue / app4_analysis / app5_tactics / app6_note / app7_customer / app8_consolidation';
COMMENT ON COLUMN biz.ai_conversations.user_id IS '用户 ID系统自动调用时为 system';
CREATE INDEX IF NOT EXISTS idx_ai_conv_user_site ON biz.ai_conversations (user_id, site_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_conv_app_site ON biz.ai_conversations (app_id, site_id, created_at DESC);
```
### DDLbiz.ai_messages
```sql
CREATE TABLE IF NOT EXISTS biz.ai_messages (
id BIGSERIAL PRIMARY KEY,
conversation_id BIGINT NOT NULL REFERENCES biz.ai_conversations(id) ON DELETE CASCADE,
role VARCHAR(10) NOT NULL
CONSTRAINT chk_ai_msg_role CHECK (role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
tokens_used INTEGER, -- 本条消息消耗的 token 数
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE biz.ai_messages IS 'AI 消息记录:对话中的每条消息(输入/输出/系统)';
CREATE INDEX IF NOT EXISTS idx_ai_msg_conv ON biz.ai_messages (conversation_id, created_at);
```
### DDLbiz.ai_cache
```sql
CREATE TABLE IF NOT EXISTS biz.ai_cache (
id BIGSERIAL PRIMARY KEY,
cache_type VARCHAR(30) NOT NULL
CONSTRAINT chk_ai_cache_type CHECK (
cache_type IN (
'app2_finance', 'app3_clue', 'app4_analysis',
'app5_tactics', 'app6_note_analysis',
'app7_customer_analysis', 'app8_clue_consolidated'
)
),
site_id BIGINT NOT NULL,
target_id VARCHAR(100) NOT NULL, -- 含义因 cache_type 而异
result_json JSONB NOT NULL,
score INTEGER, -- 应用 6 专用评分
triggered_by VARCHAR(100), -- 触发来源标识
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- 可选过期时间
);
COMMENT ON TABLE biz.ai_cache IS 'AI 应用缓存:各应用的结构化输出结果';
COMMENT ON COLUMN biz.ai_cache.target_id IS '目标 IDApp2=时间维度编码 / App3,6,7,8=member_id / App4,5={assistant_id}_{member_id}';
COMMENT ON COLUMN biz.ai_cache.score IS '评分:仅应用 6 使用1-10 分)';
CREATE INDEX IF NOT EXISTS idx_ai_cache_lookup ON biz.ai_cache (cache_type, site_id, target_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_ai_cache_cleanup ON biz.ai_cache (cache_type, site_id, target_id, created_at);
```
### target_id 约定
| cache_type | target_id 格式 | 示例 |
|---|---|---|
| app2_finance | 时间维度编码 | `this_month`, `last_week` |
| app3_clue | member_id | `12345` |
| app4_analysis | `{assistant_id}_{member_id}` | `100_12345` |
| app5_tactics | `{assistant_id}_{member_id}` | `100_12345` |
| app6_note_analysis | member_id | `12345` |
| app7_customer_analysis | member_id | `12345` |
| app8_clue_consolidated | member_id | `12345` |
### 应用 2 时间维度编码
| 编码 | 含义 | 计算规则 |
|---|---|---|
| `this_month` | 本月 | 当前营业日所在月 |
| `last_month` | 上月 | 当前月 - 1 |
| `this_week` | 本周 | 当前营业日所在周(周一起) |
| `last_week` | 上周 | 当前周 - 1 |
| `last_3_months` | 前 3 月(不含本月) | 当前月 - 3 ~ 当前月 - 1 |
| `this_quarter` | 本季 | 当前营业日所在季度 |
| `last_quarter` | 上季 | 当前季度 - 1 |
| `last_6_months` | 近 6 月(不含本月) | 当前月 - 6 ~ 当前月 - 1 |
营业日分界点:每日 08:00`BUSINESS_DAY_START_HOUR` 环境变量)。
### 应用输出 JSON Schema
#### 应用 3/6 线索格式(写入 ai_cache.result_json
```json
{
"clues": [
{
"category": "消费习惯",
"summary": "偏好周末下午时段消费",
"detail": "近 3 个月 8 次消费中 6 次在周六/日 14:00-18:00",
"emoji": "📅"
}
]
}
```
应用 6 额外包含 `score` 字段1-10写入 `ai_cache.score`
#### 应用 8 整合线索格式
```json
{
"clues": [
{
"category": "消费习惯",
"summary": "偏好周末下午时段消费",
"detail": "近 3 个月 8 次消费中 6 次在周六/日 14:00-18:00",
"emoji": "📅",
"providers": "系统,张三"
}
]
}
```
#### 应用 4 关系分析格式
```json
{
"task_description": "...",
"action_suggestions": ["建议1", "建议2"],
"one_line_summary": "..."
}
```
#### 应用 5 话术参考格式
```json
{
"tactics": [
{ "scenario": "...", "script": "..." }
]
}
```
#### 应用 7 客户分析格式
```json
{
"strategies": [
{ "title": "...", "content": "..." }
],
"summary": "..."
}
```
#### 应用 2 财务洞察格式
```json
{
"insights": [
{ "seq": 1, "title": "...", "body": "..." }
]
}
```
### 与现有表的关系
- `member_retention_clue`:应用 8 的 `ClueWriter` 全量替换 `source IN ('ai_consumption', 'ai_note')` 的记录,`source='manual'` 的人工线索不受影响
- `biz.notes`:应用 6 触发点,`note_service.create_note()` 中的 `ai_analyze_note()` 占位函数将被替换为真实调用
- `biz.trigger_jobs`:新增 AI 相关的事件触发器配置(`consumption_settled``note_created``task_assigned`
- `biz.coach_tasks`:应用 4 触发条件之一(任务分配事件)
## 正确性属性
*正确性属性是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: BailianClient 双模式调用一致性
*For any* 合法的消息列表,`chat_stream` 应返回非空的 chunk 序列(拼接后为完整文本),`chat_json` 应返回可解析的 JSON dict 和正整数 tokens_used。两种模式对相同输入都应成功返回mock API 正常响应时)。
**Validates: Requirements 2.1, 2.3, 2.4**
### Property 2: 指数退避重试策略
*For any* 失败次数 n1 ≤ n ≤ max_retriesBailianClient 应在第 n 次失败后等待 base_interval × 2^(n-1) 秒后重试;当失败次数超过 max_retries 时,应抛出 BailianApiError。
**Validates: Requirements 2.2**
### Property 3: JSON 解析失败错误处理
*For any* 非法 JSON 字符串作为 API 响应,`chat_json` 应抛出 BailianJsonParseError 而非静默返回空值或崩溃。
**Validates: Requirements 2.5**
### Property 4: current_time 注入不变量
*For any* 消息列表,经 `_inject_current_time` 处理后,首条消息的 content解析为 JSON应包含 `current_time` 字段,且值为 ISO 格式的时间字符串(精确到秒),其余消息不受影响。
**Validates: Requirements 2.6**
### Property 5: AI 调用记录持久化 round-trip
*For any* AI 应用调用app1-app8调用完成后(a) `ai_conversations` 应包含一条匹配 app_id、site_id 的记录;(b) `ai_messages` 应包含至少一条 role='system' 或 role='user' 的输入消息和一条 role='assistant' 的输出消息;(c) 输出消息的 tokens_used 应为正整数。
**Validates: Requirements 3.2, 3.4, 3.5, 13.1, 13.2, 13.3**
### Property 6: 历史对话列表排序与分页
*For any* 用户和 site_id查询历史对话列表返回的记录应按 created_at 严格降序排列,且每页数量不超过 page_size默认 20
**Validates: Requirements 3.7**
### Property 7: 缓存写入 round-trip
*For any* AI 应用app2-app8的调用结果写入 `ai_cache` 后,按 (cache_type, site_id, target_id) 查询最新记录应返回与写入内容一致的 result_json。
**Validates: Requirements 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
### Property 8: AI 应用输出 JSON 结构验证
*For any* AI 应用调用结果的 result_json
- App2: 应包含 `insights` 数组,每项含 `seq`(正整数)、`title`(非空字符串)、`body`(非空字符串)
- App3: 应包含 `clues` 数组,每条含 `category`(∈ {客户基础, 消费习惯, 玩法偏好})、`summary``detail``emoji`
- App4: 应包含 `task_description``action_suggestions`(数组)、`one_line_summary`
- App5: 应包含 `tactics` 数组
- App6: 应包含 `score`1-10 整数)和 `clues` 数组,每条 category ∈ 6 个枚举值
- App7: 应包含 `strategies` 数组(每项含 `title``content`)和 `summary`
- App8: 应包含 `clues` 数组,每条含 `category`(∈ 6 个枚举值)、`summary``detail``emoji``providers`
**Validates: Requirements 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
### Property 9: Prompt reference 历史注入
*For any* 应用 3/4/5/6/7/8 的 Prompt 构建reference 字段应包含相关应用的缓存结果(如有),且历史记录附带 `generated_at` 时间戳。当缓存不存在时reference 应为空对象。
**Validates: Requirements 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
### Property 10: 事件调用链顺序正确性
*For any* 业务事件:
- 消费事件(无助教):调用顺序严格为 App3 → App8 → App7
- 消费事件(有助教):调用顺序严格为 App3 → App8 → {App7, App4 → App5}App7 和 App4 均在 App8 之后)
- 备注事件:调用顺序严格为 App6 → App8
- 任务分配事件:调用顺序严格为 App4 → App5
**Validates: Requirements 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
### Property 11: 调用链容错不变量
*For any* 调用链执行过程中某个应用调用失败,后续应用应继续执行(使用已有缓存),整条链不应因单点失败而中断。失败的应用应有错误日志记录。
**Validates: Requirements 11.7**
### Property 12: ClueWriter 全量替换不变量
*For any* member_id 和 site_id执行 `ClueWriter.replace_ai_clues(member_id, site_id, new_clues)` 后:
- (a) 该客户的 AI 来源线索source IN ('ai_consumption', 'ai_note'))应恰好等于 new_clues 的数量
- (b) 人工线索source='manual')的数量应与替换前完全一致
- (c) 写入的记录中 recorded_by_assistant_id 应为 NULL
- (d) summary 字段应为 emoji + 空格 + 原始 summary 的拼接格式
**Validates: Requirements 10.7, 10.8, 10.9**
### Property 13: 缓存查询 site_id 隔离
*For any* 两个不同的 site_idA 和 B写入 site_id=A 的缓存记录后,以 site_id=B 查询应返回空结果(即使 cache_type 和 target_id 相同)。
**Validates: Requirements 12.1, 12.5**
### Property 14: 缓存保留上限
*For any* (cache_type, site_id, target_id) 组合,无论写入多少条记录,清理后该组合的记录总数应 ≤ 500。
**Validates: Requirements 12.3**
## 错误处理
### 百炼 API 层
| 错误场景 | 处理策略 |
|---|---|
| API 超时 | 指数退避重试(最多 3 次),超时阈值 30s |
| API 返回 HTTP 4xx | 不重试,立即抛出 BailianApiError |
| API 返回 HTTP 5xx | 指数退避重试 |
| 响应非法 JSON | 抛出 BailianJsonParseError记录原始响应到日志 |
| API Key 无效 | 不重试,抛出 BailianAuthError记录告警日志 |
| 流式连接中断 | 已接收的 chunk 拼接为部分回复,标记 incomplete |
### 事件调度层
| 错误场景 | 处理策略 |
|---|---|
| 调用链中某应用失败 | 记录错误日志 + 写入失败 conversation 记录,后续应用使用已有缓存继续 |
| 数据库连接失败 | 整条链中止,记录错误日志 |
| 缓存查询失败 | 传空 reference 继续执行,不阻塞 |
### SSE 端点层
| 错误场景 | 处理策略 |
|---|---|
| 用户未认证 | 返回 HTTP 401 |
| 消息为空 | 返回 HTTP 422 |
| 流式过程中百炼 API 失败 | 发送 `{"type": "error", "message": "..."}` SSE 事件 |
| 客户端断开连接 | 取消百炼 API 调用,清理资源 |
### 缓存服务层
| 错误场景 | 处理策略 |
|---|---|
| 查询无结果 | 返回 null/None不抛异常 |
| 写入失败 | 抛出异常,由调用方处理 |
| 清理超限失败 | 记录警告日志,不影响写入操作 |
### ClueWriter 层
| 错误场景 | 处理策略 |
|---|---|
| 全量替换事务失败 | 回滚整个事务,保留原有线索不变 |
| 线索数据不符合 CHECK 约束 | 回滚事务记录错误日志category 枚举不匹配) |
## 测试策略
### 属性测试Property-Based Testing
- **测试库**hypothesisPython
- **最小迭代次数**:每个属性测试 100 次
- **测试文件位置**`tests/test_p5_ai_integration_properties.py`Monorepo 级)+ `apps/backend/tests/test_ai_*.py`(模块级)
- **标签格式**`# Feature: 05-miniapp-ai-integration, Property {N}: {property_text}`
每个正确性属性对应一个属性测试:
| Property | 测试策略 | 生成器 |
|---|---|---|
| P1: 双模式调用 | Mock 百炼 API验证两种模式返回格式 | 随机消息列表 |
| P2: 重试策略 | Mock 可控失败次数的 API | 随机失败次数 (0-5) |
| P3: JSON 解析失败 | Mock 返回非法 JSON | 随机非 JSON 字符串 |
| P4: current_time 注入 | 纯函数测试 | 随机消息列表 |
| P5: 记录持久化 | Mock 百炼 + 真实 DBtest_zqyy_app | 随机 app_id、消息内容 |
| P6: 历史列表排序 | 真实 DB | 随机对话记录(随机时间戳) |
| P7: 缓存 round-trip | 真实 DB | 随机 cache_type、target_id、result_json |
| P8: 输出 JSON 结构 | JSON Schema 验证 | 随机 AI 响应(符合各应用 schema |
| P9: reference 历史注入 | Mock 缓存数据 | 随机缓存记录(含/不含历史) |
| P10: 调用链顺序 | Mock 所有应用,记录调用序列 | 随机事件类型和参数 |
| P11: 调用链容错 | Mock 随机应用失败 | 随机失败位置 |
| P12: ClueWriter 替换 | 真实 DB | 随机线索列表 + 预置人工线索 |
| P13: site_id 隔离 | 真实 DB | 随机 site_id 对 |
| P14: 缓存上限 | 真实 DB | 批量写入(>500 条) |
### 单元测试
单元测试聚焦于具体示例和边界条件,与属性测试互补:
| 测试范围 | 测试内容 |
|---|---|
| 表结构验证 | 验证 3 张表的列、类型、约束、索引(需求 1.1-1.5 |
| App2 时间维度 | 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00 |
| App2 字段映射 | 验证 Prompt 使用 items_sum 口径而非 consume_money |
| SSE 协议 | 验证 Content-Type: text/event-stream 和事件格式 |
| ClueWriter 字段映射 | 验证 emoji+summary 拼接、source 判断逻辑 |
| 缓存 CHECK 约束 | 验证非法 cache_type 被拒绝 |
| App6 评分范围 | 验证 score 字段存储在 ai_cache.score |
### 集成测试
| 测试范围 | 测试内容 |
|---|---|
| 完整消费事件链 | Mock 百炼 API验证 App3→App8→App7 全链路 |
| 备注事件链 | Mock 百炼 API验证 App6→App8 全链路 |
| note_service 集成 | 验证 `ai_analyze_note` 占位函数被替换后的调用流程 |
| SSE 端到端 | 使用 httpx 的 SSE 客户端验证流式响应 |

View File

@@ -0,0 +1,257 @@
# 需求文档P5 AI 集成层miniapp-ai-integration
## 简介
本文档定义小程序 AI 集成层的需求规格,覆盖 P5-A 阶段(管道 + 骨架)。系统为台球门店助教和管理者提供 8 个 AI 应用,包括通用对话、财务洞察、客户数据分析、关系分析、话术参考、备注分析、客户分析和维客线索整理。技术栈为 FastAPI 后端 + 微信小程序前端 + PostgreSQLzqyy_app 业务库),通过阿里云百炼 API通义千问提供 AI 能力。
P5-A 阶段交付"管道":建表、百炼封装、缓存 API、SSE 框架,以及 Prompt 已完全确定的应用(应用 2、应用 8。应用 3/4/5/6/7 只实现触发机制和调用骨架Prompt 拼接函数留接口)。
> P5-B 阶段Prompt 细化)不在本 spec 范围,将分散到 P6task-detail和 P9customer-detail的对应任务中完成。
## 术语表
- **AI_Integration_System**P5 AI 集成层系统整体,包含后端 API、百炼封装、事件调度、缓存管理等
- **Bailian_Client**:百炼 API 统一封装层,负责与阿里云通义千问 API 的通信(流式/非流式)
- **SSE_Endpoint**Server-Sent Events 流式返回端点,用于应用 1 通用对话的逐字推送
- **AI_Cache_Service**AI 缓存读写服务,管理 `biz.ai_cache` 表的 CRUD 和保留策略
- **Event_Dispatcher**:事件调度器,负责根据业务事件(消费、备注、任务分配)触发对应 AI 应用调用链
- **Clue_Writer**:维客线索写入器,负责将应用 8 整合后的线索全量替换写入 `member_retention_clue`
- **App1_Chat**:应用 1 通用对话,用户主动发起的流式对话
- **App2_Finance**:应用 2 财务洞察,每日自动生成 8 个时间维度的财务分析
- **App3_Clue**:应用 3 客户数据维客线索分析,客户新增消费时自动触发
- **App4_Analysis**:应用 4 关系分析/任务建议,助教参与新结算或任务分配时触发
- **App5_Tactics**:应用 5 话术参考,联动应用 4 自动触发
- **App6_Note**:应用 6 备注分析,备注提交时自动触发
- **App7_Customer**:应用 7 客户分析,消费事件链中应用 8 完成后触发
- **App8_Consolidation**:应用 8 维客线索整理,应用 3 或应用 6 产出后触发
- **items_sum**:校准后的消费金额口径,= table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money禁止使用 consume_money
- **ai_conversations**AI 对话表,记录每次对话的元信息
- **ai_messages**AI 消息表,记录对话中的每条消息
- **ai_cache**AI 缓存表,存储各应用的结构化输出结果
- **member_retention_clue**:维客线索表,存储整合后的客户维护线索
- **营业日分界点**:每日 08:00用于时间维度计算的日切点
## 需求
### 需求 1数据库表结构
**用户故事:** 作为系统,我需要持久化存储所有 AI 对话记录和缓存结果,以便支撑 8 个 AI 应用的数据读写需求。
#### 验收标准
1. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_conversations`包含字段id、user_id、nickname、app_id、site_id、source_page、source_contextJSON、created_at
2. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_messages`包含字段id、conversation_id外键关联 ai_conversations、roleuser/assistant/system、content、tokens_used、created_at
3. THE AI_Integration_System SHALL 在 `biz` schema 下创建 `ai_cache`包含字段id、cache_type枚举app2_finance / app3_clue / app4_analysis / app5_tactics / app6_note_analysis / app7_customer_analysis / app8_clue_consolidated、site_id、target_id、result_json、score应用 6 专用、triggered_bytrigger_job_id、created_at、expires_at
4. THE AI_Integration_System SHALL 对 ai_cache 表的 target_id 按应用约定存储:应用 2 存时间维度编码、应用 3/6/7/8 存 member_id、应用 4/5 存 `{assistant_id}_{member_id}` 格式
5. THE AI_Integration_System SHALL 对所有三张表启用 site_id 字段以支持多门店隔离
### 需求 2百炼 API 统一封装层
**用户故事:** 作为开发者,我需要一个统一的百炼 API 封装层,以便所有 AI 应用通过一致的接口调用阿里云通义千问,降低重复代码和维护成本。
#### 验收标准
1. THE Bailian_Client SHALL 支持流式调用模式(用于应用 1 SSE 推送)和非流式调用模式(用于应用 2-8 结构化输出)
2. THE Bailian_Client SHALL 在 API 调用失败时执行自动重试(含指数退避策略)
3. THE Bailian_Client SHALL 记录每次 API 调用的 tokens_used 统计信息
4. THE Bailian_Client SHALL 支持 JSON 输出模式,确保应用 2-8 返回的内容可解析为结构化 JSON
5. IF 百炼 API 返回非预期格式或解析失败THEN THE Bailian_Client SHALL 记录错误日志并返回明确的错误信息
6. THE Bailian_Client SHALL 在每次调用的首条 Prompt JSON 中统一注入 `current_time` 字段(精确到秒)
### 需求 3应用 1 通用对话SSE 流式)
**用户故事:** 作为助教,我可以在任意页面点击 AI 按钮,跳转到对话页面与 AI 交流AI 了解当前页面上下文。
#### 验收标准
1. THE SSE_Endpoint SHALL 以 Server-Sent Events 协议向前端推送 AI 回复,实现逐字展示效果
2. WHEN 用户从任意页面进入 chat 页面时THE App1_Chat SHALL 始终新建一条 ai_conversations 记录(不复用已有对话)
3. THE App1_Chat SHALL 在首条消息中注入页面上下文,包含 source_page来源页面标识、page_context页面上下文摘要、screen_content屏幕可见内容文本化描述
4. WHEN 用户发送消息时THE App1_Chat SHALL 立即将用户消息写入 ai_messagesrole=user
5. WHEN 流式返回完成后THE App1_Chat SHALL 将完整的 assistant 回复写入 ai_messagesrole=assistant包含 tokens_used
6. THE App1_Chat SHALL 通过 `biz_params.user_prompt_params` 传入 User_ID、Role助教/管理者、Nickname 实现信息隔离
7. THE App1_Chat SHALL 提供历史对话列表接口,按时间倒序展示,每页 20 条懒加载
8. THE App1_Chat SHALL 搭建上下文注入框架页面文本化工具留接口P5-B 阶段各页面逐步实现)
### 需求 4应用 2 财务洞察
**用户故事:** 作为管理者,我在财务看板能看到 AI 生成的财务洞察分析,覆盖多个时间维度。
#### 验收标准
1. THE App2_Finance SHALL 由 ETL 调度器在每日 08:00营业日分界点后的首次任务执行时触发
2. THE App2_Finance SHALL 在 DWS 日更数据更新完成后,依次对 8 个时间维度发起独立调用(共 8 次百炼 API 调用)
3. THE App2_Finance SHALL 覆盖 8 个时间维度本月this_month、上月last_month、本周this_week、上周last_week、前 3 月不含本月last_3_months、本季this_quarter、上季last_quarter、近 6 月不含本月last_6_months
4. THE App2_Finance SHALL 返回结构化 JSON格式为序号 + 标题 + 正文的数组
5. THE App2_Finance SHALL 在 Prompt 中包含当期和上期的收入结构table_fee、assistant_pd、assistant_cx、goods、recharge、储值资产、费用汇总、平台结算数据
6. THE App2_Finance SHALL 使用已校准的收入结构字段映射table_fee = table_charge_money、assistant_pd = assistant_pd_money、assistant_cx = assistant_cx_money、goods = goods_money、recharge = 充值 pay_amountsettle_type=5
7. THE App2_Finance SHALL 将每次调用结果写入 ai_cachecache_type=app2_financetarget_id=时间维度编码)
8. IF ETL 调度器中尚无应用 2 的调度逻辑THEN THE AI_Integration_System SHALL 在 P5-A 阶段补充该调度任务
### 需求 5应用 3 客户数据维客线索分析(骨架)
**用户故事:** 作为系统,客户新增消费时自动通过 AI 分析客户数据,提取维客线索。
#### 验收标准
1. WHEN 客户新增消费结账单出现THE Event_Dispatcher SHALL 触发 App3_Clue 调用
2. THE App3_Clue SHALL 返回 JSON 格式的线索数组,每条线索包含 category分类标签、summary摘要、detail详情、emoji
3. THE App3_Clue SHALL 将分类标签限定为 3 个枚举值:客户基础、消费习惯、玩法偏好
4. THE App3_Clue SHALL 将线索提供者统一标记为"系统"
5. THE App3_Clue SHALL 使用 items_sum 作为消费金额口径(= table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money禁止使用 consume_money
6. THE App3_Clue SHALL 将结果写入 ai_cachecache_type=app3_cluetarget_id=member_id
7. THE App3_Clue SHALL 实现触发机制和调用框架Prompt 拼接函数留接口consumption_records 等字段待 P9-T1 细化)
8. THE App3_Clue SHALL 在 Prompt 的 reference 中包含应用 6 的线索结果(如有)和最近 2 套应用 8 的历史信息(附 generated_at 时间)
### 需求 6应用 4 关系分析/任务建议(骨架)
**用户故事:** 作为系统,助教参与新结算或被分配召回任务时,自动生成关系分析和任务建议。
#### 验收标准
1. WHEN 助教参与新结算时THE Event_Dispatcher SHALL 在消费事件链中等待应用 3 → 应用 8 完成后触发 App4_Analysis
2. WHEN 优先召回任务分配或高优先召回任务分配时THE Event_Dispatcher SHALL 直接触发 App4_Analysis读取应用 8 已有缓存)
3. THE App4_Analysis SHALL 返回 JSON 格式,包含任务描述、行动建议数组、一句话总结
4. THE App4_Analysis SHALL 在 Prompt 的 reference 中包含应用 8 当前最新维客线索和最近 2 套历史信息(附 generated_at 时间)
5. IF 应用 8 缓存不存在如新客户首次结算THEN THE App4_Analysis SHALL 在 reference 中传空对象Prompt 中标注"暂无历史线索"
6. THE App4_Analysis SHALL 将结果写入 ai_cachecache_type=app4_analysistarget_id=`{assistant_id}_{member_id}`
7. THE App4_Analysis SHALL 实现触发机制和调用框架Prompt 拼接函数留接口service_history、assistant_info 等字段待 P6-T4 细化)
### 需求 7应用 5 话术参考(骨架)
**用户故事:** 作为系统,应用 4 生成任务建议后,自动联动生成沟通话术参考。
#### 验收标准
1. WHEN App4_Analysis 调用完成后THE Event_Dispatcher SHALL 自动触发 App5_Tactics
2. THE App5_Tactics SHALL 接收应用 4 的完整返回结果作为 Prompt 中的 task_suggestion 字段
3. THE App5_Tactics SHALL 返回 JSON 格式的话术内容数组
4. THE App5_Tactics SHALL 在 Prompt 的 reference 中包含最近 2 套应用 8 的历史信息(附 generated_at 时间)
5. THE App5_Tactics SHALL 将结果写入 ai_cachecache_type=app5_tacticstarget_id=`{assistant_id}_{member_id}`
6. THE App5_Tactics SHALL 实现联动框架Prompt 拼接函数留接口service_history、assistant_info 等字段随应用 4 同步在 P6-T4 细化)
### 需求 8应用 6 备注分析(骨架)
**用户故事:** 作为系统,助教提交备注后,自动通过 AI 分析备注内容,提取维客线索并评分。
#### 验收标准
1. WHEN 备注提交时THE Event_Dispatcher SHALL 触发 App6_Note 调用
2. THE App6_Note SHALL 返回 JSON 格式,包含 score评分 1-10和 clues线索数组每条含 category、summary、detail、emoji
3. THE App6_Note SHALL 将分类标签限定为 6 个枚举值:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈
4. THE App6_Note SHALL 将线索提供者标记为当前备注提供人
5. THE App6_Note SHALL 使用 6 分为标准分的评分规则:重复信息/低价值/时效性低酌情扣分,高价值信息酌情加分
6. THE App6_Note SHALL 将结果写入 ai_cachecache_type=app6_note_analysistarget_id=member_idscore 字段存储评分
7. THE App6_Note SHALL 实现触发机制和调用框架Prompt 拼接函数留接口consumption_data 等字段待 P9-T1 细化)
8. THE App6_Note SHALL 在 Prompt 的 reference 中包含应用 3 的线索结果(如有)和最近 2 套应用 8 的历史信息(附 generated_at 时间)
### 需求 9应用 7 客户分析(骨架)
**用户故事:** 作为系统,客户结账单出现后自动生成客户全量分析与运营建议。
#### 验收标准
1. WHEN 消费事件链中 App8_Consolidation 完成后THE Event_Dispatcher SHALL 串行触发 App7_Customer确保读到本次消费触发的最新线索
2. THE App7_Customer SHALL 返回 JSON 格式,包含 strategies 数组(每条含 title 和 content和 summary一句话总结
3. THE App7_Customer SHALL 使用 items_sum 作为消费金额口径,禁止使用 consume_money
4. THE App7_Customer SHALL 对主观信息来自备注标注【来源XXX请甄别信息真实性】
5. THE App7_Customer SHALL 将结果写入 ai_cachecache_type=app7_customer_analysistarget_id=member_id
6. THE App7_Customer SHALL 实现触发机制和调用框架Prompt 拼接函数留接口objective_data 等字段待 P9-T1 细化)
7. THE App7_Customer SHALL 在 Prompt 的 reference 中包含最新 + 最近 2 套应用 8 的历史信息(附 generated_at 时间)
### 需求 10应用 8 维客线索整理
**用户故事:** 作为系统,应用 3 或应用 6 产出新线索后,自动整合去重生成统一维客线索,并写入 member_retention_clue 表。
#### 验收标准
1. WHEN App3_Clue 产出新线索后THE Event_Dispatcher SHALL 立即触发 App8_Consolidation
2. WHEN App6_Note 产出新线索后THE Event_Dispatcher SHALL 立即触发 App8_Consolidation
3. THE App8_Consolidation SHALL 接收应用 3 和应用 6 的全部线索内容作为输入(附 generated_at 时间)
4. THE App8_Consolidation SHALL 返回 JSON 格式的整合后线索数组,每条含 category、summary、detail、emoji、providers
5. THE App8_Consolidation SHALL 将分类标签限定为 6 个枚举值:客户基础、消费习惯、玩法偏好、促销偏好、社交关系、重要反馈(与 member_retention_clue 表 CHECK 约束一致)
6. THE App8_Consolidation SHALL 合并相似线索(多提供者以逗号分隔),其余线索原文返回,遵循最小改动原则
7. THE App8_Consolidation SHALL 将整合后的线索全量替换该客户在 member_retention_clue 中的所有 AI 来源线索source IN ('ai_consumption', 'ai_note')人工线索source='manual')不受影响
8. THE Clue_Writer SHALL 按以下字段映射写入 member_retention_cluecategory → category、emoji + summary → summaryemoji 拼接在前,如"📅 偏好周末下午时段消费"、detail → detail、providers → recorded_by_name、source 根据线索来源判断(纯应用 3 → ai_consumption纯应用 6 → ai_note混合来源 → ai_consumption
9. THE Clue_Writer SHALL 对系统触发的线索将 recorded_by_assistant_id 填 NULL
10. THE App8_Consolidation SHALL 将结果同时写入 ai_cachecache_type=app8_clue_consolidatedtarget_id=member_id
### 需求 11事件调度与调用链编排
**用户故事:** 作为系统,我需要根据业务事件(消费、备注、任务分配)自动编排 AI 应用调用链,确保执行顺序和数据依赖正确。
#### 验收标准
1. WHEN 消费事件结账单发生时THE Event_Dispatcher SHALL 按严格串行顺序执行:应用 3 → 应用 8 → 应用 7
2. WHEN 消费事件中该结算单有助教参与时THE Event_Dispatcher SHALL 在应用 8 完成后额外执行:应用 4 → 应用 5
3. WHEN 备注提交事件发生时THE Event_Dispatcher SHALL 按串行顺序执行:应用 6 → 应用 8
4. WHEN 任务分配事件(优先召回/高优先召回发生时THE Event_Dispatcher SHALL 执行:应用 4 → 应用 5直接读取应用 8 已有缓存)
5. THE Event_Dispatcher SHALL 确保消费事件链中应用 7 等待应用 8 完成后再启动,保证读到本次消费触发的最新线索
6. THE Event_Dispatcher SHALL 确保消费事件链中应用 4 等待应用 3 → 应用 8 完成后再执行,确保读到本次消费的最新线索
7. IF 调用链中某个应用调用失败THEN THE Event_Dispatcher SHALL 记录错误日志,后续应用使用已有缓存继续执行(不阻塞整条链)
### 需求 12AI 缓存读写 API
**用户故事:** 作为前端,我需要通过 API 读取各 AI 应用的缓存结果,以便在对应页面展示 AI 分析内容。
#### 验收标准
1. THE AI_Cache_Service SHALL 提供按 cache_type + site_id + target_id 查询最新缓存结果的 API
2. THE AI_Cache_Service SHALL 支持以下前端消费场景:应用 2 结果展示在 board-finance 财务看板、应用 4/5 结果展示在 task-detail 任务详情页、应用 6 的 score 以打星方式展示在备注卡片、应用 7 结果展示在 customer-detail 客户详情页、应用 8 结果通过 member_retention_clue 表展示
3. THE AI_Cache_Service SHALL 对每个 (cache_type, site_id, target_id) 组合保留最近 500 条记录,超过时删除最旧的
4. WHEN 写入新缓存记录后THE AI_Cache_Service SHALL 异步检查并清理超限记录
5. THE AI_Cache_Service SHALL 对所有查询和写入操作执行 site_id 隔离
### 需求 13AI 调用记录持久化
**用户故事:** 作为系统,所有 AI 对话(含用户主动和系统自动调用)都要持久化记录,以便追溯和统计。
#### 验收标准
1. THE AI_Integration_System SHALL 对所有 8 个应用的每次 AI 调用创建 ai_conversations 记录,包含 conversation_id、app_id、user_id系统调用时为系统标识、nickname、site_id
2. THE AI_Integration_System SHALL 对每次 AI 调用的输入和输出分别写入 ai_messages包含 roleuser/assistant/system、content、tokens_used、created_at
3. THE AI_Integration_System SHALL 在 ai_conversations 中记录 source_page 和 source_contextJSON标识调用来源
### 需求 14百炼技术方案确认
**用户故事:** 作为开发者,我需要确认百炼 API 的流式返回技术方案和 JSON 输出最佳实践,以便正确实现封装层。
#### 验收标准
1. THE AI_Integration_System SHALL 查阅百炼官方文档确认流式返回的技术方案SSE vs WebSocket
2. THE AI_Integration_System SHALL 确认百炼 API 的 JSON 输出模式配置方式response_format 参数或 System Prompt 约束)
3. THE AI_Integration_System SHALL 基于确认结果输出技术方案文档,作为 Bailian_Client 实现的依据
---
## 范围说明
### P5-A 阶段(本 spec 覆盖)
| 任务 | 对应需求 | 说明 |
|------|---------|------|
| T1 | 需求 1 | 建表ai_conversations + ai_messages + ai_cache |
| T2 | 需求 2 | 百炼 API 统一封装层 |
| T3 | 需求 3 | 应用 1 通用对话 SSE |
| T5 | 需求 4 | 应用 2 财务洞察Prompt 已确定) |
| T6-骨架 | 需求 5 | 应用 3 触发机制 + 调用框架 |
| T7-骨架 | 需求 6 | 应用 4 触发机制 + 调用框架 |
| T8-骨架 | 需求 7 | 应用 5 联动框架 |
| T9-骨架 | 需求 8 | 应用 6 触发机制 + 调用框架 |
| T10-骨架 | 需求 9 | 应用 7 触发机制 + 调用框架 |
| T11 | 需求 10 | 应用 8 维客线索整理Prompt 已确定) |
| T12 | 需求 12 | AI 缓存读写 API |
| T13 | 需求 14 | 百炼技术方案确认 |
| — | 需求 11 | 事件调度与调用链编排(贯穿 T6-T11 |
| — | 需求 13 | AI 调用记录持久化(贯穿所有应用) |
### P5-B 阶段(不在本 spec 范围)
以下任务将分散到对应页面的开发 spec 中完成:
- T4页面内容文本化工具 → 随 P6-P9 各页面逐步实现
- T6-完整:应用 3 Prompt JSON 细化 → P9-T1customer-detail API
- T7-完整:应用 4 Prompt JSON 细化 → P6-T4task-detail API
- T8-完整:应用 5 Prompt JSON 细化 → P6-T4task-detail API
- T9-完整:应用 6 Prompt JSON 细化 → P9-T1customer-detail API
- T10-完整:应用 7 Prompt JSON 细化 → P9-T1customer-detail API

View File

@@ -0,0 +1,322 @@
# 实现计划P5 AI 集成层miniapp-ai-integration
## 概述
基于 P5-A 阶段设计,在 `apps/backend/app/ai/` 新建 AI 模块,实现百炼 API 封装、SSE 对话、事件调度、缓存服务、8 个 AI 应用(其中 App2/App8 含完整 PromptApp3/4/5/6/7 仅骨架)。每个任务增量构建,最终通过路由和事件调度器串联所有组件。
## 任务
- [x] 1. 数据库表结构与基础模块搭建
- [x] 1.1 创建 DDL 迁移脚本,在 `biz` schema 下建表 `ai_conversations``ai_messages``ai_cache`
- 按设计文档中的 DDL 创建三张表包含所有字段、CHECK 约束、索引
- DDL 文件放置于 `db/zqyy_app/migrations/` 目录,日期前缀命名
- _需求: 1.1, 1.2, 1.3, 1.4, 1.5_
- [x] 1.2 创建 AI 模块目录结构和 Pydantic Schema
- 创建 `apps/backend/app/ai/` 目录及 `__init__.py`
- 创建 `apps/backend/app/ai/apps/` 子目录及 `__init__.py`
- 创建 `apps/backend/app/ai/prompts/` 子目录及 `__init__.py`
-`apps/backend/app/ai/schemas.py` 中定义所有 Pydantic 模型:
- `ChatStreamRequest`message, source_page, page_context, screen_content
- `SSEEvent`type, content, conversation_id, tokens_used, message
- `CacheTypeEnum`7 个枚举值)
- `ClueItem`category, summary, detail, emoji
- `ConsolidatedClueItem`(含 providers
- `App2InsightItem``App4Result``App5TacticsItem``App6Result``App7Result`
- `App2Result``App3Result``App8Result`
- _需求: 4.4, 5.2, 5.3, 6.3, 7.3, 8.2, 8.3, 9.2, 10.4, 10.5_
- [x] 1.3 编写属性测试AI 应用输出 JSON 结构验证
- **Property 8: AI 应用输出 JSON 结构验证**
- 使用 hypothesis 生成随机 JSON验证各应用 Pydantic 模型的解析和校验
- 验证 App3 category ∈ {客户基础, 消费习惯, 玩法偏好}App6/8 category ∈ 6 个枚举值
- 测试文件:`tests/test_p5_ai_integration_properties.py`
- **验证: 需求 4.4, 5.2, 5.3, 5.4, 6.3, 7.3, 8.2, 8.3, 8.4, 9.2, 10.4, 10.5**
- [x] 2. 百炼 API 统一封装层BailianClient
- [x] 2.1 实现 BailianClient 核心逻辑
- 文件:`apps/backend/app/ai/bailian_client.py`
- 使用 `openai` Python SDK`base_url` 指向百炼端点
- 实现 `chat_stream`流式AsyncGenerator[str, None]
- 实现 `chat_json`(非流式,返回 tuple[dict, int]
- 实现 `_inject_current_time`(首条消息注入 current_time
- 实现 `_call_with_retry`(指数退避,最多 3 次1s→2s→4s
- 定义异常类:`BailianApiError``BailianJsonParseError``BailianAuthError`
- 环境变量:`BAILIAN_API_KEY``BAILIAN_BASE_URL``BAILIAN_MODEL`
- _需求: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6_
- [x] 2.2 编写属性测试:双模式调用一致性
- **Property 1: BailianClient 双模式调用一致性**
- Mock 百炼 API验证 `chat_stream` 返回非空 chunk 序列,`chat_json` 返回可解析 JSON + 正整数 tokens_used
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.1, 2.3, 2.4**
- [x] 2.3 编写属性测试:指数退避重试策略
- **Property 2: 指数退避重试策略**
- Mock 可控失败次数的 API验证重试间隔为 base_interval × 2^(n-1),超过 max_retries 抛出 BailianApiError
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.2**
- [x] 2.4 编写属性测试JSON 解析失败错误处理
- **Property 3: JSON 解析失败错误处理**
- Mock 返回非法 JSON 字符串,验证 `chat_json` 抛出 BailianJsonParseError
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.5**
- [x] 2.5 编写属性测试current_time 注入不变量
- **Property 4: current_time 注入不变量**
- 纯函数测试,随机消息列表,验证首条消息注入 current_timeISO 格式精确到秒),其余消息不变
- 测试文件:`apps/backend/tests/test_ai_bailian.py`
- **验证: 需求 2.6**
- [x] 3. 对话记录持久化服务ConversationService
- [x] 3.1 实现 ConversationService
- 文件:`apps/backend/app/ai/conversation_service.py`
- `create_conversation`:创建 ai_conversations 记录,系统调用时 user_id='system'
- `add_message`:写入 ai_messages 记录role, content, tokens_used
- `get_conversations`:按 user_id + site_id 查询created_at DESC分页page_size=20
- `get_messages`:按 conversation_id 查询所有消息
- _需求: 3.2, 3.4, 3.5, 3.7, 13.1, 13.2, 13.3_
- [x] 3.2 编写属性测试AI 调用记录持久化 round-trip
- **Property 5: AI 调用记录持久化 round-trip**
- 使用 test_zqyy_app 数据库,随机 app_id 和消息内容,验证写入后查询一致
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
- **验证: 需求 3.2, 3.4, 3.5, 13.1, 13.2, 13.3**
- [x] 3.3 编写属性测试:历史对话列表排序与分页
- **Property 6: 历史对话列表排序与分页**
- 使用 test_zqyy_app 数据库,随机时间戳创建对话,验证返回严格降序且每页 ≤ page_size
- 测试文件:`apps/backend/tests/test_ai_conversation.py`
- **验证: 需求 3.7**
- [x] 4. AI 缓存读写服务AICacheService
- [x] 4.1 实现 AICacheService
- 文件:`apps/backend/app/ai/cache_service.py`
- `get_latest`:按 (cache_type, site_id, target_id) 查询最新记录
- `get_history`查询历史记录created_at DESC默认 limit=2用于 Prompt reference
- `write_cache`:写入缓存记录,写入后异步清理超限记录
- `_cleanup_excess`:保留最近 500 条,删除最旧的
- _需求: 12.1, 12.2, 12.3, 12.4, 12.5_
- [x] 4.2 编写属性测试:缓存写入 round-trip
- **Property 7: 缓存写入 round-trip**
- 使用 test_zqyy_app 数据库,随机 cache_type、target_id、result_json验证写入后查询一致
- 测试文件:`apps/backend/tests/test_ai_cache.py`
- **验证: 需求 4.7, 5.6, 6.6, 7.5, 8.6, 9.5, 10.10**
- [x] 4.3 编写属性测试:缓存查询 site_id 隔离
- **Property 13: 缓存查询 site_id 隔离**
- 使用 test_zqyy_app 数据库,写入 site_id=A 的记录,以 site_id=B 查询应返回空
- 测试文件:`apps/backend/tests/test_ai_cache.py`
- **验证: 需求 12.1, 12.5**
- [x] 4.4 编写属性测试:缓存保留上限
- **Property 14: 缓存保留上限**
- 使用 test_zqyy_app 数据库,批量写入 >500 条记录,验证清理后 ≤ 500
- 测试文件:`apps/backend/tests/test_ai_cache.py`
- **验证: 需求 12.3**
- [x] 5. 检查点 - 基础服务验证
- 确保所有测试通过ask the user if questions arise.
- 验证 BailianClient、ConversationService、AICacheService 三个核心服务可独立工作
- [x] 6. 应用 1 通用对话 SSE 端点
- [x] 6.1 实现 App1 Chat 核心逻辑
- 文件:`apps/backend/app/ai/apps/app1_chat.py`
- 每次进入 chat 页面新建 ai_conversations 记录(不复用)
- 首条消息注入页面上下文source_page、page_context、screen_content
- 用户消息立即写入 ai_messagesrole=user
- 流式返回完成后写入完整 assistant 回复(含 tokens_used
- 通过 `biz_params.user_prompt_params` 传入 User_ID、Role、Nickname
- 上下文注入框架留接口(页面文本化工具 P5-B 实现)
- _需求: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.8_
- [x] 6.2 实现 SSE 路由端点
- 文件:`apps/backend/app/routers/xcx_ai_chat.py`
- `POST /api/ai/chat/stream`SSE 协议推送Content-Type: text/event-stream
- SSE 事件格式chunk / done / error
- `GET /api/ai/conversations`:历史对话列表(分页,每页 20 条)
- `GET /api/ai/conversations/{conversation_id}/messages`:对话消息列表
- JWT 认证,从 token 提取 user_id、site_id、nickname、role
- 注册路由到 FastAPI app
- _需求: 3.1, 3.7_
- [x] 6.3 编写单元测试SSE 端点
- 验证 SSE Content-Type 和事件格式chunk/done/error
- 验证未认证返回 401、空消息返回 422
- 测试文件:`apps/backend/tests/test_ai_chat.py`
- _需求: 3.1_
- [x] 7. 应用 2 财务洞察(完整 Prompt
- [x] 7.1 实现 App2 Finance Prompt 模板
- 文件:`apps/backend/app/ai/prompts/app2_finance_prompt.py`
- 完整 Prompt 包含当期和上期收入结构table_fee=table_charge_money、assistant_pd=assistant_pd_money、assistant_cx=assistant_cx_money、goods=goods_money、recharge=充值 pay_amount settle_type=5
- 包含储值资产、费用汇总、平台结算数据
- 使用 items_sum 口径,禁止 consume_money
- _需求: 4.5, 4.6_
- [x] 7.2 实现 App2 Finance 核心逻辑
- 文件:`apps/backend/app/ai/apps/app2_finance.py`
- 8 个时间维度独立调用this_month, last_month, this_week, last_week, last_3_months, this_quarter, last_quarter, last_6_months
- 营业日分界点 08:00`BUSINESS_DAY_START_HOUR` 环境变量)
- 每次调用结果写入 ai_cachecache_type=app2_financetarget_id=时间维度编码)
- 每次调用创建 ai_conversations + ai_messages 记录
- 返回结构化 JSONinsights 数组seq + title + body
- _需求: 4.1, 4.2, 4.3, 4.4, 4.7_
- [x] 7.3 编写单元测试App2 时间维度计算
- 验证 8 个时间维度编码的计算逻辑(营业日分界点 08:00
- 验证 Prompt 使用 items_sum 口径字段映射
- 测试文件:`apps/backend/tests/test_ai_app2.py`
- _需求: 4.3, 4.6_
- [x] 8. 应用 3/4/5/6/7 骨架实现
- [x] 8.1 实现 App3 Clue 骨架
- 文件:`apps/backend/app/ai/apps/app3_clue.py`
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache
- `build_prompt`:留接口,返回占位 Prompt标注待细化字段consumption_records 等待 P9-T1
- 线索 category 限定 3 个枚举值providers 标记为"系统"
- 使用 items_sum 口径
- Prompt reference 包含 App6 线索 + 最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app3_cluetarget_id=member_id
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8_
- [x] 8.2 实现 App4 Analysis 骨架
- 文件:`apps/backend/app/ai/apps/app4_analysis.py`
- `build_prompt`留接口service_history、assistant_info 待 P6-T4
- Prompt reference 包含 App8 最新 + 最近 2 套历史(附 generated_at
- 缓存不存在时 reference 传空对象,标注"暂无历史线索"
- 结果写入 ai_cachecache_type=app4_analysistarget_id=`{assistant_id}_{member_id}`
- _需求: 6.1, 6.2, 6.3, 6.4, 6.5, 6.6, 6.7_
- [x] 8.3 实现 App5 Tactics 骨架
- 文件:`apps/backend/app/ai/apps/app5_tactics.py`
- 接收 App4 完整返回结果作为 Prompt 中的 task_suggestion 字段
- `build_prompt`留接口service_history、assistant_info 随 App4 同步在 P6-T4
- Prompt reference 包含最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app5_tacticstarget_id=`{assistant_id}_{member_id}`
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
- [x] 8.4 实现 App6 Note 骨架
- 文件:`apps/backend/app/ai/apps/app6_note.py`
- `build_prompt`留接口consumption_data 待 P9-T1
- 返回 score1-10+ clues 数组category 限定 6 个枚举值
- 线索提供者标记为当前备注提供人
- 评分规则6 分为标准分
- Prompt reference 包含 App3 线索 + 最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app6_note_analysistarget_id=member_idscore 存入 ai_cache.score
- _需求: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8_
- [x] 8.5 实现 App7 Customer 骨架
- 文件:`apps/backend/app/ai/apps/app7_customer.py`
- `build_prompt`留接口objective_data 待 P9-T1
- 使用 items_sum 口径
- 对主观信息标注【来源XXX请甄别信息真实性】
- Prompt reference 包含最新 + 最近 2 套 App8 历史(附 generated_at
- 结果写入 ai_cachecache_type=app7_customer_analysistarget_id=member_id
- _需求: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7_
- [x] 8.6 编写属性测试Prompt reference 历史注入
- **Property 9: Prompt reference 历史注入**
- Mock 缓存数据,验证各应用 build_prompt 的 reference 字段包含正确的缓存结果和 generated_at 时间戳
- 缓存不存在时 reference 为空对象
- 测试文件:`apps/backend/tests/test_ai_apps_prompt.py`
- **验证: 需求 5.8, 6.4, 6.5, 7.2, 7.4, 8.8, 9.7**
- [x] 9. 应用 8 维客线索整理(完整 Prompt+ ClueWriter
- [x] 9.1 实现 App8 Consolidation Prompt 模板
- 文件:`apps/backend/app/ai/prompts/app8_consolidation_prompt.py`
- 完整 Prompt接收 App3 和 App6 全部线索(附 generated_at整合去重
- 分类标签限定 6 个枚举值(与 member_retention_clue CHECK 约束一致)
- 合并相似线索(多提供者逗号分隔),其余原文返回,最小改动原则
- _需求: 10.3, 10.4, 10.5, 10.6_
- [x] 9.2 实现 ClueWriter 全量替换逻辑
- 集成在 `apps/backend/app/ai/apps/app8_consolidation.py`
- DELETE source IN ('ai_consumption', 'ai_note') → INSERT 新线索(事务)
- 字段映射emoji+summary 拼接、providers→recorded_by_name、source 判断逻辑
- recorded_by_assistant_id 填 NULL
- 人工线索source='manual')不受影响
- _需求: 10.7, 10.8, 10.9_
- [x] 9.3 实现 App8 Consolidation 核心逻辑
- 文件:`apps/backend/app/ai/apps/app8_consolidation.py`
- `run` 函数:构建 Prompt → 调用百炼 → 写入 conversation + cache + member_retention_clue
- 结果同时写入 ai_cachecache_type=app8_clue_consolidatedtarget_id=member_id
- _需求: 10.1, 10.2, 10.10_
- [x] 9.4 编写属性测试ClueWriter 全量替换不变量
- **Property 12: ClueWriter 全量替换不变量**
- 使用 test_zqyy_app 数据库,随机线索列表 + 预置人工线索
- 验证AI 线索数量 = new_clues 数量、人工线索不变、recorded_by_assistant_id=NULL、summary=emoji+空格+原始 summary
- 测试文件:`apps/backend/tests/test_ai_clue_writer.py`
- **验证: 需求 10.7, 10.8, 10.9**
- [x] 10. 检查点 - 应用层验证
- 确保所有测试通过ask the user if questions arise.
- 验证 8 个应用的 run 函数可独立调用Mock 百炼 API
- [x] 11. 事件调度与调用链编排AIDispatcher
- [x] 11.1 实现 AIDispatcher 核心逻辑
- 文件:`apps/backend/app/ai/dispatcher.py`
- `handle_consumption_event`App3 → App8 → App7+ App4 → App5 如有助教)
- `handle_note_event`App6 → App8
- `handle_task_assign_event`App4 → App5读已有 App8 缓存)
- `_run_chain`:串行执行调用链,某步失败记录日志后继续
- 容错:失败应用记录错误日志 + 写入失败 conversation后续应用使用已有缓存
- 整条链后台异步执行,不阻塞业务请求
- _需求: 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7_
- [x] 11.2 集成事件触发点
-`trigger_scheduler.fire_event()` 中注册 AI 事件处理器
- 消费事件consumption_settled`ai_dispatcher.handle_consumption_event`
- 备注事件note_created`ai_dispatcher.handle_note_event`
- 任务分配事件task_assigned`ai_dispatcher.handle_task_assign_event`
- _需求: 5.1, 6.1, 6.2, 7.1, 8.1, 9.1, 11.1, 11.2, 11.3, 11.4_
- [x] 11.3 编写属性测试:事件调用链顺序正确性
- **Property 10: 事件调用链顺序正确性**
- Mock 所有应用,记录调用序列,验证四种事件链的严格顺序
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
- **验证: 需求 11.1, 11.2, 11.3, 11.4, 11.5, 11.6**
- [x] 11.4 编写属性测试:调用链容错不变量
- **Property 11: 调用链容错不变量**
- Mock 随机应用失败,验证后续应用继续执行且失败应用有错误日志
- 测试文件:`apps/backend/tests/test_ai_dispatcher.py`
- **验证: 需求 11.7**
- [x] 12. 缓存查询路由与环境配置
- [x] 12.1 实现缓存查询路由
- 文件:`apps/backend/app/routers/xcx_ai_cache.py`
- `GET /api/ai/cache/{cache_type}?target_id=xxx`:查询最新缓存
- JWT 认证site_id 从 token 提取强制过滤
- 注册路由到 FastAPI app
- _需求: 12.1, 12.2, 12.5_
- [x] 12.2 新增环境变量配置
-`.env.template` 中添加 `BAILIAN_API_KEY``BAILIAN_BASE_URL``BAILIAN_MODEL``BUSINESS_DAY_START_HOUR`
- 在后端配置加载逻辑中读取这些变量,缺失时报错
- _需求: 2.1, 14.1, 14.2_
- [x] 13. 百炼技术方案确认文档 ✅
- [x] 13.1 输出百炼技术方案确认文档
- 文件:`docs/reports/bailian-technical-solution.md`
- 确认流式返回方案OpenAI 兼容 SSE
- 确认 JSON 输出模式response_format + System Prompt 约束)
- 确认 SDK 选择openai Python SDK + base_url 指向百炼)
- 作为 BailianClient 实现的依据
- _需求: 14.1, 14.2, 14.3_
- [x] 14. 最终检查点 - 全量验证 ✅
- 全部 9 个测试文件、62 个测试用例通过2026-03-09
- 验证所有路由注册正确、事件触发点集成完毕、环境变量配置完整
## 备注
- 标记 `*` 的任务为可选,可跳过以加速 MVP 交付
- 每个任务引用具体需求编号以确保可追溯
- 属性测试验证设计文档中定义的 14 个正确性属性
- 使用 test_zqyy_app 测试库执行数据库相关测试,禁止连接正式库
- App3/4/5/6/7 的 Prompt 细化将在 P5-B 阶段P6/P9 对应任务)中完成

View File

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

View File

@@ -0,0 +1,252 @@
# 设计文档ETL 全流程前后端联调etl-fullstack-integration
## 概述
本 Spec 是一个运维联调任务,不涉及新功能开发。目标是验证 `admin-web-console` Spec 产出的前后端代码在真实环境下的端到端正确性,同时收集性能数据。
核心流程:
1. 启动后端 + 前端服务
2. 通过 API 登录获取 JWT
3. 提交全流程 ETL 任务api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~当前时间, 30天切分, 全部门店)
4. 实时监控执行过程,捕获错误/警告
4. 执行完成后进行黑盒数据一致性测试(全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`
5. 生成综合报告(含性能数据和黑盒测试结果)
## 架构
```
联调脚本 (scripts/ops/)
├── 1. 启动服务
│ ├── uvicorn app.main:app (后端 :8000)
│ └── pnpm dev (前端 :5173)
├── 2. API 调用链
│ ├── POST /api/auth/login → JWT
│ ├── GET /api/tasks/registry → 任务列表
│ ├── GET /api/tasks/sync-check → 同步检查
│ ├── POST /api/tasks/validate → CLI 预览
│ └── POST /api/execution/run → 触发执行
├── 3. 监控循环
│ ├── GET /api/execution/queue → 状态轮询
│ ├── GET /api/execution/{id}/logs → 日志获取
│ └── 错误/警告检测
├── 4. 黑盒数据一致性测试
│ ├── 全链路检查器scripts/ops/etl_consistency_check.py
│ │ ├── API JSON vs ODS字段完整性 + 值采样比对
│ │ ├── ODS vs DWD行数 + 字段映射 + 值比对(含 EX 表合并)
│ │ ├── DWD vs DWS聚合表行数 + 数值列健全性检查
│ │ └── 白名单机制ETL_META_COLS / SCD2_COLS / 空字符串≡None
│ ├── FlowRunner 内置检查quality/consistency_checker.py自动执行
│ └── etl-data-consistency Hook可选手动触发
└── 5. 报告生成
└── 输出到 SYSTEM_LOG_ROOT
```
## 任务参数
根据用户需求,联调任务的具体参数:
```python
INTEGRATION_TASK_CONFIG = {
"flow": "api_full", # 全流程API → ODS → DWD → DWS → INDEX
"processing_mode": "full_window", # 全窗口处理
"window_mode": "custom", # 自定义时间范围
"window_start": "2025-11-01 00:00",
"window_end": "", # 补充当前时间
"window_split": "day", # 按天切分
"window_split_days": 30, # 30天一个切片
"force_full": True, # 强制全量
"dry_run": False,
"tasks": [ # 全选 is_common=True 的任务
# ODS 层22 个)
"ODS_ASSISTANT_ACCOUNT", "ODS_ASSISTANT_LEDGER", "ODS_ASSISTANT_ABOLISH",
"ODS_SETTLEMENT_RECORDS", "ODS_TABLE_USE", "ODS_TABLE_FEE_DISCOUNT",
"ODS_TABLES", "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_GOODS_CATEGORY", "ODS_STORE_GOODS", "ODS_STORE_GOODS_SALES", "ODS_TENANT_GOODS",
# DWD 层1 个常用)
"DWD_LOAD_FROM_ODS",
# DWS 层15 个常用,排除 DWS_MAINTENANCE
"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",
# INDEX 层3 个常用,排除 DWS_ML_MANUAL_IMPORT
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
],
# store_id 由后端从 JWT 注入(默认管理员 site_id=1
# 注意:用户要求"全部门店",但当前系统只有 site_id=1后续多门店需逐个执行
}
```
## 监控策略
- 轮询间隔30 秒
- 最长等待30 分钟(无新日志输出时)
- 错误检测:日志行匹配 `ERROR``CRITICAL``Traceback``Exception`
- 警告检测:日志行匹配 `WARNING``WARN`
- 计时解析:从日志中提取时间戳,计算阶段耗时
## 黑盒数据一致性测试
### 两套检查工具的定位
| 工具 | 路径 | 触发方式 | 覆盖范围 | 适用场景 |
|------|------|---------|---------|---------|
| 全链路检查器 | `scripts/ops/etl_consistency_check.py` | 手动运行 / `etl-data-consistency` Hook | API→ODS→DWD→DWS 四层全链路 | 联调后独立全面验证(本 Spec 主要使用) |
| FlowRunner 内置检查 | `apps/etl/connectors/feiqiu/quality/consistency_checker.py` | FlowRunner 自动调用 `_run_post_consistency_check()` | API→ODS 字段 + ODS→DWD 映射/值 | ETL 执行后自动轻量检查 |
联调场景下FlowRunner 在 ETL 执行完成后已自动运行内置检查并输出报告到 `ETL_REPORT_ROOT`。联调脚本额外运行全链路检查器,覆盖 DWD→DWS 聚合验证和更深入的值采样比对。
### etl-data-consistency Hook
`.kiro/hooks/etl-data-consistency.kiro.hook` 提供手动触发入口,执行 `scripts/ops/etl_consistency_check.py`。联调任务 5 也可通过此 Hook 替代手动运行脚本。
### 白名单机制
全链路检查器在值比对时使用三层白名单过滤,避免 ETL 框架自动填充的列和已知等价差异产生误报:
#### 1. ETL 元数据列白名单(`ETL_META_COLS`
不参与 API↔ODS 值比对的列:
```python
ETL_META_COLS = {"source_file", "source_endpoint", "fetched_at", "payload", "content_hash"}
```
这些列由 ETL 框架在 ODS 落库时自动填充API 源数据中不存在。
#### 2. SCD2 管理列白名单(`SCD2_COLS`
不参与 ODS↔DWD 值比对的列:
```python
SCD2_COLS = {
"valid_from", "valid_to", "is_current", "etl_loaded_at", "etl_batch_id",
"scd2_start_time", "scd2_end_time", "scd2_is_current", "scd2_version",
}
```
这些列由 DWD 层 SCD2 逻辑自动维护ODS 源数据中不存在。
#### 3. 空字符串 vs None 等价规则(`_values_differ()`
API 返回空字符串 `""` 而数据库存储为 `None` 时,视为等价(不算差异),但标记为 `whitelist`
```python
# API 空字符串 "" vs DB None → 白名单(等价但标记)
if api_val is not None and ods_val is None:
if isinstance(api_val, str) and api_val.strip() == "":
return False, "whitelist"
```
报告中白名单差异以折叠 `<details>` 块展示,不计入失败统计。
#### 4. FlowRunner 内置检查的白名单
`consistency_checker.py` 使用类似但略有不同的白名单:
- `ODS_META_COLUMNS`:与 `ETL_META_COLS` 相同,额外包含 `record_index`
- `KNOWN_NO_SOURCE`:按表配置的已知无源字段(如 `dwd.dim_member.update_time`),标记为已知无源而非报错
### 调用方式
联调脚本在 ETL 全流程执行完成后,运行全链路检查器:
```bash
cd C:\NeoZQYY
uv run python scripts/ops/etl_consistency_check.py
```
脚本自动完成:
1.`LOG_ROOT` 找到最近一次 ETL 日志,解析执行的任务列表
2.`FETCH_ROOT` 读取 API JSON 落盘文件
3. 连接数据库(`PG_DSN`),逐表逐字段比对:
- API JSON vs ODS字段完整性 + 值采样比对(随机 5 条)
- ODS vs DWD行数 + 字段映射 + 值比对(含 EX 表合并)
- DWD vs DWS聚合表行数 + 数值列健全性NULL 率、负值、min/max
4. 输出 Markdown 报告到 `ETL_REPORT_ROOT`
### 检查内容
| 检查类型 | 对比对象 | 检查项 | 白名单处理 |
|---------|---------|--------|-----------|
| API vs ODS | API JSON 缓存 vs ODS 表 | 字段完整性 + 值采样比对5 条记录) | `ETL_META_COLS` 排除空字符串≡None |
| ODS vs DWD | ODS 表 vs DWD 表(含 EX 表) | 行数对比 + 字段映射 + 值比对 | `SCD2_COLS` 排除空字符串≡None |
| DWD vs DWS | DWD 源表 vs DWS 聚合表 | 行数非空 + 数值列健全性NULL 率、负值、min/max | 无DWS 为聚合结果,不做逐行值比对) |
### 报告格式
全链路检查器输出 Markdown 报告,包含:
1. ETL 执行概览(任务列表、成功/失败/跳过统计)
2. API↔ODS 数据一致性(逐表逐字段值比对,白名单差异折叠展示)
3. ODS↔DWD 数据一致性(行数对比 + 映射验证 + 值采样,含字段级统计)
4. DWD↔DWS 数据一致性DWS 表概览 + 数值列健全性检查)
5. 异常汇总与建议
### 参考数据
`dataflow-field-completion` 的实际执行结果API vs ODS 22/22 通过ODS vs DWD 38/42 通过。本次联调执行 api_full 全流程后,预期结果应与此一致或更优(因为联调包含最新的字段补全)。
## 报告格式
报告输出为 Markdown 文件,路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
```markdown
# ETL 全流程联调报告
## 执行概要
- 任务参数:...
- 开始时间 / 结束时间 / 总时长
- 退出码 / 最终状态
## 性能报告
- 各窗口切片耗时对比表
- Top-5 耗时阶段
- 总体吞吐量估算
## 黑盒测试报告
- API vs ODSX/Y 张表通过(白名单差异 N 处)
- ODS vs DWDX/Y 张表通过(白名单差异 N 处)
- DWD vs DWSX 张表有数据 / Y 张总计,异常指标 N 个
- 失败表清单及差异明细
## DEBUG 报告(如有)
- 错误摘要
- 警告摘要
- 相关日志片段
- 可能的原因分析
```
## 正确性属性
本 Spec 为运维联调任务,不涉及新功能代码开发,因此不定义形式化的属性测试。验证通过以下方式进行:
- 服务健康检查通过
- 任务提交成功并开始执行
- 执行完成后退出码和日志符合预期
- 黑盒数据一致性测试通过(全链路检查器覆盖 API→ODS→DWD→DWS 四层,白名单差异不计入失败)
- 报告文件成功生成(含性能报告和黑盒测试报告)
## 测试策略
本 Spec 本身就是一次集成测试。不额外编写单元测试或属性测试。验证标准:
- 后端 API 响应正确
- ETL CLI 子进程正常启动和执行
- 日志正确捕获和推送
- 黑盒数据一致性测试通过(全链路检查器 API→ODS→DWD→DWS + FlowRunner 内置检查)
- 报告文件正确生成到 ETL_REPORT_ROOT全链路检查报告和 SYSTEM_LOG_ROOT联调综合报告
### 黑盒测试验证标准
- API vs ODS所有已采集的 ODS 表字段完整性和值采样检查通过(白名单差异不计入失败)
- ODS vs DWD所有已配置映射的表行数和值比对检查通过SCD2 列排除,白名单差异不计入失败)
- DWD vs DWS所有 DWS 聚合表行数非空,数值列无异常(高 NULL 率、金额负值等)
- 全链路检查报告 `consistency_check_<timestamp>.md` 成功生成到 ETL_REPORT_ROOT
- 综合联调报告中包含黑盒测试结果摘要(含白名单差异统计)

View File

@@ -30,7 +30,7 @@
#### 验收标准 #### 验收标准
1. WHEN 提交 TaskConfigapi_full + full_window + 全选常用任务 + 自定义窗口 2025-11-01~2026-02-20 + 30天切分 + force-full, THE Backend_API SHALL 验证配置有效并返回 CLI 命令预览 1. WHEN 提交 TaskConfigapi_full + full_window + 全选常用任务 + 自定义窗口 2025-11-01~当前时间 + 30天切分 + force-full, THE Backend_API SHALL 验证配置有效并返回 CLI 命令预览
2. WHEN 提交任务到执行队列, THE Backend_API SHALL 创建队列任务并自动开始执行 2. WHEN 提交任务到执行队列, THE Backend_API SHALL 创建队列任务并自动开始执行
3. WHILE ETL 子进程运行中, THE Backend_API SHALL 通过 WebSocket 推送实时日志 3. WHILE ETL 子进程运行中, THE Backend_API SHALL 通过 WebSocket 推送实时日志
4. WHEN ETL 子进程完成, THE Backend_API SHALL 记录退出码、执行时长、完整日志到 task_execution_log 4. WHEN ETL 子进程完成, THE Backend_API SHALL 记录退出码、执行时长、完整日志到 task_execution_log
@@ -58,13 +58,28 @@
3. THE 报告 SHALL 标注耗时最长的 Top-5 阶段/任务 3. THE 报告 SHALL 标注耗时最长的 Top-5 阶段/任务
4. THE 报告 SHALL 包含每个窗口切片30天的独立耗时对比 4. THE 报告 SHALL 包含每个窗口切片30天的独立耗时对比
### 需求 5联调报告输出 ### 需求 5黑盒数据一致性测试
**用户故事:** 作为开发者,我希望联调完成后获得一份综合报告,包含执行情况、性能数据和可能的 DEBUG 信息 **用户故事:** 作为开发者,我希望在 ETL 全流程执行完成后,以黑盒视角对比数据上下游的字段差异和值差异,验证数据从 API 到 ODS 再到 DWD 再到 DWS 的完整性和正确性
#### 验收标准
1. WHEN ETL 全流程执行完成后, THE 联调脚本 SHALL 运行全链路检查器 `scripts/ops/etl_consistency_check.py`,执行 API→ODS→DWD→DWS 四层数据一致性检查
2. WHEN 执行 API vs ODS 检查时, THE 检查器 SHALL 对比 API JSON 落盘数据与 ODS 落库数据的字段完整性和值采样(随机 5 条记录的关键字段),覆盖所有已采集的 ODS 表
3. WHEN 执行 ODS vs DWD 检查时, THE 检查器 SHALL 对比 ODS 数据与 DWD 落库数据的行数、字段映射正确性和值一致性(含 EX 表合并比对)
4. WHEN 执行 DWD vs DWS 检查时, THE 检查器 SHALL 验证 DWS 聚合表的行数非空、数值列健全性NULL 率、负值、min/max标注异常指标
5. WHEN 值比对遇到白名单场景时, THE 检查器 SHALL 将 ETL 元数据列(`source_file``source_endpoint``fetched_at``payload``content_hash`)和 SCD2 管理列排除在值比对之外,并将 API 空字符串 `""` vs DB `None` 视为等价(标记为 whitelist
6. WHEN 黑盒测试完成后, THE 检查器 SHALL 输出 Markdown 报告到 `ETL_REPORT_ROOT` 环境变量指定的目录
7. WHEN 黑盒测试报告生成后, THE 报告 SHALL 包含每张表的检查结果、差异明细(含白名单差异折叠展示)、通过/失败状态、字段级统计、以及汇总统计
### 需求 6联调报告输出
**用户故事:** 作为开发者,我希望联调完成后获得一份综合报告,包含执行情况、性能数据、黑盒测试结果和可能的 DEBUG 信息。
#### 验收标准 #### 验收标准
1. THE 报告 SHALL 包含:执行概要(任务参数、开始/结束时间、总时长、退出码) 1. THE 报告 SHALL 包含:执行概要(任务参数、开始/结束时间、总时长、退出码)
2. THE 报告 SHALL 包含性能报告各阶段耗时、窗口切片耗时对比、Top-5 瓶颈) 2. THE 报告 SHALL 包含性能报告各阶段耗时、窗口切片耗时对比、Top-5 瓶颈)
3. IF 执行过程中出现错误或警告, THEN THE 报告 SHALL 包含 DEBUG 报告(错误摘要、相关日志片段、可能的原因分析 3. THE 报告 SHALL 包含黑盒测试结果摘要API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览、失败表清单、白名单差异数量
4. THE 报告 SHALL 输出到 `SYSTEM_LOG_ROOT` 环境变量指定的目录 4. IF 执行过程中出现错误或警告, THEN THE 报告 SHALL 包含 DEBUG 报告(错误摘要、相关日志片段、可能的原因分析)
5. THE 报告 SHALL 输出到 `SYSTEM_LOG_ROOT` 环境变量指定的目录

View File

@@ -0,0 +1,131 @@
# 实现计划ETL 全流程前后端联调etl-fullstack-integration
## 概述
基于 `admin-web-console` 已完成的前后端代码,进行端到端联调验证。全程使用 Playwright 浏览器模拟真实用户操作(登录、配置、提交、监控),不直接调用 API。通过管理后台 UI 提交 api_full 全流程 ETL 任务(自定义窗口 2025-11-01~当前时间30天切分force-full全选常用任务实时监控执行过程收集性能数据执行黑盒数据一致性测试全链路检查器 `scripts/ops/etl_consistency_check.py` + FlowRunner 内置 `ConsistencyChecker`),最终生成综合报告。
## 任务
- [x] 1. 服务启动与健康检查
- [x] 1.1 启动后端服务(`apps/backend/`uvicorn :8000确认 API 可达
- 使用 `controlPwshProcess` 启动 `uvicorn app.main:app --host 0.0.0.0 --port 8000`cwd 为 `apps/backend/`
- 等待服务就绪,验证 `http://localhost:8000/docs` 可访问
- _Requirements: 1.1_
- [x] 1.2 启动前端服务(`apps/admin-web/`pnpm dev :5173确认页面可访问
- 使用 `controlPwshProcess` 启动 `pnpm dev`cwd 为 `apps/admin-web/`
- 等待 Vite 就绪,验证 `http://localhost:5173` 可访问
- _Requirements: 1.2_
- [x] 1.3 浏览器登录与健康检查
- 使用 Playwright 打开 `http://localhost:5173`,应自动跳转到 `/login`
- 在登录表单中输入用户名 `admin`、密码 `admin123`,点击登录按钮
- 验证登录成功后跳转到任务配置页面(`/`
- 确认侧边栏导航菜单正常渲染任务配置、任务管理、ETL 状态、数据库、日志、环境配置、运维面板)
- _Requirements: 1.3, 1.4, 1.5_
- [-] 2. 浏览器操作:任务配置与提交
- [x] 2.1 在任务配置页面填写全流程参数
- 在任务配置页面(`/`),选择 Flow 为 `api_full`API → ODS → DWD → DWS → INDEX
- 选择处理模式为 `full_window`(全窗口)
- 设置时间窗口模式为"自定义",填入开始时间 `2025-11-01`、结束时间 当前时间
- 设置窗口切分为"按天",切分天数为 `30`
- 勾选 `force_full`(强制全量)
- 在任务选择区域,全选 `is_common=True` 的常用任务(共 41 个)
- 确认 CLI 命令预览区域显示完整参数(--flow api_full --processing-mode full_window --window-start ... --window-end ... --window-split day --window-split-days 30 --force-full --tasks ...
- _Requirements: 2.1_
- [x] 2.2 通过浏览器提交任务执行
- 点击"直接执行"按钮SendOutlined 图标),触发 `POST /api/execution/run`
- 确认页面显示任务提交成功的提示消息
- 记录返回的 execution_id从页面响应或跳转中获取
- _Requirements: 2.2, 2.4_
- [x] 3. 浏览器操作:执行监控与 DEBUG
- [x] 3.1 在任务管理页面监控执行状态
- 导航到"任务管理"页面(`/task-manager`),点击侧边栏"任务管理"菜单
- 在"队列"Tab 中确认刚提交的任务状态为 `running`
- 点击 running 状态的任务行,打开 WebSocket 实时日志流抽屉
- 持续观察日志输出,每 30 秒检查一次页面状态
- 检测日志中的 ERROR / CRITICAL / Traceback / Exception / WARNING 关键字
- 如果连续 30 分钟无新日志输出,报告超时警告
- 任务完成success/failed/cancelled时停止监控
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [x] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
- 从日志流中收集所有 ERROR 和 WARNING 日志行及其上下文
- 分析错误类型API 超时、数据库连接、数据质量、配置问题等
- 如果任务失败,切换到"历史"Tab 查看完整执行详情和日志
- 记录 DEBUG 发现到报告中
- _Requirements: 3.2, 3.5_
- [x] 4. 性能计时与报告生成
- [x] 4.1 从浏览器获取执行日志,提取精细计时数据
- 在"任务管理"→"历史"Tab 中,点击已完成的任务查看执行详情
- 通过 `GET /api/execution/{id}/logs` 获取完整日志(可通过浏览器或 API 辅助)
- 从日志中提取每个窗口切片30天的开始/结束时间
- 计算每个切片的耗时
- 识别 ODS / DWD / DWS / INDEX 各阶段的耗时
- 标注 Top-5 耗时最长的阶段/任务
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [x] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
- 报告包含:执行概要(参数、时间、退出码)
- 报告包含性能报告各切片耗时对比、Top-5 瓶颈)
- 报告包含DEBUG 报告(如有错误/警告)
- 黑盒测试结果摘要将在任务 5.3 中追加
- 输出路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
- 路径通过 `SYSTEM_LOG_ROOT` 环境变量获取,缺失时报错
- _Requirements: 6.1, 6.2, 6.4, 6.5_
- [x] 5. 黑盒数据一致性测试
- [x] 5.1 运行全链路检查器,执行 API→ODS→DWD→DWS 四层数据一致性检查
- 运行 `uv run python scripts/ops/etl_consistency_check.py`cwd 为项目根目录 `C:\NeoZQYY`
- 脚本自动从 `LOG_ROOT` 找到最近一次 ETL 日志,解析本次执行的任务列表
- 脚本自动从 `FETCH_ROOT` 读取 API JSON 落盘文件
- 脚本连接数据库(`PG_DSN`),逐表逐字段比对:
- API JSON vs ODS字段完整性 + 值采样比对(随机 5 条记录),`ETL_META_COLS` 白名单列排除
- ODS vs DWD行数对比 + 字段映射 + 值比对(含 EX 表合并),`SCD2_COLS` 白名单列排除
- DWD vs DWS聚合表行数非空检查 + 数值列健全性NULL 率、负值、min/max
- 白名单处理API 空字符串 `""` vs DB `None` 视为等价,标记为 whitelist不计入失败
- 报告自动输出到 `ETL_REPORT_ROOT`(环境变量,缺失时报错)
- 备选触发方式:可通过 `etl-data-consistency` Hook 手动触发(效果等同)
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [x] 5.2 检查 FlowRunner 内置一致性报告
- FlowRunner 在 ETL 执行完成后已自动调用 `_run_post_consistency_check()` 生成报告到 `ETL_REPORT_ROOT`
- 确认内置报告已生成,检查 API vs ODS 和 ODS vs DWD 的通过/失败统计
- 内置检查使用 `ODS_META_COLUMNS` 白名单(含 `record_index`)和 `KNOWN_NO_SOURCE` 按表白名单
- 对比两份报告的结论是否一致(全链路检查器 vs FlowRunner 内置检查)
- _Requirements: 5.1, 5.2, 5.3_
- [x] 5.3 将黑盒测试结果摘要写入综合联调报告
- 在任务 4.2 生成的联调报告中追加"黑盒测试报告"章节
- 包含API vs ODS 通过数/总数、ODS vs DWD 通过数/总数、DWD vs DWS 表概览
- 包含:白名单差异数量统计、失败表清单
- 引用全链路检查报告的完整路径
- _Requirements: 6.3_
- [x] 6. 服务清理
- [x] 6.1 关闭浏览器,停止后端和前端服务,清理资源
- 关闭 Playwright 浏览器实例
- 停止 uvicorn 后端进程(`controlPwshProcess` stop
- 停止 pnpm dev 前端进程(`controlPwshProcess` stop
- 报告联调完成状态
## 说明
- 本 Spec 为运维联调任务,不涉及新功能代码开发
- 不编写属性测试或单元测试,联调本身即为集成验证
- **全程使用 Playwright 浏览器模拟真实用户操作**:登录、页面导航、表单填写、按钮点击、日志查看等均通过浏览器完成
- **黑盒测试使用两套工具**
- 全链路检查器 `scripts/ops/etl_consistency_check.py`:覆盖 API→ODS→DWD→DWS 四层,联调主要使用
- FlowRunner 内置 `ConsistencyChecker``quality/consistency_checker.py`ETL 执行后自动运行,覆盖 API→ODS + ODS→DWD
- **白名单机制**`ETL_META_COLS`ODS 元数据列)、`SCD2_COLS`SCD2 管理列排除在值比对之外API 空字符串 `""` vs DB `None` 视为等价
- **`etl-data-consistency` Hook**`.kiro/hooks/etl-data-consistency.kiro.hook`)可作为手动触发全链路检查的替代方式
- 黑盒测试在 ETL 全流程执行完成后、服务清理前执行,确保数据库中有最新数据可供对比
- 全选常用任务 = 任务注册表中 `is_common=True` 的所有任务(共 41 个)
- "全部门店":当前系统仅有 site_id=1默认管理员绑定如需多门店需逐个执行
- 监控允许空闲等待,最长 30 分钟无新日志才报超时
- 报告输出路径遵循 export-paths 规范:全链路检查报告输出到 `ETL_REPORT_ROOT`,联调综合报告输出到 `SYSTEM_LOG_ROOT`
- 全链路检查器需要 `PG_DSN``FETCH_ROOT``LOG_ROOT``ETL_REPORT_ROOT` 环境变量,缺失时报错

View File

@@ -132,12 +132,40 @@
#### 验收标准 #### 验收标准
1. THE Admin_Web SHALL 提供侧边栏导航,包含个功能模块入口任务配置、任务管理、环境配置、数据库、ETL 状态、日志 1. THE Admin_Web SHALL 提供侧边栏导航,包含个功能模块入口任务配置、任务管理、环境配置、数据库、ETL 状态、日志、球房编号管理、租户管理员管理
2. WHEN Operator 点击导航项, THE Admin_Web SHALL 切换到对应的功能模块页面,且不触发整页刷新 2. WHEN Operator 点击导航项, THE Admin_Web SHALL 切换到对应的功能模块页面,且不触发整页刷新
3. THE Admin_Web SHALL 在状态栏区域展示当前数据库连接状态和任务执行状态 3. THE Admin_Web SHALL 在状态栏区域展示当前数据库连接状态和任务执行状态
4. WHILE 有任务正在执行, THE Admin_Web SHALL 在导航栏或状态栏显示执行中的视觉指示 4. WHILE 有任务正在执行, THE Admin_Web SHALL 在导航栏或状态栏显示执行中的视觉指示
### 需求 11Task_Config 序列化与反序列化 ### 需求 11租户管理员账号管理
**用户故事:** 作为系统管理员Operator我需要在系统管理后台中创建和管理租户管理员账号以便租户管理员能登录独立的租户管理后台`apps/tenant-admin/`进行用户审核、Excel 上传等操作。
#### 验收标准
1. WHEN Operator 打开租户管理员管理页面, THE Admin_Web SHALL 展示所有租户管理员账号列表,包含用户名、所属租户、管辖球房列表、账号状态(启用/禁用)、创建时间
2. WHEN Operator 创建租户管理员账号, THE Backend_API SHALL 接受用户名、初始密码、所属租户标识、管辖球房 ID 列表(`site_id` 数组),并在 `auth` Schema 中创建对应记录,密码以 bcrypt 哈希存储
3. WHEN Operator 编辑租户管理员账号, THE Backend_API SHALL 允许修改管辖球房列表、账号状态(启用/禁用),以及重置密码
4. WHEN Operator 禁用某租户管理员账号, THE Backend_API SHALL 将该账号状态设为禁用,该管理员后续登录租户管理后台时 SHALL 被拒绝
5. WHEN Operator 为租户管理员分配球房, THE Backend_API SHALL 验证球房 ID`site_id`)在 `auth.site_code_mapping` 中存在,不存在时返回 422 错误
6. THE Backend_API SHALL 确保同一用户名不可重复创建(唯一约束)
7. WHEN Operator 查看某租户管理员详情, THE Admin_Web SHALL 展示该管理员管辖的球房列表及每个球房的球房代码(`site_code`)和名称
### 需求 12球房编号管理
**用户故事:** 作为系统管理员Operator我需要在系统管理后台中为每个门店`site_id`)分配球房编号(`site_code`),以便小程序用户申请时通过球房编号定位到对应门店。
#### 验收标准
1. WHEN Operator 打开球房编号管理页面, THE Admin_Web SHALL 展示 `auth.site_code_mapping` 中所有球房编号映射列表,包含球房编号(`site_code`)、门店 ID`site_id`)、创建时间
2. WHEN Operator 新增球房编号映射, THE Backend_API SHALL 接受 `site_code`格式2 字母 + 3 数字,如 `AB123`)和 `site_id`BIGINT验证格式正确后写入 `auth.site_code_mapping`
3. IF 提交的 `site_code` 已存在, THEN THE Backend_API SHALL 返回 409 冲突错误
4. IF 提交的 `site_id` 已绑定其他 `site_code`, THEN THE Backend_API SHALL 返回 409 冲突错误(`site_code``site_id` 一对一)
5. WHEN Operator 编辑球房编号映射, THE Backend_API SHALL 允许修改 `site_code`(需验证新编号不与其他记录冲突)
6. WHEN Operator 删除球房编号映射, THE Backend_API SHALL 检查是否有用户申请引用该 `site_code`,若有则拒绝删除并提示关联数据存在
7. THE Admin_Web SHALL 在球房编号管理页面提供搜索功能,支持按 `site_code``site_id` 搜索
### 需求 13Task_Config 序列化与反序列化
**用户故事:** 作为 Operator我希望任务配置能在前后端之间正确传输和持久化以确保配置不丢失。 **用户故事:** 作为 Operator我希望任务配置能在前后端之间正确传输和持久化以确保配置不丢失。

View File

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

View File

@@ -0,0 +1,422 @@
# 设计文档业务日分割点机制Business Day Cutoff
## 概述
本设计将全系统的统计时间口径从自然日切换为以可配置小时值(默认 08:00为分割点的营业日。影响范围覆盖六个层面
1. **配置层**`.env` 新增 `BUSINESS_DAY_START_HOUR`ETL `AppConfig` 和后端 `config.py` 同步加载
2. **共享工具层**`packages/shared``datetime_utils.py` 扩展 `business_day_range``business_week_range``business_month_range` 三个范围函数
3. **ETL DWS 层**`BaseDwsTask.iter_dwd_rows``DATE()` 替换为 `biz_date_sql_expr`18 个具体 DWS 任务的 SQL 全面改造
4. **后端 API 层**:新增 `/api/config/business-day` 端点,时间范围查询统一使用 `business_*_range` 函数
5. **数据库层**:新增 PostgreSQL `biz_date()` 函数,物化视图迁移
6. **前端展示层**:管理后台日期选择器标注营业日口径,小程序透传后端数据
### 设计决策
1. **单一配置源**`BUSINESS_DAY_START_HOUR` 仅在根 `.env` 定义一次ETL 通过 `AppConfig`、后端通过 `config.py`、数据库通过迁移脚本参数化读取,避免多处硬编码导致不一致。
2. **共享包作为唯一逻辑实现**:所有营业日归属计算集中在 `packages/shared/datetime_utils.py`ETL 和后端均从此导入,禁止各子系统重复实现。
3. **SQL 表达式生成器模式**`biz_date_sql_expr(col, hour)` 生成 `DATE(col - INTERVAL 'N hours')` 字符串DWS 任务在 SQL 拼接时调用,避免在 Python 侧逐行转换。
4. **BaseDwsTask 基类统一改造**`iter_dwd_rows` 的日期过滤从 `DATE(col)` 改为 `biz_date_sql_expr(col)`,所有子类自动继承,减少逐任务修改量。
5. **物化视图通过迁移脚本重建**:物化视图的时间过滤条件无法动态参数化,需通过 SQL 迁移脚本 DROP + CREATE 重建。
6. **历史数据重算采用 CLI 批量模式**:提供独立重算脚本,复用正式 ETL 任务逻辑(相同 `Business_Day_Cutoff` 配置),按日期窗口分批执行。
## 架构
```mermaid
graph TD
subgraph 配置层
ENV[".env<br/>BUSINESS_DAY_START_HOUR=8"]
ENV --> AC[AppConfig<br/>app.business_day_start_hour]
ENV --> BC[Backend config.py<br/>BUSINESS_DAY_START_HOUR]
end
subgraph 共享工具层
DU["datetime_utils.py<br/>business_date / business_*_range<br/>biz_date_sql_expr"]
end
subgraph ETL DWS 层
BDT["BaseDwsTask<br/>iter_dwd_rowsbiz_date_sql_expr<br/>get_time_window_range"]
BDT --> FT["FinanceBaseTask / FinanceDailyTask<br/>FinanceRechargeTask / FinanceDiscountTask<br/>FinanceIncomeTask"]
BDT --> AT["AssistantDailyTask / AssistantMonthlyTask<br/>AssistantFinanceTask / AssistantCustomerTask<br/>AssistantOrderContributionTask"]
BDT --> MT["MemberVisitTask / MemberConsumptionTask"]
BDT --> GT["GoodsStockDailyTask / WeeklyTask / MonthlyTask"]
BDT --> IT["SpendingPowerIndexTask / MemberIndexBase"]
BDT --> MV["MvRefreshTask"]
end
subgraph 后端 API 层
API["/api/config/business-day<br/>GET → business_day_start_hour"]
TR["时间范围计算<br/>business_day_range / week_range / month_range"]
end
subgraph 数据库层
PGF["biz_date(timestamptz, int)<br/>PostgreSQL 函数"]
MVR["物化视图重建<br/>迁移脚本"]
end
subgraph 前端
AW["Admin_Web<br/>日期选择器标注"]
MP["Miniprogram<br/>透传后端数据"]
end
AC --> BDT
AC --> DU
BC --> API
BC --> TR
DU --> BDT
DU --> TR
API --> AW
API --> MP
PGF --> MVR
```
## 组件与接口
### 1. 共享时间工具(`packages/shared/src/neozqyy_shared/datetime_utils.py`
现有函数(已实现):
- `business_date(dt, day_start_hour) -> date`
- `business_month(dt, day_start_hour) -> date`
- `business_week_monday(dt, day_start_hour) -> date`
- `biz_date_sql_expr(col, day_start_hour) -> str`
新增函数:
```python
def business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
"""返回给定营业日的精确时间戳范围 [start, end)。
start = biz_date 当天 day_start_hour:00
end = biz_date 次日 day_start_hour:00
"""
def business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
"""返回给定营业周(周一)的精确时间戳范围 [start, end)。
start = week_monday 当天 day_start_hour:00
end = week_monday + 7天 day_start_hour:00
"""
def business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]:
"""返回给定营业月(首日)的精确时间戳范围 [start, end)。
start = month_first 当天 day_start_hour:00
end = 次月1日 day_start_hour:00
"""
```
所有 `*_range` 函数返回的时间戳带 `Asia/Shanghai` 时区信息(使用 `SHANGHAI_TZ`)。
### 2. ETL 配置层(`apps/etl/connectors/feiqiu/config/`
**已完成**(代码库中已存在):
- `defaults.py``"app": {"business_day_start_hour": 8}`
- `env_parser.py``"BUSINESS_DAY_START_HOUR": ("app.business_day_start_hour",)`
**需新增**`settings.py``_validate` 方法增加范围校验:
```python
# 在 _validate 中新增
hour = cfg["app"].get("business_day_start_hour", 8)
if not isinstance(hour, int) or not (0 <= hour <= 23):
raise SystemExit("app.business_day_start_hour 必须为 023 的整数")
```
### 3. 后端配置层(`apps/backend/app/config.py`
新增模块级常量:
```python
BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))
```
### 4. 后端配置查询 API`apps/backend/app/routers/business_day.py`
```python
router = APIRouter(prefix="/api/config", tags=["业务配置"])
@router.get("/business-day")
async def get_business_day_config():
"""返回当前营业日分割点配置。"""
return {"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}
```
无需认证(公开配置),前端启动时调用一次缓存。
### 5. BaseDwsTask 基类改造
**`iter_dwd_rows` 改造**
```python
def iter_dwd_rows(self, table_name, columns, start_date, end_date,
date_col="created_at", ...):
cutoff = self.config.get("app.business_day_start_hour", 8)
biz_expr = biz_date_sql_expr(date_col, cutoff)
where_parts = [f"{biz_expr} >= %s", f"{biz_expr} <= %s"]
# ... 其余逻辑不变
```
**`get_time_window_range` 改造**
当前方法返回 `TimeRange(start=date, end=date)`,改造后语义不变(仍返回 `date` 范围),但内部使用 `business_date` 计算 `base_date` 的营业日归属:
```python
def get_time_window_range(self, window, base_date=None):
if base_date is None:
from neozqyy_shared.datetime_utils import now_shanghai, business_date
cutoff = self.config.get("app.business_day_start_hour", 8)
base_date = business_date(now_shanghai(), cutoff)
# ... 其余逻辑使用 base_date已是营业日
```
### 6. 各 DWS 任务 SQL 改造模式
所有任务的 SQL 改造遵循统一模式:
```sql
-- 改造前
DATE(pay_time) AS stat_date
WHERE DATE(pay_time) >= %s AND DATE(pay_time) <= %s
GROUP BY DATE(pay_time)
-- 改造后cutoff_hour=8 时)
DATE(pay_time - INTERVAL '8 hours') AS stat_date
WHERE DATE(pay_time - INTERVAL '8 hours') >= %s AND DATE(pay_time - INTERVAL '8 hours') <= %s
GROUP BY DATE(pay_time - INTERVAL '8 hours')
```
任务从 `self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值,调用 `biz_date_sql_expr(col, cutoff)` 生成表达式。
**受影响任务清单**18 个):
| 任务 | 主要时间列 | 聚合粒度 |
|------|-----------|---------|
| FinanceBaseTask | pay_time | 日 |
| FinanceDailyTask | pay_time | 日 |
| FinanceRechargeTask | pay_time | 日 |
| FinanceDiscountTask | pay_time | 日 |
| FinanceIncomeTask | pay_time | 日 |
| AssistantDailyTask | start_use_time | 日 |
| AssistantOrderContributionTask | pay_time, start_use_time | 日 |
| AssistantCustomerTask | start_use_time | 日 |
| AssistantMonthlyTask | (基于日度数据) | 月 |
| AssistantFinanceTask | start_use_time | 日 |
| MemberVisitTask | pay_time, start_use_time, ledger_end_time | 日 |
| MemberConsumptionTask | pay_time, create_time | 日 |
| GoodsStockDailyTask | fetched_at | 日 |
| GoodsStockWeeklyTask | fetched_at | 周 |
| GoodsStockMonthlyTask | fetched_at | 月 |
| SpendingPowerIndexTask | pay_time | 日 |
| MemberIndexBase | pay_time | 日 |
| MvRefreshTask | (物化视图刷新) | - |
### 7. 数据库层
**新增 PostgreSQL 函数**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`
```sql
CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8)
RETURNS date AS $$
SELECT (ts - make_interval(hours => cutoff_hour))::date;
$$ LANGUAGE sql IMMUTABLE PARALLEL SAFE;
```
**物化视图重建**(迁移脚本 `db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`
物化视图 `mv_dws_finance_daily_summary_l1..l4``mv_dws_assistant_daily_detail_l1..l4` 的时间过滤条件从 `CURRENT_DATE` 改为 `dws.biz_date(NOW())` 或等效表达式。
### 8. 前端适配
**Admin_Web**
- 日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
- 降级策略API 不可用时使用默认值 8`console.warn` 输出警告
**Miniprogram**
- 不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
- 无需前端改造,仅确认后端 API 返回的数据已是营业日口径
### 9. 历史数据重算
提供 `scripts/ops/rebuild_dws_biz_date.py` 脚本:
```python
# 伪代码
for task_cls in ALL_DWS_TASKS:
for date_window in split_by_month(history_start, history_end):
task = task_cls(config)
task.run(window_start=date_window.start, window_end=date_window.end)
```
- 复用正式 ETL 任务逻辑,确保与正式运行使用相同的 `Business_Day_Cutoff`
- 按月分窗口执行,避免单次事务过大
- 执行前后记录行数对比到日志
- 支持 `--dry-run` 模式预览影响范围
### 10. 运维脚本排查
| 脚本 | 涉及的 DATE() 调用 | 处理方式 |
|------|-------------------|---------|
| `scripts/ops/export_bug_report.py` | `DATE(trash_time)`, `DATE(create_time)`, `DATE(start_use_time)` | 替换为 `biz_date_sql_expr` 生成的表达式 |
| `scripts/ops/etl_consistency_check.py` | 日期比较逻辑 | 评估后按需替换 |
| `apps/etl/.../debug_blackbox.py` | `::date` 类型转换 | 替换为 `biz_date()` 函数调用 |
| `apps/etl/.../run_update.py` | `.date()``datetime.combine` | 替换为 `business_date()` + `business_day_range()` |
## 数据模型
### 配置数据
```
BUSINESS_DAY_START_HOUR: int (023, 默认 8)
```
存储位置:
-`.env``BUSINESS_DAY_START_HOUR=8`
- ETL`AppConfig.config["app"]["business_day_start_hour"]`
- 后端:`config.BUSINESS_DAY_START_HOUR`
### 时间工具函数签名
```python
# 输入/输出类型
business_date(dt: datetime, day_start_hour: int = 8) -> date
business_month(dt: datetime, day_start_hour: int = 8) -> date
business_week_monday(dt: datetime, day_start_hour: int = 8) -> date
business_day_range(biz_date: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
business_week_range(week_monday: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
business_month_range(month_first: date, day_start_hour: int = 8) -> tuple[datetime, datetime]
biz_date_sql_expr(col: str, day_start_hour: int = 8) -> str
```
### 数据库函数
```sql
dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date
-- 等价于 Python 的 business_date用于 SQL 查询和物化视图
```
### API 响应模型
```json
// GET /api/config/business-day
{
"business_day_start_hour": 8
}
```
### DWS 表影响
所有 DWS 表的 `stat_date` 字段语义从"自然日"变为"营业日"。表结构不变,仅数据内容因重算而变化。
## 正确性属性
*属性Property是在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 营业日归属往返一致性Round-Trip
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h023`business_day_range(business_date(dt, h), h)` 返回的范围 `[start, end)` 应满足 `start <= dt < end`
**Validates: Requirements 2.9, 11.1**
### Property 2: 营业月与营业日一致性
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h023`business_month(dt, h)` 应等于 `business_date(dt, h).replace(day=1)`
**Validates: Requirements 2.10, 11.2**
### Property 3: 营业周与营业日一致性
*对任意* datetime `dt` 和任意合法的 `day_start_hour` h023`business_week_monday(dt, h)` 应等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果的 `weekday()` 始终为 0周一
**Validates: Requirements 2.11, 11.3**
### Property 4: 营业日归属单调性
*对任意* 两个 datetime `dt1 < dt2` 和任意合法的 `day_start_hour` h023`dt1``dt2` 都在同一个 `business_day_range(d, h)` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`。等价表述:`business_date(dt, h)` 关于 `dt` 是单调非递减的。
**Validates: Requirements 11.9**
### Property 5: 时间范围长度不变量
*对任意* date `d` 和任意合法的 `day_start_hour` h023
- `business_day_range(d, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(hours=24)`
- `business_week_range(monday, h)` 返回的 `(start, end)` 满足 `end - start == timedelta(days=7)`
**Validates: Requirements 11.6, 11.7**
### Property 6: SQL 表达式生成幂等性
*对任意* 列名 `col` 和任意合法的 `day_start_hour` h023`biz_date_sql_expr(col, h)` 多次调用应返回完全相同的字符串。
**Validates: Requirements 11.4**
### Property 7: 非法配置值拒绝
*对任意* 不在 023 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 设为 `v` 时,`AppConfig.load()` 应抛出 `SystemExit`
**Validates: Requirements 1.3**
### Property 8: 合法配置值正确加载
*对任意* 023 范围内的整数值 `v`,当 `BUSINESS_DAY_START_HOUR` 环境变量设为 `v` 时,`AppConfig.load()``cfg.get("app.business_day_start_hour")` 应返回 `v`
**Validates: Requirements 3.4**
## 错误处理
| 场景 | 处理方式 |
|------|---------|
| `BUSINESS_DAY_START_HOUR` 值超出 023 | `AppConfig._validate` 抛出 `SystemExit`,明确提示合法范围 |
| `BUSINESS_DAY_START_HOUR` 环境变量缺失 | 使用默认值 8不报错 |
| `BUSINESS_DAY_START_HOUR` 值为非整数字符串 | `env_parser._coerce_env` 保持字符串,`_validate` 阶段类型检查失败抛出 `SystemExit` |
| 后端 `/api/config/business-day` 不可用 | Admin_Web 使用默认值 8`console.warn` 输出警告 |
| 历史数据重算脚本执行失败 | 按月窗口回滚当前批次,记录错误日志,继续下一窗口或中止(由 `--fail-fast` 参数控制) |
| 物化视图迁移脚本执行失败 | 标准 PostgreSQL 事务回滚,迁移脚本幂等设计(`CREATE OR REPLACE` |
| `business_day_range` 等函数收到非法 `day_start_hour` | 函数内部不做校验(调用方负责),依赖 AppConfig 加载阶段的前置校验 |
## 测试策略
### 属性测试Property-Based Testing
使用 `hypothesis` 库,测试文件位于 `tests/test_property_business_day_cutoff.py`
每个属性测试最少运行 100 次迭代,使用 `@settings(max_examples=200)` 配置。
生成策略:
- `day_start_hour``st.integers(min_value=0, max_value=23)`
- `dt``st.datetimes(min_value=datetime(2020, 1, 1), max_value=datetime(2030, 12, 31))`(避免极端日期)
- `biz_date``st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31))`
每个测试函数以注释标注对应的设计属性:
```python
# Feature: business-day-cutoff, Property 1: 营业日归属往返一致性
@given(dt=st.datetimes(...), h=st.integers(0, 23))
@settings(max_examples=200)
def test_business_date_round_trip(dt, h):
...
# Feature: business-day-cutoff, Property 2: 营业月与营业日一致性
# Feature: business-day-cutoff, Property 3: 营业周与营业日一致性
# Feature: business-day-cutoff, Property 4: 营业日归属单调性
# Feature: business-day-cutoff, Property 5: 时间范围长度不变量
# Feature: business-day-cutoff, Property 6: SQL 表达式生成幂等性
# Feature: business-day-cutoff, Property 7: 非法配置值拒绝
# Feature: business-day-cutoff, Property 8: 合法配置值正确加载
```
### 单元测试
单元测试覆盖属性测试不适合的场景:
- **边界示例**`day_start_hour=8`07:59:59 归属前一天08:00:00 归属当天
- **默认值行为**`BUSINESS_DAY_START_HOUR` 缺失时 AppConfig 返回 8
- **API 端点**`/api/config/business-day` 返回正确 JSON 格式
- **SQL 表达式格式**`biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
- **月末边界**1月31日 07:00 归属1月30日营业日`business_month` 返回1月1日
### 测试配置
- 属性测试库:`hypothesis`(已在项目 `pyproject.toml` 中声明)
- 每个属性测试对应设计文档中的一个 Property由单个 `@given` 装饰的测试函数实现
- 运行命令:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`

View File

@@ -0,0 +1,186 @@
# 需求文档业务日分割点机制Business Day Cutoff
## 简介
引入"业务日分割点"机制,将全系统的统计时间口径从自然日/自然周/自然月切换为以可配置的小时值(默认 08:00为分割点的营业日/营业周/营业月。影响范围覆盖配置层、共享包、ETL 层ODS→DWD→DWS、后端 API 层、前端展示层(管理后台、小程序)及数据库层。
## 术语表
- **Business_Day_Cutoff**营业日分割点一个整数小时值023定义一个"业务日"的起始时刻。默认值为 8即 08:00
- **Business_Date**:营业日,从当天 `Business_Day_Cutoff` 时刻到次日 `Business_Day_Cutoff` 时刻的时间段所归属的日期。`Business_Day_Cutoff` 之前的时间戳归属前一天。
- **Business_Week**:营业周,从周一 `Business_Day_Cutoff` 到次周一 `Business_Day_Cutoff` 的时间段。
- **Business_Month**营业月从当月1日 `Business_Day_Cutoff` 到次月1日 `Business_Day_Cutoff` 的时间段。
- **Shared_DateTime_Utils**`packages/shared/src/neozqyy_shared/datetime_utils.py`,跨子系统共享的时间工具模块。
- **AppConfig**ETL 配置管理器(`apps/etl/connectors/feiqiu/config/settings.py`),通过 `AppConfig.load()` 加载配置。
- **Backend_Config**:后端配置模块(`apps/backend/app/config.py`),从 `.env` 加载环境变量。
- **DWS_Task**DWS 层聚合任务,从 DWD 事实表读取数据并按时间维度聚合写入 DWS 汇总表。
- **biz_date_sql_expr**`Shared_DateTime_Utils` 中生成 PostgreSQL 营业日归属 SQL 表达式的函数。
- **stat_date**DWS 汇总表中的统计日期字段,存储的是 Business_Date 而非自然日期。
- **Admin_Web**:管理后台前端(`apps/admin-web/`React + Vite + Ant Design
- **Miniprogram**:微信小程序前端(`apps/miniprogram/`)。
## 需求
### 需求 1环境变量配置
**用户故事:** 作为运维人员,我希望通过 `.env` 环境变量配置营业日分割点小时值,以便在不修改代码的情况下调整统计时间口径。
#### 验收标准
1. THE Root_Env SHALL 定义 `BUSINESS_DAY_START_HOUR` 环境变量,值为 023 的整数,默认值为 8
2. THE Env_Template SHALL 同步包含 `BUSINESS_DAY_START_HOUR` 的定义及注释说明(日/周/月统计的分割语义)
3. WHEN `BUSINESS_DAY_START_HOUR` 的值不在 023 范围内时, THEN THE AppConfig SHALL 在加载阶段抛出 `SystemExit` 错误并给出明确提示
4. WHEN `BUSINESS_DAY_START_HOUR` 环境变量缺失时, THE AppConfig SHALL 使用默认值 8
### 需求 2共享时间工具函数
**用户故事:** 作为开发者,我希望有一组经过充分测试的共享时间工具函数,以便所有子系统使用统一的营业日归属逻辑。
#### 验收标准
1. THE Shared_DateTime_Utils SHALL 提供 `business_date(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Date
2. THE Shared_DateTime_Utils SHALL 提供 `business_month(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Month 首日
3. THE Shared_DateTime_Utils SHALL 提供 `business_week_monday(dt, day_start_hour)` 函数,将任意时间戳归属到对应的 Business_Week 的周一日期
4. THE Shared_DateTime_Utils SHALL 提供 `biz_date_sql_expr(col, day_start_hour)` 函数,生成 PostgreSQL 营业日归属 SQL 表达式(形如 `DATE(col - INTERVAL 'N hours')`
5. WHEN `day_start_hour` 参数未传入时, THE Shared_DateTime_Utils SHALL 使用 `DEFAULT_BUSINESS_DAY_START_HOUR`(值为 8作为默认值
6. THE Shared_DateTime_Utils SHALL 提供 `business_day_range(biz_date, day_start_hour)` 函数,返回给定 Business_Date 对应的精确时间戳范围 `(start_dt, end_dt)`,即 `(biz_date 当天 day_start_hour:00, biz_date 次日 day_start_hour:00)`
7. THE Shared_DateTime_Utils SHALL 提供 `business_week_range(week_monday, day_start_hour)` 函数,返回给定 Business_Week 周一对应的精确时间戳范围
8. THE Shared_DateTime_Utils SHALL 提供 `business_month_range(month_first, day_start_hour)` 函数,返回给定 Business_Month 首日对应的精确时间戳范围
9. FOR ALL 合法的 datetime 输入, `business_date` 的输出 SHALL 满足:`business_day_range(business_date(dt, h), h)[0] <= dt < business_day_range(business_date(dt, h), h)[1]`(往返一致性)
10. FOR ALL 合法的 datetime 输入, `business_month(dt, h)` SHALL 等于 `business_date(dt, h).replace(day=1)`(月归属与日归属一致性)
11. FOR ALL 合法的 datetime 输入, `business_week_monday(dt, h)` SHALL 等于 `business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`(周归属与日归属一致性)
### 需求 3ETL 配置层集成
**用户故事:** 作为 ETL 开发者,我希望 `AppConfig` 正确加载并传播 `BUSINESS_DAY_START_HOUR`,以便 ETL 任务能获取到配置的分割点值。
#### 验收标准
1. THE AppConfig SHALL 在 `app.business_day_start_hour` 路径下存储 `BUSINESS_DAY_START_HOUR` 的整数值
2. THE Env_Parser SHALL 将环境变量 `BUSINESS_DAY_START_HOUR` 映射到 `app.business_day_start_hour` 配置路径
3. THE AppConfig_Defaults SHALL 将 `app.business_day_start_hour` 的默认值设为 8
4. WHEN AppConfig 加载完成后, THE AppConfig SHALL 通过 `cfg.get("app.business_day_start_hour")` 返回正确的整数值
### 需求 4ETL DWS 层聚合逻辑
**用户故事:** 作为数据分析师,我希望 DWS 层的所有日度/周度/月度聚合统计都基于营业日口径,以便统计结果与门店实际营业周期一致。
#### 验收标准
1. WHEN DWS_Task 从 DWD 表提取数据时, THE DWS_Task SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期归属计算
2. WHEN DWS_Task 按日聚合时, THE DWS_Task SHALL 使用 `DATE(timestamp_col - INTERVAL 'N hours')` 作为 `stat_date` 的分组依据,其中 N 为 `Business_Day_Cutoff`
3. WHEN DWS_Task 按月聚合时, THE DWS_Task SHALL 使用 Business_Month 口径当月1日 cutoff 到次月1日 cutoff
4. WHEN DWS_Task 按周聚合时, THE DWS_Task SHALL 使用 Business_Week 口径(周一 cutoff 到次周一 cutoff
5. THE BaseDwsTask.iter_dwd_rows SHALL 使用 `biz_date_sql_expr` 替代 `DATE()` 进行日期过滤
6. THE BaseDwsTask.get_time_window_range SHALL 返回基于 Business_Date 口径的时间范围
7. WHILE ETL 任务运行期间, THE DWS_Task SHALL 从 `AppConfig` 读取 `app.business_day_start_hour` 值,禁止硬编码
### 需求 5受影响的 DWS 任务全面排查
**用户故事:** 作为项目负责人,我希望所有使用 `DATE()` 进行时间归属的 DWS 任务都被排查并改造,确保无遗漏。
#### 验收标准
1. THE FinanceBaseTask SHALL 将所有 `DATE(pay_time)` 替换为 `biz_date_sql_expr("pay_time", cutoff_hour)` 生成的表达式
2. THE FinanceDailyTask SHALL 使用 Business_Date 口径提取和聚合结账单、团购核销、充值等数据
3. THE FinanceRechargeTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
4. THE FinanceDiscountTask SHALL 使用 Business_Date 口径聚合优惠明细
5. THE FinanceIncomeTask SHALL 使用 Business_Date 口径聚合收入结构
6. THE AssistantDailyTask SHALL 使用 Business_Date 口径聚合助教日度明细
7. THE AssistantOrderContributionTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
8. THE AssistantCustomerTask SHALL 将 `DATE(start_use_time)` 替换为营业日归属表达式
9. THE AssistantMonthlyTask SHALL 使用 Business_Month 口径聚合助教月度汇总
10. THE AssistantFinanceTask SHALL 使用 Business_Date 口径聚合助教财务分析
11. THE MemberVisitTask SHALL 将 `DATE(pay_time)``DATE(start_use_time)``DATE(ledger_end_time)` 替换为营业日归属表达式
12. THE MemberConsumptionTask SHALL 将 `DATE(pay_time)``DATE(create_time)` 替换为营业日归属表达式
13. THE GoodsStockDailyTask SHALL 将 `DATE(fetched_at)` 替换为营业日归属表达式
14. THE GoodsStockWeeklyTask SHALL 使用 Business_Week 口径聚合库存周报
15. THE GoodsStockMonthlyTask SHALL 使用 Business_Month 口径聚合库存月报
16. THE SpendingPowerIndexTask SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
17. THE MemberIndexBase SHALL 将 `DATE(pay_time)` 替换为营业日归属表达式
18. THE MvRefreshTask SHALL 确保物化视图刷新的时间过滤条件使用 Business_Date 口径
### 需求 6后端 API 层时间范围计算
**用户故事:** 作为后端开发者,我希望后端 API 在处理"今日/本周/本月"等时间范围查询时使用营业日口径,以便前端展示的数据与 DWS 统计一致。
#### 验收标准
1. THE Backend_Config SHALL 加载 `BUSINESS_DAY_START_HOUR` 环境变量并暴露为模块级常量
2. WHEN 后端 API 需要计算"今日"时间范围时, THE Backend SHALL 使用 `business_day_range` 函数计算从当天 cutoff 到次日 cutoff 的时间戳范围
3. WHEN 后端 API 需要计算"本周"时间范围时, THE Backend SHALL 使用 `business_week_range` 函数计算从本周一 cutoff 到次周一 cutoff 的时间戳范围
4. WHEN 后端 API 需要计算"本月"时间范围时, THE Backend SHALL 使用 `business_month_range` 函数计算从本月1日 cutoff 到次月1日 cutoff 的时间戳范围
5. THE Backend SHALL 从 `Shared_DateTime_Utils` 导入时间工具函数,禁止在后端重复实现营业日逻辑
### 需求 7前端展示层适配
**用户故事:** 作为前端开发者,我希望管理后台和小程序在展示日期选择器和统计数据时,能正确反映营业日口径,避免用户困惑。
#### 验收标准
1. WHEN Admin_Web 展示日期选择器时, THE Admin_Web SHALL 在日期选择器旁标注营业日口径说明(如"营业日08:00 起"
2. WHEN Admin_Web 展示"今日统计"时, THE Admin_Web SHALL 显示的时间范围为当天 cutoff 到次日 cutoff
3. WHEN Miniprogram 展示统计数据时, THE Miniprogram SHALL 使用后端 API 返回的基于营业日口径的数据
4. THE Admin_Web SHALL 通过后端 API 获取 `BUSINESS_DAY_START_HOUR` 配置值,禁止前端硬编码
5. IF 后端 API 返回的 `BUSINESS_DAY_START_HOUR` 配置值不可用, THEN THE Admin_Web SHALL 使用默认值 8 并在控制台输出警告
### 需求 8后端配置查询 API
**用户故事:** 作为前端开发者,我希望有一个 API 端点能返回当前的营业日分割点配置,以便前端动态获取并展示。
#### 验收标准
1. THE Backend SHALL 提供一个 API 端点返回当前 `BUSINESS_DAY_START_HOUR` 的值
2. WHEN 前端请求该端点时, THE Backend SHALL 返回包含 `business_day_start_hour` 字段的 JSON 响应
3. THE Backend SHALL 确保该端点的响应值与 ETL 层使用的 `BUSINESS_DAY_START_HOUR` 值一致(均来源于同一 `.env` 配置)
### 需求 9数据库层适配
**用户故事:** 作为 DBA我希望数据库中的物化视图和 SQL 函数使用营业日口径,以便直接查询数据库时也能获得正确的统计结果。
#### 验收标准
1. WHEN 物化视图使用 `date_trunc``CURRENT_DATE` 进行时间过滤时, THE Migration_Script SHALL 将其替换为基于 `Business_Day_Cutoff` 的表达式
2. THE Migration_Script SHALL 提供一个 PostgreSQL 函数 `biz_date(timestamptz, int)` 用于在 SQL 中直接计算营业日归属
3. WHEN 迁移脚本执行后, THE Database SHALL 确保所有物化视图的时间过滤条件使用营业日口径
4. THE Migration_Script SHALL 使用日期前缀命名(如 `2026-03-XX__add_biz_date_function.sql`),遵循项目迁移脚本规范
### 需求 10数据迁移与历史数据兼容
**用户故事:** 作为运维人员,我希望引入营业日机制后,历史数据能被正确重算,确保统计连续性。
#### 验收标准
1. THE Migration_Plan SHALL 提供 DWS 历史数据重算脚本,按营业日口径重新聚合所有受影响的 DWS 表
2. WHEN 历史数据重算执行时, THE Rebuild_Script SHALL 使用与正式 ETL 任务相同的 `Business_Day_Cutoff` 配置值
3. THE Migration_Plan SHALL 记录重算前后的数据行数对比,用于验证重算正确性
4. IF 重算过程中发生错误, THEN THE Rebuild_Script SHALL 回滚到重算前的状态并记录错误日志
### 需求 11属性测试覆盖
**用户故事:** 作为测试工程师,我希望营业日归属逻辑有完整的属性测试覆盖,确保边界条件和不变量得到验证。
#### 验收标准
1. THE Property_Test SHALL 验证 `business_date` 的往返一致性:对任意 datetime dt`business_day_range(business_date(dt, h), h)` 的范围包含 dt
2. THE Property_Test SHALL 验证 `business_month``business_date` 的一致性:`business_month(dt, h) == business_date(dt, h).replace(day=1)`
3. THE Property_Test SHALL 验证 `business_week_monday``business_date` 的一致性:`business_week_monday(dt, h).weekday() == 0`(结果始终为周一)
4. THE Property_Test SHALL 验证 `biz_date_sql_expr` 的幂等性:对同一输入参数多次调用返回相同结果
5. THE Property_Test SHALL 验证边界条件cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天
6. THE Property_Test SHALL 验证 `business_day_range` 返回的范围恰好为 24 小时
7. THE Property_Test SHALL 验证 `business_week_range` 返回的范围恰好为 7 天168 小时)
8. THE Property_Test SHALL 使用 hypothesis 库生成随机 datetime 和 day_start_hour023进行测试
9. FOR ALL `day_start_hour`023, THE Property_Test SHALL 验证 `business_date` 函数的单调性:若 dt1 < dt2 且两者在同一 Business_Date 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`
### 需求 12运维脚本中的 DATE() 排查
**用户故事:** 作为运维人员,我希望 `scripts/ops/` 和 ETL `scripts/` 中使用 `DATE()` 的运维脚本也被排查,确保调试和排查工具与正式统计口径一致。
#### 验收标准
1. THE Ops_Scripts SHALL 排查 `scripts/ops/export_bug_report.py` 中的 `DATE(trash_time)``DATE(create_time)``DATE(start_use_time)` 调用,评估是否需要替换为营业日归属表达式
2. THE Ops_Scripts SHALL 排查 `scripts/ops/etl_consistency_check.py` 中的日期比较逻辑
3. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py` 中的 `::date` 类型转换
4. THE ETL_Scripts SHALL 排查 `apps/etl/connectors/feiqiu/scripts/run_update.py` 中的 `.date()` 调用和 `datetime.combine` 逻辑
5. WHEN 运维脚本用于与 DWS 数据对比验证时, THE Ops_Scripts SHALL 使用与 DWS 任务相同的营业日归属逻辑

View File

@@ -0,0 +1,260 @@
# 实现计划业务日分割点机制Business Day Cutoff
## 概述
将全系统统计时间口径从自然日切换为以可配置小时值(默认 08:00为分割点的营业日。实现顺序配置层 → 共享工具层 → ETL 层 → 后端 API 层 → 数据库层 → 前端适配 → 历史数据重算 → 运维脚本排查。
## 任务
- [x] 1. 配置层:环境变量与配置加载
- [x] 1.1 在根 `.env``.env.template` 中新增 `BUSINESS_DAY_START_HOUR=8`
- `.env` 新增 `BUSINESS_DAY_START_HOUR=8`
- `.env.template` 新增带注释的定义,说明日/周/月统计分割语义
- _Requirements: 1.1, 1.2_
- [x] 1.2 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验
-`apps/etl/connectors/feiqiu/config/settings.py``_validate` 方法中增加校验
- 值不在 023 范围内或非整数时抛出 `SystemExit`
- 环境变量缺失时使用默认值 8已由 `defaults.py` 保证)
- _Requirements: 1.3, 1.4, 3.1, 3.2, 3.3, 3.4_
- [x] 1.3 在后端 `apps/backend/app/config.py` 中新增 `BUSINESS_DAY_START_HOUR` 常量
- 新增 `BUSINESS_DAY_START_HOUR: int = int(get("BUSINESS_DAY_START_HOUR", "8"))`
- _Requirements: 6.1_
- [x] 2. 共享时间工具层:新增 range 函数
- [x] 2.1 在 `packages/shared/src/neozqyy_shared/datetime_utils.py` 中实现三个 range 函数
- 实现 `business_day_range(biz_date, day_start_hour)``tuple[datetime, datetime]`
- 实现 `business_week_range(week_monday, day_start_hour)``tuple[datetime, datetime]`
- 实现 `business_month_range(month_first, day_start_hour)``tuple[datetime, datetime]`
- 所有返回值带 `Asia/Shanghai` 时区(使用 `SHANGHAI_TZ`
- 默认 `day_start_hour=8`
- _Requirements: 2.6, 2.7, 2.8, 2.5_
- [x] 2.2 编写属性测试营业日归属往返一致性Property 1
- **Property 1: 营业日归属往返一致性Round-Trip**
- `business_day_range(business_date(dt, h), h)` 的范围 `[start, end)` 应满足 `start <= dt < end`
- 使用 `hypothesis``@given(dt=st.datetimes(...), h=st.integers(0, 23))``@settings(max_examples=200)`
- 测试文件:`tests/test_property_business_day_cutoff.py`
- **Validates: Requirements 2.9, 11.1**
- [x] 2.3 编写属性测试营业月与营业日一致性Property 2
- **Property 2: 营业月与营业日一致性**
- `business_month(dt, h) == business_date(dt, h).replace(day=1)`
- **Validates: Requirements 2.10, 11.2**
- [x] 2.4 编写属性测试营业周与营业日一致性Property 3
- **Property 3: 营业周与营业日一致性**
- `business_week_monday(dt, h) == business_date(dt, h) - timedelta(days=business_date(dt, h).weekday())`,且结果 `weekday() == 0`
- **Validates: Requirements 2.11, 11.3**
- [x] 2.5 编写属性测试营业日归属单调性Property 4
- **Property 4: 营业日归属单调性**
-`dt1 < dt2` 且两者在同一 `business_day_range` 范围内,则 `business_date(dt1, h) == business_date(dt2, h)`
- **Validates: Requirements 11.9**
- [x] 2.6 编写属性测试时间范围长度不变量Property 5
- **Property 5: 时间范围长度不变量**
- `business_day_range(d, h)``end - start == timedelta(hours=24)`
- `business_week_range(monday, h)``end - start == timedelta(days=7)`
- **Validates: Requirements 11.6, 11.7**
- [x] 2.7 编写属性测试SQL 表达式生成幂等性Property 6
- **Property 6: SQL 表达式生成幂等性**
- `biz_date_sql_expr(col, h)` 多次调用返回完全相同的字符串
- **Validates: Requirements 11.4**
- [x] 2.8 编写属性测试边界条件验证Property 1 补充)
- cutoff 时刻恰好在分割点上的时间戳归属当天,分割点前一秒归属前一天
- 使用 hypothesis 生成 `day_start_hour`,构造边界时间戳验证
- **Validates: Requirements 11.5**
- [x] 2.9 编写单元测试:共享时间工具函数
- 测试 `day_start_hour=8` 时 07:59:59 归属前一天、08:00:00 归属当天
- 测试 `biz_date_sql_expr("pay_time", 8)` 返回 `DATE(pay_time - INTERVAL '8 hours')`
- 测试月末边界1月31日 07:00 归属1月30日`business_month` 返回1月1日
- 测试默认值行为:不传 `day_start_hour` 时使用 8
- _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8_
- [x] 3. Checkpoint — 共享工具层验证
- 确保所有属性测试和单元测试通过,运行 `pytest tests/test_property_business_day_cutoff.py -v`,如有问题请向用户确认。
- [x] 4. ETL 配置层集成与 BaseDwsTask 基类改造
- [x] 4.1 在 `AppConfig._validate` 中新增 `business_day_start_hour` 范围校验(与 1.2 合并实现)
- 确认 `defaults.py``env_parser.py` 已有配置映射(设计文档标注"已完成"
- 仅需在 `settings.py``_validate` 中增加校验逻辑
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 4.2 编写属性测试非法配置值拒绝Property 7
- **Property 7: 非法配置值拒绝**
- 对任意不在 023 范围内的整数值,`AppConfig.load()` 应抛出 `SystemExit`
- **Validates: Requirements 1.3**
- [x] 4.3 编写属性测试合法配置值正确加载Property 8
- **Property 8: 合法配置值正确加载**
- 对任意 023 范围内的整数值 `v``AppConfig.load()``cfg.get("app.business_day_start_hour")` 应返回 `v`
- **Validates: Requirements 3.4**
- [x] 4.4 改造 `BaseDwsTask.iter_dwd_rows`,将 `DATE()` 替换为 `biz_date_sql_expr`
-`self.config.get("app.business_day_start_hour", 8)` 读取 cutoff 值
- 调用 `biz_date_sql_expr(date_col, cutoff)` 生成 SQL 表达式
- 替换 WHERE 子句中的 `DATE(col)``biz_date_sql_expr` 生成的表达式
- _Requirements: 4.1, 4.5, 4.7_
- [x] 4.5 改造 `BaseDwsTask.get_time_window_range`,使用 `business_date` 计算营业日归属
- `base_date` 为 None 时使用 `business_date(now_shanghai(), cutoff)` 计算
- 确保返回的 `TimeRange` 基于营业日口径
- _Requirements: 4.6_
- [x] 5. DWS 任务 SQL 改造(财务类)
- [x] 5.1 改造 FinanceBaseTask`DATE(pay_time)``biz_date_sql_expr("pay_time", cutoff)`
- 从 config 读取 cutoff替换所有 `DATE(pay_time)` 为营业日表达式
- 包括 SELECT、WHERE、GROUP BY 中的所有出现
- _Requirements: 5.1_
- [x] 5.2 改造 FinanceDailyTask使用 Business_Date 口径
- 替换结账单、团购核销、充值等数据的日期归属
- _Requirements: 5.2_
- [x] 5.3 改造 FinanceRechargeTask`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.3_
- [x] 5.4 改造 FinanceDiscountTask使用 Business_Date 口径聚合优惠明细
- _Requirements: 5.4_
- [x] 5.5 改造 FinanceIncomeTask使用 Business_Date 口径聚合收入结构
- _Requirements: 5.5_
- [x] 6. DWS 任务 SQL 改造(助教类)
- [x] 6.1 改造 AssistantDailyTask`DATE(start_use_time)` → 营业日归属表达式
- _Requirements: 5.6_
- [x] 6.2 改造 AssistantOrderContributionTask`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.7_
- [x] 6.3 改造 AssistantCustomerTask`DATE(start_use_time)` → 营业日归属表达式
- _Requirements: 5.8_
- [x] 6.4 改造 AssistantMonthlyTask使用 Business_Month 口径聚合月度汇总
- _Requirements: 5.9_
- [x] 6.5 改造 AssistantFinanceTask使用 Business_Date 口径聚合助教财务分析
- _Requirements: 5.10_
- [x] 7. DWS 任务 SQL 改造(会员类 + 商品库存类 + 指标类)
- [x] 7.1 改造 MemberVisitTask`DATE(pay_time)``DATE(start_use_time)``DATE(ledger_end_time)` → 营业日归属表达式
- _Requirements: 5.11_
- [x] 7.2 改造 MemberConsumptionTask`DATE(pay_time)``DATE(create_time)` → 营业日归属表达式
- _Requirements: 5.12_
- [x] 7.3 改造 GoodsStockDailyTask`DATE(fetched_at)` → 营业日归属表达式
- _Requirements: 5.13_
- [x] 7.4 改造 GoodsStockWeeklyTask使用 Business_Week 口径聚合库存周报
- _Requirements: 5.14_
- [x] 7.5 改造 GoodsStockMonthlyTask使用 Business_Month 口径聚合库存月报
- _Requirements: 5.15_
- [x] 7.6 改造 SpendingPowerIndexTask`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.16_
- [x] 7.7 改造 MemberIndexBase`DATE(pay_time)` → 营业日归属表达式
- _Requirements: 5.17_
- [x] 7.8 改造 MvRefreshTask确保物化视图刷新的时间过滤条件使用 Business_Date 口径
- _Requirements: 5.18_
- [x] 8. Checkpoint — ETL DWS 层改造验证
- 确保所有 18 个 DWS 任务的 SQL 改造完成,`DATE()` 调用已全部替换为 `biz_date_sql_expr` 生成的表达式。运行 ETL 单元测试 `cd apps/etl/connectors/feiqiu && pytest tests/unit -v`,如有问题请向用户确认。
- [x] 9. 后端 API 层
- [x] 9.1 创建 `apps/backend/app/routers/business_day.py`,实现 `/api/config/business-day` 端点
- 创建 `APIRouter(prefix="/api/config", tags=["业务配置"])`
- 实现 `GET /business-day` 返回 `{"business_day_start_hour": config.BUSINESS_DAY_START_HOUR}`
- 无需认证(公开配置)
- _Requirements: 8.1, 8.2, 8.3_
- [x] 9.2 在后端主路由中注册 `business_day` 路由
-`apps/backend/app/main.py` 或路由注册文件中 `include_router`
- _Requirements: 8.1_
- [x] 9.3 后端时间范围查询统一使用 `business_*_range` 函数
- 在后端需要计算"今日/本周/本月"时间范围的 API 中,导入并使用 `business_day_range``business_week_range``business_month_range`
-`Shared_DateTime_Utils` 导入,禁止重复实现
- _Requirements: 6.2, 6.3, 6.4, 6.5_
- [x] 9.4 编写单元测试:后端配置查询 API
- 测试 `/api/config/business-day` 返回正确 JSON 格式
- 测试返回值与 `config.BUSINESS_DAY_START_HOUR` 一致
- _Requirements: 8.1, 8.2, 8.3_
- [x] 10. 数据库层PostgreSQL 函数与物化视图迁移
- [x] 10.1 创建迁移脚本:新增 `dws.biz_date()` PostgreSQL 函数
- 文件:`db/etl_feiqiu/migrations/2026-03-XX__add_biz_date_function.sql`
- `CREATE OR REPLACE FUNCTION dws.biz_date(ts timestamptz, cutoff_hour int DEFAULT 8) RETURNS date`
- 标记为 `IMMUTABLE PARALLEL SAFE`
- _Requirements: 9.2, 9.4_
- [x] 10.2 创建迁移脚本:重建物化视图使用 `biz_date()` 函数
- 文件:`db/etl_feiqiu/migrations/2026-03-XX__rebuild_mv_with_biz_date.sql`
-`mv_dws_finance_daily_summary_l1..l4``mv_dws_assistant_daily_detail_l1..l4``CURRENT_DATE` / `date_trunc` 替换为 `dws.biz_date(NOW())`
- 使用 `DROP MATERIALIZED VIEW IF EXISTS` + `CREATE MATERIALIZED VIEW` 重建
- _Requirements: 9.1, 9.3_
- [x] 11. 前端适配
- [x] 11.1 Admin_Web日期选择器旁标注营业日口径说明
- 在日期选择器组件旁增加 Tooltip 或文字标注:`营业日:{HH}:00 起`
- 通过 `/api/config/business-day` 获取 `business_day_start_hour`,启动时请求一次存入全局状态
- 降级策略API 不可用时使用默认值 8`console.warn` 输出警告
- _Requirements: 7.1, 7.2, 7.4, 7.5_
- [x] 11.2 Miniprogram确认后端 API 返回的数据已是营业日口径
- 小程序不直接使用 cutoff 值,所有统计数据由后端 API 按营业日口径返回
- 确认无需前端改造,仅验证后端 API 数据正确性
- _Requirements: 7.3_
- [x] 12. Checkpoint — 后端 + 数据库 + 前端验证
- 确保后端 API 端点可用、迁移脚本语法正确、前端标注正常显示。如有问题请向用户确认。
- [x] 13. 历史数据重算脚本
- [x] 13.1 创建 `scripts/ops/rebuild_dws_biz_date.py` 历史数据重算脚本
- 复用正式 ETL 任务逻辑,使用相同的 `Business_Day_Cutoff` 配置
- 按月分窗口执行,避免单次事务过大
- 执行前后记录行数对比到日志
- 支持 `--dry-run` 模式预览影响范围
- 支持 `--fail-fast` 参数控制错误时中止或继续
- 错误时回滚当前批次并记录错误日志
- _Requirements: 10.1, 10.2, 10.3, 10.4_
- [x] 14. 运维脚本排查与改造
- [x] 14.1 排查并改造 `scripts/ops/export_bug_report.py`
-`DATE(trash_time)``DATE(create_time)``DATE(start_use_time)` 替换为 `biz_date_sql_expr` 生成的表达式
- _Requirements: 12.1, 12.5_
- [x] 14.2 排查并改造 `scripts/ops/etl_consistency_check.py`
- 评估日期比较逻辑,按需替换为营业日归属表达式
- _Requirements: 12.2, 12.5_
- [x] 14.3 排查并改造 `apps/etl/connectors/feiqiu/scripts/debug/debug_blackbox.py`
-`::date` 类型转换替换为 `biz_date()` 函数调用
- _Requirements: 12.3_
- [x] 14.4 排查并改造 `apps/etl/connectors/feiqiu/scripts/run_update.py`
-`.date()``datetime.combine` 替换为 `business_date()` + `business_day_range()`
- _Requirements: 12.4, 12.5_
- [x] 15. Final Checkpoint — 全量验证
- 确保所有属性测试通过:`cd C:\NeoZQYY && pytest tests/test_property_business_day_cutoff.py -v`
- 确保 ETL 单元测试通过:`cd apps/etl/connectors/feiqiu && pytest tests/unit -v`
- 确认所有 12 项需求的验收标准均有对应任务覆盖
- 如有问题请向用户确认。
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号,确保可追溯性
- 属性测试验证设计文档中的 8 个正确性属性
- Checkpoint 任务确保增量验证,及时发现问题
- 设计文档标注 `defaults.py``env_parser.py` 已完成,任务 4.1 仅需增加校验逻辑

View File

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

View File

@@ -0,0 +1,440 @@
# 设计文档DWD 业务全景梳理
## 概述
本设计文档描述如何系统梳理飞球 ETL 的 DWD 层全部业务数据,产出 5 份全景分析文档。这是一个纯文档梳理任务,不涉及代码改动,核心挑战在于:如何在"数据本源优先"的强制准则下,高效、准确地完成 7 个业务域、约 30 张表(含扩展表)的字段语义验证和跨表关联分析。
### 设计目标
1. 定义可重复执行的梳理方法论,确保每张表的分析过程一致
2. 设计每份文档的结构模板,确保产出物格式统一
3. 规划数据验证的技术方案,确保结论可追溯
4. 设计文档间的引用和关联机制,避免信息重复
### 范围
- 输入:`test_etl_feiqiu` 数据库 DWD schema 的全部表和数据
- 参考:现有 BD 手册(`apps/etl/connectors/feiqiu/docs/database/DWD/`)、已有分析报告(`docs/reports/`
- 产出5 份全景文档,输出到 `docs/reports/`
### DWD 表全景
根据现有 BD 手册DWD 层包含以下表:
| 类型 | 表数量 | 表名 |
|------|--------|------|
| 维度表(主表) | 9 | `dim_site`, `dim_table`, `dim_assistant`, `dim_member`, `dim_member_card_account`, `dim_tenant_goods`, `dim_store_goods`, `dim_goods_category`, `dim_groupbuy_package` |
| 维度表(扩展表) | 8 | 上述除 `dim_goods_category` 外均有 `_ex` 扩展表 |
| 事实表(主表) | 13 | `dwd_settlement_head`, `dwd_payment`, `dwd_table_fee_log`, `dwd_table_fee_adjust`, `dwd_assistant_service_log`, `dwd_member_balance_change`, `dwd_recharge_order`, `dwd_refund`, `dwd_groupbuy_redemption`, `dwd_platform_coupon_redemption`, `dwd_store_goods_sale`, `dwd_goods_stock_summary`, `dwd_goods_stock_movement` |
| 事实表(扩展表) | 11 | 除 `dwd_payment`, `dwd_goods_stock_summary`, `dwd_goods_stock_movement` 外均有 `_ex` 扩展表 |
共计约 43 张表。
## 架构
### 整体梳理流程
梳理工作分为两个阶段:基础层(表结构与字段语义)和全景层(跨表关联分析)。基础层产出为全景层的输入。
```mermaid
flowchart TD
subgraph Phase1["阶段一:基础层梳理"]
A[枚举 DWD 全部表] --> B[按业务域分组]
B --> C[逐表执行智能聚焦分析]
C --> D[字段分类筛选]
D --> E[业务关键字段倒推验证]
E --> F[产出DWD 表结构与字段语义总览]
end
subgraph Phase2["阶段二:全景层梳理"]
F --> G[业务全景:消费产生机制]
F --> H[账务全景:结算与支付]
F --> I[财务全景:收入与对账]
F --> J[维度表与主数据全景]
end
subgraph Validation["贯穿:数据验证"]
K[information_schema 查表结构]
L[SQL 查询验证值域分布]
M[交叉查询验证关联关系]
N[对账公式全量验证]
end
C -.-> K
E -.-> L
E -.-> M
H -.-> N
```
### 信息流向
```mermaid
flowchart LR
subgraph 输入源
DB[(test_etl_feiqiu<br/>DWD schema)]
BD[现有 BD 手册<br/>宏观参考]
RPT[已有分析报告<br/>待验证假设]
end
subgraph 梳理过程
DB -->|information_schema| META[表结构元数据]
DB -->|SQL 查询| DATA[实际数据验证]
BD -->|宏观层可参考| REF[参考起点]
RPT -->|字段级需验证| HYP[待验证假设]
end
subgraph 产出
META --> DOC1[文档1: 表结构总览]
DATA --> DOC1
REF --> DOC1
HYP --> DOC1
DOC1 --> DOC2[文档2: 业务全景]
DOC1 --> DOC3[文档3: 账务全景]
DOC1 --> DOC4[文档4: 财务全景]
DOC1 --> DOC5[文档5: 维度表全景]
end
```
## 组件与接口
### 组件一:单表智能聚焦分析器
对每张 DWD 表执行标准化的分析流程,产出该表的字段语义报告。
#### 分析流程(每张表)
```
步骤 1: 表结构获取
→ 查询 information_schema.columns 获取列名、类型、nullable
→ 禁止参考 db/ 目录下的 DDL .sql 文件
步骤 2: 字段分类筛选
→ 查询全表空字段SELECT column WHERE ALL NULL标记为"空字段-跳过"
→ 识别 ETL 管理字段_etl_loaded_at, _etl_batch_id 等),简要标注
→ 识别含义透明字段id, site_id, created_at 等),仅列出
→ 剩余为"业务关键字段",进入深度验证
步骤 3: 业务关键字段倒推验证
→ 从含义明确的字段出发(如 id, site_id, total_amount
→ 通过 JOIN / 聚合对比 / 值域交叉推断不确定字段
→ 金额字段MIN/MAX/AVG/中位数/NULL占比 + 交叉验证
→ 枚举字段DISTINCT 值 + 频次分布
→ 关联 IDJOIN 验证关联完整性
步骤 4: 偏差检测
→ 对比现有 BD 手册的字段描述
→ 标注一致/偏差/错误
```
#### 输出格式(每张表)
```markdown
### {table_name}
**业务职责**:一句话描述
**数据状态**{行数} 行,时间范围 {min_date} ~ {max_date}
**主键**{pk_fields}
**关联表**{related_tables with join fields}
#### 业务关键字段
| 字段名 | 类型 | 验证状态 | 语义说明 | 值域/分布 |
|--------|------|----------|----------|-----------|
| ... | ... | ✅/⚠️/❌ | ... | ... |
#### 空字段(附录)
{列出全 NULL 的字段名}
#### 偏差记录
{与现有文档不一致的地方}
```
### 组件二:全景文档生成器
基于单表分析结果,按业务视角组织跨表关联分析。
#### 全景文档通用模板
```markdown
# {全景文档标题}
> 数据来源test_etl_feiqiu (DWD schema)
> 验证日期:{date}
> 数据时间范围:{min_date} ~ {max_date}
## 目录
{自动生成}
## 正文
{按业务逻辑组织的分析内容}
{每个关键结论标注验证状态:✅ 已验证 / ⚠️ 部分验证 / ❌ 未验证}
## 附录
### 验证 SQL
{关键验证查询}
### 数据样例
{来自测试库的真实数据}
```
### 组件三:数据验证引擎
贯穿整个梳理过程的验证机制。
#### 验证类型
| 验证类型 | 方法 | 适用场景 |
|----------|------|----------|
| 值域验证 | MIN/MAX/AVG/MEDIAN/NULL% | 金额字段、数值字段 |
| 枚举验证 | DISTINCT + COUNT | 状态字段、类型字段 |
| 关联验证 | LEFT JOIN + NULL 检查 | 外键关联完整性 |
| 等式验证 | SUM 对比 | 对账公式F1~F6 等) |
| 交叉验证 | 多表 JOIN + 聚合对比 | 跨表金额一致性 |
| 边界验证 | WHERE = 0 / < 0 / IS NULL | 异常值业务含义 |
#### 验证结果标注规范
- ✅ 已验证:附验证 SQL 摘要或结果统计
- ⚠️ 部分验证:附已知例外数量和分类
- ❌ 未验证:附原因(数据不足/无法关联/逻辑不明)
- ⚠️ 警告:经多次交叉验证仍无法对齐的数据关系
## 数据模型
### DWD 表按业务域分组
本次梳理将 DWD 层全部表按 7 个业务域组织,每个域内的表构成一个分析单元。
```mermaid
erDiagram
%% 结算域
dwd_settlement_head ||--o{ dwd_payment : "order_settle_id"
dwd_settlement_head ||--o{ dwd_table_fee_log : "order_settle_id"
dwd_settlement_head ||--o{ dwd_store_goods_sale : "order_settle_id"
dwd_settlement_head ||--o{ dwd_assistant_service_log : "order_settle_id"
dwd_settlement_head ||--o{ dwd_platform_coupon_redemption : "order_settle_id"
dwd_settlement_head ||--o{ dwd_refund : "order_settle_id"
%% 台桌域
dim_table ||--o{ dwd_table_fee_log : "table_id"
dwd_table_fee_log ||--o{ dwd_table_fee_adjust : "table_fee_log_id"
%% 助教域(作废判断已内聚到 dwd_assistant_service_log_ex.is_trash
dim_assistant ||--o{ dwd_assistant_service_log : "assistant_id"
%% 会员域
dim_member ||--o{ dim_member_card_account : "member_id"
dim_member ||--o{ dwd_member_balance_change : "member_id"
dim_member_card_account ||--o{ dwd_recharge_order : "tenant_member_card_id"
%% 团购域
dim_groupbuy_package ||--o{ dwd_groupbuy_redemption : "groupbuy_package_id"
%% 商品域
dim_goods_category ||--o{ dim_tenant_goods : "category_id"
dim_tenant_goods ||--o{ dim_store_goods : "tenant_goods_id"
dim_store_goods ||--o{ dwd_store_goods_sale : "site_goods_id"
dim_store_goods ||--o{ dwd_goods_stock_summary : "site_goods_id"
dim_store_goods ||--o{ dwd_goods_stock_movement : "site_goods_id"
%% 门店维度
dim_site ||--o{ dwd_settlement_head : "site_id"
```
### 业务域与表映射
| 业务域 | 事实表 | 维度表 | 核心关联 |
|--------|--------|--------|----------|
| 结算 | `dwd_settlement_head`(+ex), `dwd_payment`, `dwd_refund`(+ex) | — | 结算单是所有消费的汇总入口 |
| 台桌 | `dwd_table_fee_log`(+ex), `dwd_table_fee_adjust`(+ex) | `dim_table`(+ex) | 台费计费流水 → 台费调整 |
| 助教 | `dwd_assistant_service_log`(+ex) | `dim_assistant`(+ex) | 助教服务流水(作废通过 `_ex.is_trash` 判断) |
| 会员 | `dwd_member_balance_change`(+ex), `dwd_recharge_order`(+ex) | `dim_member`(+ex), `dim_member_card_account`(+ex) | 充值 → 余额变动 |
| 团购 | `dwd_groupbuy_redemption`(+ex), `dwd_platform_coupon_redemption`(+ex) | `dim_groupbuy_package`(+ex) | 团购核销 → 平台券核销 |
| 商品 | `dwd_store_goods_sale`(+ex) | `dim_tenant_goods`(+ex), `dim_store_goods`(+ex), `dim_goods_category` | 商品销售流水 |
| 库存 | `dwd_goods_stock_summary`, `dwd_goods_stock_movement` | (复用商品域维度表) | 库存汇总 + 变动流水 |
### 5 份文档的数据依赖关系
```mermaid
flowchart TD
DOC1["文档1: DWD 表结构与字段语义总览<br/>覆盖全部 43 张表"]
DOC1 --> DOC2["文档2: 业务全景<br/>消费产生机制"]
DOC1 --> DOC3["文档3: 账务全景<br/>结算与支付流水"]
DOC1 --> DOC4["文档4: 财务全景<br/>收入确认与对账"]
DOC1 --> DOC5["文档5: 维度表与主数据全景<br/>全部维度表"]
DOC2 -->|消费构成| DOC3
DOC2 -->|消费金额| DOC4
DOC3 -->|支付渠道| DOC4
DOC5 -->|维度关联| DOC2
DOC5 -->|维度关联| DOC3
```
### 文档产出路径与文件名
| 序号 | 文档 | 文件名 | 路径 |
|------|------|--------|------|
| 1 | DWD 表结构与字段语义总览 | `dwd-table-structure-overview.md` | `docs/reports/` |
| 2 | 业务全景:消费产生机制 | `dwd-business-panorama.md` | `docs/reports/` |
| 3 | 账务全景:结算与支付流水 | `dwd-accounting-panorama.md` | `docs/reports/` |
| 4 | 财务全景:收入确认与对账 | `dwd-financial-panorama.md` | `docs/reports/` |
| 5 | 维度表与主数据全景 | `dwd-dimension-panorama.md` | `docs/reports/` |
### 文档间引用规范
- 文档间使用相对路径引用:`[表结构总览](./dwd-table-structure-overview.md#table_name)`
- 引用已有分析报告:`[消费金额口径分析](./consume-money-caliber-deep-analysis.md#章节名)`
- 引用 BD 手册:`[BD 手册](../../apps/etl/connectors/feiqiu/docs/database/DWD/main/BD_manual_xxx.md)`
- 引用结论时标注验证状态和来源文档
## 正确性属性
*属性Property是一种在系统所有合法执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: DWD 表覆盖完整性
*对于* DWD schema`test_etl_feiqiu.dwd`)中 `information_schema.tables` 返回的任意表,产出的文档集合中必须包含对该表的描述段落(表名出现在某份文档的标题或表格中)。
**Validates: Requirements 1.1, 5.1**
### Property 2: 主键标注准确性
*对于* DWD schema 中的任意表,文档中记录的主键字段集合必须与 `information_schema.table_constraints` + `key_column_usage` 查询返回的实际主键约束一致。
**Validates: Requirements 1.5**
### Property 3: 业务环节数据佐证
*对于* 业务全景文档文档2中描述的任意业务环节段落该段落必须包含至少一个来自测试库的数据样例以代码块或表格形式呈现的查询结果
**Validates: Requirements 2.6**
### Property 4: 对账公式验证一致性
*对于* 账务全景文档文档3中列出的任意对账公式文档中标注的成立率和例外数量必须与在 `test_etl_feiqiu` 全量数据上执行该公式验证 SQL 的实际结果一致。
**Validates: Requirements 3.5, 6.2**
### Property 5: 文档元数据完整性
*对于* 产出的任意全景文档,文档开头必须包含:(a) 数据来源标注(`test_etl_feiqiu`)、(b) 验证日期、(c) 数据时间范围(最早和最晚记录的时间)。
**Validates: Requirements 6.4**
### Property 6: 文档输出路径正确性
*对于* 本次梳理产出的任意文档文件,其路径必须位于 `docs/reports/` 目录下。
**Validates: Requirements 7.1**
### Property 7: 文档模板一致性
*对于* 产出的任意全景文档,其结构必须包含以下模板元素:标题、数据来源与验证日期块、目录、正文、附录(含验证 SQL 或数据样例)。
**Validates: Requirements 7.3**
### Property 8: 内部链接格式
*对于* 产出文档中的任意内部链接(指向本项目其他 markdown 文件的链接),链接必须使用相对路径格式(以 `./``../` 开头),且目标文件实际存在。
**Validates: Requirements 7.5**
## 错误处理
本任务是纯文档梳理,不涉及运行时代码。"错误"主要指梳理过程中遇到的数据异常和验证失败。
### 数据异常处理策略
| 异常场景 | 处理方式 | 文档标注 |
|----------|----------|----------|
| 表无数据0 行) | 跳过字段语义验证,仅记录表结构 | `❌ 未验证:表无数据` |
| 数据量不足(<10 行) | 执行有限验证,标注样本量不足 | `⚠️ 部分验证:仅 N 行数据` |
| 字段全 NULL | 标记为空字段,不展开分析 | 附录中列出字段名 |
| 对账公式不成立 | 分析例外案例,量化影响范围 | `⚠️ 成立率 X%,例外 N 笔` |
| 交叉验证矛盾 | 记录矛盾细节,标注为不确定 | `⚠️ 警告:无法对齐` |
| 现有文档与数据不一致 | 以数据为准,记录偏差 | `偏差记录` 段落 |
### 不确定性升级机制
当遇到以下情况时,必须在文档中以 `⚠️ 警告` 醒目标记:
1. 经过 ≥3 次不同角度的交叉验证仍无法确认的字段含义
2. 对账公式成立率 < 95% 且无法归因的例外
3. 金额字段的计算关系无法通过任何已知公式解释
4. 枚举值在现有文档中未记录且无法通过数据推断含义
警告内容须包含:已尝试的验证方法、无法确认的具体原因、建议的后续验证方向。
## 测试策略
### 测试方法说明
本任务的产出物是 markdown 文档而非代码,因此传统的单元测试和属性测试需要适配为"文档正确性验证"。
### 属性测试Property-Based Testing
使用 Python + `hypothesis` 库,针对设计文档中定义的正确性属性编写验证脚本。
**测试库**`hypothesis`(项目已有,见 `tests/` 目录)
**最低迭代次数**100 次(对于涉及随机采样的属性)
**测试位置**`tests/` 目录Monorepo 级属性测试)
#### 属性测试实现方案
| Property | 测试方法 | 实现思路 |
|----------|----------|----------|
| P1: 表覆盖完整性 | 查询 information_schema → 解析文档 → 比对 | 从数据库获取全部 DWD 表名,解析 5 份文档提取提及的表名,验证覆盖率 = 100% |
| P2: 主键标注准确性 | 查询 PK 约束 → 解析文档 → 比对 | 对于随机采样的 N 张表,比对文档中的主键与数据库实际主键 |
| P3: 业务环节数据佐证 | 解析文档段落 → 检查数据样例 | 解析业务全景文档的每个业务环节段落,验证包含代码块或数据表格 |
| P4: 对账公式验证一致性 | 提取公式 → 执行 SQL → 比对成立率 | 对于文档中的每个对账公式,重新执行验证 SQL比对成立率 |
| P5: 文档元数据完整性 | 解析文档头部 → 检查必要字段 | 对于每份文档,检查开头是否包含数据来源、验证日期、时间范围 |
| P6: 文档路径正确性 | 列出产出文件 → 检查路径 | 验证所有产出文件位于 `docs/reports/` |
| P7: 文档模板一致性 | 解析文档结构 → 检查模板元素 | 对于每份文档,检查是否包含标题、元数据块、目录、正文、附录 |
| P8: 内部链接格式 | 正则提取链接 → 检查格式和目标 | 提取所有 markdown 链接,验证使用相对路径且目标文件存在 |
#### 属性测试标签格式
每个属性测试必须包含注释标签:
```python
# Feature: dwd-business-panorama, Property 1: DWD 表覆盖完整性
# Feature: dwd-business-panorama, Property 2: 主键标注准确性
# ...
```
### 单元测试(示例测试)
针对 prework 中标记为 `yes - example` 的验收标准:
| 验收标准 | 测试内容 |
|----------|----------|
| 1.6 SCD2 字段标注 | 检查 `dim_member` 文档中是否标注了 `scd2_start_time`, `scd2_end_time`, `scd2_is_current`, `scd2_version` |
| 2.2 消费类目覆盖 | 检查业务全景文档中是否包含"台费"、"商品消费"、"助教服务"、"灯控电费"四个关键词 |
| 2.5 团购三层价格 | 检查文档中是否提及 `sale_price``pl_coupon_sale_amount``coupon_amount` |
| 2.7 Mermaid 流程图 | 检查业务全景文档中是否包含 ` ```mermaid` 代码块 |
| 3.7 consume_money 三种口径 | 检查文档中是否包含口径 A、B、C 的描述 |
| 4.5 对账矩阵 | 检查财务全景文档中是否包含矩阵格式的表格 |
| 7.2 5 份文档产出 | 检查 `docs/reports/` 下是否存在 5 个指定文件名 |
| 7.4 Mermaid 图表 | 检查每份文档中是否包含至少一个 Mermaid 代码块 |
### 边界条件测试
| 验收标准 | 边界场景 |
|----------|----------|
| 1.7 表无数据 | 验证无数据表在文档中有"数据不足"标注 |
### 测试执行
```bash
# 属性测试Monorepo 级)
cd C:\NeoZQYY && pytest tests/test_dwd_panorama_properties.py -v
# 单元测试
cd C:\NeoZQYY && pytest tests/test_dwd_panorama_examples.py -v
```

View File

@@ -0,0 +1,144 @@
# 需求文档DWD 业务全景梳理
## 简介
从 DWD 层出发,系统梳理飞球 ETL 对台球厅所有业务数据的记录方式。不仅覆盖每个字段的含义,还要搞清楚字段间的关联性,最终产出覆盖业务全景、账务全景、财务全景三个维度的分析文档。
现有的 `consume-money-caliber-deep-analysis.md``consumption-cases-analysis.md` 已深入分析了结算/支付相关的 DWD 表,但仅从支付订单金额角度无法搞清楚整个球房的业务、财务、账务全貌。本 SPEC 旨在补全剩余的业务域(台桌、助教、会员、团购、商品、库存),并将所有域串联成三个全景视图。
## ⚠️ 核心准则:数据本源优先(强制)
**启动本 SPEC 的根本原因**:项目中现有的所有文档(包括 `consume-money-caliber-deep-analysis.md``consumption-cases-analysis.md`、BD 手册、ETL 代码注释等)都可能存在纰漏、过期、遗漏或与数据库现实不符的情况。因此本次梳理必须遵循以下强制准则:
### 信息可信度分层
| 层级 | 可信度 | 说明 | 使用方式 |
|------|--------|------|----------|
| 宏观/直观层 | ✅ 可参考 | 表的业务归属、业务域划分、主要关联方向、流程大框架等直观明显的信息 | 直接参考作为起点,无需逐一验证 |
| 字段级/数据关联层 | ⚠️ 必须验证 | 字段语义、金额计算规则、枚举值含义、跨表关联关系等细节 | 慎之又慎,必须通过测试库数据关联做推理验证,不可直接采信 |
### 强制准则
1. **数据库是唯一真相源(字段级)**:字段级别的结论必须以测试库(`test_etl_feiqiu`)的实际表结构和数据为准。表结构信息必须通过查询 `information_schema``pg_catalog` 获取,禁止参考 `db/` 目录下的 DDL `.sql` 文件。宏观层面的信息(表的职责、业务域归属、流程大框架)可参考现有文档作为起点
2. **先查后写,禁止臆断**:描述任何字段语义、业务规则、金额关系之前,必须先通过 SQL 查询验证实际数据分布和值域。未经查询验证的结论禁止写入文档
3. **刨根问底,通过数据关联做推理**:对每个金额字段、状态枚举、关联关系,不能停留在"看起来是什么",必须通过交叉查询、边界案例、异常值分析确认其真实业务含义。从含义明确的字段出发,通过数据关联倒推不确定的字段
4. **现有文档作为假设而非事实**:引用现有文档的字段级结论时,必须标注为"待验证假设",并在验证后标注实际结果(一致/偏差/错误)
5. **偏差必须显式记录**:当验证发现现有文档与数据库现实不一致时,必须在新文档中明确记录偏差内容、偏差原因(如果能推断)、以及修正后的正确描述
6. **无数据不下结论**:如果测试库中某张表无数据或数据量不足以支撑结论,必须明确标注"数据不足,无法验证",禁止基于推测填充内容
7. **不确定性必须显式警告**:对于以下情况,必须在文档中以醒目的 `⚠️ 警告` 标记:(a) 经过长时间推理和多次交叉验证仍无法对齐的数据关系;(b) 根据现有数据和依据无法确认的字段含义或业务规则。警告内容须说明:已尝试的验证方法、无法确认的具体原因、建议的后续验证方向
## 术语表
- **DWD_层**Data Warehouse Detail 层ETL 管道中的明细数据层,存储经过清洗和标准化的业务事实和维度数据
- **全景文档**:按特定视角(业务/账务/财务)组织的、覆盖所有相关 DWD 表及其字段关联的分析文档
- **梳理器**:执行本 SPEC 任务的分析人员或脚本,负责读取 DWD 表结构和数据并产出文档
- **测试库**PostgreSQL 数据库 `test_etl_feiqiu`,包含 DWD 层的实际数据,用于验证文档描述的准确性
- **业务域**DWD 表按业务功能的分组,包括:结算、台桌、助教、会员、团购、商品、库存
- **主表/扩展表**DWD 层的表设计模式,主表存核心字段,`_ex` 扩展表存补充字段,通过主键 1:1 关联
- **维度表**:以 `dim_` 前缀命名的 DWD 表存储缓慢变化的主数据SCD2 模式)
- **事实表**:以 `dwd_` 前缀命名的 DWD 表,存储业务事件流水(增量写入)
- **字段语义**:字段在实际业务中的真实含义,可能与 DDL 注释不一致(如 `point_amount` 实际是线上收款而非积分)
- **对账公式**:用于校验数据一致性的等式关系,如 F1消费构成、F2收支平衡
## 需求
### 需求 1DWD 表结构与字段语义梳理(智能聚焦策略)
**用户故事:** 作为数据分析人员,我希望获得每张 DWD 表中有业务意义的字段的准确语义说明,以便理解数据如何记录业务事实。
**梳理策略说明:** `apps/etl/connectors/feiqiu/docs/database/DWD/` 下已有各表的文档,宏观层面(表的职责、业务域归属、主要关联关系)可作为参考起点。但字段级别的语义必须通过数据关联倒推验证,不可直接采信。梳理时采用"智能聚焦"策略,而非逐字段全量罗列。
#### 验收标准
1. THE 梳理器 SHALL 覆盖 DWD 层全部 7 个业务域(结算、台桌、助教、会员、团购、商品、库存)的所有主表和扩展表,以现有 DWD 文档为宏观参考起点
2. THE 梳理器 SHALL 对每张表先执行字段分类筛选:(a) 查询全表空字段(全部为 NULL 的列),标记为"空字段-跳过"(b) 识别含义明确的基础字段(如 `id``site_id``created_at`),简要标注即可;(c) 聚焦于业务关键字段(金额、状态、类型、关联 ID进行深度验证
3. WHEN 梳理业务关键字段时THE 梳理器 SHALL 采用"倒推法"先从含义明确的字段出发通过数据关联JOIN、聚合对比、值域交叉推断不确定字段的真实含义
4. WHEN 发现字段的实际业务含义与现有 DWD 文档或 DDL 注释不一致时THE 梳理器 SHALL 明确标注偏差内容并给出基于测试库数据验证的修正说明
5. THE 梳理器 SHALL 标注每张表的主键、外键关联、以及与其他 DWD 表的关联方式(关联字段和关联类型如 1:1、1:N
6. THE 梳理器 SHALL 标注每张维度表的 SCD2 生效/失效字段和当前记录标识字段
7. IF 测试库中某张表无数据或数据量不足以验证字段语义THEN THE 梳理器 SHALL 在文档中标注该表的数据状态并说明无法验证的字段
8. THE 梳理器 SHALL 主动忽略以下字段类别,不在文档中详细展开:(a) 全表 NULL 的空字段(仅在附录中列出字段名);(b) ETL 管理字段(如 `_etl_loaded_at``_etl_batch_id`),仅简要说明用途;(c) 含义完全透明且无歧义的字段(如 `id``created_at``updated_at`),仅在表结构概览中列出
### 需求 2业务全景文档——消费是怎么产生的
**用户故事:** 作为门店经营者,我希望搞清楚台球厅的消费是怎么来的、价格怎么报的、优惠怎么产生的,以便理解整个消费链路。
#### 验收标准
1. THE 全景文档 SHALL 描述从顾客开台到结算的完整业务流程,标注每个环节涉及的 DWD 表和关键字段
2. THE 全景文档 SHALL 覆盖以下消费类目的产生机制:台费(含多台桌合并)、商品消费、助教服务(陪打/超休)、灯控电费
3. THE 全景文档 SHALL 描述台费的计价规则,包括:台桌类型与单价的关系、计时方式、台费折扣(`dwd_table_fee_adjust`)的触发条件和计算方式
4. THE 全景文档 SHALL 描述优惠的产生机制,包括:平台团购券(美团/抖音)的核销流程、会员折扣的计算方式、台费调整(`adjust_amount`)的业务场景
5. THE 全景文档 SHALL 描述团购券的三层价格体系(顾客支付给平台的 `sale_price`、平台结算给门店的 `pl_coupon_sale_amount`、门店实际抵扣的 `coupon_amount`),并标注每层价格对应的 DWD 表和字段
6. WHEN 描述某个业务环节时THE 全景文档 SHALL 提供至少一个来自测试库的真实数据样例作为佐证
7. THE 全景文档 SHALL 以 Mermaid 流程图展示从开台到结算的完整数据流向
### 需求 3账务全景文档——客户怎么结算的
**用户故事:** 作为门店财务人员,我希望搞清楚客户的每一笔消费是通过什么方式结算的、支付流水怎么记录的,以便进行对账和核销。
#### 验收标准
1. THE 全景文档 SHALL 描述所有支付渠道及其在 DWD 层的记录方式,包括:线上收款(微信/支付宝)、现金、储值卡余额(含礼品卡/充值卡)、平台团购券
2. THE 全景文档 SHALL 描述 `dwd_payment` 表与 `dwd_settlement_head` 的关联方式,以及 `payment_method` 枚举值与实际支付渠道的对应关系
3. THE 全景文档 SHALL 描述会员储值卡体系,包括:充值流程(`dwd_recharge_order`)、余额变动记录(`dwd_member_balance_change`)、本金/赠送金额的分账逻辑
4. THE 全景文档 SHALL 描述退款流程,包括:结算退款、充值退款、转账退款的触发场景和在 DWD 层的记录方式
5. THE 全景文档 SHALL 列出所有已验证的对账公式F1~F6、R1~R3、RF1~RF2、B1~B4标注每个公式的适用范围、成立率、以及已知的例外情况
6. WHEN 描述支付方式推断逻辑时THE 全景文档 SHALL 提供完整的推断规则(因 `settlement_head_ex.payment_method` 全部为 0 不可用)
7. THE 全景文档 SHALL 描述 `consume_money` 字段的三种历史口径A/B/C及其时间线标注当前生效的口径
### 需求 4财务全景文档——收入确认与对账
**用户故事:** 作为门店管理者,我希望搞清楚门店的收入如何确认、各渠道的资金如何对账,以便进行财务分析和经营决策。
#### 验收标准
1. THE 全景文档 SHALL 描述门店收入的构成,按收入来源分类:台费收入、商品收入、助教服务收入、充值收入
2. THE 全景文档 SHALL 描述每种收入来源对应的 DWD 表、关键金额字段、以及从 DWD 到 DWS 的聚合路径
3. THE 全景文档 SHALL 描述平台团购券场景下的收入确认逻辑:门店实际收入 = `pl_coupon_sale_amount`(平台结算额),而非 `coupon_amount`(券面值抵扣额),差额为门店补贴
4. THE 全景文档 SHALL 描述储值卡充值场景的资金流向:充值收款 → 余额入账(本金+赠送)→ 消费扣款 → 退款(如有),标注每个环节的 DWD 表和金额字段
5. THE 全景文档 SHALL 提供按支付渠道的对账矩阵,列出每种支付渠道在 DWD 层涉及的表和字段,以及跨表校验的公式
6. THE 全景文档 SHALL 标注已知的数据质量问题和对账例外(如助教券的支付缺口、商品消费未覆盖等),并给出影响范围的量化评估
### 需求 5维度表与主数据全景
**用户故事:** 作为数据开发人员,我希望搞清楚 DWD 层所有维度表的结构和业务含义,以便在 DWS 聚合时正确关联维度。
#### 验收标准
1. THE 全景文档 SHALL 覆盖所有 DWD 维度表(`dim_site``dim_table``dim_assistant``dim_member``dim_member_card_account``dim_tenant_goods``dim_store_goods``dim_goods_category``dim_groupbuy_package`),包括主表和扩展表
2. THE 全景文档 SHALL 描述每张维度表与事实表的关联方式,标注关联字段和关联基数
3. THE 全景文档 SHALL 描述会员体系的数据结构,包括:会员档案(`dim_member`)、储值卡账户(`dim_member_card_account`)、会员等级/标签的记录方式
4. THE 全景文档 SHALL 描述商品体系的数据结构,包括:商品分类树(`dim_goods_category`)、租户商品(`dim_tenant_goods`)与门店商品(`dim_store_goods`)的关系、库存相关表(`dwd_goods_stock_summary``dwd_goods_stock_movement`)的结构
5. THE 全景文档 SHALL 描述团购套餐维度(`dim_groupbuy_package`)的结构,包括:套餐与券种的关系、价格体系(面值/售价/门店结算价)
### 需求 6数据验证与文档可信度保障
**用户故事:** 作为数据分析人员,我希望文档中的每个结论都经过测试库数据验证,以避免使用过期或错误的信息。
#### 验收标准
1. WHEN 梳理任何 DWD 表的字段语义时THE 梳理器 SHALL 通过查询测试库(`test_etl_feiqiu`)验证字段的实际值分布,确认语义描述的准确性。禁止仅凭 DDL 注释或现有文档描述字段含义
2. WHEN 文档引用对账公式时THE 梳理器 SHALL 提供该公式在测试库全量数据上的验证结果(成立率、例外数量和分类)
3. IF 验证过程中发现现有文档(`consume-money-caliber-deep-analysis.md``consumption-cases-analysis.md`、BD 手册、ETL 代码注释等的结论与测试库数据不一致THEN THE 梳理器 SHALL 在新文档中明确标注修正内容,包括:原文档的描述、实际数据的表现、偏差原因分析
4. THE 梳理器 SHALL 在每份全景文档的开头标注数据验证日期和测试库数据的时间范围
5. THE 全景文档 SHALL 对每个关键结论标注验证状态:✅ 已验证(附验证 SQL 或结果摘要)、⚠️ 部分验证(附已知例外)、❌ 未验证(附原因)
6. THE 梳理器 SHALL 对每个金额字段执行以下深度验证流程:(a) 查询值域分布MIN/MAX/AVG/中位数/NULL 占比);(b) 与关联字段交叉验证(如 `total_amount` 是否等于各子项之和);(c) 检查边界案例(零值、负值、极端值)的业务含义
7. WHEN 引用现有文档的任何结论时THE 梳理器 SHALL 将其标注为"待验证假设",并在验证后更新为实际结果(一致 ✅ / 偏差 ⚠️ / 错误 ❌),附偏差说明
### 需求 7文档产出与组织
**用户故事:** 作为项目成员,我希望全景文档按统一格式组织并落在正确的路径下,以便团队查阅和后续维护。
#### 验收标准
1. THE 梳理器 SHALL 将所有全景文档输出到 `docs/reports/` 目录下
2. THE 梳理器 SHALL 产出以下文档(文件名待确认):
- DWD 表结构与字段语义总览
- 业务全景:消费产生机制
- 账务全景:结算与支付流水
- 财务全景:收入确认与对账
- 维度表与主数据全景
3. THE 全景文档 SHALL 使用统一的文档模板,包含:标题、数据来源与验证日期、目录、正文、附录(验证 SQL、数据样例
4. THE 全景文档 SHALL 在适当位置使用 Mermaid 图表ER 图、流程图、时序图)辅助说明数据关系和业务流程
5. WHEN 全景文档引用其他文档的结论时THE 梳理器 SHALL 使用相对路径链接到源文档,并标注引用的具体章节

View File

@@ -0,0 +1,277 @@
# 实施计划DWD 业务全景梳理
## 概述
将 DWD 业务全景梳理的设计方案转化为可执行的任务序列。任务按两阶段组织基础层文档1表结构总览→ 全景层文档2~5数据验证贯穿全程。属性测试使用 Python + hypothesis放置在 `tests/` 目录。
## Tasks
- [x] 1. 阶段一DWD 表结构与字段语义总览文档1
- [x] 1.1 枚举 DWD 全部表并按业务域分组
- 查询 `test_etl_feiqiu.dwd``information_schema.tables` 获取全部表名
- 按 7 个业务域(结算、台桌、助教、会员、团购、商品、库存)分组
- 查询每张表的行数和时间范围,确认数据状态
- 创建 `docs/reports/dwd-table-structure-overview.md` 文件骨架(含模板元素:标题、元数据块、目录、正文、附录)
- _Requirements: 1.1, 7.1, 7.2, 7.3_
- [x] 1.2 结算域表梳理dwd_settlement_head/ex, dwd_payment, dwd_refund/ex
- 对每张表执行单表智能聚焦分析information_schema 获取列信息 → 字段分类筛选(空字段/ETL字段/透明字段/业务关键字段)→ 业务关键字段倒推验证
- 金额字段执行深度验证值域分布MIN/MAX/AVG/中位数/NULL占比+ 交叉验证
- 枚举字段执行 DISTINCT + 频次分布
- 对比现有 BD 手册标注偏差
- 将结果写入文档1的结算域章节
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.8, 6.1, 6.6_
- [x] 1.3 台桌域表梳理dim_table/ex, dwd_table_fee_log/ex, dwd_table_fee_adjust/ex
- 同 1.2 的分析流程
- 重点验证台费计价相关字段、台费调整的触发条件
- 标注 dim_table 的 SCD2 字段
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.4 助教域表梳理dim_assistant/ex, dwd_assistant_service_log/ex
- 同 1.2 的分析流程
- 重点验证助教服务类型枚举、`_ex.is_trash` 作废标记的业务含义
- 标注 dim_assistant 的 SCD2 字段
- 注意:`dwd_assistant_trash_event` 已于 2026-02-22 DROP作废判断改用 `dwd_assistant_service_log_ex.is_trash`
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.5 会员域表梳理dim_member/ex, dim_member_card_account/ex, dwd_member_balance_change/ex, dwd_recharge_order/ex
- 同 1.2 的分析流程
- 重点验证储值卡本金/赠送金额分账、余额变动类型枚举
- 标注 dim_member 和 dim_member_card_account 的 SCD2 字段
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.6 团购域表梳理dim_groupbuy_package/ex, dwd_groupbuy_redemption/ex, dwd_platform_coupon_redemption/ex
- 同 1.2 的分析流程
- 重点验证团购三层价格体系sale_price / pl_coupon_sale_amount / coupon_amount
- 标注 dim_groupbuy_package 的 SCD2 字段
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.8, 6.1, 6.6_
- [x] 1.7 商品域与库存域表梳理dim_tenant_goods/ex, dim_store_goods/ex, dim_goods_category, dwd_store_goods_sale/ex, dwd_goods_stock_summary, dwd_goods_stock_movement
- 同 1.2 的分析流程
- 重点验证商品分类树结构、租户商品与门店商品的关系
- 标注维度表的 SCD2 字段dim_goods_category 无扩展表)
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 6.1, 6.6_
- [x] 1.8 完善文档1跨表关联关系汇总与文档收尾
- 汇总所有表的主键、外键关联、关联类型1:1, 1:N
- 补充 Mermaid ER 图展示跨域关联
- 补充附录:空字段汇总、验证 SQL 汇总
- 添加文档间引用链接(相对路径格式)
- 确认文档模板完整性(标题、元数据块、目录、正文、附录)
- _Requirements: 1.5, 6.4, 7.3, 7.4, 7.5_
- [x] 2. 检查点 - 文档1 完成确认
- 确认文档1`docs/reports/dwd-table-structure-overview.md`)已覆盖全部 42 张表(含新发现的 dim_staff/dim_staff_ex
- 确认每张表的业务关键字段均已通过测试库数据验证
- 确认偏差记录完整point_amount 等偏差已标注)
- 第9章门店维度有占位符待后续补充不阻塞全景文档
- [x] 3. 阶段二A业务全景文档文档2
- [x] 3.1 创建文档2骨架并梳理消费产生链路
- 创建 `docs/reports/dwd-business-panorama.md`,使用全景文档通用模板
- 描述从开台到结算的完整业务流程,标注每个环节涉及的 DWD 表和关键字段
- 以 Mermaid 流程图展示从开台到结算的完整数据流向
- 基于文档1的字段语义引用而非重复描述
- _Requirements: 2.1, 2.7, 6.4, 7.3_
- [x] 3.2 梳理各消费类目的产生机制
- 台费(含多台桌合并):计价规则、台桌类型与单价关系、计时方式
- 台费折扣dwd_table_fee_adjust触发条件和计算方式
- 商品消费:商品销售流水的记录方式
- 助教服务(陪打/超休):服务类型和计费方式
- 灯控电费:记录方式
- 每个环节提供至少一个测试库真实数据样例
- _Requirements: 2.2, 2.3, 2.6_
- [x] 3.3 梳理优惠与团购机制
- 平台团购券(美团/抖音)核销流程
- 会员折扣计算方式
- 台费调整adjust_amount的业务场景
- 团购券三层价格体系sale_price / pl_coupon_sale_amount / coupon_amount标注每层价格对应的 DWD 表和字段
- 每个环节提供测试库数据样例
- _Requirements: 2.4, 2.5, 2.6_
- [x] 3.4 文档2收尾附录与引用
- 补充附录(验证 SQL、数据样例
- 添加文档间引用链接
- 确认文档模板完整性
- _Requirements: 7.3, 7.4, 7.5_
- [x] 4. 阶段二B账务全景文档文档3
- [x] 4.1 创建文档3骨架并梳理支付渠道
- 创建 `docs/reports/dwd-accounting-panorama.md`,使用全景文档通用模板
- 描述所有支付渠道及其在 DWD 层的记录方式(线上收款、现金、储值卡余额、平台团购券)
- 描述 dwd_payment 与 dwd_settlement_head 的关联方式
- 描述 payment_method 枚举值与实际支付渠道的对应关系
- 描述支付方式推断逻辑(因 settlement_head_ex.payment_method 全部为 0 不可用)
- _Requirements: 3.1, 3.2, 3.6, 6.4, 7.3_
- [x] 4.2 梳理会员储值卡体系与退款流程
- 充值流程dwd_recharge_order
- 余额变动记录dwd_member_balance_change
- 本金/赠送金额分账逻辑
- 退款流程:结算退款、充值退款、转账退款的触发场景和 DWD 记录方式
- _Requirements: 3.3, 3.4_
- [x] 4.3 梳理对账公式与 consume_money 口径
- 列出所有已验证的对账公式F1~F6、R1~R3、RF1~RF2、B1~B4
- 对每个公式在 test_etl_feiqiu 全量数据上执行验证,标注成立率和例外情况
- 描述 consume_money 字段的三种历史口径A/B/C及时间线标注当前生效口径
- _Requirements: 3.5, 3.7, 6.2_
- [x] 4.4 文档3收尾附录与引用
- 补充附录(验证 SQL、对账公式验证结果
- 添加文档间引用链接
- 确认文档模板完整性
- _Requirements: 7.3, 7.4, 7.5_
- [x] 5. 检查点 - 文档2和文档3完成确认
- 确认文档2覆盖全部消费类目和优惠机制台费/商品/助教/灯控/团购券/会员折扣)
- 确认文档3覆盖全部支付渠道和对账公式F1/F2/B1-B3/consume_money三种口径
- 确认所有关键结论标注了验证状态
- 确认文档3覆盖全部支付渠道和对账公式
- 确认所有关键结论标注了验证状态
- Ensure all tests pass, ask the user if questions arise.
- [x] 6. 阶段二C财务全景文档文档4
- [x] 6.1 创建文档4骨架并梳理收入构成
- 创建 `docs/reports/dwd-financial-panorama.md`,使用全景文档通用模板
- 按收入来源分类描述门店收入构成:台费收入、商品收入、助教服务收入、充值收入
- 描述每种收入来源对应的 DWD 表、关键金额字段、从 DWD 到 DWS 的聚合路径
- _Requirements: 4.1, 4.2, 6.4, 7.3_
- [x] 6.2 梳理团购收入确认与储值卡资金流向
- 团购券场景收入确认逻辑:门店实际收入 = pl_coupon_sale_amount差额为门店补贴
- 储值卡充值资金流向:充值收款 → 余额入账(本金+赠送)→ 消费扣款 → 退款
- 标注每个环节的 DWD 表和金额字段
- _Requirements: 4.3, 4.4_
- [x] 6.3 构建对账矩阵与数据质量评估
- 按支付渠道构建对账矩阵:每种支付渠道涉及的 DWD 表和字段、跨表校验公式
- 标注已知数据质量问题和对账例外(助教券支付缺口、商品消费未覆盖等)
- 给出影响范围的量化评估
- _Requirements: 4.5, 4.6_
- [x] 6.4 文档4收尾附录与引用
- 补充附录(验证 SQL、对账矩阵详细数据
- 添加文档间引用链接引用文档2的消费构成、文档3的支付渠道
- 确认文档模板完整性
- _Requirements: 7.3, 7.4, 7.5_
- [x] 7. 阶段二D维度表与主数据全景文档5
- [x] 7.1 创建文档5骨架并梳理门店与台桌维度
- 创建 `docs/reports/dwd-dimension-panorama.md`,使用全景文档通用模板
- 梳理 dim_site/ex门店维度结构、SCD2 字段、与事实表的关联
- 梳理 dim_table/ex台桌维度结构、台桌类型枚举、与台费流水的关联
- _Requirements: 5.1, 5.2, 6.4, 7.3_
- [x] 7.2 梳理会员体系与助教维度
- 会员档案dim_member/ex会员等级/标签记录方式
- 储值卡账户dim_member_card_account/ex账户类型、余额字段
- 助教维度dim_assistant/ex助教类型、服务能力
- 描述维度表与事实表的关联方式,标注关联字段和基数
- _Requirements: 5.2, 5.3_
- [x] 7.3 梳理商品体系与团购维度
- 商品分类树dim_goods_category分类层级结构
- 租户商品dim_tenant_goods/ex与门店商品dim_store_goods/ex的关系
- 库存相关表dwd_goods_stock_summary, dwd_goods_stock_movement的结构
- 团购套餐维度dim_groupbuy_package/ex套餐与券种关系、价格体系
- _Requirements: 5.4, 5.5_
- [x] 7.4 文档5收尾Mermaid ER 图与附录
- 补充 Mermaid ER 图展示全部维度表与事实表的关联关系
- 补充附录(验证 SQL、SCD2 字段汇总)
- 添加文档间引用链接
- 确认文档模板完整性
- _Requirements: 5.1, 5.2, 7.3, 7.4, 7.5_
- [x] 8. 检查点 - 全部5份文档完成确认
- 确认 5 份文档均已创建在 `docs/reports/` 目录下
- 确认文档间引用链接格式正确(相对路径)且目标文件存在
- 确认每份文档包含完整模板元素(标题、元数据块、目录、正文、附录)
- Ensure all tests pass, ask the user if questions arise.
- [x] 9. 属性测试与示例测试
- [x] 9.1 创建属性测试文件骨架与公共工具
- 创建 `tests/test_dwd_panorama_properties.py`
- 实现公共工具函数:读取文档内容、解析 markdown 结构、提取表名、提取链接
- 配置 hypothesis settingsmin_examples=100
- 使用 `load_dotenv` 加载根 `.env`,通过 `TEST_DB_DSN` 连接测试库
- _Requirements: 6.1_
- [x] 9.2 编写 Property 1: DWD 表覆盖完整性
- **Property 1: DWD 表覆盖完整性**
- 查询 information_schema.tables 获取 DWD schema 全部表名
- 解析 5 份文档提取提及的表名
- 验证覆盖率 = 100%
- **Validates: Requirements 1.1, 5.1**
- [x] 9.3 编写 Property 2: 主键标注准确性
- **Property 2: 主键标注准确性**
- 对随机采样的表,查询 information_schema.table_constraints + key_column_usage 获取实际主键
- 解析文档1中该表的主键标注
- 验证一致性
- **Validates: Requirements 1.5**
- [x] 9.4 编写 Property 3: 业务环节数据佐证
- **Property 3: 业务环节数据佐证**
- 解析业务全景文档文档2的每个业务环节段落
- 验证每个段落包含至少一个代码块或数据表格
- **Validates: Requirements 2.6**
- [x] 9.5 编写 Property 4: 对账公式验证一致性
- **Property 4: 对账公式验证一致性**
- 提取账务全景文档文档3中的对账公式和标注的成立率
- 重新执行验证 SQL比对成立率
- **Validates: Requirements 3.5, 6.2**
- [x] 9.6 编写 Property 5: 文档元数据完整性
- **Property 5: 文档元数据完整性**
- 对每份全景文档检查开头是否包含数据来源test_etl_feiqiu、验证日期、数据时间范围
- **Validates: Requirements 6.4**
- [x] 9.7 编写 Property 6: 文档输出路径正确性
- **Property 6: 文档输出路径正确性**
- 验证所有产出文件位于 `docs/reports/` 目录下
- **Validates: Requirements 7.1**
- [x] 9.8 编写 Property 7: 文档模板一致性
- **Property 7: 文档模板一致性**
- 对每份文档,检查是否包含标题、元数据块、目录、正文、附录
- **Validates: Requirements 7.3**
- [x] 9.9 编写 Property 8: 内部链接格式
- **Property 8: 内部链接格式**
- 正则提取所有 markdown 内部链接
- 验证使用相对路径格式(以 `./``../` 开头)且目标文件存在
- **Validates: Requirements 7.5**
- [x] 9.10 编写示例测试
- 创建 `tests/test_dwd_panorama_examples.py`
- SCD2 字段标注检查dim_member 文档中包含 scd2_start_time 等字段
- 消费类目覆盖检查:业务全景文档包含"台费"、"商品消费"、"助教服务"、"灯控电费"
- 团购三层价格检查:文档包含 sale_price、pl_coupon_sale_amount、coupon_amount
- Mermaid 流程图检查:业务全景文档包含 mermaid 代码块
- consume_money 三种口径检查:文档包含口径 A、B、C
- 对账矩阵检查:财务全景文档包含矩阵格式表格
- 5 份文档产出检查docs/reports/ 下存在 5 个指定文件名
- 每份文档 Mermaid 图表检查
- 无数据表标注检查(边界条件)
- _Requirements: 1.6, 1.7, 2.2, 2.5, 2.7, 3.5, 3.7, 4.5, 7.2, 7.4_
- [x] 10. 最终检查点 - 全部完成确认
- 运行全部属性测试:`cd C:\NeoZQYY && pytest tests/test_dwd_panorama_properties.py -v`
- 运行全部示例测试:`cd C:\NeoZQYY && pytest tests/test_dwd_panorama_examples.py -v`
- 确认 5 份文档内容完整、验证状态标注齐全
- Ensure all tests pass, ask the user if questions arise.
## Notes
- 标记 `*` 的任务为可选,可跳过以加速 MVP
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点任务确保增量验证
- 属性测试验证文档的结构正确性,示例测试验证具体内容覆盖
- 所有数据库查询必须使用测试库 `test_etl_feiqiu`,通过 `TEST_DB_DSN` 连接
- 文档1是其他4份文档的基础必须先完成阶段一再进入阶段二

View File

@@ -1,464 +0,0 @@
# 设计文档
## 概述
本设计覆盖 v8 联调中 4 个"临时止血"修复的深度方案,按优先级排列:
1. **需求 DP0**`DwdLoadTask.load()` 返回值格式规范化
2. **需求 C1P1**:会员生日字段 ETL 链路补齐
3. **需求 BP1**:多门店会员查询支持
4. **需求 AP2**:助教月度聚合按档位分段统计
5. **需求 C2P2**:助教手动补录会员生日
设计原则:
- 每个需求独立可部署,按优先级逐步实施
- DDL 变更通过迁移脚本执行,支持回滚
- 保持现有 ETL 架构BaseTask E/T/L 模板)不变
## 架构
整体架构不变,变更集中在以下层面:
```mermaid
graph TD
subgraph "需求 D: 返回值规范化"
D1[DwdLoadTask.load] -->|errors: int| D2[BaseTask._accumulate_counts]
D2 -->|sum| D3[FlowRunner._safe_int]
end
subgraph "需求 A: 档位分段统计"
A1[dws_assistant_daily_detail] -->|GROUP BY level_code| A2[AssistantMonthlyTask]
A2 -->|多行/档位| A3[dws_assistant_monthly_summary]
A3 --> A4[AssistantSalaryTask 分段计算]
end
subgraph "需求 B: 多门店会员查询"
B1[dwd.事实表] -->|member_id IN| B2[dim_member]
B2 --> B3[DWS 任务]
end
subgraph "需求 C: 生日字段"
C1[ODS payload] -->|birthday 提取| C2[dim_member.birthday]
C3[后端 API] -->|UPSERT| C4[zqyy_app.member_birthday_manual]
C2 --> C5[DWS 任务: COALESCE]
C4 -->|FDW 只读| C5
end
```
## 组件与接口
### 需求 D返回值格式规范化
**变更文件:**
- `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
- `apps/etl/connectors/feiqiu/tasks/base_task.py`
**DwdLoadTask.load() 返回值变更:**
```python
# 变更前
return {"tables": summary, "errors": errors}
# errors: list[dict],如 [{"table": "dim_assistant_ex", "error": "..."}]
# 变更后
return {
"tables": summary,
"errors": len(errors), # int — 与其他任务一致
"error_details": errors, # list[dict] — 保留详情供日志使用
}
```
**BaseTask._accumulate_counts() 防御层增强:**
```python
@staticmethod
def _accumulate_counts(total: dict, current: dict) -> dict:
for key, value in (current or {}).items():
if isinstance(value, (int, float)):
total[key] = (total.get(key) or 0) + value
elif isinstance(value, list):
# 防御层list 类型转为 len() 累加
total[key] = (total.get(key) or 0) + len(value)
else:
total.setdefault(key, value)
return total
```
**FlowRunner._safe_int() 保留不变**,作为最终防御层。
### 需求 A助教月度聚合按档位分段统计
**DDL 变更:**
```sql
-- 迁移脚本:删除旧唯一约束,创建新约束
ALTER TABLE dws.dws_assistant_monthly_summary
DROP CONSTRAINT IF EXISTS uk_dws_assistant_monthly;
ALTER TABLE dws.dws_assistant_monthly_summary
ADD CONSTRAINT uk_dws_assistant_monthly
UNIQUE (site_id, assistant_id, stat_month, assistant_level_code);
```
**AssistantMonthlyTask._extract_daily_aggregates() 变更:**
```sql
-- 变更前GROUP BY assistant_id, DATE_TRUNC('month', stat_date)
-- 变更后:加入 assistant_level_code 分组
SELECT
assistant_id,
assistant_level_code,
assistant_level_name,
-- nickname 取时间最后一条
(ARRAY_AGG(assistant_nickname ORDER BY stat_date DESC))[1] AS assistant_nickname,
DATE_TRUNC('month', stat_date)::DATE AS stat_month,
COUNT(DISTINCT stat_date) AS work_days,
SUM(total_service_count) AS total_service_count,
-- ... 其余聚合字段不变
FROM dws.dws_assistant_daily_detail
WHERE site_id = %s AND ({month_where})
GROUP BY assistant_id, assistant_level_code, assistant_level_name,
DATE_TRUNC('month', stat_date)
```
**AssistantSalaryTask 适配:**
- `_extract_monthly_summary()` 返回多行(同一助教不同档位)
- `transform()` 遍历每行分别计算工资,按档位使用对应的 `level_price``tier`
- 最终每个 `(assistant_id, stat_month, assistant_level_code)` 生成一条工资记录
**AssistantFinanceTask._extract_daily_revenue() nickname 修复:**
```sql
-- 变更前MAX(s.nickname) AS assistant_nickname
-- 变更后:
(ARRAY_AGG(s.nickname ORDER BY s.start_use_time DESC))[1] AS assistant_nickname
```
**AssistantCustomerTask._extract_service_pairs() nickname 修复:**
```sql
-- 变更前MAX(assistant_nickname) AS assistant_nickname
-- 变更后:
(ARRAY_AGG(assistant_nickname ORDER BY service_date DESC))[1] AS assistant_nickname
```
### 需求 B多门店会员查询支持
**变更模式:** 所有 `_extract_member_info(site_id)` 方法的 SQL 从:
```sql
WHERE register_site_id = %s AND scd2_is_current = 1
```
改为通过事实表反查:
```sql
WHERE member_id IN (
SELECT DISTINCT tenant_member_id
FROM dwd.{}
WHERE site_id = %s AND tenant_member_id IS NOT NULL AND tenant_member_id != 0
) AND scd2_is_current = 1
```
**受影响的任务和对应事实表:**
| 任务 | 方法 | 事实表 |
|------|------|--------|
| `member_visit_task.py` | `_extract_member_info` | `dwd_settlement_head` |
| `member_consumption_task.py` | `_extract_member_info` | `dwd_settlement_head` |
| `assistant_customer_task.py` | `_extract_member_info` | `dwd_assistant_service_log` |
**`dim_member_card_account` 的处理:**
- `member_consumption_task.py``finance_recharge_task.py` 中对 `dim_member_card_account` 的查询也使用 `register_site_id`
- 同样改为通过事实表的 `tenant_member_id` 反查:
```sql
WHERE tenant_member_id IN (
SELECT DISTINCT tenant_member_id
FROM dwd.{}
WHERE site_id = %s AND tenant_member_id IS NOT NULL AND tenant_member_id != 0
) AND scd2_is_current = 1
```
### 需求 C1会员生日字段 ETL 链路补齐
**DDL 变更:**
```sql
-- dim_member 加列
ALTER TABLE dwd.dim_member ADD COLUMN IF NOT EXISTS birthday DATE;
COMMENT ON COLUMN dwd.dim_member.birthday IS '会员生日来源ODS member_profiles payload 中的 birthday 字段';
```
**ODS → DWD 装载:**
- `DwdLoadTask` 的列映射是自动的(通过 `_get_columns()` 读取 DWD 表列名,与 ODS 列名匹配)
- ODS `member_profiles` 表没有 `birthday` 列,但 `payload` JSONB 中可能包含
- 需要在 `_build_column_mapping()``_fetch_source_rows()` 中增加从 `payload` 提取 `birthday` 的逻辑
- 方案:在 ODS 表也加 `birthday` 列(保持 ODS 与 API 字段对齐ODS 入库时从 JSON 提取
```sql
-- ODS member_profiles 加列
ALTER TABLE ods.member_profiles ADD COLUMN IF NOT EXISTS birthday DATE;
```
ODS 入库逻辑(`ods_tasks.py`)已有从 JSON 提取字段的机制,新增 `birthday` 字段映射即可。DwdLoadTask 的自动列匹配会自动将 `ods.member_profiles.birthday` 映射到 `dwd.dim_member.birthday`
**SCD2 处理:**
- `birthday` 作为 `dim_member` 的普通维度列SCD2 变化检测会自动包含
- 当 API 返回的 birthday 值变化时,会触发 SCD2 版本更新
**DWS 任务恢复 birthday 引用:**
- `member_visit_task.py``_extract_member_info()` SQL 中加入 `birthday`
- `member_consumption_task.py` 同理
### 需求 C2助教手动补录会员生日
**DDL`zqyy_app` / `test_zqyy_app` 业务库):**
```sql
CREATE TABLE IF NOT EXISTS member_birthday_manual (
id BIGSERIAL PRIMARY KEY,
member_id BIGINT NOT NULL,
birthday_value DATE NOT NULL,
recorded_by_assistant_id BIGINT,
recorded_by_name VARCHAR(50),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
source VARCHAR(20) DEFAULT 'assistant',
CONSTRAINT uk_member_birthday_manual
UNIQUE (member_id, recorded_by_assistant_id)
);
COMMENT ON TABLE member_birthday_manual IS '助教手动补录的会员生日信息';
CREATE INDEX idx_mbd_member ON member_birthday_manual (member_id);
```
**FDW 映射ETL 库读取业务库数据):**
当前 FDW 方向是 `zqyy_app``etl_feiqiu`(业务库读 ETL 数据)。需求 C2 需要反向ETL DWS 任务读取业务库的手动补录表。
方案:在 `etl_feiqiu` 库中创建指向 `zqyy_app` 的 FDW 外部表:
```sql
-- 在 etl_feiqiu 中执行
CREATE SERVER IF NOT EXISTS zqyy_app_server
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host 'localhost', dbname 'zqyy_app', port '5432');
CREATE USER MAPPING IF NOT EXISTS FOR etl_user
SERVER zqyy_app_server
OPTIONS (user 'app_reader', password '***');
CREATE SCHEMA IF NOT EXISTS fdw_app;
CREATE FOREIGN TABLE fdw_app.member_birthday_manual (
id BIGINT,
member_id BIGINT,
birthday_value DATE,
recorded_by_assistant_id BIGINT,
recorded_by_name VARCHAR(50),
recorded_at TIMESTAMPTZ,
source VARCHAR(20)
) SERVER zqyy_app_server
OPTIONS (schema_name 'public', table_name 'member_birthday_manual');
```
**DWS 任务生日读取逻辑:**
```sql
-- 优先手动补录值,其次 API 值
COALESCE(
(SELECT birthday_value
FROM fdw_app.member_birthday_manual
WHERE member_id = m.member_id
ORDER BY recorded_at ASC -- 最早提交优先
LIMIT 1),
m.birthday
) AS member_birthday
```
**后端 API**
```python
# apps/backend/app/routers/member_birthday.py
@router.post("/member-birthday")
async def submit_member_birthday(
member_id: int,
birthday_value: date,
assistant_id: int,
assistant_name: str,
db=Depends(get_db),
):
"""助教提交会员生日UPSERT"""
sql = """
INSERT INTO member_birthday_manual
(member_id, birthday_value, recorded_by_assistant_id, recorded_by_name)
VALUES (%s, %s, %s, %s)
ON CONFLICT (member_id, recorded_by_assistant_id)
DO UPDATE SET
birthday_value = EXCLUDED.birthday_value,
recorded_at = NOW()
"""
db.execute(sql, (member_id, birthday_value, assistant_id, assistant_name))
return {"status": "ok"}
```
## 数据模型
### 变更汇总
| 库 | 表 | 变更类型 | 说明 |
|----|-----|---------|------|
| `etl_feiqiu` | `ods.member_profiles` | 加列 | `birthday DATE` |
| `etl_feiqiu` | `dwd.dim_member` | 加列 | `birthday DATE` |
| `etl_feiqiu` | `dws.dws_assistant_monthly_summary` | 改约束 | UK 加入 `assistant_level_code` |
| `zqyy_app` | `member_birthday_manual` | 新建表 | 手动补录生日 |
| `etl_feiqiu` | `fdw_app.member_birthday_manual` | 新建外部表 | FDW 映射 |
### 迁移脚本清单
按优先级排序,每个迁移脚本独立可执行:
1. `2026-02-22__D_dwd_load_return_format.sql` — 无 DDL纯代码变更
2. `2026-02-22__C1_dim_member_add_birthday.sql` — ODS/DWD 加列
3. `2026-02-22__B_no_ddl_code_only.sql` — 无 DDL纯代码变更
4. `2026-02-22__A_monthly_summary_uk_change.sql` — 唯一约束变更
5. `2026-02-22__C2_member_birthday_manual.sql` — 新建表 + FDW
## 正确性属性
*属性Property是系统在所有合法执行中都应保持为真的特征或行为——本质上是对"系统应该做什么"的形式化陈述。属性是连接人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: DwdLoadTask 返回值格式一致性
*对于任意* DwdLoadTask.load() 的执行结果,返回字典中 `errors` 键的值应为 `int` 类型,且等于 `error_details` 列表的长度。
**验证: 需求 1.1**
### Property 2: _accumulate_counts 类型安全累加
*对于任意* 包含 `int``float``list` 类型值的计数字典,`_accumulate_counts()` 应将 `int`/`float` 直接累加,将 `list` 转为 `len()` 后累加,且不抛出异常。
**验证: 需求 1.2**
### Property 3: 档位分段聚合正确性
*对于任意* 助教在同一月内存在 N 个不同 `assistant_level_code` 的日度数据,`_extract_daily_aggregates()` 应返回恰好 N 行记录,每行的业绩指标之和应等于该助教该月的总业绩。
**验证: 需求 2.1**
### Property 4: nickname 按时间倒序取值
*对于任意* 助教在聚合周期内有多条不同 nickname 的记录,聚合结果中的 nickname 应等于时间最晚的那条记录的 nickname。此属性适用于 AssistantMonthlyTask、AssistantFinanceTask、AssistantCustomerTask 三个任务。
**验证: 需求 2.3, 2.5, 2.6**
### Property 5: 工资按档位分段计算
*对于任意* 助教在同一月有多个档位的月度汇总记录AssistantSalaryTask 应为每个档位分别计算工资,每个档位使用对应的 `level_price``tier` 配置,且所有档位的工资记录数等于月度汇总的行数。
**验证: 需求 2.4**
### Property 6: 跨店会员可查
*对于任意* 在 A 店注册但在 B 店有消费记录的会员B 店的 DWS 任务通过事实表反查 `dim_member`应能获取到该会员的维度信息nickname、mobile 等)。
**验证: 需求 3.1, 3.2**
### Property 7: birthday ODS→DWD 装载正确性
*对于任意* ODS `member_profiles` 中包含 `birthday` 值的记录DwdLoadTask 装载后 `dwd.dim_member` 中对应记录的 `birthday` 应与 ODS 源值一致。
**验证: 需求 4.2**
### Property 8: birthday SCD2 变化检测
*对于任意* `dim_member` 现有记录,当 ODS 中同一会员的 `birthday` 值发生变化时SCD2 应关闭旧版本并创建新版本,新版本的 `birthday` 等于新值。
**验证: 需求 4.3**
### Property 9: 生日 UPSERT 幂等性
*对于任意* `(member_id, assistant_id)` 组合,连续两次提交不同的 `birthday_value``member_birthday_manual` 表中该组合应只有一条记录,且 `birthday_value` 等于最后一次提交的值。
**验证: 需求 5.2, 5.5**
### Property 10: 手动补录优先于 API 来源
*对于任意* 同时在 `dim_member.birthday``member_birthday_manual` 中有值的会员DWS 任务输出的 `member_birthday` 应等于手动补录表中的值。
**验证: 需求 5.4**
### Property 11: SCD2 更新不影响手动补录表
*对于任意*`member_birthday_manual` 中有记录的会员,执行 DwdLoadTask SCD2 更新 `dim_member.birthday` 后,`member_birthday_manual` 中的记录应保持不变。
**验证: 需求 5.6**
## 错误处理
### 需求 D返回值格式
- `DwdLoadTask.load()` 中单表装载失败时,错误信息追加到 `error_details` 列表,`errors` 计数递增
- `_accumulate_counts()` 遇到未知类型时使用 `setdefault` 保留原值(现有行为不变)
- `_safe_int()` 遇到非 int/list 类型时返回 0现有行为不变
### 需求 A档位分段
- 助教在某月无任何服务记录时,不生成月度汇总行(现有行为不变)
- `assistant_level_code` 为 NULL 时作为独立分组处理NULL 视为一个档位)
- 唯一约束变更后需要清理旧数据DELETE + 重新计算当月数据)
### 需求 B多门店查询
- 事实表中无该门店消费记录时,`_extract_member_info()` 返回空字典(现有行为不变)
- 子查询返回空集时,`WHERE member_id IN (空集)` 等价于 `WHERE FALSE`,不会报错
### 需求 C1生日字段
- ODS 中 `birthday` 为 NULL 或空字符串时DWD 中存为 NULL
- 无效日期格式时DwdLoadTask 的现有类型转换逻辑会将其置为 NULL
### 需求 C2手动补录
- `member_id` 不存在于 `dim_member` 时,仍允许提交(助教可能先于 ETL 发现新客户)
- `birthday_value` 格式校验由后端 API 的 Pydantic schema 处理
- FDW 连接失败时DWS 任务应 catch 异常并降级为仅使用 `dim_member.birthday`
## 测试策略
### 测试框架
- 属性测试:`hypothesis`Python每个属性测试最少 100 次迭代
- 单元测试:`pytest`
- 测试工具:`apps/etl/connectors/feiqiu/tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI
### 属性测试
每个正确性属性对应一个 hypothesis 属性测试,标注格式:
```python
# Feature: etl-aggregation-fix, Property N: {property_text}
@given(...)
def test_property_N_xxx(data):
...
```
属性测试放置位置:
- 需求 D 相关Property 1-2`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
- 需求 A 相关Property 3-5`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- 需求 B 相关Property 6`apps/etl/connectors/feiqiu/tests/unit/test_multi_store_properties.py`
- 需求 C 相关Property 7-11`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
### 单元测试
单元测试覆盖具体示例和边界情况:
- DDL 结构验证(唯一约束、列存在性)
- 空数据 / NULL 值边界
- 迁移脚本的回滚验证
### 测试环境
- 数据库:`test_etl_feiqiu` / `test_zqyy_app`(通过 `TEST_DB_DSN` 环境变量)
- 纯单元测试使用 FakeDB/FakeAPI不涉及真实数据库连接
- ETL 测试 cwd`apps/etl/connectors/feiqiu/`
- 后端测试 cwd`apps/backend/`

View File

@@ -1,84 +0,0 @@
# 需求文档
## 简介
v8 联调修复了 11 个 BUG其中 4 个的当前修复方式是"临时止血",需要更完整的方案。本 Spec 覆盖以下四个需求:
- **需求 A**:助教月度聚合按档位分段统计,替代 `MAX()` 聚合
- **需求 B**:多门店会员查询 `register_site_id` 已知限制标记 + 预留扩展方案
- **需求 C**会员生日字段全链路补齐C1: ETL 链路 / C2: 手动补录)
- **需求 D**`DwdLoadTask.load()` 返回值格式规范化
## 术语表
- **DwdLoadTask**DWD 层装载任务,负责将 ODS 原始数据清洗装载至 DWD 明细层
- **DWS 任务**:数据汇总层任务,从 DWD 层聚合生成业务报表数据
- **SCD2**:缓慢变化维度类型 2通过版本化记录维度属性的历史变化
- **BaseTask**ETL 任务基类,提供 Extract/Transform/Load 模板方法和计数累加逻辑
- **FlowRunner**ETL 流程编排器,按层级顺序执行任务并汇总计数
- **dim_member**DWD 层会员维度表,使用 SCD2 管理历史版本
- **dws_assistant_monthly_summary**DWS 层助教月度汇总表
- **dws_assistant_salary_calc**DWS 层助教工资计算表
- **register_site_id**:会员注册门店 ID当前 `dim_member` 中唯一的门店标识
- **assistant_level_code**:助教档位代码,助教月内可能因升级/降级而变化
- **dim_member_birthday_manual**:手动补录生日表(位于 `zqyy_app` 业务库),存储助教提交的会员生日信息,通过 FDW 只读映射供 ETL DWS 任务读取
- **_safe_int()**`flow_runner.py` 中的类型安全辅助函数,将 `int`/`list`/`None` 统一转为 `int`
- **_accumulate_counts()**`BaseTask` 中的计数累加方法,合并多段执行的统计结果
## 需求
### 需求 1DwdLoadTask 返回值格式规范化(需求 D
**用户故事:** 作为 ETL 开发者,我希望 DwdLoadTask.load() 的返回值格式与其他任务保持一致,以便 FlowRunner 能安全地汇总所有任务的计数。
#### 验收标准
1. WHEN DwdLoadTask.load() 执行完成, THE DwdLoadTask SHALL 返回包含 `errors` 键(值为 `int` 类型,等于错误列表长度)和 `error_details` 键(值为 `list[dict]` 类型,包含错误详情)的字典
2. WHEN BaseTask._accumulate_counts() 遇到值为 `list` 类型的计数项, THE BaseTask SHALL 将该值转换为 `len(list)` 后再累加(防御层)
3. WHEN FlowRunner 汇总所有任务计数, THE FlowRunner SHALL 保留 `_safe_int()` 作为最终防御层,确保 `sum()` 不会因类型不一致而崩溃
### 需求 2助教月度聚合按档位分段统计需求 A
**用户故事:** 作为运营管理者,我希望助教月度汇总按档位分段统计业绩,以便准确反映助教在不同档位期间的表现和工资计算。
#### 验收标准
1. WHEN 助教在同一月内存在多个 assistant_level_code, THE AssistantMonthlyTask SHALL 按 `(assistant_id, stat_month, assistant_level_code)` 分组生成多行记录,分别统计各档位的业绩指标
2. THE dws_assistant_monthly_summary 表 SHALL 使用 `(site_id, assistant_id, stat_month, assistant_level_code)` 作为唯一约束
3. WHEN AssistantMonthlyTask 需要取 nickname 值, THE AssistantMonthlyTask SHALL 按时间倒序取最后一条记录的 nickname而非使用 `MAX()` 聚合
4. WHEN AssistantSalaryTask 计算工资, THE AssistantSalaryTask SHALL 按档位分段计算抽成,适配新的多行月度汇总结构
5. WHEN AssistantFinanceTask 提取日度收入需要 nickname, THE AssistantFinanceTask SHALL 按时间倒序取最后一条记录的 nickname而非使用 `MAX()` 聚合
6. WHEN AssistantCustomerTask 提取服务对需要 nickname, THE AssistantCustomerTask SHALL 按时间倒序取最后一条记录的 nickname而非使用 `MAX()` 聚合
### 需求 3多门店会员查询支持需求 B
**用户故事:** 作为运营管理者,我希望 DWS 任务能正确查询跨店消费的会员信息,以便 B 店能看到在 A 店注册但在 B 店消费的会员维度数据。
#### 验收标准
1. WHEN DWS 任务需要查询会员信息, THE DWS 任务 SHALL 通过事实表中的 `member_id` 反查 `dim_member`,而非使用 `WHERE register_site_id = %s` 预筛选
2. WHEN 会员在 A 店注册并在 B 店消费, THE B 店的 DWS 任务 SHALL 能查询到该会员的昵称、手机号等维度信息
3. WHEN DWS 任务执行会员信息提取, THE DWS 任务 SHALL 使用 `WHERE member_id IN (SELECT DISTINCT member_id FROM dwd.事实表 WHERE site_id = %s)` 模式获取会员维度数据
### 需求 4会员生日字段 ETL 链路补齐(需求 C1
**用户故事:** 作为运营管理者,我希望会员生日信息能从上游 API 完整传递到 DWS 层,以便用于会员分析和销售线索。
#### 验收标准
1. THE dim_member 表 SHALL 包含 `birthday DATE`
2. WHEN DwdLoadTask 执行 ODS → DWD 装载, THE DwdLoadTask SHALL 在列映射中包含 `birthday` 字段,将 ODS 中的生日数据装载到 `dim_member.birthday`
3. WHEN SCD2 更新 dim_member 记录, THE DwdLoadTask SHALL 将 `birthday` 作为变化检测字段之一,正常处理生日值的变化
4. WHEN MemberVisitTask 等 DWS 任务提取会员信息, THE DWS 任务 SHALL 从 `dim_member.birthday` 读取生日字段并写入 DWS 目标表
### 需求 5助教手动补录会员生日需求 C2
**用户故事:** 作为助教,我希望能手动提交客户的生日信息,以便在上游 API 未提供生日数据时补充这一重要销售线索。
#### 验收标准
1. THE `zqyy_app` 业务库(开发/测试环境使用 `test_zqyy_app`SHALL 包含 `member_birthday_manual` 表,结构包含 `member_id``birthday_value``recorded_by_assistant_id``recorded_by_name``recorded_at``source` 字段,唯一约束为 `(member_id, recorded_by_assistant_id)`
2. WHEN 同一助教对同一会员重复提交生日, THE 系统 SHALL 更新该助教的已有记录UPSERT保留所有其他助教的提交记录
3. WHEN DWS 任务需要读取手动补录生日, THE DWS 任务 SHALL 通过 FDW 只读映射从 `zqyy_app.member_birthday_manual` 读取数据
4. WHEN dim_member.birthdayAPI 来源)和 member_birthday_manual手动来源同时存在, THE DWS 任务 SHALL 优先使用手动补录值
5. WHEN 助教通过后端 API 提交生日, THE 后端 SHALL 提供 POST 接口接收 `member_id``birthday_value``assistant_id``assistant_name` 参数,执行 UPSERT 写入 `zqyy_app.member_birthday_manual`
6. WHEN SCD2 更新 dim_member.birthday, THE DwdLoadTask SHALL 正常更新 API 来源的生日值,不影响业务库中手动补录表的数据

View File

@@ -1,228 +0,0 @@
# 实现计划ETL 聚合修复与生日字段补齐
## 概述
按优先级D → C1 → B → A → C2逐步实施每个需求独立可部署。代码变更集中在 ETL Connector 的 tasks 层和后端 APIDDL 变更通过迁移脚本执行。
## 任务
- [x] 1. 需求 DDwdLoadTask 返回值格式规范化
- [x] 1.1 修改 DwdLoadTask.load() 返回值格式
-`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py``load()` 方法中,将 `return {"tables": summary, "errors": errors}` 改为 `return {"tables": summary, "errors": len(errors), "error_details": errors}`
- _需求: 1.1_
- [x] 1.2 增强 BaseTask._accumulate_counts() 防御层
-`apps/etl/connectors/feiqiu/tasks/base_task.py``_accumulate_counts()` 方法中,增加 `isinstance(value, list)` 分支,将 list 转为 `len()` 后累加
- _需求: 1.2_
- [x] 1.3 编写属性测试DwdLoadTask 返回值格式一致性
- **Property 1: DwdLoadTask 返回值格式一致性**
- **验证: 需求 1.1**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
- [x] 1.4 编写属性测试_accumulate_counts 类型安全累加
- **Property 2: _accumulate_counts 类型安全累加**
- **验证: 需求 1.2**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_return_format_properties.py`
- [x] 2. 检查点 — 需求 D 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 3. 需求 C1会员生日字段 ETL 链路补齐
- [x] 3.1 编写迁移脚本ODS/DWD 加 birthday 列
- 创建 `db/etl_feiqiu/migrations/2026-02-22__C1_dim_member_add_birthday.sql`
- ODS: `ALTER TABLE ods.member_profiles ADD COLUMN IF NOT EXISTS birthday DATE;`
- DWD: `ALTER TABLE dwd.dim_member ADD COLUMN IF NOT EXISTS birthday DATE;`
- 包含回滚语句和验证 SQL
- _需求: 4.1_
- [-] 3.1a 在测试库执行迁移脚本 C1
-`test_etl_feiqiu` 上执行 `2026-02-22__C1_dim_member_add_birthday.sql`
- 执行验证 SQL 确认列已添加
- _需求: 4.1_
- [x] 3.2 更新 ODS 入库逻辑:提取 birthday 字段
- 在 ODS 入库任务中增加 `birthday` 字段的 JSON 提取映射
- 确认 `ods_tasks.py``member_profiles` 的字段列表包含 `birthday`
- _需求: 4.2_
- [x] 3.3 验证 DwdLoadTask 自动列映射包含 birthday
- DwdLoadTask 通过 `_get_columns()` 自动读取 DWD 表列名,确认 `birthday` 被自动包含在列映射中
- SCD2 变化检测自动包含所有非 SCD2 元数据列,确认 `birthday` 参与变化检测
- _需求: 4.2, 4.3_
- [x] 3.4 恢复 DWS 任务中的 birthday 引用
- 修改 `member_visit_task.py``_extract_member_info()` SQL加入 `birthday` 字段
- 修改 `member_consumption_task.py``_extract_member_info()` SQL加入 `birthday` 字段
- 修改 DWS 任务的 `transform()` 方法,将 `member_birthday` 写入输出记录
- _需求: 4.4_
- [x] 3.5 编写属性测试birthday ODS→DWD 装载正确性
- **Property 7: birthday ODS→DWD 装载正确性**
- **验证: 需求 4.2**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 3.6 编写属性测试birthday SCD2 变化检测
- **Property 8: birthday SCD2 变化检测**
- **验证: 需求 4.3**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 4. 检查点 — 需求 C1 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 5. 需求 B多门店会员查询支持
- [x] 5.1 修改 member_visit_task.py 的 _extract_member_info()
-`WHERE register_site_id = %s` 改为通过 `dwd_settlement_head` 事实表的 `tenant_member_id` 反查
- _需求: 3.1, 3.2_
- [x] 5.2 修改 member_consumption_task.py 的 _extract_member_info()
-`WHERE register_site_id = %s` 改为通过 `dwd_settlement_head` 事实表的 `tenant_member_id` 反查
- 同时修改 `dim_member_card_account` 查询,改为通过事实表反查
- _需求: 3.1, 3.2_
- [x] 5.3 修改 assistant_customer_task.py 的 _extract_member_info()
-`WHERE register_site_id = %s` 改为通过 `dwd_assistant_service_log` 事实表的 `tenant_member_id` 反查
- _需求: 3.1, 3.2_
- [x] 5.4 修改 finance_recharge_task.py 的 dim_member_card_account 查询
-`WHERE register_site_id = %s` 改为通过事实表反查
- _需求: 3.1_
- [x] 5.5 编写属性测试:跨店会员可查
- **Property 6: 跨店会员可查**
- **验证: 需求 3.1, 3.2**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_multi_store_properties.py`
- [x] 6. 检查点 — 需求 B 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 7. 需求 A助教月度聚合按档位分段统计
- [x] 7.1 编写迁移脚本:唯一约束变更
- 创建 `db/etl_feiqiu/migrations/2026-02-22__A_monthly_summary_uk_change.sql`
- DROP 旧约束 `uk_dws_assistant_monthly`ADD 新约束 `(site_id, assistant_id, stat_month, assistant_level_code)`
- 包含回滚语句和验证 SQL
- _需求: 2.2_
- [x] 7.1a 在测试库执行迁移脚本 A
-`test_etl_feiqiu` 上执行 `2026-02-22__A_monthly_summary_uk_change.sql`
- 执行验证 SQL 确认约束已变更(`SELECT conname FROM pg_constraint ...`
- _需求: 2.2_
- [x] 7.2 修改 AssistantMonthlyTask._extract_daily_aggregates()
- GROUP BY 加入 `assistant_level_code, assistant_level_name`
- nickname 改为 `(ARRAY_AGG(assistant_nickname ORDER BY stat_date DESC))[1]`
- _需求: 2.1, 2.3_
- [x] 7.3 修改 AssistantMonthlyTask._process_month() 适配多行
- 确认 `_process_month()` 能正确处理同一助教多个档位的聚合数据
- 每行使用自己的 `assistant_level_code` 进行档位匹配和排名计算
- _需求: 2.1_
- [x] 7.4 修改 AssistantSalaryTask 适配档位分段工资计算
- `_extract_monthly_summary()` 已能返回多行(同一助教不同档位)
- `transform()` 遍历每行分别计算工资,按档位使用对应的 `level_price``tier`
- _需求: 2.4_
- [x] 7.5 修改 AssistantFinanceTask._extract_daily_revenue() nickname 取值
-`MAX(s.nickname)` 改为 `(ARRAY_AGG(s.nickname ORDER BY s.start_use_time DESC))[1]`
- _需求: 2.5_
- [x] 7.6 修改 AssistantCustomerTask._extract_service_pairs() nickname 取值
-`MAX(assistant_nickname)` 改为 `(ARRAY_AGG(assistant_nickname ORDER BY service_date DESC))[1]`
- _需求: 2.6_
- [x] 7.7 编写属性测试:档位分段聚合正确性
- **Property 3: 档位分段聚合正确性**
- **验证: 需求 2.1**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- [x] 7.8 编写属性测试nickname 按时间倒序取值
- **Property 4: nickname 按时间倒序取值**
- **验证: 需求 2.3, 2.5, 2.6**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- [x] 7.9 编写属性测试:工资按档位分段计算
- **Property 5: 工资按档位分段计算**
- **验证: 需求 2.4**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_monthly_aggregation_properties.py`
- [x] 8. 检查点 — 需求 A 完成
- 确保所有测试通过ask the user if questions arise.
- [x] 9. 需求 C2助教手动补录会员生日
- [x] 9.1 编写迁移脚本:创建 member_birthday_manual 表
- 创建 `db/zqyy_app/migrations/2026-02-22__C2_member_birthday_manual.sql`
-`zqyy_app` / `test_zqyy_app` 中创建 `member_birthday_manual`
- 包含回滚语句和验证 SQL
- _需求: 5.1_
- [x] 9.1a 在测试库执行迁移脚本 C2
-`test_zqyy_app` 上执行 `2026-02-22__C2_member_birthday_manual.sql`
- 执行验证 SQL 确认表和约束已创建
- _需求: 5.1_
- [x] 9.2 编写 FDW 映射脚本ETL 库读取业务库
- 创建 `db/fdw/setup_fdw_reverse.sql`etl_feiqiu → zqyy_app 方向)
- 创建 `db/fdw/setup_fdw_reverse_test.sql`test 环境版本)
- 在 ETL 库中创建 `fdw_app.member_birthday_manual` 外部表
- _需求: 5.3_
- [x] 9.2a 在测试库执行 FDW 映射脚本
-`test_etl_feiqiu` 上执行 `setup_fdw_reverse_test.sql`
- 验证 `fdw_app.member_birthday_manual` 外部表可读取
- _需求: 5.3_
- [x] 9.3 修改 DWS 任务:生日读取优先级逻辑
-`member_visit_task.py``member_consumption_task.py``_extract_member_info()` 中,使用 `COALESCE(fdw_app.member_birthday_manual, dim_member.birthday)` 逻辑
- 增加 FDW 连接失败的降级处理
- _需求: 5.4_
- [x] 9.4 实现后端 API生日提交接口
- 创建 `apps/backend/app/routers/member_birthday.py`
- 实现 `POST /member-birthday` 接口,执行 UPSERT
- 创建 Pydantic schema `apps/backend/app/schemas/member_birthday.py`
-`apps/backend/app/main.py` 中注册路由
- _需求: 5.5_
- [x] 9.5 编写属性测试:生日 UPSERT 幂等性
- **Property 9: 生日 UPSERT 幂等性**
- **验证: 需求 5.2, 5.5**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 9.6 编写属性测试:手动补录优先于 API 来源
- **Property 10: 手动补录优先于 API 来源**
- **验证: 需求 5.4**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 9.7 编写属性测试SCD2 更新不影响手动补录表
- **Property 11: SCD2 更新不影响手动补录表**
- **验证: 需求 5.6**
- 文件:`apps/etl/connectors/feiqiu/tests/unit/test_birthday_properties.py`
- [x] 10. 收尾:主 DDL 合并与文档更新
- [x] 10.1 合并迁移变更到主 DDL 文件
-`birthday` 列定义合并到 `db/etl_feiqiu/schemas/ods.sql``member_profiles` 表)
-`birthday` 列定义合并到 `db/etl_feiqiu/schemas/dwd.sql``dim_member` 表)
- 将新唯一约束合并到 `db/etl_feiqiu/schemas/dws.sql``dws_assistant_monthly_summary` 表)
-`member_birthday_manual` 表定义合并到 `db/zqyy_app/schemas/init.sql`
- 将 FDW 反向映射合并到 `db/fdw/setup_fdw.sql``db/fdw/setup_fdw_test.sql`
- [x] 10.2 更新数据库文档
-`docs/database/` 中新建或更新受影响表的文档:
- `dim_member`:新增 `birthday` 列说明
- `dws_assistant_monthly_summary`:唯一约束变更说明
- `member_birthday_manual`:新建表文档(含 FDW 映射说明)
- 遵循现有 `BD_Manual_*.md` 命名规范
- [x] 10.3 最终验证
- 确保所有测试通过
- 确认主 DDL 文件与测试库实际结构一致
- ask the user if questions arise
## 备注
- 标记 `*` 的任务为可选测试任务,可跳过以加速 MVP
- 每个需求独立可部署,检查点确保增量验证
- 迁移脚本需在 `test_etl_feiqiu` / `test_zqyy_app` 上先行验证
- 属性测试使用 hypothesis每个属性最少 100 次迭代
- 单元测试使用 FakeDB/FakeAPI不依赖真实数据库

View File

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

View File

@@ -0,0 +1,357 @@
# 技术设计:团购详情接口整合 ETL 数据流
> 对应需求文档:#[[file:.kiro/specs/etl-coupon-detail/requirements.md]]
## 1. 架构概览
```
ODS_GROUP_PACKAGE 任务BaseOdsTask + OdsTaskSpec
│ 阶段 1列表拉取UnifiedPipeline #1
│ QueryPackageCouponList → ods.group_buy_packages
ods.group_buy_packages ──写入完成──▶ 阶段 2详情拉取自动启动
│ │
│ ┌───────┴───────┐
│ │ UnifiedPipeline│
│ │ #2 │
│ │ (detail_mode) │
│ └───────┬───────┘
│ │
│ ┌───────▼───────┐
│ │ 串行请求线程 │
│ │ (主线程) │
│ │ RateLimiter │
│ └───────┬───────┘
│ │ 响应提交
│ ┌───────▼───────┐
│ │ 处理队列 │
│ │ N 个工作线程 │
│ └───────┬───────┘
│ │ 处理完成
│ ┌───────▼───────┐
│ │ 写入队列 │
│ │ 单线程写库 │
│ └───────┬───────┘
│ │
▼ ▼
DWD dim_groupbuy_package ods.group_buy_package_details
DWD dim_groupbuy_package_ex ◀── SCD2 合并 ──┘
```
> 不再新建独立的 `DetailFetcher` 类。详情拉取完全复用 `BaseOdsTask` 已有的 `detail_endpoint` 二级拉取模式,通过 `OdsTaskSpec` 声明式配置即可驱动。
## 2. 调研结论与设计决策
### 2.1 ODS 层表方案 → 选项 A新建独立表
决策依据:
- 现有 `ods.group_buy_packages` 使用 `OdsTaskSpec` 驱动payload 存储列表接口的原始 JSONPK 为 `id + content_hash`
- 详情接口返回嵌套结构(子数组 `packageCouponAssistants``grouponSiteInfos` 等),与列表接口的扁平结构差异大
- 两个接口的写入时机不同(列表先写完,详情后写),混在同一表会导致 `SnapshotMode.FULL_TABLE` 的软删除逻辑冲突
- 独立表可以独立演进字段,不影响现有列表数据的稳定性
新表:`ods.group_buy_package_details`PK = `coupon_id`BIGINT全量快照覆盖
### 2.2 DWD 层表方案 → 选项 A在现有扩展表新增字段
决策依据:
- `dim_groupbuy_package_ex` 当前 21 个业务字段(不含 SCD2密度适中
- 详情接口的增量价值字段仅 4 个 JSONB 列(`table_area_ids``table_area_names``assistant_services``groupon_site_infos`
- 新建独立表会增加 SCD2 版本同步复杂度,且下游查询需要额外 JOIN
- 扩展表的 SCD2 合并已与主表同步,新增字段自动纳入变更检测
### 2.3 取消信号 → 复用现有 `CancellationToken`
`CancellationToken`(封装 `threading.Event`)已在 etl-unified-pipeline 中实现。详情阶段的 `UnifiedPipeline` 实例共享同一个 `cancel_token`,在请求循环和 `RateLimiter.wait()` 中检查取消状态。
## 3. 组件设计
### 3.1 OdsTaskSpec 详情配置(声明式,无需新建类)
> `RateLimiter``api/rate_limiter.py`)、`CancellationToken``utils/cancellation.py`)和 `UnifiedPipeline``pipeline/unified_pipeline.py`)已在 etl-unified-pipeline 中实现并上线。`BaseOdsTask.execute()` 内置了 `detail_endpoint` 二级详情拉取模式,本 spec 通过 `OdsTaskSpec` 声明式配置驱动,不新建独立类。
`tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` 任务 spec 中添加以下配置:
```python
OdsTaskSpec(
code="ODS_GROUP_PACKAGE",
# ... 现有列表拉取配置 ...
# ── Detail_Mode 配置 ──
detail_endpoint="/PackageCoupon/QueryPackageCouponInfo",
detail_param_builder=lambda rec: {"couponId": rec["id"]},
detail_target_table="ods.group_buy_package_details",
detail_data_path=("data",),
detail_id_column="id",
)
```
配置字段说明:
| 字段 | 值 | 说明 |
|------|-----|------|
| `detail_endpoint` | `/PackageCoupon/QueryPackageCouponInfo` | 详情接口 endpoint |
| `detail_param_builder` | `lambda rec: {"couponId": rec["id"]}` | 将列表表的 `id` 映射为详情接口的 `couponId` 参数 |
| `detail_target_table` | `ods.group_buy_package_details` | 详情数据写入的目标表 |
| `detail_data_path` | `("data",)` | 详情响应的数据路径 |
| `detail_id_column` | `id` | 从 `ods.group_buy_packages` 提取 couponId 列表的列名 |
### 3.2 执行流程BaseOdsTask.execute() 内置)
`BaseOdsTask.execute()` 在列表拉取全部完成后,自动检测 `spec.detail_endpoint` 是否配置,若已配置则启动详情拉取阶段:
```python
# BaseOdsTask.execute() 内置逻辑(已实现,无需修改):
if spec.detail_endpoint:
# 1. 创建独立的 UnifiedPipeline 实例(共享 cancel_token
detail_pipeline = UnifiedPipeline(
api_client=self.api,
db_connection=self.db,
logger=self.logger,
config=pipeline_config, # PipelineConfig.from_app_config()
cancel_token=cancel_token, # 与列表阶段共享
)
# 2. 从 ODS 目标表查询 ID 列表,生成详情请求序列
detail_requests = self._build_detail_requests(spec)
# → SELECT DISTINCT {detail_id_column} FROM {table_name}
# → 对每个 ID 调用 detail_param_builder 构造参数
# → yield PipelineRequest(is_detail=True, detail_id=record_id)
# 3. 构建处理和写入函数
detail_process_fn = self._build_detail_process_fn(spec)
detail_write_fn = self._build_detail_write_fn(spec, source_file)
# → 写入 detail_target_table使用 _insert_records_schema_aware()
# 4. 执行管道
detail_result = detail_pipeline.run(
detail_requests, detail_process_fn, detail_write_fn,
)
self.db.commit()
```
### 3.3 详情响应处理(需自定义 process_fn
默认的 `_build_detail_process_fn``response.get("records", [])` 提取记录。对于团购详情接口,需要自定义字段提取逻辑:
-`data.groupPurchasePackage` 提取结构化字段(`package_name``duration``start_time``end_time` 等)
-`data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
-`data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
-`data.grouponSiteInfos` 提取关联门店数组为 JSONB
-`data.packagePackageService``data.packageCouponDetailsList` 提取为 JSONB
- 计算 `content_hash`,保留完整原始响应为 `payload`
实现方式:在 `ODS_GROUP_PACKAGE` 任务中覆盖 `_build_detail_process_fn`,或在 `OdsTaskSpec` 中扩展 `detail_process_fn` 回调。
### 3.4 详情数据写入(复用 _insert_records_schema_aware
`_build_detail_write_fn` 已内置调用 `_insert_records_schema_aware()`,按目标表结构动态写入,支持 ON CONFLICT UPSERT。写入目标为 `ods.group_buy_package_details`PK = `coupon_id`
### 3.5 DWD 加载扩展
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`
`_merge_dim_scd2()` 处理 `dim_groupbuy_package_ex` 时,需要额外从 `ods.group_buy_package_details` 读取详情字段并合并到 ODS 快照中:
```python
# 伪代码:在 DWD 加载 dim_groupbuy_package_ex 时
def _load_groupbuy_package_ex(self, cur, now):
# 1. 从 ods.group_buy_packages 读取列表数据(现有逻辑)
# 2. 从 ods.group_buy_package_details 读取详情数据
# 3. 通过 coupon_id = id 关联,将详情字段合并到 ODS 快照
# 4. 执行 SCD2 合并(现有 _merge_dim_scd2 逻辑)
```
新增字段映射(`ods.group_buy_package_details``dwd.dim_groupbuy_package_ex`
| ODS 详情字段 | DWD 扩展表字段 | 类型 |
|-------------|---------------|------|
| `table_area_ids` | `table_area_ids` | JSONB |
| `table_area_names` | `table_area_names` | JSONB |
| `assistant_services` | `assistant_services` | JSONB |
| `groupon_site_infos` | `groupon_site_infos` | JSONB |
## 4. 数据库变更
### 4.1 新建 ODS 表
```sql
-- db/etl_feiqiu/ods/group_buy_package_details.sql
CREATE TABLE IF NOT EXISTS ods.group_buy_package_details (
coupon_id BIGINT NOT NULL,
package_name TEXT,
duration INTEGER, -- 台费计时时长(秒)
start_time TIMESTAMPTZ, -- 可用日期开始
end_time TIMESTAMPTZ, -- 可用日期结束
add_start_clock TEXT, -- 可用时段开始
add_end_clock TEXT, -- 可用时段结束
is_enabled INTEGER,
is_delete INTEGER,
site_id BIGINT,
tenant_id BIGINT,
create_time TIMESTAMPTZ,
creator_name TEXT,
-- JSONB 数组字段
table_area_ids JSONB, -- [2791960001957765, ...]
table_area_names JSONB, -- ["A区", ...]
assistant_services JSONB, -- [{skillId, assistantLevel, assistantDuration}, ...]
groupon_site_infos JSONB, -- [{siteId, siteName}, ...]
package_services JSONB, -- 待调研,可能为空
coupon_details_list JSONB, -- 待调研,可能为空
-- ETL 元数据
content_hash TEXT,
payload JSONB, -- 完整原始响应
fetched_at TIMESTAMPTZ DEFAULT now(),
-- 主键
CONSTRAINT pk_group_buy_package_details PRIMARY KEY (coupon_id)
);
COMMENT ON TABLE ods.group_buy_package_details IS '团购套餐详情 ODSQueryPackageCouponInfo 原始数据';
```
### 4.2 DWD 扩展表 ALTER
```sql
-- db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql
ALTER TABLE dwd.dim_groupbuy_package_ex
ADD COLUMN IF NOT EXISTS table_area_ids JSONB,
ADD COLUMN IF NOT EXISTS table_area_names JSONB,
ADD COLUMN IF NOT EXISTS assistant_services JSONB,
ADD COLUMN IF NOT EXISTS groupon_site_infos JSONB;
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_ids IS '可用台区 ID 列表(来自详情接口 tableAreaId';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.table_area_names IS '可用台区名称列表(来自详情接口 tableAreaNameList';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.assistant_services IS '助教服务关联(来自详情接口 packageCouponAssistants';
COMMENT ON COLUMN dwd.dim_groupbuy_package_ex.groupon_site_infos IS '关联门店信息(来自详情接口 grouponSiteInfos';
```
## 5. 线程模型详细设计
详情阶段复用 `UnifiedPipeline` 的三层并发架构,与列表阶段完全一致:
```
UnifiedPipeline #2detail_mode
│ 主线程_request_loop
│ ┌─────────────────────────────────────────┐
│ │ for req in _build_detail_requests(spec): │
│ │ if cancel_token.is_cancelled: break │
│ │ resp = api.post(req.endpoint, params) │
│ │ processing_queue.put((req, resp)) │
│ │ rate_limiter.wait(cancel_token.event) │
│ └─────────────────┬───────────────────────┘
│ │
│ processing_queue.put(SENTINEL × worker_count)
│ 等待所有 worker 完成
│ write_queue.put(SENTINEL)
│ 等待 writer 完成
├──▶ Worker Thread 1 ──┐
├──▶ Worker Thread 2 ──┤
│ │
│ processing_queue │
│ ┌─────────────┐ │
│ │ (req, resp) │───▶ detail_process_fn(resp)
│ │ (req, resp) │ → 提取字段、计算 content_hash
│ │ SENTINEL │ write_queue.put(records)
│ └─────────────┘ │
│ │
│ ▼
│ Write Thread
│ ┌──────────────────┐
│ │ write_queue │
│ │ batch=batch_size │──▶ _insert_records_schema_aware()
│ │ timeout=5s │ → UPSERT ods.group_buy_package_details
│ │ SENTINEL │
│ └──────────────────┘
PipelineResultdetail_success / detail_failure / detail_skipped
```
关键设计点(均由 `PipelineConfig` 统一控制,支持任务级覆盖):
- `processing_queue``queue.Queue(maxsize=queue_size)`,满时阻塞主线程(背压机制)
- `write_queue``queue.Queue(maxsize=queue_size * 2)`
- Worker 数量:`PipelineConfig.workers`(默认 2
- Writer 批量写入:累积 `batch_size` 条或超时 `batch_timeout` 秒后执行
- SENTINEL`None` 对象,用于通知线程退出
- 取消信号:主线程检查 `cancel_token.is_cancelled``RateLimiter.wait()` 轮询 `cancel_token.event`
## 6. 取消信号
详情阶段的 `UnifiedPipeline` 实例与列表阶段共享同一个 `CancellationToken`。取消信号的上游传递链Admin-web → Backend → Orchestration → CancellationToken属于 etl-unified-pipeline 的架构范畴,本 spec 仅关注详情阶段收到取消信号后的行为:
1. `_request_loop` 检测到 `cancel_token.is_cancelled`,停止发送新请求
2. `RateLimiter.wait()` 在 0.5s 轮询周期内检测到取消,立即返回 `False`
3. 主线程发送 SENTINEL 到处理队列,等待已入队数据处理完成
4. `PipelineResult.cancelled = True``BaseOdsTask` 据此设置任务状态
## 7. 错误处理策略
错误处理由 `UnifiedPipeline` 统一管理,各阶段行为如下:
| 场景 | 处理方式 |
|------|---------|
| 单个 couponId 请求超时/HTTP 错误 | `_request_loop` 捕获异常,`request_failures++`,继续下一个 |
| 单个 couponId 返回 `code != 0` | 同上API 层异常) |
| 连续失败超过阈值 | `_request_loop` 中断,`PipelineResult.status = "FAILED"` |
| Worker 线程处理异常 | `_process_worker` 捕获异常,`processing_failures++`,继续消费队列 |
| Writer 线程写入失败 | `_write_worker` 捕获异常,`write_failures++`,继续消费队列 |
| 取消信号到达 | 停止新请求,等待已入队数据处理完成,`cancelled = True` |
`BaseOdsTask.execute()` 在详情阶段完成后,将 `detail_result` 的统计信息合并到任务结果中,并记录每个失败项的错误日志。
连续失败阈值:`PipelineConfig.max_consecutive_failures`(默认 10支持 `pipeline.ods_group_package.max_consecutive_failures` 任务级覆盖)。
## 8. 配置参数
详情阶段复用 `PipelineConfig` 统一配置体系,支持三级回退:`pipeline.ods_group_package.<key>``pipeline.<key>` → 硬编码默认值。
| 配置键 | 默认值 | 说明 |
|--------|--------|------|
| `pipeline.workers` | 2 | 处理线程数 |
| `pipeline.queue_size` | 100 | 处理队列容量 |
| `pipeline.batch_size` | 100 | 写入批量阈值 |
| `pipeline.batch_timeout` | 5.0 | 写入等待超时(秒) |
| `pipeline.rate_min` | 5.0 | RateLimiter 最小间隔(秒) |
| `pipeline.rate_max` | 20.0 | RateLimiter 最大间隔(秒) |
| `pipeline.max_consecutive_failures` | 10 | 连续失败中断阈值 |
如需为详情阶段单独调参,可通过 `pipeline.ods_group_package.*` 任务级覆盖(列表和详情阶段共享同一 `PipelineConfig` 实例)。
> 不再需要独立的 `DETAIL_FETCH_*` 配置参数。
## 9. 实施任务清单
### Task 1新建 ODS 详情表 DDL
- 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
- 执行 DDL 到测试库
- 需求覆盖:需求 3 验收标准 1-4
### Task 2扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
-`tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 `detail_endpoint` 等配置
- 实现自定义的 `_build_detail_process_fn` 字段提取逻辑
- 实现自定义的 `_build_detail_write_fn` 写入逻辑
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`
- 需求覆盖:需求 1 验收标准 1-8需求 2 验收标准 1-6需求 5 验收标准 1-4
### Task 3DWD 扩展表 ALTER + 加载逻辑
- 执行 ALTER TABLE 到测试库
- 修改 DWD 加载逻辑,从详情 ODS 表读取并合并到扩展表
- 需求覆盖:需求 4 验收标准 1-5
### Task 4数据调研 — 获取全部团购详情并分析未标注字段
- 编写一次性脚本调用详情接口获取全部数据
- 分析未标注字段的值分布
- 确认 `packagePackageService``packageCouponDetailsList` 是否有数据
- 根据分析结果调整 ODS/DWD 字段定义
- 需求覆盖:需求 3 验收标准 6附录 B 调研 3、4
### Task 5文档同步更新
- 更新 ODS DDL 文档、字段映射文档
- 更新 BD Manual
- 更新 DWD 全景文档
- 更新 README 任务清单
- 需求覆盖:需求 6 验收标准 1-4

View File

@@ -0,0 +1,256 @@
# 需求文档:团购详情接口整合 ETL 数据流
## 简介
将飞球 SaaS 的团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流中。当前 ETL 仅拉取团购列表(`QueryPackageCouponList`),缺少每个团购套餐的详情数据(助教服务关联、可用台区列表、关联门店等)。本次改造在现有团购列表拉取任务完成后,串行遍历所有 `couponId` 调用详情接口,将详情数据落入 ODS 层并向下传导至 DWD 层,丰富团购维度表的分析能力。采用"串行请求 + 异步处理 + 单线程写库"架构在控制请求频率5-20 秒随机间隔)的同时最大化数据处理吞吐。
## 术语表
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
- **API_Client**ETL 系统中的 HTTP 客户端模块(`api/client.py`),封装 POST 请求、重试、分页逻辑
- **Rate_Limiter**:请求间隔控制器,在串行请求模式下控制相邻两次 API 请求之间的随机等待时间5-20 秒),防止触发上游风控
- **ODS_Group_Package_Task**:现有 ODS 层团购套餐拉取任务(任务代码 `ODS_GROUP_PACKAGE`),调用 `QueryPackageCouponList` 获取团购列表并写入 `ods.group_buy_packages`
- **Detail_Fetcher**:团购详情拉取子流程,在列表拉取完成后遍历所有 `couponId` 调用 `QueryPackageCouponInfo` 获取详情
- **ODS_Detail_Table**ODS 层团购详情表(`ods.group_buy_package_details`),存储详情接口返回的原始数据
- **DWD_Groupbuy_Package**DWD 层团购套餐维度主表(`dwd.dim_groupbuy_package`
- **DWD_Groupbuy_Package_Ex**DWD 层团购套餐维度扩展表(`dwd.dim_groupbuy_package_ex`
- **SCD2**:缓慢变化维度 Type 2通过版本号和生效/失效时间追踪维度历史变化
- **couponId**:团购套餐的唯一标识 ID对应 `ods.group_buy_packages.id` 字段
## 需求
### 需求 1DetailFetcher 串行请求与异步处理
> RateLimiter 和 CancellationToken 已在 etl-unified-pipeline 中实现并上线,本 spec 直接复用。
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取采用"串行请求 + 异步处理 + 单线程写库"的架构,复用现有限流和取消机制,在控制请求频率的同时最大化数据处理吞吐。
#### 验收标准
1. THE Detail_Fetcher SHALL 采用严格串行请求模式:等待上一个 API 请求的响应完整返回后,再等待 5 至 20 秒之间的随机间隔(均匀分布),然后发送下一个请求
2. THE Detail_Fetcher SHALL 在每个请求的响应返回后,立即将响应数据提交到异步处理队列,不阻塞下一次请求的等待计时
3. THE Detail_Fetcher SHALL 支持多个工作线程并行消费处理队列中的响应数据字段提取、数据清洗、content_hash 计算等)
4. THE Detail_Fetcher SHALL 使用单独的写入线程汇总所有处理完成的结果,统一执行数据库写入操作,避免并发写入冲突
5. THE Detail_Fetcher SHALL 复用现有 RateLimiter`api/rate_limiter.py`)控制请求间隔,通过构造参数配置最小/最大间隔
6. WHEN 所有请求发送完毕后THE Detail_Fetcher SHALL 等待处理队列和写入线程全部完成后再返回最终结果
7. THE Detail_Fetcher SHALL 通过 CancellationToken`utils/cancellation.py`)支持外部取消信号,收到信号后立即停止发送新请求、等待当前已提交到处理队列的数据处理完成并写入数据库、然后返回部分完成的统计结果
8. WHEN 取消信号到达时THE Detail_Fetcher SHALL 在当前请求的等待间隔期或响应等待期中断,不再发起后续请求,已进入处理队列的数据不丢弃、正常完成处理和写入
### 需求 2团购详情 API 拉取
**用户故事:** 作为数据分析师,我希望 ETL 系统能拉取每个团购套餐的详情数据(助教服务关联、可用台区、关联门店等),以便进行团购套餐的深度分析。
#### 验收标准
1. WHEN ODS_Group_Package_Task 完成团购列表拉取后THE Detail_Fetcher SHALL 遍历所有已拉取的 `couponId`(包含 `is_enabled=0``is_delete=1` 的记录)调用 `QueryPackageCouponInfo` 接口
2. THE Detail_Fetcher SHALL 向 `https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo` 发送 POST 请求,请求体为 `{"couponId": <id>}`
3. THE Detail_Fetcher SHALL 严格串行发送请求,等待上一个请求响应返回后,经过 5-20 秒随机间隔再发送下一个请求
4. WHEN 详情接口返回成功响应时THE Detail_Fetcher SHALL 将响应数据提交到异步处理队列,由工作线程提取 `data` 层级下的 `groupPurchasePackage``tableAreaNameList``packageCouponAssistants``grouponSiteInfos``packagePackageService``packageCouponDetailsList` 等字段
5. IF 详情接口对某个 `couponId` 返回错误或超时THEN THE Detail_Fetcher SHALL 记录错误日志(含 `couponId` 和错误信息)并继续处理下一个 `couponId`
6. WHEN 所有 `couponId` 遍历完成后THE Detail_Fetcher SHALL 等待异步处理和写入线程全部完成,然后返回拉取统计信息(成功数、失败数、跳过数)
### 需求 3ODS 层团购详情数据存储
**用户故事:** 作为数据工程师,我希望团购详情数据以结构化方式存储在 ODS 层,以便下游 DWD 层消费。
#### 验收标准
1. THE ETL_System SHALL 将团购详情数据存储到 ODS 层,具体方案(独立新表 `ods.group_buy_package_details` 还是在现有 `ods.group_buy_packages` 中扩展字段)在技术设计阶段通过实际数据调研后确定(见附录 B待调研项
2. THE ODS_Detail_Table SHALL 包含以下结构化字段:`coupon_id`BIGINT, PK`package_name`TEXT`duration`INT`start_time`TIMESTAMPTZ`end_time`TIMESTAMPTZ`add_start_clock`TEXT`add_end_clock`TEXT`is_enabled`INT`is_delete`INT`site_id`BIGINT`tenant_id`BIGINT`create_time`TIMESTAMPTZ`creator_name`TEXT
3. THE ODS_Detail_Table SHALL 将数组类型字段以 JSONB 格式存储:`table_area_ids`(台区 ID 列表)、`table_area_names`(台区名称列表)、`assistant_services`(助教服务关联数组,含 `skillId``assistantLevel``assistantDuration`)、`groupon_site_infos`(关联门店数组)、`package_services`(套餐服务数组)、`coupon_details_list`(券明细数组)
4. THE ODS_Detail_Table SHALL 包含 ETL 元数据字段:`content_hash`TEXT`payload`JSONB完整原始响应`fetched_at`TIMESTAMPTZ
5. THE ETL_System SHALL 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 ODS_Detail_Table每次运行覆盖全部记录
6. WHEN 详情接口返回的字段中存在未标注字段且所有记录的值均相同时THE ETL_System SHALL 忽略该字段不写入 ODS 表
### 需求 4DWD 层团购维度表扩展
**用户故事:** 作为数据分析师,我希望 DWD 层的团购维度表能包含详情数据中的关键字段,以便在结算分析和财务审计中使用团购套餐的完整信息。
#### 验收标准
1. THE ETL_System SHALL 在 DWD 层扩展团购维度数据,具体方案(在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增字段,还是新建独立详情维度表)在技术设计阶段通过实际数据调研后确定(见附录 B待调研项
2. THE ETL_System SHALL 在 DWD 加载任务中建立 ODS 详情数据到 DWD 团购维度表的字段映射,通过 `coupon_id` = `groupbuy_package_id` 关联
3. WHEN ODS 详情表中存在 `assistant_services` 数据时THE DWD_Groupbuy_Package_Ex SHALL 保留完整的助教服务关联信息(`skillId``assistantLevel``assistantDuration``assistantDuration` 单位为秒
4. THE ETL_System SHALL 通过 SCD2 机制追踪团购详情字段的历史变化,与现有 DWD_Groupbuy_Package_Ex 的 SCD2 版本保持同步
5. WHEN `ods.group_buy_package_details` 中某个 `coupon_id``ods.group_buy_packages` 中不存在时THE ETL_System SHALL 记录警告日志并跳过该记录的 DWD 加载
### 需求 5任务编排与依赖管理
**用户故事:** 作为 ETL 运维人员,我希望团购详情拉取任务能正确嵌入现有调度流程,不影响其他任务的执行顺序。
#### 验收标准
1. THE Detail_Fetcher SHALL 作为 ODS_Group_Package_Task 的内部子流程执行,在列表数据写入 ODS 完成后串行启动,不注册为独立的调度任务
2. WHEN ODS_Group_Package_Task 执行时THE ETL_System SHALL 先完成 `QueryPackageCouponList` 的全量拉取和 ODS 写入,再启动 Detail_Fetcher 遍历详情
3. THE ODS_Group_Package_Task SHALL 在执行结果中包含详情拉取的统计信息(详情成功数、详情失败数),与列表拉取统计合并返回
4. IF Detail_Fetcher 执行过程中发生不可恢复的错误如网络完全不可达THEN THE ODS_Group_Package_Task SHALL 将任务状态标记为部分成功,并在结果中记录已完成的列表拉取数据和详情拉取的中断点
### 需求 6文档同步更新
**用户故事:** 作为开发者,我希望所有相关文档在功能交付时同步更新,以保持文档与代码的一致性。
#### 验收标准
1. WHEN 团购详情 ETL 功能开发完成后THE ETL_System 的文档 SHALL 更新以下内容ODS 层 DDL 文档(新增 `ods.group_buy_package_details` 表定义、ODS 字段映射文档(新增 `QueryPackageCouponInfo` 映射、数据库手册BD Manual 中新增详情表说明)
2. WHEN DWD 层扩展字段添加完成后THE ETL_System 的文档 SHALL 更新DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md` 中 dim_groupbuy_package_ex 章节、DWD 表结构概览文档
3. THE ETL_System 的 README 文档 SHALL 更新任务清单,反映 ODS_GROUP_PACKAGE 任务新增的详情拉取子流程
4. WHEN 全局限流机制实现后THE ETL_System 的架构文档 SHALL 新增限流机制的说明,包含配置参数和使用方式
---
## 附录 AAPI 请求与响应示例
### 请求
```
POST https://pc.ficoo.vip/apiprod/admin/v1/PackageCoupon/QueryPackageCouponInfo
Content-Type: application/json
Authorization: Bearer <token>
{"couponId": 3030873437310021}
```
### 响应
```json
{
"data": {
"groupPurchasePackage": {
"count": 1,
"tableAreaId": [2791960001957765],
"tableAreaNameList": ["A区"],
"id": 3030873437310021,
"add_end_clock": "1.00:00:00",
"add_start_clock": "00:00:00",
"area_tag_type": 1,
"card_type_ids": "0",
"coupon_money": 0.00,
"create_time": "2025-12-31 12:23:56",
"creator_name": "店长:郑丽珊",
"date_info": "",
"date_type": 1,
"duration": 7200,
"end_clock": "1.00:00:00",
"end_time": "2027-01-01 00:00:00",
"group_type": 1,
"is_delete": 0,
"is_enabled": 1,
"is_first_limit": 1,
"max_selectable_categories": 0,
"package_id": 1173128804,
"package_name": "助理教练竞技教学两小时",
"selling_price": 0.00,
"site_id": 2790685415443269,
"sort": 100,
"start_clock": "00:00:00",
"start_time": "2025-07-21 00:00:00",
"system_group_type": 1,
"table_area_id": "0",
"table_area_id_list": "",
"table_area_name": "",
"tenant_id": 2790683160709957,
"tenant_table_area_id": "0",
"tenant_table_area_id_list": "",
"type": 1,
"usable_count": 0,
"usable_range": ""
},
"couponCode": "",
"grouponSiteInfos": [
{
"siteId": 2790685415443269,
"siteName": "朗朗桌球"
}
],
"packageCouponAssistants": [
{
"couponAssistantId": 3030873437310023,
"skillId": 0,
"assistantLevel": "",
"assistantDuration": 7200
}
],
"packagePackageService": [],
"packageCouponDetailsList": []
},
"code": 0
}
```
### 字段标注
| 字段路径 | 含义 | 已标注 |
|----------|------|--------|
| `groupPurchasePackage.id` | 团购 ID= couponId | ✅ |
| `groupPurchasePackage.package_name` | 团购名称 | ✅ |
| `groupPurchasePackage.duration` | 台费计时时长(秒) | ✅ |
| `groupPurchasePackage.start_time` / `end_time` | 可用日期范围 | ✅ |
| `groupPurchasePackage.add_start_clock` / `add_end_clock` | 可用时段范围 | ✅ |
| `groupPurchasePackage.is_enabled` | 是否启用 | ✅ |
| `groupPurchasePackage.is_delete` | 是否已删除 | ✅ |
| `groupPurchasePackage.site_id` | 店铺 ID | ✅ |
| `groupPurchasePackage.tenant_id` | 租户 ID | ✅ |
| `groupPurchasePackage.create_time` | 创建时间 | ✅ |
| `groupPurchasePackage.creator_name` | 创建人 | ✅ |
| `groupPurchasePackage.tableAreaId` / `tableAreaNameList` | 可用台桌区域 | ✅ |
| `packageCouponAssistants[].skillId` | 可用课程 ID | ✅ |
| `packageCouponAssistants[].assistantLevel` | 可用助教等级 | ✅ |
| `packageCouponAssistants[].assistantDuration` | 助教时长(秒) | ✅ |
| `grouponSiteInfos[].siteId` / `siteName` | 关联门店 | ✅ |
| `groupPurchasePackage.count` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.area_tag_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.card_type_ids` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.coupon_money` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.date_info` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.date_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.end_clock` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.group_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.is_first_limit` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.max_selectable_categories` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.package_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.selling_price` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.sort` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.start_clock` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.system_group_type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_id_list` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.table_area_name` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.tenant_table_area_id` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.tenant_table_area_id_list` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.type` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.usable_count` | 未标注 — 待调研 | ❌ |
| `groupPurchasePackage.usable_range` | 未标注 — 待调研 | ❌ |
| `couponCode` | 未标注 — 待调研 | ❌ |
| `packageCouponAssistants[].couponAssistantId` | 未标注 — 待调研 | ❌ |
| `packagePackageService` | 示例为空 — 待调研实际数据 | ❌ |
| `packageCouponDetailsList` | 示例为空 — 待调研实际数据 | ❌ |
---
## 附录 B待调研项技术设计阶段完成
### 调研 1ODS 层表方案
- 选项 A新建独立表 `ods.group_buy_package_details`
- 选项 B在现有 `ods.group_buy_packages` 中扩展字段
- 决策依据:详情数据与列表数据的字段重叠度、数据量差异、写入模式兼容性
- 需要调研现有 `ods.group_buy_packages` 的表结构和写入逻辑
### 调研 2DWD 层表方案
- 选项 A在现有 `dwd.dim_groupbuy_package_ex` 扩展表中新增 JSONB 字段
- 选项 B新建独立的团购详情维度表
- 决策依据现有扩展表的字段密度、SCD2 版本同步复杂度、下游查询模式
- 需要调研现有 DWD 团购相关表结构(参考 `docs/reports/dwd-panorama/`
### 调研 3未标注字段用途
- 获取全部团购的详情数据后,对附录 A 中标记为 ❌ 的字段进行值分布分析
- 所有记录值完全相同的字段 → 忽略,不写入 ODS
- 值有变化的字段 → 推测用途,决定是否纳入 ODS/DWD 字段映射
### 调研 4空数组字段实际数据
- `packagePackageService`:获取全部团购后确认是否有非空数据
- `packageCouponDetailsList`:同上
- 如有数据,需补充 ODS 字段定义和 DWD 映射

View File

@@ -0,0 +1,110 @@
# 实施计划:团购详情接口整合 ETL 数据流
## 概述
将飞球团购详情接口(`QueryPackageCouponInfo`)整合到现有 ETL 的 API → ODS → DWD 三层数据流。利用 `BaseOdsTask` 已有的 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline` + `RateLimiter` + `CancellationToken`),在 `ODS_GROUP_PACKAGE` 任务的 `OdsTaskSpec` 中配置详情拉取参数,将详情数据写入新建的 `ods.group_buy_package_details` 表,并向下传导至 `dwd.dim_groupbuy_package_ex`
## 任务
- [x] 1. 新建 ODS 详情表 DDL
- [x] 1.1 创建 `db/etl_feiqiu/ods/group_buy_package_details.sql`
- 按设计文档 §4.1 定义表结构:`coupon_id` BIGINT PK、结构化字段、JSONB 数组字段、ETL 元数据字段
- 添加表和列的 COMMENT
- _需求覆盖需求 3 验收标准 1-4_
- [x] 1.2 在测试库 `test_etl_feiqiu` 执行 DDL 验证表创建成功
- _需求覆盖需求 3 验收标准 1_
- [x] 2. 扩展 ODS_GROUP_PACKAGE 任务 — 配置详情拉取
- [x] 2.1 在 `tasks/ods/ods_tasks.py``ODS_GROUP_PACKAGE` OdsTaskSpec 中添加 detail_endpoint 配置
- 设置 `detail_endpoint="/PackageCoupon/QueryPackageCouponInfo"`
- 设置 `detail_param_builder=lambda rec: {"couponId": rec["id"]}`(将列表表的 `id` 映射为详情接口的 `couponId` 参数)
- 设置 `detail_target_table="ods.group_buy_package_details"`
- 设置 `detail_data_path=("data",)`
- 设置 `detail_id_column="id"`(从 `ods.group_buy_packages` 提取 couponId 列表)
- 复用 `BaseOdsTask.execute()` 已有的详情拉取流程(`UnifiedPipeline` + `RateLimiter` + `CancellationToken`
- _需求覆盖需求 1 验收标准 1-8需求 2 验收标准 1-6需求 5 验收标准 1-4_
- [x] 2.2 实现详情响应的 `_build_detail_process_fn` 字段提取逻辑
-`data.groupPurchasePackage` 提取结构化字段(`package_name``duration``start_time``end_time``add_start_clock``add_end_clock``is_enabled``is_delete``site_id``tenant_id``create_time``creator_name`
-`data.groupPurchasePackage.tableAreaId` / `tableAreaNameList` 提取台区数组为 JSONB
-`data.packageCouponAssistants` 提取助教服务关联数组为 JSONB
-`data.grouponSiteInfos` 提取关联门店数组为 JSONB
-`data.packagePackageService``data.packageCouponDetailsList` 提取为 JSONB
- 计算 `content_hash`,保留完整原始响应为 `payload`
- _需求覆盖需求 2 验收标准 4需求 3 验收标准 2-4_
- [x] 2.3 实现详情数据的 `_build_detail_write_fn` 写入逻辑
- 采用全量快照模式(`SnapshotMode.FULL_TABLE`)写入 `ods.group_buy_package_details`
- UPSERT on `coupon_id`,每次运行覆盖全部记录
- _需求覆盖需求 3 验收标准 5_
- [x] 2.4 编写 ODS 详情拉取的单元测试
- 测试 `detail_param_builder` 参数构造
- 测试字段提取逻辑(正常响应、空数组、缺失字段)
- 测试 `content_hash` 计算一致性
- _需求覆盖需求 2 验收标准 4-5_
- [x] 3. 检查点 — ODS 层验证
- 确保所有测试通过ask the user if questions arise。
-`--dry-run --tasks ODS_GROUP_PACKAGE` 验证任务注册和配置正确
- [x] 4. DWD 扩展表 ALTER + 加载逻辑
- [x] 4.1 创建迁移脚本 `db/etl_feiqiu/migrations/2026-03-05__add_detail_fields_to_dim_groupbuy_package_ex.sql`
- ALTER TABLE 添加 4 个 JSONB 列:`table_area_ids``table_area_names``assistant_services``groupon_site_infos`
- 添加列 COMMENT
- 在测试库 `test_etl_feiqiu` 执行迁移验证
- _需求覆盖需求 4 验收标准 1_
- [x] 4.2 修改 `tasks/dwd/dwd_load_task.py``TABLE_MAPPING``COLUMN_OVERRIDES`
-`COLUMN_OVERRIDES["dwd.dim_groupbuy_package_ex"]` 中新增 4 个详情字段的映射
- _需求覆盖需求 4 验收标准 2_
- [x] 4.3 修改 `_fetch_source_rows``_merge_dim_scd2` 流程,在加载 `dim_groupbuy_package_ex` 时 LEFT JOIN `ods.group_buy_package_details`
- 通过 `ods.group_buy_packages.id = ods.group_buy_package_details.coupon_id` 关联
- 将详情表的 `table_area_ids``table_area_names``assistant_services``groupon_site_infos` 合并到 ODS 快照行
- 当详情表中 `coupon_id` 在列表表中不存在时,记录警告日志并跳过
- 新增字段自动纳入 SCD2 变更检测(现有 `_is_row_changed` 逻辑已支持 JSONB 比较)
- _需求覆盖需求 4 验收标准 2-5_
- [x] 4.4 编写 DWD 加载扩展的单元测试
- 测试 LEFT JOIN 逻辑:详情存在 / 详情缺失 / 详情多余(应跳过并警告)
- 测试 SCD2 变更检测包含新增 JSONB 字段
- _需求覆盖需求 4 验收标准 3-5_
- [x] 5. 检查点 — DWD 层验证
- 确保所有测试通过ask the user if questions arise。
-`--dry-run --tasks DWD_LOAD_FROM_ODS` 验证 DWD 加载配置正确
- [x] 6. 数据调研 — 获取全部团购详情并分析未标注字段
- [x] 6.1 编写一次性调研脚本 `apps/etl/connectors/feiqiu/scripts/research_coupon_details.py`
- 使用 `load_dotenv` 加载根 `.env`,通过 `AppConfig.load()` 获取配置
- 连接测试库 `test_etl_feiqiu``TEST_DB_DSN`
-`ods.group_buy_packages` 读取所有 `coupon_id`
- 串行调用详情接口(复用 `RateLimiter(5, 20)`),将原始响应存入 `ods.group_buy_package_details.payload`
- _需求覆盖附录 B 调研 3、4_
- [x] 6.2 分析未标注字段的值分布
- 对附录 A 中标记为 ❌ 的字段,统计所有记录的值分布
- 所有记录值完全相同的字段 → 标记为忽略
- 值有变化的字段 → 推测用途,输出分析报告到 `docs/reports/`
- 确认 `packagePackageService``packageCouponDetailsList` 是否有非空数据
- 根据分析结果调整 ODS 表字段定义和 DWD 映射(如需要)
- _需求覆盖需求 3 验收标准 6附录 B 调研 3、4_
- [x] 7. 文档同步更新
- [x] 7.1 更新 ODS 层文档
- 更新 ODS DDL 文档(新增 `ods.group_buy_package_details` 表定义)
- 更新 ODS 字段映射文档(新增 `QueryPackageCouponInfo``ods.group_buy_package_details` 映射)
- 更新 BD Manual`docs/database/BD_Manual_*.md`)新增详情表说明
- _需求覆盖需求 6 验收标准 1_
- [x] 7.2 更新 DWD 层文档
- 更新 DWD 全景文档(`docs/reports/dwd-panorama/dwd-dimension-panorama.md``dim_groupbuy_package_ex` 章节)
- 更新 DWD 表结构概览文档,反映新增的 4 个 JSONB 字段
- _需求覆盖需求 6 验收标准 2_
- [x] 7.3 更新 ETL README 和架构文档
- 更新 README 任务清单,反映 `ODS_GROUP_PACKAGE` 任务新增的详情拉取子流程
- _需求覆盖需求 6 验收标准 3_
- [x] 8. 最终检查点 — 全量验证
- 37 个单元测试全部通过ODS 16 + DWD 12 + column ref 9
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- `RateLimiter``api/rate_limiter.py`)和 `CancellationToken``utils/cancellation.py`)已在 etl-unified-pipeline 中实现,本 spec 直接复用,不重复创建
- `BaseOdsTask.execute()` 已内置 `detail_endpoint` 二级详情拉取模式(通过 `UnifiedPipeline`Task 2 利用此现有机制而非新建独立的 `DetailFetcher`
- 每个任务引用具体需求条款以确保可追溯性
- 检查点确保增量验证,避免问题累积

View File

@@ -124,6 +124,7 @@ class BaseDwsTask(BaseTask):
- `_do_extract()` 而非直接修改 `extract()` 签名,是为了保持向后兼容——已覆盖 `extract()` 的子类无需改动。 - `_do_extract()` 而非直接修改 `extract()` 签名,是为了保持向后兼容——已覆盖 `extract()` 的子类无需改动。
- `DATE_COL = None` 作为哨兵值,未声明时 load() 回退到 `"stat_date"` 默认值。 - `DATE_COL = None` 作为哨兵值,未声明时 load() 回退到 `"stat_date"` 默认值。
- 子类迁移是渐进式的:先在基类添加默认实现,再逐个子类迁移。 - 子类迁移是渐进式的:先在基类添加默认实现,再逐个子类迁移。
- **营业日切点**:所有 `stat_date` / `stat_month` 等日期列的值为营业日,以 `BUSINESS_DAY_START_HOUR`(默认 08:00为分割点。08:00 前的记录归属前一天/前一月。月统计 = 当月1日 08:00 ~ 次月1日 08:00周统计 = 周一 08:00 ~ 次周一 08:00。
### 组件 2dws_helpers.py 公共辅助模块 ### 组件 2dws_helpers.py 公共辅助模块

View File

@@ -22,7 +22,7 @@
- **Flow**ETL 编排单元,定义一组按层顺序执行的任务集合(原名 pipeline - **Flow**ETL 编排单元,定义一组按层顺序执行的任务集合(原名 pipeline
- **Layer**ETL 数据处理层级,包括 ODS、DWD、DWS、INDEX - **Layer**ETL 数据处理层级,包括 ODS、DWD、DWS、INDEX
- **Connector**ETL 连接器,对接特定上游 SaaS 的数据抽取模块(原名 pipeline 目录) - **Connector**ETL 连接器,对接特定上游 SaaS 的数据抽取模块(原名 pipeline 目录)
- **DATE_COL**DWS 子类声明的日期列名,用于 extract 和 delete_existing_data 的时间过滤 - **DATE_COL**DWS 子类声明的日期列名,用于 extract 和 delete_existing_data 的时间过滤。日期值为营业日(以 `BUSINESS_DAY_START_HOUR`(默认 08:00为日切点
- **TaskContext**:运行期上下文数据类,包含 store_id、window_start/end、window_minutes、cursor - **TaskContext**:运行期上下文数据类,包含 store_id、window_start/end、window_minutes、cursor
- **拓扑排序**:根据任务间依赖关系确定执行顺序的算法,确保被依赖任务先于依赖方执行 - **拓扑排序**:根据任务间依赖关系确定执行顺序的算法,确保被依赖任务先于依赖方执行
- **幂等**:同一操作执行多次与执行一次效果相同,本系统通过 delete-before-insert 实现 - **幂等**:同一操作执行多次与执行一次效果相同,本系统通过 delete-before-insert 实现

View File

@@ -1,126 +0,0 @@
# 设计文档ETL 全流程前后端联调etl-fullstack-integration
## 概述
本 Spec 是一个运维联调任务,不涉及新功能开发。目标是验证 `admin-web-console` Spec 产出的前后端代码在真实环境下的端到端正确性,同时收集性能数据。
核心流程:
1. 启动后端 + 前端服务
2. 通过 API 登录获取 JWT
3. 提交全流程 ETL 任务api_full, full_window, force-full, 全选常用任务, 自定义窗口 2025-11-01~2026-02-20, 30天切分, 全部门店)
4. 实时监控执行过程,捕获错误/警告
5. 执行完成后生成综合报告
## 架构
```
联调脚本 (scripts/ops/)
├── 1. 启动服务
│ ├── uvicorn app.main:app (后端 :8000)
│ └── pnpm dev (前端 :5173)
├── 2. API 调用链
│ ├── POST /api/auth/login → JWT
│ ├── GET /api/tasks/registry → 任务列表
│ ├── GET /api/tasks/sync-check → 同步检查
│ ├── POST /api/tasks/validate → CLI 预览
│ └── POST /api/execution/run → 触发执行
├── 3. 监控循环
│ ├── GET /api/execution/queue → 状态轮询
│ ├── GET /api/execution/{id}/logs → 日志获取
│ └── 错误/警告检测
└── 4. 报告生成
└── 输出到 SYSTEM_LOG_ROOT
```
## 任务参数
根据用户需求,联调任务的具体参数:
```python
INTEGRATION_TASK_CONFIG = {
"flow": "api_full", # 全流程API → ODS → DWD → DWS → INDEX
"processing_mode": "full_window", # 全窗口处理
"window_mode": "custom", # 自定义时间范围
"window_start": "2025-11-01 00:00",
"window_end": "2026-02-20 00:00",
"window_split": "day", # 按天切分
"window_split_days": 30, # 30天一个切片
"force_full": True, # 强制全量
"dry_run": False,
"tasks": [ # 全选 is_common=True 的任务
# ODS 层22 个)
"ODS_ASSISTANT_ACCOUNT", "ODS_ASSISTANT_LEDGER", "ODS_ASSISTANT_ABOLISH",
"ODS_SETTLEMENT_RECORDS", "ODS_TABLE_USE", "ODS_TABLE_FEE_DISCOUNT",
"ODS_TABLES", "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_GOODS_CATEGORY", "ODS_STORE_GOODS", "ODS_STORE_GOODS_SALES", "ODS_TENANT_GOODS",
# DWD 层1 个常用)
"DWD_LOAD_FROM_ODS",
# DWS 层15 个常用,排除 DWS_MAINTENANCE
"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",
# INDEX 层3 个常用,排除 DWS_ML_MANUAL_IMPORT
"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX",
],
# store_id 由后端从 JWT 注入(默认管理员 site_id=1
# 注意:用户要求"全部门店",但当前系统只有 site_id=1后续多门店需逐个执行
}
```
## 监控策略
- 轮询间隔30 秒
- 最长等待30 分钟(无新日志输出时)
- 错误检测:日志行匹配 `ERROR``CRITICAL``Traceback``Exception`
- 警告检测:日志行匹配 `WARNING``WARN`
- 计时解析:从日志中提取时间戳,计算阶段耗时
## 报告格式
报告输出为 Markdown 文件,路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
```markdown
# ETL 全流程联调报告
## 执行概要
- 任务参数:...
- 开始时间 / 结束时间 / 总时长
- 退出码 / 最终状态
## 性能报告
- 各窗口切片耗时对比表
- Top-5 耗时阶段
- 总体吞吐量估算
## DEBUG 报告(如有)
- 错误摘要
- 警告摘要
- 相关日志片段
- 可能的原因分析
```
## 正确性属性
本 Spec 为运维联调任务,不涉及新功能代码开发,因此不定义形式化的属性测试。验证通过以下方式进行:
- 服务健康检查通过
- 任务提交成功并开始执行
- 执行完成后退出码和日志符合预期
- 报告文件成功生成
## 测试策略
本 Spec 本身就是一次集成测试。不额外编写单元测试或属性测试。验证标准:
- 后端 API 响应正确
- ETL CLI 子进程正常启动和执行
- 日志正确捕获和推送
- 报告文件正确生成到 SYSTEM_LOG_ROOT

View File

@@ -1,86 +0,0 @@
# 实现计划ETL 全流程前后端联调etl-fullstack-integration
## 概述
基于 `admin-web-console` 已完成的前后端代码,进行端到端联调验证。通过后端 API 提交 api_full 全流程 ETL 任务(自定义窗口 2025-11-01~2026-02-2030天切分force-full全选常用任务实时监控执行过程收集性能数据最终生成综合报告。
## 任务
- [ ] 1. 服务启动与健康检查
- [ ] 1.1 启动后端服务(`apps/backend/`uvicorn :8000确认 API 可达
- 启动 `uvicorn app.main:app --host 0.0.0.0 --port 8000`cwd 为 `apps/backend/`
- 验证 `GET /api/tasks/flows` 返回 200
- _Requirements: 1.1_
- [ ] 1.2 启动前端服务(`apps/admin-web/`pnpm dev :5173确认页面可访问
- 启动 `pnpm dev`cwd 为 `apps/admin-web/`
- 验证 `http://localhost:5173` 可达
- _Requirements: 1.2_
- [ ] 1.3 API 健康检查:登录获取 JWT验证任务注册表执行 sync-check
- `POST /api/auth/login` 使用默认管理员账号admin / admin123获取 JWT
- `GET /api/tasks/registry` 确认返回非空任务列表
- `GET /api/tasks/sync-check` 确认 `in_sync=true`(后端注册表与 ETL 真实注册表一致)
- 如果 sync-check 不一致,记录差异并向用户报告
- _Requirements: 1.3, 1.4, 1.5_
- [ ] 2. 全流程任务提交
- [ ] 2.1 构建 TaskConfig 并调用 validate 预览 CLI 命令
- 构建 TaskConfigflow=api_full, processing_mode=full_window, window_mode=custom, window_start="2025-11-01 00:00", window_end="2026-02-20 00:00", window_split=day, window_split_days=30, force_full=True, tasks=全选 is_common=True 的任务
- 调用 `POST /api/tasks/validate` 验证配置有效
- 记录生成的 CLI 命令预览,确认参数完整(--flow api_full --processing-mode full_window --window-start ... --window-end ... --window-split day --window-split-days 30 --force-full --store-id 1 --tasks ...
- _Requirements: 2.1_
- [ ] 2.2 提交任务执行(`POST /api/execution/run`),记录 execution_id
- 调用 `POST /api/execution/run` 提交任务
- 记录返回的 execution_id
- 确认任务状态变为 running
- _Requirements: 2.2, 2.4_
- [ ] 3. 执行监控与 DEBUG
- [ ] 3.1 启动监控循环:每 30 秒轮询状态和日志,检测错误/警告,最长等待 30 分钟
- 轮询 `GET /api/execution/queue` 检查任务状态
- 轮询 `GET /api/execution/{id}/logs` 获取增量日志
- 检测日志中的 ERROR / CRITICAL / Traceback / Exception / WARNING
- 记录每次轮询的时间戳和日志增量行数
- 如果连续 30 分钟无新日志输出,报告超时警告
- 任务完成success/failed/cancelled时停止轮询
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5_
- [ ] 3.2 对执行过程中发现的错误/警告进行 DEBUG 分析
- 收集所有 ERROR 和 WARNING 日志行及其上下文(前后各 5 行)
- 分析错误类型API 超时、数据库连接、数据质量、配置问题等
- 如果任务失败,获取完整 stderr 并分析根因
- 记录 DEBUG 发现到报告中
- _Requirements: 3.2, 3.5_
- [ ] 4. 性能计时与报告生成
- [ ] 4.1 解析执行日志,提取精细计时数据
- 从日志中提取每个窗口切片30天的开始/结束时间
- 计算每个切片的耗时
- 识别 ODS / DWD / DWS / INDEX 各阶段的耗时
- 标注 Top-5 耗时最长的阶段/任务
- _Requirements: 4.1, 4.2, 4.3, 4.4_
- [ ] 4.2 生成综合联调报告,输出到 SYSTEM_LOG_ROOT
- 报告包含:执行概要(参数、时间、退出码)
- 报告包含性能报告各切片耗时对比、Top-5 瓶颈)
- 报告包含DEBUG 报告(如有错误/警告)
- 输出路径:`{SYSTEM_LOG_ROOT}/{date}__etl_integration_report.md`
- 路径通过 `SYSTEM_LOG_ROOT` 环境变量获取,缺失时报错
- _Requirements: 5.1, 5.2, 5.3, 5.4_
- [ ] 5. 服务清理
- [ ] 5.1 停止后端和前端服务,清理资源
- 停止 uvicorn 进程
- 停止 pnpm dev 进程
- 报告联调完成状态
## 说明
- 本 Spec 为运维联调任务,不涉及新功能代码开发
- 不编写属性测试或单元测试,联调本身即为集成验证
- 全选常用任务 = 任务注册表中 `is_common=True` 的所有任务(共 41 个)
- "全部门店":当前系统仅有 site_id=1默认管理员绑定如需多门店需逐个执行
- 监控允许空闲等待,最长 30 分钟无新日志才报超时
- 报告输出路径遵循 export-paths 规范,通过 SYSTEM_LOG_ROOT 环境变量获取

View File

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

View File

@@ -0,0 +1,834 @@
# 技术设计ETL 统一请求编排与线程模型改造
> 对应需求文档:[requirements.md](./requirements.md)
## 1. 架构概览
本次改造将现有 21 个 ODS 任务从"同步串行执行"迁移到统一的"串行请求 + 异步处理 + 单线程写库"管道架构。核心设计原则:
- **请求串行化**:所有 API 请求通过全局 `RequestScheduler` 排队,严格一个接一个发送,遵循 `RateLimiter` 限流
- **处理并行化**API 响应提交到 `ProcessingPool` 多线程处理字段提取、hash 计算等),不阻塞请求线程
- **写入串行化**:所有数据库写入由单个 `WriteWorker` 线程执行,避免并发写入冲突
- **配置灵活化**:通过 `PipelineConfig` 支持全局默认 + 任务级覆盖
- **可取消**:通过 `CancellationToken` 支持外部取消信号,优雅中断
```
┌─────────────────────────────────────────────────────────────────┐
│ FlowRunner │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Unified_Pipeline │ │
│ │ │ │
│ │ ┌─────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Request │───▶│ Processing │───▶│ Write │ │ │
│ │ │ Scheduler │ │ Pool │ │ Worker │ │ │
│ │ │ (串行请求) │ │ (N 工作线程) │ │ (单线程) │ │ │
│ │ └──────┬───────┘ └──────────────┘ └─────────────┘ │ │
│ │ │ │ │
│ │ ┌──────▼───────┐ ┌──────────────┐ │ │
│ │ │ Rate │ │ Cancellation │ │ │
│ │ │ Limiter │ │ Token │ │ │
│ │ │ (5-20s) │ │ (外部取消) │ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DWD_Loader (多线程 SCD2 调度) │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │ Table1 │ │ Table2 │ │ Table3 │ │ Table4 │ ... │ │
│ │ │ SCD2 │ │ SCD2 │ │ SCD2 │ │ SCD2 │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 1.1 设计决策与理由
| 决策 | 选项 | 理由 |
|------|------|------|
| 请求串行 vs 并行 | 串行 | 上游飞球 API 无并发友好设计,并行请求易触发风控;串行 + 限流是最安全的策略 |
| 处理线程数 | 默认 2 | ODS 数据处理是轻量 CPU 操作JSON 解析、hash 计算2 线程足够消化请求间隔产生的积压 |
| 写入单线程 | 单线程 | PostgreSQL 单连接写入避免锁竞争和事务冲突,简化错误处理和回滚逻辑 |
| Pipeline 嵌入 vs 独立 | 嵌入 BaseOdsTask | Pipeline 作为 BaseOdsTask 内部执行引擎对外接口TaskExecutor、FlowRunner完全不变 |
| DWD 多线程 | 调度层并行 | 仅在调度层并行调用 `_merge_dim_scd2()`,方法本身不改,每张表独立事务 |
## 2. 架构
### 2.1 整体数据流
```mermaid
graph TD
A[FlowRunner.run] --> B[TaskExecutor.run_tasks]
B --> C{ODS 任务}
C --> D[BaseOdsTask.execute]
D --> E[UnifiedPipeline.run]
E --> F[RequestScheduler<br/>串行请求 + RateLimiter]
F --> G[ProcessingPool<br/>多线程处理]
G --> H[WriteWorker<br/>单线程写库]
H --> I[ODS 表写入完成]
I --> J{有 Detail_Mode?}
J -->|是| K[DetailFetcher<br/>二级详情拉取]
K --> F
J -->|否| L[返回结果]
K --> L
L --> M[DWD_Loader]
M --> N[多线程 SCD2 调度]
N --> O[DWD 表写入完成]
```
### 2.2 线程模型详细设计
```
主线程RequestScheduler
│ for request in request_queue:
│ if cancel_token.is_cancelled: break
│ resp = api_client.post(endpoint, params)
│ processing_queue.put((request_id, resp))
│ rate_limiter.wait(cancel_token.event)
│ processing_queue.put(SENTINEL × worker_count)
│ 等待所有 worker 完成
│ write_queue.put(SENTINEL)
│ 等待 writer 完成
├──▶ Worker Thread 1 ──┐
├──▶ Worker Thread 2 ──┤
│ │
│ processing_queue │
│ ┌─────────────┐ │
│ │ (id, resp) │───▶ 字段提取 / content_hash 计算
│ │ (id, resp) │ write_queue.put(processed_rows)
│ │ SENTINEL │
│ └─────────────┘ │
│ │
│ ▼
│ Write Thread (单线程)
│ ┌─────────────┐
│ │ write_queue │
│ │ batch=100 │──▶ UPSERT / INSERT
│ │ timeout=5s │
│ │ SENTINEL │
│ └─────────────┘
PipelineResult统计信息
```
关键设计点:
- `processing_queue``queue.Queue(maxsize=queue_size)`,默认 100满时 `RequestScheduler` 阻塞(背压机制)
- `write_queue``queue.Queue(maxsize=queue_size * 2)`,默认 200
- SENTINEL`None` 对象,通知线程退出
- 取消信号:主线程检查 `cancel_token`worker/writer 通过 SENTINEL 正常退出
- 批量写入:累积到 `batch_size`(默认 100或等待 `batch_timeout`(默认 5 秒)后执行一次
### 2.3 取消信号传递链
```
外部触发Admin-web / CLI / 超时)
│ cancel_token.cancel()
RequestScheduler
│ rate_limiter.wait() 提前返回 False
│ 主循环 break不再发新请求
ProcessingPool
│ 通过 SENTINEL 正常退出
│ 已入队数据全部处理完成
WriteWorker
│ 通过 SENTINEL 正常退出
│ 已处理数据全部写入 DB
返回 PipelineResult(cancelled=True, ...)
```
## 3. 组件与接口
### 3.1 PipelineConfig配置数据类
文件:`apps/etl/connectors/feiqiu/config/pipeline_config.py`
```python
@dataclass(frozen=True)
class PipelineConfig:
"""统一管道配置,支持全局默认 + 任务级覆盖。"""
workers: int = 2 # ProcessingPool 工作线程数
queue_size: int = 100 # 处理队列容量
batch_size: int = 100 # WriteWorker 批量写入阈值
batch_timeout: float = 5.0 # WriteWorker 等待超时(秒)
rate_min: float = 5.0 # RateLimiter 最小间隔(秒)
rate_max: float = 20.0 # RateLimiter 最大间隔(秒)
max_consecutive_failures: int = 10 # 连续失败中断阈值
def __post_init__(self):
if self.workers < 1:
raise ValueError(f"workers 必须 >= 1当前值: {self.workers}")
if self.queue_size < 1:
raise ValueError(f"queue_size 必须 >= 1当前值: {self.queue_size}")
if self.batch_size < 1:
raise ValueError(f"batch_size 必须 >= 1当前值: {self.batch_size}")
if self.rate_min > self.rate_max:
raise ValueError(
f"rate_min({self.rate_min}) 不能大于 rate_max({self.rate_max})"
)
@classmethod
def from_app_config(cls, config: AppConfig, task_code: str | None = None) -> "PipelineConfig":
"""从 AppConfig 加载,支持 pipeline.<task_code>.* 任务级覆盖。"""
def _get(key: str, default):
# 优先任务级 → 全局级 → 默认值
if task_code:
val = config.get(f"pipeline.{task_code.lower()}.{key}")
if val is not None:
return type(default)(val)
val = config.get(f"pipeline.{key}")
if val is not None:
return type(default)(val)
return default
return cls(
workers=_get("workers", 2),
queue_size=_get("queue_size", 100),
batch_size=_get("batch_size", 100),
batch_timeout=_get("batch_timeout", 5.0),
rate_min=_get("rate_min", 5.0),
rate_max=_get("rate_max", 20.0),
max_consecutive_failures=_get("max_consecutive_failures", 10),
)
```
### 3.2 CancellationToken取消令牌
文件:`apps/etl/connectors/feiqiu/utils/cancellation.py`
```python
class CancellationToken:
"""线程安全的取消令牌,封装 threading.Event。"""
def __init__(self, timeout: float | None = None):
self._event = threading.Event()
self._timer: threading.Timer | None = None
if timeout is not None and timeout > 0:
self._timer = threading.Timer(timeout, self.cancel)
self._timer.daemon = True
self._timer.start()
def cancel(self):
"""发出取消信号。"""
self._event.set()
@property
def is_cancelled(self) -> bool:
return self._event.is_set()
@property
def event(self) -> threading.Event:
return self._event
def dispose(self):
"""清理超时定时器。"""
if self._timer is not None:
self._timer.cancel()
self._timer = None
```
### 3.3 RateLimiter限流器
文件:`apps/etl/connectors/feiqiu/api/rate_limiter.py`
```python
class RateLimiter:
"""请求间隔控制器,支持取消信号中断等待。"""
def __init__(self, min_interval: float = 5.0, max_interval: float = 20.0):
if min_interval > max_interval:
raise ValueError(
f"min_interval({min_interval}) 不能大于 max_interval({max_interval})"
)
self._min = min_interval
self._max = max_interval
self._last_interval: float = 0.0
def wait(self, cancel_event: threading.Event | None = None) -> bool:
"""等待随机间隔。返回 False 表示被取消信号中断。
将等待时间拆分为 0.5s 小段,每段检查 cancel_event。"""
interval = random.uniform(self._min, self._max)
self._last_interval = interval
remaining = interval
while remaining > 0:
if cancel_event and cancel_event.is_set():
return False
sleep_time = min(0.5, remaining)
time.sleep(sleep_time)
remaining -= sleep_time
return True
@property
def last_interval(self) -> float:
return self._last_interval
```
### 3.4 UnifiedPipeline统一管道引擎
文件:`apps/etl/connectors/feiqiu/pipeline/unified_pipeline.py`
这是核心组件,封装"串行请求 + 异步处理 + 单线程写库"的完整执行引擎。
```python
@dataclass
class PipelineResult:
"""管道执行结果统计。"""
status: str = "SUCCESS" # SUCCESS / PARTIAL / CANCELLED / FAILED
total_requests: int = 0
completed_requests: int = 0
total_fetched: int = 0
total_inserted: int = 0
total_updated: int = 0
total_skipped: int = 0
request_failures: int = 0
processing_failures: int = 0
write_failures: int = 0
cancelled: bool = False
errors: list[dict] = field(default_factory=list)
timing: dict[str, float] = field(default_factory=dict) # 各阶段耗时
class UnifiedPipeline:
"""统一管道引擎:串行请求 + 异步处理 + 单线程写库。"""
def __init__(
self,
api_client: APIClient,
db_connection,
logger: logging.Logger,
config: PipelineConfig,
cancel_token: CancellationToken | None = None,
):
self.api = api_client
self.db = db_connection
self.logger = logger
self.config = config
self.cancel_token = cancel_token or CancellationToken()
self._rate_limiter = RateLimiter(config.rate_min, config.rate_max)
def run(
self,
requests: Iterable[PipelineRequest],
process_fn: Callable[[Any], list[dict]],
write_fn: Callable[[list[dict]], WriteResult],
) -> PipelineResult:
"""执行管道。
Args:
requests: 请求迭代器(由 BaseOdsTask 生成,包含 endpoint、params 等)
process_fn: 处理函数,将 API 响应转换为待写入记录列表
write_fn: 写入函数,将记录批量写入数据库
"""
if self.cancel_token.is_cancelled:
return PipelineResult(status="CANCELLED", cancelled=True)
processing_queue = queue.Queue(maxsize=self.config.queue_size)
write_queue = queue.Queue(maxsize=self.config.queue_size * 2)
result = PipelineResult()
# 启动处理线程池
workers = []
for i in range(self.config.workers):
t = threading.Thread(
target=self._process_worker,
args=(processing_queue, write_queue, process_fn, result),
name=f"pipeline-worker-{i}",
daemon=True,
)
t.start()
workers.append(t)
# 启动写入线程
writer = threading.Thread(
target=self._write_worker,
args=(write_queue, write_fn, result),
name="pipeline-writer",
daemon=True,
)
writer.start()
# 主线程:串行请求
self._request_loop(requests, processing_queue, result)
# 发送 SENTINEL 到处理队列
for _ in workers:
processing_queue.put(None)
for w in workers:
w.join()
# 发送 SENTINEL 到写入队列
write_queue.put(None)
writer.join()
# 确定最终状态
if result.cancelled:
result.status = "CANCELLED"
elif result.request_failures + result.processing_failures + result.write_failures > 0:
result.status = "PARTIAL"
return result
```
### 3.5 BaseOdsTask 改造
文件:`apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py`(修改现有文件)
改造策略:在 `BaseOdsTask.execute()` 内部用 `UnifiedPipeline` 替代现有的同步循环但保留所有现有功能时间窗口解析、分页拉取、结构感知写入、快照软删除、content_hash 去重)。
```python
class BaseOdsTask(BaseTask):
"""改造后的 ODS 任务基类。"""
def execute(self, cursor_data: dict | None = None) -> dict:
spec = self.SPEC
# ... 现有的窗口解析、分段逻辑保持不变 ...
# 构建 PipelineConfig支持任务级覆盖
pipeline_config = PipelineConfig.from_app_config(self.config, spec.code)
cancel_token = getattr(self, '_cancel_token', None)
pipeline = UnifiedPipeline(
api_client=self.api,
db_connection=self.db,
logger=self.logger,
config=pipeline_config,
cancel_token=cancel_token,
)
# 将现有的分页请求逻辑封装为 PipelineRequest 迭代器
# 将现有的 _insert_records_schema_aware 封装为 write_fn
# 将现有的字段提取/hash 计算封装为 process_fn
result = pipeline.run(
requests=self._build_requests(spec, segments, store_id, page_size),
process_fn=self._build_process_fn(spec),
write_fn=self._build_write_fn(spec, source_file),
)
# ... 快照软删除逻辑保持不变 ...
# ... 结果构建逻辑保持不变 ...
```
关键约束:
- `OdsTaskSpec` 数据类的所有现有字段保持不变
- `_insert_records_schema_aware()``_mark_missing_as_deleted()` 等方法保持不变
- `TaskExecutor` 调用 `task.execute(cursor_data)` 的接口保持不变
- `TaskRegistry` 中的注册代码保持不变
### 3.6 OdsTaskSpec 扩展Detail_Mode 支持)
在现有 `OdsTaskSpec` 数据类中新增可选字段:
```python
@dataclass(frozen=True)
class OdsTaskSpec:
# ... 所有现有字段保持不变 ...
# Detail_Mode 可选配置(新增)
detail_endpoint: str | None = None # 详情接口 endpoint
detail_param_builder: Callable[[dict], dict] | None = None # 详情请求参数构造
detail_target_table: str | None = None # 详情数据目标表名
detail_data_path: tuple[str, ...] | None = None # 详情数据的 data_path
detail_list_key: str | None = None # 详情数据的 list_key
detail_id_column: str | None = None # 从列表数据中提取 ID 的列名
```
`detail_endpoint``None`Pipeline 跳过详情拉取阶段,行为与纯列表模式完全一致。
### 3.7 DWD 多线程调度器
文件:`apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py`(修改现有文件)
改造 `DwdLoadTask.load()` 方法,将现有的串行 `for dwd_table, ods_table in TABLE_MAP` 循环改为 `concurrent.futures.ThreadPoolExecutor` 并行调度:
```python
def load(self, extracted: dict[str, Any], context: TaskContext) -> dict[str, Any]:
now = extracted["now"]
parallel_workers = int(self.config.get("dwd.parallel_workers", 4))
# 将表分为维度表和事实表两组
dim_tables = [(d, o) for d, o in self.TABLE_MAP.items()
if self._table_base(d).startswith("dim_")]
fact_tables = [(d, o) for d, o in self.TABLE_MAP.items()
if not self._table_base(d).startswith("dim_")]
summary = []
errors = []
# 维度表并行 SCD2 合并(每张表独立事务、独立数据库连接)
with ThreadPoolExecutor(max_workers=parallel_workers) as executor:
futures = {}
for dwd_table, ods_table in dim_tables:
if only_tables and ...: # 过滤逻辑保持不变
continue
future = executor.submit(
self._process_single_table, dwd_table, ods_table, now, context
)
futures[future] = dwd_table
for future in as_completed(futures):
dwd_table = futures[future]
try:
table_result = future.result()
summary.append(table_result)
except Exception as exc:
errors.append({"table": dwd_table, "error": str(exc)})
# 事实表同样并行处理
# ... 类似逻辑 ...
return {"tables": summary, "errors": len(errors), "error_details": errors}
```
关键约束:
- `_merge_dim_scd2()` 方法本身不改
- 每张表使用独立的数据库连接和事务
- 单张表失败不影响其他表
### 3.8 任务日志管理器
文件:`apps/etl/connectors/feiqiu/utils/task_log_buffer.py`(新建)
```python
class TaskLogBuffer:
"""任务级日志缓冲区,收集单个任务的所有日志,任务完成后一次性输出。"""
def __init__(self, task_code: str, parent_logger: logging.Logger):
self.task_code = task_code
self._buffer: list[LogEntry] = []
self._lock = threading.Lock()
self._parent = parent_logger
def log(self, level: int, message: str, *args, **kwargs):
"""线程安全地缓冲一条日志。"""
with self._lock:
self._buffer.append(LogEntry(
timestamp=datetime.now(),
level=level,
task_code=self.task_code,
message=message % args if args else message,
))
def flush(self) -> list[LogEntry]:
"""将缓冲区内容按时间顺序一次性输出到父 logger并返回日志列表。"""
with self._lock:
entries = sorted(self._buffer, key=lambda e: e.timestamp)
for entry in entries:
self._parent.log(
entry.level,
"[%s] %s",
entry.task_code,
entry.message,
)
self._buffer.clear()
return entries
```
## 4. 数据模型
### 4.1 PipelineConfig 配置命名空间
`AppConfig` 中新增 `pipeline.*` 命名空间:
| 配置键 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `pipeline.workers` | int | 2 | ProcessingPool 工作线程数 |
| `pipeline.queue_size` | int | 100 | 处理队列容量 |
| `pipeline.batch_size` | int | 100 | WriteWorker 批量写入阈值 |
| `pipeline.batch_timeout` | float | 5.0 | WriteWorker 等待超时(秒) |
| `pipeline.rate_min` | float | 5.0 | RateLimiter 最小间隔(秒) |
| `pipeline.rate_max` | float | 20.0 | RateLimiter 最大间隔(秒) |
| `pipeline.max_consecutive_failures` | int | 10 | 连续失败中断阈值 |
| `pipeline.<TASK_CODE>.workers` | int | - | 任务级覆盖:工作线程数 |
| `pipeline.<TASK_CODE>.rate_min` | float | - | 任务级覆盖:最小间隔 |
| `pipeline.<TASK_CODE>.rate_max` | float | - | 任务级覆盖:最大间隔 |
| `dwd.parallel_workers` | int | 4 | DWD 层并行线程数 |
任务级覆盖示例(`.env`
```
PIPELINE_WORKERS=2
PIPELINE_RATE_MIN=5.0
PIPELINE_ODS_GROUP_PACKAGE.RATE_MIN=8.0
PIPELINE_ODS_GROUP_PACKAGE.RATE_MAX=25.0
```
CLI 参数覆盖:
```
--pipeline-workers 4
--pipeline-batch-size 200
--pipeline-rate-min 3.0
--pipeline-rate-max 15.0
```
### 4.2 PipelineRequest 数据类
```python
@dataclass
class PipelineRequest:
"""管道请求描述。"""
endpoint: str
params: dict
page_size: int | None = 200
data_path: tuple[str, ...] = ("data",)
list_key: str | None = None
segment_index: int = 0 # 所属窗口分段索引
is_detail: bool = False # 是否为详情请求
detail_id: Any = None # 详情请求的 ID
```
### 4.3 PipelineResult 数据类
```python
@dataclass
class PipelineResult:
"""管道执行结果。"""
status: str = "SUCCESS"
total_requests: int = 0
completed_requests: int = 0
total_fetched: int = 0
total_inserted: int = 0
total_updated: int = 0
total_skipped: int = 0
total_deleted: int = 0
request_failures: int = 0
processing_failures: int = 0
write_failures: int = 0
cancelled: bool = False
errors: list[dict] = field(default_factory=list)
timing: dict[str, float] = field(default_factory=dict)
# Detail_Mode 统计(仅在启用时填充)
detail_success: int = 0
detail_failure: int = 0
detail_skipped: int = 0
```
### 4.4 WriteResult 数据类
```python
@dataclass
class WriteResult:
"""单次批量写入结果。"""
inserted: int = 0
updated: int = 0
skipped: int = 0
errors: int = 0
```
### 4.5 LogEntry 数据类
```python
@dataclass
class LogEntry:
"""日志条目。"""
timestamp: datetime
level: int
task_code: str
message: str
```
### 4.6 现有数据模型不变
以下现有数据模型保持不变:
- `OdsTaskSpec`:仅新增 Detail_Mode 可选字段,所有现有字段不变
- `TaskContext`:不变
- `TaskMeta`:不变
- `SnapshotMode`:不变
- `ColumnSpec`:不变
## 5. 正确性属性
*属性Property是系统在所有有效执行中都应保持为真的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
以下属性基于需求文档中的验收标准推导,经过冗余消除和合并后得到 16 个独立属性。
### Property 1: 请求严格串行
*对于任意*一组提交到 RequestScheduler 的 API 请求,每个请求的发送时间戳必须严格晚于上一个请求的响应完成时间戳,无论请求来自同一个 ODS 任务还是不同的 ODS 任务。
**Validates: Requirements 1.2, 1.6**
### Property 2: RateLimiter 间隔范围
*对于任意*有效的 min_interval 和 max_interval 配置min_interval <= max_intervalRateLimiter.wait() 的实际等待时间始终在 [min_interval, max_interval] 范围内(允许 ±0.5s 的系统调度误差)。
**Validates: Requirements 1.3**
### Property 3: PipelineConfig 构造与验证
*对于任意*一组配置参数,当 workers >= 1、queue_size >= 1、batch_size >= 1 且 rate_min <= rate_max 时PipelineConfig 应成功构造并正确存储所有参数值;当任一条件不满足时,应抛出 ValueError。
**Validates: Requirements 1.5, 4.1, 4.4, 4.5**
### Property 4: 配置分层与任务级覆盖
*对于任意*任务代码和配置值组合PipelineConfig.from_app_config() 应遵循优先级:任务级配置(`pipeline.<task_code>.*`> 全局配置(`pipeline.*`> 默认值。当任务级配置存在时,应覆盖全局配置;当任务级配置不存在时,应回退到全局配置。
**Validates: Requirements 1.4, 2.3, 2.6, 4.2, 4.3, 4.6, 7.2**
### Property 5: 管道完成语义
*对于任意*一组成功的 API 请求无失败、无取消UnifiedPipeline.run() 返回时PipelineResult 中的 total_fetched 应等于所有请求返回的记录总数,且 total_inserted + total_updated + total_skipped 应等于 total_fetched。
**Validates: Requirements 2.7**
### Property 6: WriteWorker 批量大小约束
*对于任意*配置的 batch_sizeWriteWorker 每次调用 write_fn 时传入的记录数不超过 batch_size。
**Validates: Requirements 2.5**
### Property 7: CancellationToken 状态转换
*对于任意* CancellationToken 实例,初始状态 is_cancelled 为 False调用 cancel() 后 is_cancelled 变为 True 且不可逆转。对于配置了超时的 CancellationToken在超时时间到达后 is_cancelled 自动变为 True。
**Validates: Requirements 3.1, 3.6**
### Property 8: 取消后已入队数据不丢失
*对于任意*管道执行过程中触发取消信号的时刻,管道返回时:(a) 不再发送新的 API 请求,(b) 所有已提交到 processing_queue 的数据全部被处理完成,(c) 所有已处理完成的数据全部被写入数据库,(d) 返回结果的 status 为 CANCELLED 且 cancelled 为 True。
**Validates: Requirements 3.2, 3.3, 3.4, 3.5**
### Property 9: 迁移前后输出等价
*对于任意* ODS 任务和相同的输入数据API 响应序列),通过 UnifiedPipeline 执行后产生的数据库写入结果inserted/updated/skipped 计数和记录内容)应与迁移前的同步串行执行完全一致。
**Validates: Requirements 5.1, 5.3, 5.4, 5.5**
### Property 10: Detail_Mode 可选性
*对于任意* OdsTaskSpec当 detail_endpoint 为 None 时,管道执行应跳过详情拉取阶段,结果中 detail_success/detail_failure/detail_skipped 均为 0当 detail_endpoint 已配置时,管道应在列表拉取完成后执行详情拉取,且详情请求遵循与列表请求相同的限流规则。
**Validates: Requirements 6.1, 6.3, 6.4**
### Property 11: 单项失败不中断整体
*对于任意*管道执行中的单个失败项API 请求失败、处理异常、详情接口错误、DWD 单表合并失败),管道应继续处理后续项目,不中断整体流程,且失败项被正确记录在结果的 errors 列表中。
**Validates: Requirements 6.5, 9.1, 9.2, 7.3, 7.4**
### Property 12: 连续失败触发中断
*对于任意*管道执行,当连续失败次数超过 max_consecutive_failures 配置值时,管道应主动中断执行,返回结果的 status 为 FAILED。当连续失败次数未超过阈值时管道应继续执行。
**Validates: Requirements 9.5**
### Property 13: 写入失败回滚当前批次
*对于任意*批量写入操作,当 write_fn 抛出数据库异常时,当前批次的事务应被回滚(不产生部分写入),该批次的记录被标记为写入失败,后续批次不受影响。
**Validates: Requirements 9.3**
### Property 14: 结果统计完整性
*对于任意*管道执行(包括 Detail_Mode 和 DWD 多线程返回结果中的统计信息应完整且一致request_failures + processing_failures + write_failures 应等于 errors 列表的长度detail_success + detail_failure + detail_skipped 应等于详情请求总数DWD 汇总中成功表数 + 失败表数应等于总表数。
**Validates: Requirements 6.6, 8.2, 9.4, 7.5**
### Property 15: 日志缓冲区按任务隔离
*对于任意*多个并发任务的日志流,每个 TaskLogBuffer 的 flush() 输出应仅包含该任务的日志条目,且按时间戳升序排列,不包含其他任务的日志。
**Validates: Requirements 10.1, 10.4**
### Property 16: DWD 并行与串行结果一致
*对于任意*一组 DWD 表的 SCD2 合并操作,多线程并行执行的最终结果(每张表的 inserted/updated 计数)应与串行逐表执行的结果完全一致。
**Validates: Requirements 7.1**
## 6. 错误处理
### 6.1 错误分类与处理策略
| 错误类型 | 触发条件 | 处理方式 | 影响范围 |
|----------|----------|----------|----------|
| API 请求失败 | HTTP 错误、超时、API 返回错误码 | 由 `APIClient` 内置重试3 次指数退避);耗尽后记录错误,继续下一个请求 | 单个请求 |
| 处理异常 | 字段提取、hash 计算等抛出异常 | 捕获异常,记录错误日志(含记录标识),标记为处理失败,继续处理队列 | 单条记录 |
| 写入失败 | 数据库错误(约束冲突、连接断开等) | 回滚当前批次事务,记录错误日志(含批次大小),标记为写入失败 | 单个批次 |
| 连续失败 | 连续 N 次请求/处理/写入失败 | 主动中断管道status=FAILED | 整个任务 |
| 取消信号 | 外部触发 cancel_token.cancel() | 停止新请求,等待已入队数据处理完成后退出 | 整个任务 |
| 配置错误 | workers<1, rate_min>rate_max 等 | 构造时抛出 ValueError任务不启动 | 整个任务 |
| DWD 单表失败 | SCD2 合并过程中异常 | 回滚该表事务,记录错误,继续处理其他表 | 单张表 |
### 6.2 连续失败计数逻辑
```python
consecutive_failures = 0
for request in requests:
try:
response = api_client.post(...)
consecutive_failures = 0 # 成功则重置
except Exception:
consecutive_failures += 1
if consecutive_failures >= config.max_consecutive_failures:
result.status = "FAILED"
break
```
### 6.3 事务管理
- ODS 层每个窗口分段segment的数据在该分段全部处理完成后统一 commit分段失败时 rollback 该分段(保留现有语义)
- DWD 层:每张表独立事务,单表失败 rollback 不影响其他表
- WriteWorker每个批次独立事务批次失败 rollback 不影响后续批次
## 7. 测试策略
### 7.1 测试框架
- 单元测试:`pytest`ETL 模块内 `tests/unit/`
- 属性测试:`hypothesis`Monorepo 级 `tests/`
- 每个属性测试最少运行 100 次迭代
### 7.2 属性测试计划
每个正确性属性对应一个属性测试,使用 `hypothesis` 库实现:
| 属性 | 测试文件 | 生成器策略 |
|------|----------|-----------|
| P1: 请求严格串行 | `tests/test_pipeline_properties.py` | 生成随机请求序列,用 FakeAPI 记录时间戳 |
| P2: RateLimiter 间隔范围 | `tests/test_rate_limiter_properties.py` | 生成随机 (min, max) 对,验证 wait() 时间 |
| P3: PipelineConfig 构造 | `tests/test_pipeline_config_properties.py` | 生成随机配置参数组合(含无效值) |
| P4: 配置分层覆盖 | `tests/test_pipeline_config_properties.py` | 生成随机的多层配置字典 |
| P5: 管道完成语义 | `tests/test_pipeline_properties.py` | 生成随机记录集,验证计数一致 |
| P6: WriteWorker 批量约束 | `tests/test_pipeline_properties.py` | 生成随机 batch_size 和记录流 |
| P7: CancellationToken 状态 | `tests/test_cancellation_properties.py` | 生成随机超时值 |
| P8: 取消后数据不丢失 | `tests/test_pipeline_properties.py` | 生成随机请求序列 + 随机取消时刻 |
| P9: 迁移等价 | `tests/test_migration_properties.py` | 生成随机 API 响应,对比新旧实现 |
| P10: Detail_Mode 可选性 | `tests/test_detail_mode_properties.py` | 生成有/无 detail_endpoint 的 OdsTaskSpec |
| P11: 单项失败不中断 | `tests/test_pipeline_properties.py` | 生成含随机失败的请求序列 |
| P12: 连续失败中断 | `tests/test_pipeline_properties.py` | 生成连续失败序列 + 随机阈值 |
| P13: 写入失败回滚 | `tests/test_pipeline_properties.py` | 生成含随机写入失败的批次 |
| P14: 结果统计完整性 | `tests/test_pipeline_properties.py` | 生成随机执行结果,验证计数一致性 |
| P15: 日志缓冲区隔离 | `tests/test_log_buffer_properties.py` | 生成多任务随机日志流 |
| P16: DWD 并行串行一致 | `tests/test_dwd_parallel_properties.py` | 生成随机表集合 + mock SCD2 |
每个测试必须包含注释标签:
```python
# Feature: etl-unified-pipeline, Property 1: 请求严格串行
```
### 7.3 单元测试计划
单元测试聚焦于具体示例、边界条件和集成点:
| 测试目标 | 测试文件 | 覆盖内容 |
|----------|----------|----------|
| RateLimiter | `tests/unit/test_rate_limiter.py` | 边界min=max、取消中断、min>max 抛错 |
| CancellationToken | `tests/unit/test_cancellation.py` | 边界:预取消、超时=0、dispose |
| PipelineConfig | `tests/unit/test_pipeline_config.py` | 边界无效参数、CLI 覆盖 |
| UnifiedPipeline | `tests/unit/test_unified_pipeline.py` | 集成FakeAPI + FakeDB 端到端 |
| TaskLogBuffer | `tests/unit/test_task_log_buffer.py` | 边界:空缓冲区、并发写入 |
| DWD 多线程调度 | `tests/unit/test_dwd_parallel.py` | 集成mock SCD2 + 单表失败 |
| Detail_Mode | `tests/unit/test_detail_mode.py` | 集成:列表→详情完整流程 |
### 7.4 测试环境
- 单元测试使用 FakeDB/FakeAPI不涉及真实数据库连接
- 属性测试使用 `hypothesis` 库,最少 100 次迭代
- 集成测试(如需)使用 `test_etl_feiqiu` 测试库,通过 `TEST_DB_DSN` 连接

View File

@@ -0,0 +1,159 @@
# 需求文档ETL 统一请求编排与线程模型改造
## 简介
对飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`)的请求编排和线程模型进行全局统一化改造。当前系统所有 ODS 任务在 `BaseOdsTask.execute()` 中同步串行执行 API 请求、数据处理和数据库写入,无限流机制、无取消信号、无并行处理能力。本次改造建立统一的请求编排框架,所有 21 个 ODS 任务迁移到"串行请求 + 异步处理 + 单线程写库"架构支持全局限流5-20 秒随机间隔)、外部取消信号、可选的"列表→详情"二级拉取模式,并对 DWD 层加载进行多线程优化。
## 术语表
- **ETL_System**:飞球 Connector ETL 系统(`apps/etl/connectors/feiqiu/`),负责从飞球 SaaS API 拉取数据并加载到 PostgreSQL 的 ODS → DWD → DWS 各层
- **Unified_Pipeline**:统一请求编排框架,所有 ODS 任务共用的"串行请求 + 异步处理 + 单线程写库"执行引擎
- **Request_Scheduler**:全局请求调度器,负责将所有 API 请求统一排队、串行发送、遵循限流规则
- **Rate_Limiter**:请求间隔控制器,控制相邻两次 API 请求之间的随机等待时间(默认 5-20 秒均匀分布),防止触发上游风控
- **Processing_Pool**:异步处理线程池,多个工作线程并行消费 API 响应数据执行字段提取、数据清洗、content_hash 计算等 CPU 密集操作
- **Write_Worker**:单线程写入工作器,汇总所有处理完成的结果,统一执行数据库写入操作,保证写入串行化
- **CancellationToken**:取消令牌,外部组件(如 Admin-web、CLI通过设置该令牌通知正在执行的任务中断
- **ODS_Task**ODS 层数据拉取任务的统称,当前共 21 个,通过 `OdsTaskSpec` 数据类定义、`_build_task_class()` 动态生成任务类
- **Detail_Mode**:二级详情拉取模式,在列表接口拉取完成后逐条调用详情接口获取更丰富的数据,属于可选能力
- **Pipeline_Config**:管道配置,包含 worker 数、队列大小、批量写入阈值、限流间隔等参数,不同任务可独立配置
- **BaseOdsTask**:当前 ODS 任务基类(`tasks/ods/ods_tasks.py`封装时间窗口解析、API 分页拉取、结构感知写入、快照软删除等核心逻辑
- **TaskExecutor**:任务执行器(`orchestration/task_executor.py`),封装单个任务的执行生命周期(游标管理、运行记录、数据源路由)
- **FlowRunner**:流程编排器(`orchestration/flow_runner.py`编排多层任务ODS → DWD → DWS → INDEX的执行顺序
- **DWD_Loader**DWD 层加载任务(`DwdLoadTask`),通过 `_merge_dim_scd2()` 执行 SCD2 合并,将 ODS 原始数据转换为维度/事实表
## 需求
### 需求 1统一请求调度器
**用户故事:** 作为 ETL 运维人员,我希望所有 API 请求通过统一的调度器串行发送并遵循限流规则,避免触发上游风控导致 IP 封禁或数据拉取失败。
#### 验收标准
1. THE Request_Scheduler SHALL 维护一个全局请求队列,所有 ODS 任务的 API 请求统一进入该队列排队等待发送
2. THE Request_Scheduler SHALL 严格串行发送请求:等待上一个请求的 HTTP 响应完整返回后,再等待限流间隔,然后发送队列中的下一个请求
3. THE Rate_Limiter SHALL 在每两个相邻请求之间插入 5 至 20 秒之间的随机等待时间(均匀分布)
4. THE Rate_Limiter SHALL 支持通过 Pipeline_Config 调整最小间隔(默认 5 秒)和最大间隔(默认 20 秒),不同任务可配置不同的间隔范围
5. IF Rate_Limiter 初始化时最小间隔大于最大间隔THEN THE Rate_Limiter SHALL 抛出 `ValueError` 并包含描述性错误信息
6. WHEN 同一次 FlowRunner 执行中包含多个 ODS 任务时THE Request_Scheduler SHALL 按任务注册顺序依次处理每个任务的请求,同一时刻仅有一个 HTTP 请求在途
7. THE Request_Scheduler SHALL 在每个请求完成后记录请求耗时、响应状态码和目标 endpoint 到日志
### 需求 2异步处理与单线程写库架构
**用户故事:** 作为 ETL 运维人员,我希望 API 响应数据的处理字段提取、清洗、hash 计算)能与请求发送并行执行,同时保证数据库写入的串行化,在不增加 API 压力的前提下提升整体吞吐。
#### 验收标准
1. THE Unified_Pipeline SHALL 在每个 API 请求的响应返回后,立即将响应数据提交到 Processing_Pool 的任务队列,不阻塞 Request_Scheduler 的限流等待计时
2. THE Processing_Pool SHALL 支持多个工作线程并行消费处理队列中的响应数据执行字段提取、数据清洗、content_hash 计算、record 层合并等操作
3. THE Processing_Pool 的工作线程数量 SHALL 通过 Pipeline_Config 配置(默认值 2不同任务可独立配置
4. THE Write_Worker SHALL 作为单独的线程运行,从处理完成队列中消费数据,统一执行数据库 INSERT/UPSERT 操作
5. THE Write_Worker SHALL 支持批量写入:累积到配置的阈值(默认 100 条记录)或等待超时(默认 5 秒)后执行一次批量写入
6. THE Write_Worker 的批量写入阈值和等待超时 SHALL 通过 Pipeline_Config 配置,不同任务可独立配置
7. WHEN 所有请求发送完毕后THE Unified_Pipeline SHALL 等待 Processing_Pool 和 Write_Worker 全部完成后再返回最终结果
8. THE Unified_Pipeline SHALL 保证多线程读库操作的安全性Processing_Pool 中的工作线程可并行读取数据库(如查询最新 content_hash使用独立的只读数据库连接
### 需求 3外部取消信号支持
**用户故事:** 作为 ETL 运维人员,我希望能通过 Admin-web 或 CLI 发送取消信号中断正在执行的 ODS 任务,避免长时间运行的任务无法停止。
#### 验收标准
1. THE CancellationToken SHALL 提供线程安全的 `cancel()` 方法和 `is_cancelled` 属性,供外部组件触发取消
2. WHEN CancellationToken 被触发时THE Request_Scheduler SHALL 在当前请求的限流等待期或响应等待期中断,不再发起后续请求
3. WHEN CancellationToken 被触发时THE Processing_Pool SHALL 完成当前已提交到队列中的所有数据处理任务,不丢弃已入队的数据
4. WHEN CancellationToken 被触发时THE Write_Worker SHALL 将所有已处理完成的数据写入数据库后再退出,保证已处理数据的持久化
5. WHEN 任务因取消信号中断时THE Unified_Pipeline SHALL 返回部分完成的统计结果(已完成的请求数、已处理的记录数、已写入的记录数),任务状态标记为 `CANCELLED`
6. THE CancellationToken SHALL 支持超时自动取消:可在创建时指定最大执行时间(秒),超时后自动触发取消信号
7. IF CancellationToken 在任务启动前已处于取消状态THEN THE Unified_Pipeline SHALL 立即返回空结果,不发送任何请求
### 需求 4Pipeline 配置体系
**用户故事:** 作为 ETL 运维人员我希望线程模型的各项参数worker 数、队列大小、批量写入阈值、限流间隔)足够灵活,不同接口可以有不同的配置,以适应不同 API 的特性。
#### 验收标准
1. THE Pipeline_Config SHALL 支持以下可配置参数Processing_Pool 工作线程数(`workers`,默认 2、处理队列容量`queue_size`,默认 100、Write_Worker 批量写入阈值(`batch_size`,默认 100、Write_Worker 等待超时秒数(`batch_timeout`,默认 5.0、Rate_Limiter 最小间隔秒数(`rate_min`,默认 5.0、Rate_Limiter 最大间隔秒数(`rate_max`,默认 20.0
2. THE Pipeline_Config SHALL 遵循现有配置分层体系(根 `.env` < `.env.local` < 环境变量 < CLI 参数),通过 `AppConfig``pipeline.*` 命名空间读取
3. THE Pipeline_Config SHALL 支持任务级覆盖:通过 `pipeline.<task_code>.*` 命名空间为特定任务指定独立配置,未指定时回退到 `pipeline.*` 全局默认值
4. IF Pipeline_Config 中 `workers` 小于 1 或 `queue_size` 小于 1THEN THE Unified_Pipeline SHALL 抛出 `ValueError` 并包含描述性错误信息
5. IF Pipeline_Config 中 `batch_size` 小于 1THEN THE Unified_Pipeline SHALL 抛出 `ValueError` 并包含描述性错误信息
6. THE Pipeline_Config SHALL 支持运行时通过 CLI 参数 `--pipeline-workers``--pipeline-batch-size``--pipeline-rate-min``--pipeline-rate-max` 覆盖全局默认值
### 需求 5现有 ODS 任务迁移
**用户故事:** 作为 ETL 开发者,我希望现有 21 个 ODS 任务全部迁移到统一管道框架上,保持功能完全等价,不丢失任何现有能力。
#### 验收标准
1. THE Unified_Pipeline SHALL 完整保留 BaseOdsTask 的所有现有功能:时间窗口解析(`_resolve_window`)、窗口分段(`build_window_segments`、API 分页拉取(`iter_paginated`)、结构感知写入(`_insert_records_schema_aware`)、快照软删除(`_mark_missing_as_deleted`、content_hash 去重(`skip_unchanged`
2. THE Unified_Pipeline SHALL 保留 OdsTaskSpec 数据类的所有现有字段定义,迁移后的任务通过相同的 `OdsTaskSpec` 实例配置
3. WHEN 迁移完成后THE ETL_System 对每个 ODS 任务执行相同输入数据时 SHALL 产生与迁移前完全相同的数据库写入结果(相同的 inserted/updated/skipped 计数和相同的记录内容)
4. THE Unified_Pipeline SHALL 保留现有的 `endpoint_routing` 逻辑recent/former 路由拆分),迁移后的请求路由行为与现有系统一致
5. THE Unified_Pipeline SHALL 保留现有的 `source_file``source_endpoint``fetched_at` 等元数据写入逻辑
6. THE Unified_Pipeline SHALL 兼容现有的 `TaskExecutor` 执行生命周期(游标管理、运行记录、数据源路由),迁移后 TaskExecutor 无需修改调用方式
7. WHEN 迁移完成后THE TaskRegistry 中所有 21 个 ODS 任务的注册代码和元数据 SHALL 保持不变
### 需求 6二级详情拉取模式
**用户故事:** 作为 ETL 开发者,我希望统一管道框架支持"列表接口拉完后逐条调详情"的二级拉取模式,以便团购详情等需要二次请求的业务能在框架内实现。
#### 验收标准
1. THE Unified_Pipeline SHALL 支持可选的 Detail_Mode在列表接口的所有分页数据拉取并写入 ODS 完成后,从已写入的记录中提取 ID 列表,逐条调用详情接口
2. THE OdsTaskSpec SHALL 新增可选字段支持 Detail_Mode 配置:详情接口 endpoint、详情请求参数构造函数、详情数据目标表名、详情数据的 data_path 和 list_key
3. WHEN OdsTaskSpec 未配置 Detail_Mode 相关字段时THE Unified_Pipeline SHALL 跳过详情拉取阶段,行为与纯列表拉取模式完全一致
4. THE Detail_Mode 的详情请求 SHALL 通过 Request_Scheduler 统一排队,遵循与列表请求相同的限流规则
5. IF 详情接口对某个 ID 返回错误或超时THEN THE Unified_Pipeline SHALL 记录错误日志(含 ID 和错误信息)并继续处理下一个 ID不中断整体流程
6. WHEN 详情拉取完成后THE Unified_Pipeline SHALL 在任务执行结果中包含详情拉取的统计信息(详情成功数、详情失败数、详情跳过数),与列表拉取统计分开记录
### 需求 7DWD 层多线程优化
**用户故事:** 作为 ETL 运维人员,我希望 DWD 层加载任务能利用多线程并行处理多张表的 SCD2 合并,缩短 DWD 层的整体执行时间。
#### 验收标准
1. THE DWD_Loader SHALL 支持多线程并行执行多张 DWD 表的 SCD2 合并操作,每张表的合并在独立线程中运行
2. THE DWD_Loader 的并行线程数 SHALL 通过配置参数控制(默认值 4通过 `AppConfig``dwd.parallel_workers` 读取
3. THE DWD_Loader SHALL 保证每张表的 SCD2 合并操作在独立的数据库事务中执行,单张表失败不影响其他表的处理
4. WHEN 某张 DWD 表的 SCD2 合并失败时THE DWD_Loader SHALL 记录错误日志(含表名和错误信息),将该表标记为失败,继续处理其他表
5. THE DWD_Loader SHALL 在所有表处理完成后返回汇总结果:成功表数、失败表数、每张表的 inserted/updated 计数
6. THE DWD_Loader 的现有 `_merge_dim_scd2()` 方法 SHALL 保持不变,多线程优化仅在调度层面并行调用该方法
### 需求 8可观测性与监控
**用户故事:** 作为 ETL 运维人员,我希望统一管道框架提供充分的运行时可观测性,便于监控执行状态和排查问题。
#### 验收标准
1. THE Unified_Pipeline SHALL 在任务执行过程中记录以下关键指标到日志当前请求队列深度、Processing_Pool 活跃线程数、Write_Worker 待写入队列深度、已完成请求数/总请求数
2. THE Unified_Pipeline SHALL 在任务完成后输出执行摘要:总耗时、请求阶段耗时、处理阶段耗时、写入阶段耗时、各阶段的记录数统计
3. WHEN Processing_Pool 的任务队列达到容量上限时THE Unified_Pipeline SHALL 记录警告日志Request_Scheduler 暂停发送新请求直到队列有空位(背压机制)
4. WHEN Write_Worker 的待写入队列积压超过 `queue_size * 2`THE Unified_Pipeline SHALL 记录警告日志
5. THE Unified_Pipeline SHALL 与现有的 `EtlTimer` 集成,在 FlowRunner 的计时报告中体现各 ODS 任务的请求/处理/写入阶段耗时
### 需求 9错误处理与容错
**用户故事:** 作为 ETL 运维人员,我希望统一管道框架具备完善的错误处理机制,单个请求或记录的失败不影响整体任务的执行。
#### 验收标准
1. IF 单个 API 请求失败HTTP 错误、超时、API 返回错误码THEN THE Request_Scheduler SHALL 按现有 `APIClient` 的重试策略(最多 3 次,指数退避)重试,重试耗尽后记录错误并继续处理下一个请求
2. IF Processing_Pool 中某条记录的处理抛出异常THEN THE Processing_Pool SHALL 记录错误日志(含记录标识和异常信息),将该记录标记为处理失败,继续处理队列中的其他记录
3. IF Write_Worker 执行批量写入时发生数据库错误THEN THE Write_Worker SHALL 回滚当前批次的事务,记录错误日志(含批次大小和错误信息),将该批次的记录标记为写入失败
4. WHEN 任务执行完成后THE Unified_Pipeline SHALL 在执行结果中汇总所有错误:请求失败数、处理失败数、写入失败数,以及每个失败项的错误摘要
5. IF 任务执行过程中连续失败次数超过配置阈值(默认 10 次THEN THE Unified_Pipeline SHALL 主动中断任务执行,将任务状态标记为 `FAILED`,避免无效重试浪费时间
6. THE Unified_Pipeline SHALL 保留现有 BaseOdsTask 的事务管理语义每个窗口分段segment的数据在该分段全部处理完成后统一 commit分段失败时 rollback 该分段
### 需求 10Admin-web 日志输出优化
**用户故事:** 作为 ETL 运维人员,我希望在 Admin-web 管理后台查看 ETL 执行日志时,各个任务的日志按任务分组、有序展示,避免多任务并行执行时日志行交叉混乱导致难以阅读和排查问题。
#### 验收标准
1. THE ETL_System SHALL 为每个 ODS 任务的执行日志添加任务标识前缀(任务代码),使日志行可按任务归属区分
2. THE Admin-web SHALL 支持按任务代码过滤和分组展示 ETL 执行日志,用户可选择查看单个任务的日志或全部日志
3. THE Unified_Pipeline SHALL 在多线程环境下保证日志写入的原子性:每条日志消息作为完整的一行输出,不会被其他线程的日志截断或插入
4. THE ETL_System SHALL 为每个任务维护独立的日志缓冲区,任务完成后将该任务的完整日志按时间顺序一次性输出到 Admin-web避免执行过程中不同任务的日志行交叉
5. THE Admin-web SHALL 在 ETL 执行结果页面中按任务分段展示日志:每个任务的日志折叠为独立区块,展开后显示该任务的完整执行日志(含时间戳、日志级别、消息内容)
6. WHEN 多个 ODS 任务在同一次 FlowRunner 执行中运行时THE Admin-web SHALL 在顶部展示任务执行时间线概览(每个任务的开始时间、结束时间、状态),用户可点击跳转到对应任务的日志区块

View File

@@ -0,0 +1,229 @@
# 实施计划ETL 统一请求编排与线程模型改造
## 概述
将飞球 Connector ETL 系统的 ODS 任务从同步串行执行迁移到"串行请求 + 异步处理 + 单线程写库"统一管道架构。按组件依赖顺序逐步实现:基础组件 → 核心引擎 → 任务迁移 → DWD 优化 → 日志优化。
## 任务
- [x] 1. 实现基础组件PipelineConfig、CancellationToken、RateLimiter
- [x] 1.1 创建 `apps/etl/connectors/feiqiu/config/pipeline_config.py`,实现 `PipelineConfig` 数据类
- 定义 `workers``queue_size``batch_size``batch_timeout``rate_min``rate_max``max_consecutive_failures` 字段及默认值
- 实现 `__post_init__` 参数校验workers>=1、queue_size>=1、batch_size>=1、rate_min<=rate_max
- 实现 `from_app_config(config, task_code)` 类方法,支持 `pipeline.<task_code>.*` 任务级覆盖 → 全局 `pipeline.*` → 默认值的三级回退
- _需求: 1.3, 1.4, 1.5, 2.3, 2.5, 2.6, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
- [x] 1.2 编写 PipelineConfig 属性测试
- **Property 3: PipelineConfig 构造与验证** — 生成随机配置参数组合(含无效值),验证合法参数成功构造、非法参数抛出 ValueError
- **Property 4: 配置分层与任务级覆盖** — 生成随机多层配置字典,验证任务级 > 全局级 > 默认值的优先级
- 测试文件:`tests/test_pipeline_config_properties.py`
- **验证: 需求 1.4, 1.5, 4.1, 4.2, 4.3, 4.4, 4.5, 4.6**
- [x] 1.3 创建 `apps/etl/connectors/feiqiu/utils/cancellation.py`,实现 `CancellationToken`
- 基于 `threading.Event` 实现线程安全的 `cancel()` 方法和 `is_cancelled` 属性
- 实现超时自动取消(构造时传入 `timeout` 秒数,通过 `threading.Timer` 触发)
- 实现 `dispose()` 清理定时器
- _需求: 3.1, 3.6_
- [x] 1.4 编写 CancellationToken 属性测试
- **Property 7: CancellationToken 状态转换** — 生成随机超时值,验证初始 False、cancel() 后 True 且不可逆、超时自动触发
- 测试文件:`tests/test_cancellation_properties.py`
- **验证: 需求 3.1, 3.6**
- [x] 1.5 创建 `apps/etl/connectors/feiqiu/api/rate_limiter.py`,实现 `RateLimiter`
- 构造时校验 `min_interval <= max_interval`,否则抛出 `ValueError`
- 实现 `wait(cancel_event)` 方法:生成 `[min, max]` 均匀分布随机间隔,拆分为 0.5s 小段轮询 cancel_event
- 暴露 `last_interval` 属性
- _需求: 1.3, 1.5_
- [x] 1.6 编写 RateLimiter 属性测试
- **Property 2: RateLimiter 间隔范围** — 生成随机 (min, max) 对,验证 wait() 实际等待时间在 [min, max] ± 0.5s 范围内
- 测试文件:`tests/test_rate_limiter_properties.py`
- **验证: 需求 1.3**
- [x] 1.7 编写基础组件单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_pipeline_config.py``tests/unit/test_cancellation.py``tests/unit/test_rate_limiter.py`
- 覆盖边界条件RateLimiter min=max、CancellationToken 预取消/timeout=0/dispose、PipelineConfig 无效参数/CLI 覆盖
- _需求: 1.3, 1.5, 3.1, 3.6, 4.1, 4.4, 4.5_
- [x] 2. 检查点 — 基础组件验证
- 确保所有测试通过ask the user if questions arise.
- [x] 3. 实现核心管道引擎UnifiedPipeline
- [x] 3.1 创建数据类 `PipelineRequest``PipelineResult``WriteResult`
- 文件:`apps/etl/connectors/feiqiu/pipeline/models.py`
- `PipelineRequest`endpoint、params、page_size、data_path、list_key、segment_index、is_detail、detail_id
- `PipelineResult`status、各阶段计数、errors 列表、timing 字典、Detail_Mode 统计
- `WriteResult`inserted、updated、skipped、errors
- _需求: 2.7, 6.6, 8.2, 9.4_
- [x] 3.2 创建 `apps/etl/connectors/feiqiu/pipeline/unified_pipeline.py`,实现 `UnifiedPipeline` 核心引擎
- 实现 `__init__`:接收 api_client、db_connection、logger、PipelineConfig、CancellationToken初始化 RateLimiter
- 实现 `run(requests, process_fn, write_fn) -> PipelineResult` 主方法:
- 预取消检查cancel_token 已取消则立即返回空结果)
- 创建 processing_queuemaxsize=queue_size和 write_queuemaxsize=queue_size*2
- 启动 N 个 worker 线程(`_process_worker`)和 1 个 writer 线程(`_write_worker`
- 主线程执行 `_request_loop`:串行发送请求、限流等待、取消检查、背压阻塞
- 发送 SENTINEL 通知线程退出join 等待完成
- 计算最终 statusSUCCESS/PARTIAL/CANCELLED/FAILED
- _需求: 1.1, 1.2, 1.6, 2.1, 2.2, 2.4, 2.7, 2.8, 3.2, 3.7, 8.3_
- [x] 3.3 实现 `_request_loop` 请求调度逻辑
- 遍历 requests 迭代器,逐个发送 API 请求
- 每个请求完成后记录耗时、状态码、endpoint 到日志
- 将响应数据 put 到 processing_queue满时阻塞 = 背压)
- 请求间调用 rate_limiter.wait(cancel_event),被取消则 break
- 实现连续失败计数:成功重置为 0失败 +1超过 max_consecutive_failures 则中断
- _需求: 1.2, 1.7, 3.2, 8.1, 8.3, 9.1, 9.5_
- [x] 3.4 实现 `_process_worker` 处理线程逻辑
- 从 processing_queue 消费数据,调用 process_fn 处理
- 处理结果 put 到 write_queue
- 单条记录处理异常时捕获、记录错误、标记失败、继续处理
- 收到 SENTINEL 时退出
- _需求: 2.1, 2.2, 9.2_
- [x] 3.5 实现 `_write_worker` 写入线程逻辑
- 从 write_queue 消费数据,累积到 batch_size 或等待 batch_timeout 后调用 write_fn 批量写入
- 写入失败时回滚当前批次事务、记录错误、标记失败、继续处理后续批次
- 队列积压超过 queue_size*2 时记录警告日志
- 收到 SENTINEL 时将剩余数据 flush 写入后退出
- _需求: 2.4, 2.5, 2.6, 8.4, 9.3, 9.6_
- [x] 3.6 编写 UnifiedPipeline 属性测试
- **Property 1: 请求严格串行** — 用 FakeAPI 记录时间戳,验证每个请求发送时间 > 上一个响应完成时间
- **Property 5: 管道完成语义** — 生成随机记录集,验证 total_fetched == total_inserted + total_updated + total_skipped
- **Property 6: WriteWorker 批量大小约束** — 生成随机 batch_size 和记录流,验证每次 write_fn 调用的记录数 <= batch_size
- **Property 8: 取消后已入队数据不丢失** — 生成随机请求序列 + 随机取消时刻,验证已入队数据全部处理和写入
- **Property 11: 单项失败不中断整体** — 生成含随机失败的请求序列,验证后续项目继续处理
- **Property 12: 连续失败触发中断** — 生成连续失败序列 + 随机阈值,验证超过阈值时中断
- **Property 13: 写入失败回滚当前批次** — 生成含随机写入失败的批次,验证回滚且后续批次不受影响
- **Property 14: 结果统计完整性** — 验证各计数字段的一致性关系
- 测试文件:`tests/test_pipeline_properties.py`
- **验证: 需求 1.2, 1.6, 2.5, 2.7, 3.2, 3.3, 3.4, 3.5, 6.6, 8.2, 9.1, 9.2, 9.3, 9.4, 9.5**
- [x] 3.7 编写 UnifiedPipeline 单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_unified_pipeline.py`
- 使用 FakeAPI + FakeDB 端到端测试:正常流程、空请求、预取消、背压触发
- _需求: 2.7, 3.7, 8.1, 8.3_
- [x] 4. 检查点 — 核心引擎验证
- 确保所有测试通过ask the user if questions arise.
- [x] 5. BaseOdsTask 改造与 ODS 任务迁移
- [x] 5.1 扩展 `OdsTaskSpec` 数据类,新增 Detail_Mode 可选字段
-`apps/etl/connectors/feiqiu/tasks/ods/ods_tasks.py` 中为 `OdsTaskSpec` 新增:`detail_endpoint``detail_param_builder``detail_target_table``detail_data_path``detail_list_key``detail_id_column`
- 所有新增字段默认值为 `None`,不影响现有 21 个任务的 OdsTaskSpec 实例
- _需求: 6.2, 6.3_
- [x] 5.2 改造 `BaseOdsTask.execute()` 方法,嵌入 UnifiedPipeline
-`execute()` 内部构建 `PipelineConfig.from_app_config(self.config, spec.code)`
- 将现有分页请求逻辑封装为 `_build_requests()``Iterable[PipelineRequest]`
- 将现有字段提取/hash 计算封装为 `_build_process_fn()``Callable`
- 将现有 `_insert_records_schema_aware` 封装为 `_build_write_fn()``Callable`
- 调用 `pipeline.run(requests, process_fn, write_fn)` 替代现有同步循环
- 保留快照软删除(`_mark_missing_as_deleted`、endpoint_routing、元数据写入source_file、source_endpoint、fetched_at
- 保留 TaskExecutor 调用接口不变(`task.execute(cursor_data)` 签名不变)
- _需求: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7_
- [x] 5.3 实现 Detail_Mode 详情拉取逻辑
-`BaseOdsTask` 中实现 `_build_detail_requests()` 方法:从已写入 ODS 的记录中提取 ID 列表,生成 `PipelineRequest(is_detail=True)` 序列
- 详情请求通过同一个 UnifiedPipeline 的 RequestScheduler 排队,遵循相同限流规则
- 单个详情请求失败时记录错误日志(含 ID 和错误信息),继续处理下一个
- 在 PipelineResult 中填充 detail_success/detail_failure/detail_skipped 统计
- _需求: 6.1, 6.4, 6.5, 6.6_
- [x] 5.4 编写 Detail_Mode 属性测试
- **Property 10: Detail_Mode 可选性** — 生成有/无 detail_endpoint 的 OdsTaskSpec验证无配置时跳过详情阶段、有配置时执行详情拉取且遵循限流
- 测试文件:`tests/test_detail_mode_properties.py`
- **验证: 需求 6.1, 6.3, 6.4**
- [x] 5.5 编写迁移等价属性测试
- **Property 9: 迁移前后输出等价** — 生成随机 API 响应序列,对比 UnifiedPipeline 与原同步串行实现的数据库写入结果inserted/updated/skipped 计数和记录内容)
- 测试文件:`tests/test_migration_properties.py`
- **验证: 需求 5.1, 5.3, 5.4, 5.5**
- [x] 5.6 编写 Detail_Mode 和迁移单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_detail_mode.py`
- 覆盖:列表→详情完整流程、无 detail_endpoint 跳过、详情单条失败不中断
- _需求: 6.1, 6.3, 6.5_
- [x] 6. 检查点 — ODS 迁移验证
- 确保所有测试通过ask the user if questions arise.
- [x] 7. DWD 层多线程优化
- [x] 7.1 改造 `apps/etl/connectors/feiqiu/tasks/dwd/dwd_load_task.py` 中的 `DwdLoadTask.load()` 方法
-`AppConfig` 读取 `dwd.parallel_workers`(默认 4
- 将现有串行 `for dwd_table, ods_table in TABLE_MAP` 循环改为 `concurrent.futures.ThreadPoolExecutor` 并行调度
- 每张表调用 `_process_single_table()` 在独立线程中执行,使用独立数据库连接和事务
- `_merge_dim_scd2()` 方法本身不改
- 单张表失败时捕获异常、记录错误日志(含表名和错误信息)、标记失败、继续处理其他表
- 所有表处理完成后返回汇总结果:成功表数、失败表数、每张表的 inserted/updated 计数
- _需求: 7.1, 7.2, 7.3, 7.4, 7.5, 7.6_
- [x] 7.2 编写 DWD 并行属性测试
- **Property 16: DWD 并行与串行结果一致** — 生成随机表集合 + mock SCD2验证多线程并行执行的结果与串行逐表执行完全一致
- 测试文件:`tests/test_dwd_parallel_properties.py`
- **验证: 需求 7.1**
- [x] 7.3 编写 DWD 多线程单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_dwd_parallel.py`
- 覆盖mock SCD2 正常并行、单表失败不影响其他表、汇总结果正确
- _需求: 7.3, 7.4, 7.5_
- [x] 8. 可观测性与日志优化
- [x] 8.1 在 UnifiedPipeline 中集成运行时指标日志
-`_request_loop` 中定期记录当前请求队列深度、ProcessingPool 活跃线程数、WriteWorker 待写入队列深度、已完成请求数/总请求数
-`run()` 返回前计算并记录执行摘要:总耗时、请求/处理/写入各阶段耗时、各阶段记录数统计
- 与现有 `EtlTimer` 集成,在 FlowRunner 计时报告中体现各 ODS 任务的阶段耗时
- _需求: 8.1, 8.2, 8.5_
- [x] 8.2 创建 `apps/etl/connectors/feiqiu/utils/task_log_buffer.py`,实现 `TaskLogBuffer`
- 实现线程安全的 `log(level, message)` 方法,将日志条目缓冲到内存列表
- 实现 `flush()` 方法:按时间戳升序排列,一次性输出到父 logger添加 `[task_code]` 前缀
- 定义 `LogEntry` 数据类timestamp、level、task_code、message
- _需求: 10.1, 10.3, 10.4_
- [x] 8.3 编写日志缓冲区属性测试
- **Property 15: 日志缓冲区按任务隔离** — 生成多任务随机日志流,验证每个 TaskLogBuffer 的 flush() 仅包含该任务日志且按时间戳升序
- 测试文件:`tests/test_log_buffer_properties.py`
- **验证: 需求 10.1, 10.4**
- [x] 8.4 编写 TaskLogBuffer 单元测试
- 测试文件:`apps/etl/connectors/feiqiu/tests/unit/test_task_log_buffer.py`
- 覆盖:空缓冲区 flush、并发多线程写入、日志前缀格式
- _需求: 10.1, 10.3, 10.4_
- [x] 9. 检查点 — DWD 优化与日志验证
- 确保所有测试通过ask the user if questions arise.
- [x] 10. Admin-web 日志展示优化
- [x] 10.1 在 `apps/etl/connectors/feiqiu/` 中集成 TaskLogBuffer 到 BaseOdsTask 和 FlowRunner
- 在 BaseOdsTask.execute() 中创建 TaskLogBuffer 实例,替代直接 logger 调用
- 在 FlowRunner 中为每个任务分配独立的 TaskLogBuffer任务完成后调用 flush()
- 保证多线程环境下日志写入原子性(每条日志完整一行)
- _需求: 10.1, 10.3, 10.4_
- [x] 10.2 在 `apps/admin-web/` 中实现按任务分组的日志展示
- 在 ETL 执行结果页面中按任务分段展示日志:每个任务折叠为独立区块
- 展开后显示该任务的完整执行日志(时间戳、日志级别、消息内容)
- 支持按任务代码过滤和分组展示
- 顶部展示任务执行时间线概览(每个任务的开始/结束时间、状态),可点击跳转
- _需求: 10.2, 10.5, 10.6_
- [x] 11. CLI 参数扩展
- [x] 11.1 在 `apps/etl/connectors/feiqiu/cli/` 中添加 Pipeline 相关 CLI 参数
- 新增 `--pipeline-workers``--pipeline-batch-size``--pipeline-rate-min``--pipeline-rate-max` 参数
- 将 CLI 参数值注入到 AppConfig使其在 PipelineConfig.from_app_config() 中生效
- _需求: 4.6_
- [x] 12. 最终检查点 — 全量验证
- 确保所有测试通过ask the user if questions arise.
## 备注
- 标记 `*` 的子任务为可选,可跳过以加速 MVP 交付
- 每个任务引用了具体的需求编号,确保可追溯
- 检查点任务用于增量验证,确保每个阶段的正确性
- 属性测试验证通用正确性属性,单元测试验证具体示例和边界条件
- 属性测试位于 Monorepo 级 `tests/` 目录,单元测试位于 ETL 模块内 `tests/unit/`

View File

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

View File

@@ -0,0 +1,498 @@
# 技术设计文档H5 → 微信小程序像素精调
## 1. 概述
本设计文档为 17 个页面(共 79 个对照处理单元)的 Step 6-7 像素精调提供技术方案。所有页面已完成 Step 0-5 结构迁移TS 零诊断、路由注册、四态处理)。
核心设计思路:以「对照处理单元」为最小粒度,通过两阶段收敛流程(结构级修正 → 像素级精调)系统性消除视觉差异。
参考文档(不重复,仅索引):
- 迁移规则与转换公式:`docs/prd/MIGRATION-PLAYBOOK.md`
- 样式标准值:`docs/h5_ui/design-tokens.json`(颜色灰阶、字号、圆角、阴影的标准 rpx 值)
- 工具链源码:`scripts/ops/anchor_compare.py`
## 2. 截图策略
### 2.1 双端参数对齐
| 参数 | H5 | MP | 对比基准 |
|------|-----|-----|---------|
| viewport | 430×752 | 430×752windowHeight | 统一 |
| DPR | 1.5 | 1.5MCP 有效 DPR | 统一 |
| 输出尺寸 | 645×1128 | 645×1128 | 统一,无需缩放 |
> 验证数据2026-03-10 board-finance 实测H5 Playwright DPR=1.5 输出 645×1128MP MCP screenshot 输出 645×1128。两端尺寸完全一致无需任何缩放处理。
### 2.2 固定步长滚动方案
采用固定 600px 步长滚动截图,替代锚点方案。两端使用完全相同的 scrollTop 序列0, 600, 1200, ...),不依赖锚点位置,消除 H5/MP 元素位置差异导致的对齐问题。
核心参数:
- 步长600px逻辑像素
- scrollTop 序列:从 0 开始,每次 +600最后一屏 clamp 到 `maxScroll = scrollHeight - viewportHeight`
- 段数计算:`N = floor(maxScroll / 600) + 1`(首屏 step-0 + 每 600px 一步 + 最后一步 clamp 到 maxScroll`maxScroll ≤ 10` 的页面视为单屏N=1
实测精度board-finance2026-03-10
- H5 端Playwright `window.scrollTo` 精确命中目标值(偏差 0px
- MP 端:`wx.pageScrollTo` 精确命中目标值
### 2.3 底部浮动元素处理
H5 端存在 `#bottomNav`64px fixed 底部导航)和 `.ai-float-btn-container`56px 浮动按钮MP 端使用原生 tabBar不在 webview 截图中)。
处理方式H5 截图前用 JS 隐藏所有底部浮动元素:
```javascript
document.getElementById('bottomNav').style.display = 'none';
document.querySelectorAll('.ai-float-btn-container').forEach(el => el.style.display = 'none');
```
MP 端原生 tabBar 本来不在截图中无需处理。MP 端如果存在 AI 浮动按钮,因 H5 端已隐藏,该区域的差异在对比时可忽略,不计入差异率。
### 2.4 浮动元素专项检测(结构校验阶段)
长页面滚动截图中sticky/fixed 元素(如 sticky 导航栏、筛选栏)在每一屏的相同位置出现。通过滚屏上下页对比,可以精准识别这些浮动元素的差异并专项修复。
检测方法:
1. 对同一页面的相邻屏截图进行对比,在不同屏中持续出现在相同位置的差异区域即为 sticky/fixed 元素
2. 对 H5 vs MP 的 sticky 区域做像素对比,差异即为浮动元素的样式偏差
3. sticky 元素差异在每一屏都会重复出现,修复一次即可消除所有屏的该区域差异
适用场景:
- board-finance`.safe-area-top`(45px) + `#filterBar`(71px) = 116px sticky
- board-coach/board-customer`.board-tabs`(42px) + `.filter-bar`(68px) = 110px sticky
- 所有带 sticky 头部的长页面
优化效果:修复 sticky 区域差异后,所有屏的差异率会同步下降(因为每屏都包含相同的 sticky 区域)。
### 2.5 截图操作指南
#### 2.5.1 H5 截图
底层技术Playwright + Chromiumviewport 430×752DPR=1.5headless=True。
流程:
1. 打开 Live Server 提供的 H5 页面(`http://localhost:5500/docs/h5_ui/<page>.html`
2. 等待 Tailwind CDN JIT 渲染1500ms
3. 隐藏滚动条 + 底部浮动元素(`#bottomNav` + `.ai-float-btn-container`
4. 按 scrollTop 序列逐屏截图:先 `scrollTo(0,0)``scrollTo(0, target)`
截图脚本:`scripts/ops/anchor_compare.py extract-h5 <page>`(固定 600px 步长模式)
输出:`docs/h5_ui/compare/<page>/h5--step-<scrollTop>.png`645×1128
#### 2.5.2 MP 截图
前置条件微信开发者工具已打开MCP 已连接。
流程:
1. 导航到目标页面tabBar 页面用 `switch_tab`,其他用 `navigate_to`
2. 等待页面加载2000ms
3. 按相同 scrollTop 序列逐屏截图:
```javascript
// 每屏:先回顶再滚到目标,确保精确
wx.pageScrollTo({ scrollTop: 0, duration: 0 })
// 等待 200ms
wx.pageScrollTo({ scrollTop: target, duration: 0 })
// 等待 500ms读取实际 scrollTop截图
```
输出:`docs/h5_ui/compare/<page>/mp--step-<scrollTop>.png`645×1128
#### 2.5.3 多维度页面 MP 截图
board-coach 和 board-customer 为多维度页面,每个维度的卡片模板不同,需逐维度截图。
board-coach4 种排序维度):
- 维度通过筛选栏下拉切换(`onSortChange`),对应 4 种卡片模板:`perf`(定档业绩)、`salary`(工资)、`sv`(客源储值)、`task`(任务完成)
- MP 操作:获取页面快照 → 点击排序筛选下拉 → 选择目标维度 → 等待列表刷新 → 截图
- H5 操作:通过 JS 调用 `selectType('perf_desc')` 等切换 dim-container 显隐 → 截图
- 子目录:`board-coach/perf/`、`board-coach/salary/`、`board-coach/sv/`、`board-coach/task/`
board-customer8 种客户维度):
- 维度通过筛选栏下拉切换(`onDimensionChange`),对应 8 种卡片模板:`recall`(最应召回)、`potential`(消费潜力)、`balance`(最高余额)、`recharge`(最近充值)、`recent`(最近到店)、`spend60`(最高消费)、`freq60`(最频繁)、`loyal`(最专一)
- MP 操作:获取页面快照 → 点击维度筛选下拉 → 选择目标维度 → 等待列表刷新 → 截图
- H5 操作:通过 JS 调用 `selectType('recall')` 等切换 dim-container 显隐 → 截图
- 子目录:`board-customer/recall/`、`board-customer/potential/` ... `board-customer/loyal/`
> 交互文档参考:`docs/h5_ui/interactions/board-coach.md`、`docs/h5_ui/interactions/board-customer.md`
#### 2.5.4 逐屏对比
对同一 scrollTop 的 H5/MP 截图做像素对比:
```
mcp_image_compare_compare_images
image1_path: "docs/h5_ui/compare/<page>/h5--step-<N>.png"
image2_path: "docs/h5_ui/compare/<page>/mp--step-<N>.png"
diff_output_path: "docs/h5_ui/compare/<page>/diff--step-<N>.png"
threshold: 0.1
```
### 2.5.5 双端高度不一致处理
MP 端页面高度可能与 H5 不一致(样式差异导致内容更短或更长)。如果直接按 H5 的 scrollTop 序列去滚 MP可能滚过头scrollTo 被 clamp或漏截页面更长
处理流程:
1. 先截双端 step-0首屏对比确认基线
2. 再截 step-600第二屏读取 MP 端实际 scrollTop
- 如果 MP 实际 scrollTop 远小于 600被 clamp说明 MP 页面比 H5 短,后续步骤需按 MP 实际 maxScroll 调整序列
- 如果 MP 实际 scrollTop ≈ 600说明双端高度接近继续按 H5 序列推进
3. 之后逐屏推进,每屏截图前先读取 MP 端实际 scrollTop确认到达预期位置
4. 如果 MP 页面比 H5 长MP 还能继续滚但 H5 序列已结束),追加额外步骤直到 MP 也到达 maxScroll
> 此规则防止因双端高度差异导致后续屏截图错位或遗漏。
### 2.6 长页面级联影响与修正规则
修正某一屏的 WXSS 时,可能影响后续屏的布局。因此:
1. 修正后必须重新截取所有屏的截图(因为固定步长方案下,布局变化会影响每屏内容)
2. 优先修复 sticky 区域差异(一次修复,所有屏受益)
3. 从 step-0 开始顺序审查差异,确保前序屏达标后再处理后续屏
4. 如果修正引入回归,回退到受影响的最早屏重新验证
### 2.7 结构级视觉元素识别清单(阶段一修正范围)
以下元素类型在双端截图中容易产生大面积差异(>10%),属于阶段一优先识别和修正的对象。严重缺失时可能导致 >15% 触发重写。
#### 2.7.1 大面积背景
| 背景类型 | MP 支持 | 处理方式 |
|----------|---------|---------|
| 纯色 / `linear-gradient` / `radial-gradient` / `repeating-linear-gradient` | ✅ | 直接迁移,查 `docs/h5_ui/design-tokens.json` 取精确色值和方向 |
| `backdrop-filter: blur()` | ❌ | 改用 `background: rgba(255,255,255,0.95)` 半透明纯色 |
| `url("data:image/svg+xml,...")` | ❌ | 用 CSS 渐变模拟,或导出为 PNG/base64 引用 |
精调要点:
- 渐变方向和色值必须与 H5 完全一致,大面积色差肉眼极其明显
- 半透明背景透明度差 0.1 在大面积上可见
#### 2.7.2 复杂图标与 SVG
Step 0-5 已完成所有 SVG 迁移决策(详见 `MIGRATION-PLAYBOOK.md` 3.5 节)。精调阶段关注:
| 图标类型 | 精调关注点 | 已审计案例 |
|----------|-----------|-----------|
| TDesign `<t-icon>` | `size`rpx和 `color` 与 H5 原始 SVG 一致 | 目录按钮 `view-list`、筛选箭头 `caret-down-small` |
| 导出 SVG + `<image>` | `width`/`height`rpx与 H5 显示尺寸一致 | AI 悬浮按钮、底部导航栏图标 |
| 文字/Emoji 替代 | 可接受差异,不计入差异率 | 环比箭头 ↑↓、AI 机器人 🤖 |
| CSS 渐变 + 文字组合(如头像) | 渐变方向、色值、圆角、文字大小一致 | 客户头像8 种渐变色 135deg |
| 帮助"?"图标(文字+圆形背景) | 圆形背景 size/color、文字 font-size 一致 | board-finance 指标标签旁 |
图标缺失或尺寸严重偏差属于阶段一结构级问题;尺寸/颜色微调属于阶段二。
#### 2.7.3 带文字的标签Badge / Tag
标签是高频偏差元素,涉及背景色、圆角、字号、内边距的组合,任一偏差都会在截图中明显可见:
| 标签类型 | 精调维度 | 已审计偏差案例 |
|----------|---------|---------------|
| 状态 Badge跟/弃) | 背景渐变、font-size、min-width、height、border-radius | 弃 badge radius 10rpx→应为 12rpx |
| 超期标签(超期>7天/≤7天 | 背景透明度、padding、border-radius、文字色 | radius 6rpx→应为 8rpx |
| 潜力标签(高频/高客单/高余额) | 背景色、文字色、font-weight | 已正确迁移 |
| 高消费标签 | `bg-warning/10` + `text-warning` + `font-bold` | 已正确迁移 |
| 板块标题标签Emoji + 文字) | Emoji 渲染、font-size、间距 | 板块 Emoji 📈💳💰🧾📤🎱 已正确 |
标签精调规则:
- 背景色优先查 `docs/h5_ui/design-tokens.json`,禁止使用非标准色值
- `border-radius` 是标签最常见偏差±2rpx必须逐个核对
- `padding` 注意 H5 Tailwind 的 `px-1.5 py-0.5` 换算×2×0.875
- 渐变背景标签(如跟/弃 badge的渐变方向和双色值必须精确
#### 2.7.4 组合元素(图标+文字+背景)
多个基础元素组合成的复合 UI 单元,整体偏差会被放大:
| 组合元素 | 构成 | 精调关注点 |
|----------|------|-----------|
| 筛选按钮 | 文字 + 下拉箭头 + 背景 + 圆角 | 整体高度、内边距、箭头与文字间距 |
| 环比指标 | 数值 + 箭头(↑↓) + 百分比 + 颜色 | 上升色 #e34d59 / 下降色 #00a870、font-size |
| 确认收入横条 | 标签 + 金额 + 半透明背景 | `bg-white/10` 透明度、padding、border-radius |
| 迷你柱状图 | 柱条 + 数字 + 标签 | 柱条高度/gap/圆角/透明度渐变、数字 font-size |
| 助教服务行 | 头像 + 姓名 + 分隔符 + 跟/弃 badge | 分隔符颜色/间距、badge 与文字对齐 |
| AI 悬浮按钮 | 图标 + 渐变背景 + 圆角 + 阴影 | 整体 size、border-radius、渐变色 |
组合元素精调策略先确认整体布局flex 方向、对齐方式)正确,再逐个子元素微调。
## 3. 两阶段收敛流程
### 3.1 流程图
```
获取双端截图
image_compare 对比 → 初始差异率
首轮审计报告(截图 + H5 源码 + MP 源码 三方对照)
┌─ 差异率 > 10% ──→ 阶段一:结构级修正(每轮 2-5 处)
│ ↓ 循环直到 ≤ 10%
├─ 差异率 5%~10% ─→ 阶段二:像素级精调(每轮 1-3 处)
│ ↓ 循环直到 < 5%
├─ 差异率 < 5% ───→ ✅ 通过
└─ > 15% 且无法收敛 → 🔄 触发重写
```
### 3.2 首轮审计报告(强制)
第一轮对比完成后,必须同时读取 H5 源码HTML + 内联 CSS 的 Tailwind 类名和 `<style>` 块)和 MP 源码WXML + WXSS结合 diff 截图三方对照,产出一份完整的审计报告。此报告是后续所有修改的指导依据。
审计报告格式包含:
| 章节 | 内容 | 数据来源 |
|------|------|---------|
| A. 结构对照 | 页面区域结构是否完整、顺序是否一致 | diff 截图 + H5 HTML 结构 |
| B. CSS 风险点 | 不支持的 CSS 特性(`::after` 复杂场景、`clip-path`、`backdrop-filter` 等) | H5 `<style>` 块 |
| C. 关键样式映射 | 逐元素核对Tailwind 类名 → computed 值 → WXSS 现值 → 是否一致 | H5 源码 + MP WXSS |
| D. 图标处理 | 每个 SVG/图标的迁移决策和当前状态 | H5 源码 + MP WXML |
| E. 偏差清单 | 所有 ⚠️ 偏差项汇总,按优先级排序 | C/D 节中标记的偏差 |
审计报告输出到 `docs/h5_ui/compare/<page>/audit.md`。
关键原则:
- 不能只看 diff 图猜测问题,必须回溯到 H5 源码确认正确值
- Tailwind 类名是唯一可信的样式来源(不是肉眼估算)
- 每个偏差项必须记录H5 值(含 Tailwind 类名)→ 换算后的 rpx 值 → MP 现值 → 差异
- 审计报告完成后,阶段一/二的修正严格按报告中的偏差清单执行
### 3.3 阶段一:结构级修正
目标:消除明显的视觉差异,将差异率从 >10% 降到 ≤10%。
修正重点(按优先级):
1. 区域缺失或顺序错误
2. 整块背景色/渐变色不匹配
3. 字号明显偏差≥4rpx
4. 间距明显偏差≥4rpx
操作模式:
- 按审计报告偏差清单逐项修正,不靠肉眼猜测
- 对照 H5 源码 Tailwind 类名 + `docs/h5_ui/design-tokens.json` 确认正确值
- 修改 WXSS → 重新截图 → 重新对比
### 3.4 阶段二:像素级精调
目标:消除细微差异,将差异率从 5%~10% 降到 <5%。
修正重点(按优先级):
1. 小边距偏差padding/margin/gap ±2rpx如卡片 30→28rpx、列表容器 24→28rpx、分隔符 margin 6→10rpx
2. 圆角border-radius偏差±2rpx如标签 6→8rpx、卡片 28→32rpx
3. 颜色色值微调(灰阶偏差如 #c5c5c5→#a6a6a6、透明度差 0.1
4. 行高line-height缺失或偏差、字重font-weight微调如 600→500
5. 阴影box-shadow参数微调
操作模式:
- 使用 image_compare 精确定位差异像素区域
- 参考 computed-styles.json如有或 H5 源码精确值
- 每次只改 1-3 处,验证不引入新差异
### 3.5 收敛停滞处理
当差异率连续 3 轮未下降超过 1% 时:
1. 分析剩余差异是否为不可消除的结构性差异(字体渲染、抗锯齿、头部区域)
2. 若是 → 标注为可接受差异,记录到 report.md停止该区域精调
3. 若否 → 尝试不同修正策略(如换用 flex 布局替代绝对定位)
## 4. 修改优先级规则(经验提炼)
基于 board-finance17 项偏差、board-coach17 项偏差、board-customer42 项偏差)的实际审计经验,提炼出以下规则:
### 4.1 高频偏差类型
| 偏差类型 | 出现频率 | 典型案例 | 修正策略 |
|----------|---------|---------|---------|
| font-size 偏差 | 极高 | Tab 26rpx→24rpx, 金额 28rpx→32rpx | 查 Tailwind 类名换算 |
| border-radius 偏差 | 高 | 标签 6rpx→8rpx, 卡片 28rpx→32rpx, 头像 14rpx→24rpx | 查 `design-tokens.json` |
| padding/margin 偏差 | 高 | 卡片 30rpx→28rpx, 列表容器 24rpx→28rpx | 查 Tailwind 类名换算 |
| line-height 缺失 | 中 | Tab 文字未写 line-height | 补 `line-height: 36rpx` |
| 颜色偏差 | 中 | 灰色 #c5c5c5→#a6a6a6, 边框 #eeeeee→#e7e7e7 | 查 `design-tokens.json` 灰阶 |
| font-weight 偏差 | 低 | 数据行 600→500 | 查 Tailwind 类名 |
| flex 比例偏差 | 低 | 筛选按钮 1.8→2 | 查 H5 源码 |
### 4.2 跨页面共性偏差
以下偏差在三个看板页面中重复出现,修正一个页面后应同步检查其他页面:
- Tab 导航font-size 26→24rpx, padding 24→22rpx, line-height 缺失→36rpx
- 筛选栏内层border-radius 14→16rpx或 32rpx取决于页面
- 卡片padding-top/bottom 30→28rpx, margin-bottom 20→22rpx
- 标签border-radius 6→8rpx
### 4.3 不修复的差异
以下差异属于结构性差异或设计决策差异,不计入差异率:
- 头部区域H5 safe-area-top vs MP 状态栏+胶囊)
- 字体渲染差异H5 Noto Sans SC vs MP 系统字体)
- 环比箭头H5 SVG vs MP 文字 ↑↓,已确认可接受)
- AI 浮动按钮区域H5 已隐藏MP 端如存在则忽略该区域差异)
## 5. 页面清单与截图参数
所有页面统一使用固定 600px 步长滚动截图,不依赖锚点。步数基于 Playwright 实测的 `maxScroll = scrollHeight - viewportHeight`,公式:`N = floor(maxScroll / 600) + 1``maxScroll ≤ 10` 视为单屏N=1
> 以下数据基于 2026-03-10 Playwright 实测430×752 视口DPR=1.5,展开所有折叠区域),验证脚本 `scripts/ops/_verify_step_counts.py`,原始数据 `export/SYSTEM/REPORTS/h5_page_heights/step_counts_verified.json`。
| # | 页面 | scrollHeight | maxScroll | 实测步数 | scrollTop 序列 | 主要内容区域 |
|---|------|-------------|-----------|---------|---------------|-------------|
| 1 | board-finance | 5600 | 4848 | 10 | 0,600,...,4800,4848 | 经营一览 / 预收资产 / 应计收入确认 / 现金流入 / 现金流出 / 助教分析 |
| 2 | board-coach | 754 | 2 | 1★ | 0 | 助教卡片列表×4 排序维度perf/salary/sv/task |
| 3 | board-customer | 752 | 0 | 1 | 0 | 客户卡片列表×8 客户维度recall/potential/balance/recharge/recent/spend60/freq60/loyal |
| 4 | task-detail | 2995 | 2243 | 5 | 0,600,1200,1800,2243 | Banner / 关系 / 建议 / 线索 / 备注 |
| 5 | task-detail-callback | 2397 | 1645 | 4 | 0,600,1200,1645 | 同上teal 主题) |
| 6 | task-detail-priority | 2389 | 1637 | 4 | 0,600,1200,1637 | 同上orange 主题) |
| 7 | task-detail-relationship | 2275 | 1523 | 4 | 0,600,1200,1523 | 同上pink 主题) |
| 8 | coach-detail | 2918 | 2166 | 5 | 0,600,1200,1800,2166 | Banner / 绩效概览 / 收入明细 / 前10客户 |
| 9 | customer-detail | 3070 | 2318 | 5 | 0,600,1200,1800,2318 | Banner / AI洞察 / 线索 / 任务 / 最爱助教 / 消费记录 |
| 10 | performance | 7705 | 6953 | 13 | 0,600,...,6600,6953 | Banner / 收入 / 本月业绩 / 上月收入 / 新客 / 常客(最长页面) |
| 11 | task-list | 1428 | 676 | 3 | 0,600,676 | Banner业绩卡 / 任务列表 |
| 12 | my-profile | 752 | 0 | 1 | 0 | 整页(单屏) |
| 13 | customer-service-records | 961 | 209 | 2 | 0,209 | Banner统计 / 记录列表 |
| 14 | performance-records | 2677 | 1925 | 5 | 0,600,1200,1800,1925 | Banner / 统计概览 / 记录列表 |
| 15 | chat | 1061 | 309 | 2 | 0,309 | 对话区 / 输入区 |
| 16 | chat-history | 752 | 0 | 1 | 0 | 整页(单屏) |
| 17 | notes | 1709 | 957 | 3 | 0,600,957 | 导航 / 备注列表 |
> ★ board-coach maxScroll=2px按实用主义规则≤10px视为单屏。
> 单屏页面board-coach、board-customer、my-profile、chat-history只需 step-0 一张截图。多维度页面需切换维度后分别截图board-coach ×4 排序维度通过排序筛选下拉切换、board-customer ×8 客户维度(通过维度筛选下拉切换)。
### 5.1 对照处理单元计算
对照处理单元 = 每页实测步数 × 维度数(无维度的页面维度数=1。数据来源2026-03-10 Playwright 实测(`scripts/ops/_verify_step_counts.py`)。
| 页面 | 步数 | 维度数 | 单元数 |
|------|------|--------|--------|
| board-finance | 10 | 1 | 10 |
| board-coach | 1★ | 4 | 4 |
| board-customer | 1 | 8 | 8 |
| task-detail | 5 | 1 | 5 |
| task-detail-callback | 4 | 1 | 4 |
| task-detail-priority | 4 | 1 | 4 |
| task-detail-relationship | 4 | 1 | 4 |
| coach-detail | 5 | 1 | 5 |
| customer-detail | 5 | 1 | 5 |
| performance | 13 | 1 | 13 |
| task-list | 3 | 1 | 3 |
| my-profile | 1 | 1 | 1 |
| customer-service-records | 2 | 1 | 2 |
| performance-records | 5 | 1 | 5 |
| chat | 2 | 1 | 2 |
| chat-history | 1 | 1 | 1 |
| notes | 3 | 1 | 3 |
| **合计** | | | **79** |
> ★ board-coach maxScroll=2px按实用主义规则≤10px视为单屏。
## 6. 产出物归档结构
所有截图、diff、审计报告统一归入 `docs/h5_ui/compare/`,按页面分子目录。
```
docs/h5_ui/compare/
├── board-finance/
│ ├── h5--step-0.png # H5 截图645×1128
│ ├── h5--step-600.png
│ ├── mp--step-0.png # MP 截图645×1128
│ ├── mp--step-600.png
│ ├── diff--step-0.png # 像素对比 diff 图
│ ├── diff--step-600.png
│ ├── report.md # 差异率汇总 + 对比分析
│ └── audit.md # 三方对照审计报告
├── board-coach/
│ ├── perf/ # 多维度页面按排序维度分子目录
│ │ ├── h5--step-0.png
│ │ ├── mp--step-0.png
│ │ └── diff--step-0.png
│ ├── salary/
│ ├── sv/
│ ├── task/
│ ├── report.md
│ └── audit.md
├── board-customer/
│ ├── recall/ # 8 种客户维度各一个子目录
│ ├── potential/
│ ├── balance/
│ ├── recharge/
│ ├── recent/
│ ├── spend60/
│ ├── freq60/
│ ├── loyal/
│ ├── report.md
│ └── audit.md
├── my-profile/ # 单屏页面
│ ├── h5--step-0.png
│ ├── mp--step-0.png
│ ├── diff--step-0.png
│ ├── report.md
│ └── audit.md
...17 个页面各一个子目录)
```
文件命名规则:
- `h5--step-<scrollTop>.png` / `mp--step-<scrollTop>.png` / `diff--step-<scrollTop>.png`
- scrollTop 值0, 600, 1200, 1800 ...
- 文件名不含页面名(已在子目录中)
- 单屏页面只有 `--step-0` 一组
- 多维度页面board-coach ×4 排序维度、board-customer ×8 客户维度)按维度分子目录
废弃目录(不再使用):
- `docs/h5_ui/screenshots/` → 已清理
- `docs/h5_ui/mp-screenshots/` → 已清理
- `docs/h5_ui/h5-segments/` → 已清理
- `docs/h5_ui/diffs/` → 已清理
- `docs/h5_ui/anchors/` → 已清理
- `docs/h5_ui/analysis/` → 审计报告迁入各页面子目录
## 7. 风险与依赖
### 7.1 外部依赖
| 依赖项 | 风险等级 | 说明 | Fallback |
|--------|---------|------|----------|
| 微信开发者工具 MCP 连接 | 中 | MCP 连接偶尔断开(超时/端口占用),需重连 | `reconnect_devtools` 重连;若反复失败,手动截图后放入 `compare/<page>/` |
| Playwright 浏览器 | 低 | 首次运行需 `playwright install chromium` | `uv run playwright install chromium` |
| image_compare MCP | 低 | MCP server 未启动时 compare_images 不可用 | 使用 `anchor_compare.py compare` 命令行替代 |
### 7.2 已知限制
- 字体渲染引擎差异Chromium vs 微信 webview可能引入约 1-2% 的固有差异率
- 字体渲染差异H5 Noto Sans SC vs MP 系统字体)不可消除,属于可接受差异
- 微信开发者工具截图不支持指定 DPR实际 DPR 取决于模拟器设置(当前 iPhone 15 Pro Max 有效 DPR=1.5
- board-coach/board-customer 的多维度截图验证4/8 种维度)需要 Mock 数据支持不同维度的展示
## 8. 页面执行顺序
### 8.0 前置任务TS 零诊断基线检查与共性偏差批量修复
在进入 A 批次之前,需完成两项前置工作:
#### 8.0.1 TS 零诊断基线检查
对 17 个页面的 `.ts` 文件运行 `getDiagnostics`,确认全部为零诊断。这是 Step 0-5 结构迁移的交付基线,如果此时已有 TS 错误,必须先修复再进入像素精调,否则精调过程中引入的新错误会与历史错误混淆。
#### 8.0.2 跨页面共性偏差批量修复
基于 4.2 节已识别的跨页面共性偏差,在 `app.wxss` 或共享组件样式中统一修正:
| 共性偏差 | 涉及页面 | 修正方案 |
|----------|---------|---------|
| Tab 导航 font-size 26→24rpx | board-finance/coach/customer | 统一修正各页面 Tab 样式 |
| Tab 导航 padding 24→22rpx | board-finance/coach/customer | 同上 |
| Tab 导航 line-height 缺失→36rpx | board-finance/coach/customer | 同上 |
| 筛选栏内层 border-radius 14→16rpx | board-finance/coach/customer | 统一修正筛选栏样式 |
| 卡片 padding-top/bottom 30→28rpx | board-finance/coach/customer | 统一修正卡片容器样式 |
| 标签 border-radius 6→8rpx | board-finance/coach/customer | 统一修正标签样式 |
先批量修复共性偏差,再进入各页面的个性化精调,可显著减少重复工作量。修复后需对三个看板页面快速截图验证,确认共性修正未引入新问题。
### 8.1 批次执行顺序
按原批次顺序,简单页面优先积累经验:
| 批次 | 页面 | 预估复杂度 | 对照单元总数 |
|------|------|-----------|------------|
| A | board-finance, board-coach, board-customer | 高 | 22 |
| B | task-list, my-profile | 低-中 | 4 |
| C | task-detail主页面 5 单元)+ 3 变体(各 4 单元,仅验证主题色) | 中 | 17 |
| D | coach-detail, customer-detail, customer-service-records | 中 | 12 |
| E | performance, performance-records | 高 | 18 |
| F | chat, chat-history | 低 | 3 |
| G | notes | 低 | 3 |
| | **合计** | | **79** |

View File

@@ -0,0 +1,147 @@
# 需求文档H5 → 微信小程序像素精调
## 简介
本 spec 承接 `h5-miniprogram-migration` 的 Step 0-5 结构迁移成果。17 个页面已全部完成结构迁移、编译验证和结构还原验证。本 spec 专注于 Step 6-7像素级视觉还原与验收签收。
核心单位是「对照处理单元」——每个页面被分解为多个可独立截图、独立对比、独立修正的最小区域段。17 个页面共分解为 79 个对照处理单元(详见 design.md §5.1,基于 2026-03-10 Playwright 实测校准)。
权威参考:
- 迁移规则:`docs/prd/MIGRATION-PLAYBOOK.md`
- 样式标准:`docs/h5_ui/design-tokens.json`(颜色、字号、圆角、阴影的标准值)
- 工具链:`scripts/ops/anchor_compare.py`(固定步长截图 + 逐屏对比)
## 术语表
| 术语 | 定义 |
|------|------|
| 对照处理单元 | 一个页面内可独立截图、独立对比、独立修正的最小区域段(如"经营一览"板块、"助教卡片列表"区域) |
| 差异率 | pixelmatch / image_compare 输出的像素差异百分比,仅针对内容区域计算(头部导航栏/状态栏不计入) |
| 结构级修正 | 差异率 > 10% 时的修正阶段,修复明显的布局/颜色/缺失问题 |
| 像素级精调 | 差异率 5%~10% 时的精调阶段,微调 padding/font-size/color 等 |
| 固定步长 | 600px 逻辑像素为一步,两端使用完全相同的 scrollTop 序列逐屏截图 |
| 双端截图 | 同一页面的 H5 截图DPR=1.5, 645×1128和 MP 截图DPR=1.5, 645×1128两端参数完全一致无需缩放 |
## 需求
### 需求 1页面分解与对照处理单元建立
**用户故事:** 作为迁移执行者,我希望每个页面被分解为可独立处理的最小对照单元,以便逐屏精确对比和修正。
#### 验收标准
1. THE 分解流程 SHALL 对每个页面执行以下步骤:分析 H5 原型结构 → 识别语义区域 → 按固定 600px 步长计算截图屏数 → 建立对照处理单元清单
2. THE 对照处理单元 SHALL 满足以下条件每屏为一个对照单元645×1128 像素),语义完整性由审计报告人工确认
3. THE 步数计算 SHALL 基于 Playwright 实测的 `maxScroll = scrollHeight - viewportHeight``N = floor(maxScroll / 600) + 1``maxScroll ≤ 10` 的页面视为单屏N=1
4. WHEN 页面为多维度页面(如 board-coach ×4 排序维度、board-customer ×8 客户维度THE 分解流程 SHALL 按维度分子目录每个维度独立截图和对比。维度切换通过页面筛选栏下拉操作完成board-coach 通过排序筛选切换 perf/salary/sv/task 四种卡片模板board-customer 通过维度筛选切换 recall/potential/balance/recharge/recent/spend60/freq60/loyal 八种卡片模板)
5. THE 分解结果 SHALL 输出为每页一份的对照单元清单包含单元编号step-N、scrollTop 值、预估复杂度
### 需求 2双端截图标准
**用户故事:** 作为迁移执行者,我希望 H5 和 MP 的截图在尺寸、视口、DPR 上严格统一,以确保像素对比结果有意义。
#### 验收标准
1. THE H5 截图 SHALL 使用以下参数viewport 430×752, DPR=1.5, 输出 645×1128通过 Playwright 截取Live Server `http://localhost:5500`),等待 Tailwind CDN JIT 渲染 1500ms
2. THE MP 截图 SHALL 使用以下参数viewport 430×752, DPR=1.5MCP 有效 DPR, 输出 645×1128通过微信开发者工具 MCP 截取。两端输出尺寸完全一致,无需任何缩放
3. WHEN 对比页面时THE 截图流程 SHALL 使用固定 600px 步长滚动截图,两端使用完全相同的 scrollTop 序列0, 600, 1200, ...),不依赖锚点
4. THE H5 截图前 SHALL 隐藏底部浮动元素:`#bottomNav`64px fixed 底部导航)和 `.ai-float-btn-container`56px 浮动按钮),通过 JS 设置 `display: none`。MP 端原生 tabBar 不在截图中,无需处理
5. THE 头部区域H5 safe-area-top ~46px / MP 状态栏+胶囊 ~88pxSHALL 不计入像素差异率,属结构性差异
6. WHEN 截图流程中发现 H5 页面或 MP 页面无法正常渲染时THE 流程 SHALL 暂停并报告问题,禁止使用异常截图进行对比
7. WHEN MP 端页面高度与 H5 不一致时THE 截图流程 SHALL 先截双端 step-0首屏确认基线再截 step-600第二屏校准双端高度差异之后逐屏推进。每屏截图前读取 MP 端实际 scrollTop确认到达预期位置
### 需求 3两阶段收敛流程
**用户故事:** 作为迁移执行者,我希望像素精调按差异率分阶段处理——先修大问题再调细节——以高效收敛到达标标准。
#### 验收标准
1. THE 收敛流程 SHALL 分为两个阶段:
- 阶段一(结构级修正):差异率 > 10%,每轮修复 2-5 处明显差异(布局/颜色/缺失/字号)
- 阶段二(像素级精调):差异率 5%~10%,每轮修复 1-3 处精细差异padding ±2rpx / line-height / border-radius / 色值)
2. THE 达标标准 SHALL 为:差异率 < 5% 标记通过;差异率 > 15% 且多轮无法收敛则触发重写
3. WHEN 进入每轮修正时THE 流程 SHALL 先截图对比 → 肉眼审查 diff 图 → 定位差异区域 → 修改 WXSS/WXML → 重新截图验证
4. THE 每轮修正 SHALL 控制修改范围(阶段一 2-5 处、阶段二 1-3 处),避免一次改太多难以定位效果
5. WHEN 差异率在连续 3 轮内未下降超过 1% 时THE 流程 SHALL 评估是否为结构性差异(如头部区域、字体渲染差异),若是则标注为可接受差异并停止该区域的精调
### 需求 4分析报告持久化
**用户故事:** 作为项目管理者,我希望每个页面的对比分析结果持久化为 MD 文件,以便追溯和复查。
#### 验收标准
1. THE 每个页面 SHALL 在 `docs/h5_ui/compare/<page>/report.md` 输出对比分析报告
2. THE 报告 SHALL 包含:初始差异率、每轮修正记录(轮次/修改内容/修正后差异率)、最终差异率、遗留的可接受差异说明
3. THE 报告 SHALL 在每轮修正后更新,记录收敛过程
4. THE 截图产出物 SHALL 统一归入 `docs/h5_ui/compare/<page>/`,按页面分子目录:
- H5 截图:`h5--step-<scrollTop>.png`
- MP 截图:`mp--step-<scrollTop>.png`
- Diff 图:`diff--step-<scrollTop>.png`
- 审计报告:`audit.md`
- 对比报告:`report.md`
- 多维度页面按维度再分子目录(如 `board-coach/perf/`
5. THE 每轮新截图 SHALL 覆盖上一轮同名文件,不保留历史版本(依赖 git 追溯)
### 需求 5修改优先级规则
**用户故事:** 作为迁移执行者,我希望有明确的修改优先级指导,以便在每轮修正中优先处理影响最大的差异。
#### 验收标准
1. THE 修改优先级 SHALL 按以下顺序排列(从高到低):
- P0区域缺失或顺序错误结构性问题
- P1整块背景色/渐变色不匹配
- P2字号font-size明显偏差≥4rpx
- P3间距padding/margin/gap明显偏差≥4rpx
- P4圆角border-radius偏差
- P5颜色色值微调灰阶偏差、透明度
- P6行高line-height/ 字重font-weight微调
- P7阴影box-shadow参数微调
2. WHEN 阶段一修正时THE 流程 SHALL 优先处理 P0-P3 级别的差异
3. WHEN 阶段二精调时THE 流程 SHALL 处理 P4-P7 级别的差异
4. THE 修改 SHALL 优先使用 `docs/h5_ui/design-tokens.json` 中定义的标准值,禁止使用非标准灰色(#333/#666/#999 等)
5. THE 修改 SHALL 优先使用 flex/盒模型的确定性方案,禁止使用"碰运气"的魔法数
### 需求 6验收签收标准
**用户故事:** 作为项目管理者,我希望每个页面有明确的验收标准,以确保交付质量一致。
#### 验收标准
1. THE 单页验收 SHALL 满足以下条件:
- 内容区差异率 < 5%
- TS 编译零诊断
- 所有对照处理单元均已逐屏对比
- report.md 已归档且记录完整
2. THE 批次验收 SHALL 满足以下条件:
- 批次内所有页面单页验收通过
- 共享组件在批次内所有页面中表现一致
- 所有截图和 diff 图已按目录归档
3. THE 全局验收 SHALL 满足以下条件:
- 17 个页面79 个对照处理单元)全部单页验收通过
- 差异率汇总表已输出(页面名 / 初始差异率 / 最终差异率 / 修正轮数)
- TS 编译零诊断复查通过
### 需求 7工具链使用规范
**用户故事:** 作为迁移执行者,我希望工具链的使用有明确规范,以确保截图和对比结果的一致性和可复现性。
#### 验收标准
1. THE H5 截图 SHALL 统一通过 `scripts/ops/anchor_compare.py extract-h5 <page>` 获取(固定 600px 步长滚动截图viewport 430×752, DPR=1.5;通过 Live Server `http://localhost:5500` 访问页面)
2. THE MP 截图 SHALL 通过微信开发者工具 MCP 截取,按相同的 scrollTop 序列0, 600, 1200, ...)逐屏截图
3. THE 像素对比 SHALL 使用 `image_compare` power 的 `compare_images` 工具,或 `anchor_compare.py compare <page>` 命令
4. WHEN 页面需要逐屏对比时THE 流程 SHALL 使用 anchor_compare.py 的两步流程:`extract-h5``compare`MP 截图通过 MCP 工具独立获取)
5. THE 工具链输出 SHALL 统一存放到 `docs/h5_ui/compare/<page>/`(见需求 4禁止散放在项目根目录或临时位置
### 需求 8风险与异常处理
**用户故事:** 作为迁移执行者,我希望在工具链故障或环境异常时有明确的 Fallback 方案,以避免流程阻塞。
#### 验收标准
1. WHEN 微信开发者工具 MCP 连接断开时THE 流程 SHALL 先尝试 `reconnect_devtools` 重连,若连续 3 次失败则暂停该页面任务并报告问题
2. WHEN Live Server 端口 5500 被占用时THE 流程 SHALL 检查端口占用并提示用户释放端口,不得使用其他端口截图(避免 URL 不一致)
3. WHEN image_compare MCP 不可用时THE 流程 SHALL 使用 `anchor_compare.py compare` 命令行作为替代
4. WHEN 某页面差异率 > 15% 且连续 5 轮无法收敛时THE 流程 SHALL 暂停并输出问题分析报告,由用户决定是否触发重写
5. THE 每个批次任务 SHALL 在开始前验证页面 TS 零诊断基线,如有编译错误则先修复再进入精调

View File

@@ -0,0 +1,135 @@
# 实现计划H5 → 微信小程序视觉还原
## 概述
17 个页面(共 79 个对照处理单元)已完成 Step 0-5 结构迁移。本计划覆盖 Step 6-7视觉还原与验收。
采用固定 600px 步长滚动截图方案:两端使用完全相同的 scrollTop 序列0, 600, 1200, ...),不依赖锚点,消除 H5/MP 元素位置差异导致的对齐问题。
采用混合组织方式:前置阶段按流程批量操作(截图→对比→共性修复),修正阶段按页面逐个完成(审计→修正→验收)。
## 截图方案要点
- 双端参数viewport 430×752, DPR=1.5, 输出 645×1128, 无需缩放
- 步长600px逻辑像素
- H5 截图:`scripts/ops/anchor_compare.py extract-h5 <page>`Playwright + Live Server localhost:5500
- MP 截图MCP 工具(`wx.pageScrollTo` + `screenshot`
- 产出物根目录:`docs/h5_ui/compare/<page>/`
- 文件命名:`h5--step-<scrollTop>.png` / `mp--step-<scrollTop>.png` / `diff--step-<scrollTop>.png`
- 审计报告:`docs/h5_ui/compare/<page>/audit.md`
- 对比报告:`docs/h5_ui/compare/<page>/report.md`
- 多维度页面:`docs/h5_ui/compare/<page>/<dimension>/`(如 board-coach/perf/、board-customer/recall/
- 维度切换board-coach 通过排序筛选下拉切换 perf/salary/sv/taskboard-customer 通过维度筛选下拉切换 recall/potential/balance/recharge/recent/spend60/freq60/loyal
## 逐页视觉还原流程(阶段 3 每页统一流程)
每个页面子任务按以下步骤执行:
1. **截图**:先截双端 step-0首屏确认基线再截 step-600第二屏校准双端高度差异之后按固定步长序列逐屏推进。每屏读取 MP 端实际 scrollTop 确认到达预期位置
2. **审计报告**:同时读取 H5 源码HTML + Tailwind 类名、MP 源码WXML + WXSS、diff 截图,三方对照产出 `docs/h5_ui/compare/<page>/audit.md`
3. **浮动元素检测**(长页面):对比多屏截图中持续相同位置的差异,识别 sticky/fixed 元素,优先修复(一次修复所有屏受益)
4. **阶段一修正循环**(差异率 > 10%):按审计报告偏差清单修正 → 每轮修 2-5 处 → 重新截图对比 → 循环至 ≤ 10%
5. **阶段二精调循环**(差异率 5%~10%):精确定位差异区域 → 每轮修 1-3 处 → 重新截图对比 → 循环至 < 5%
6. **验收**:差异率 < 5% → 更新 report.md → 标记通过
### 长页面级联规则
修正某一屏的 WXSS 后,所有屏必须重新截图对比(布局变化会影响每屏内容)。优先修复 sticky 区域差异,从 step-0 开始顺序审查。
---
## 任务
### 阶段 0 — 前置准备
- [x] 0. 工具链验证与基线检查
- [x] 0.1 验证截图工具链:确认 Playwright 可通过 Live Server (localhost:5500) 截图 H5 页面、微信开发者工具 MCP 可连接截图、image_compare 可对比
- [x] 0.2 TS 零诊断基线检查:对 17 个页面的 .ts 文件运行 getDiagnostics确认全部为零诊断
- [x] 0.3 固定步长方案验证board-finance 实测 10 屏step 0-4848scrollHeight=5600, maxScroll=4848两端精确命中目标 scrollTop截图 645×1128 完全一致
---
### 阶段 1 — 批量截图与初始对比
- [ ] 1. 批量获取 H5 截图board-finance 已完成 10 屏验证)
- [ ] 1.1 board-finance H5 固定步长截图10 屏step 0-4848— 已通过 test_fixed_step.py 完成
- [x] 1.2 重构 anchor_compare.py 为固定步长模式,支持 `extract-h5 <page>` 自动计算页面高度和步数
- [ ] 1.3 对剩余 16 个页面运行 H5 固定步长截图
- [ ] 2. 批量获取 MP 截图board-finance 已完成 10 屏验证)
- [x] 2.1 board-finance MP 固定步长截图10 屏step 0-4848— 已通过 MCP 工具完成
- [ ] 2.2 对剩余 16 个页面通过 MCP 工具截取 MP 固定步长截图
- [ ] 3. 批量初始对比与差异率采集
- [x] 3.1 board-finance 逐屏对比完成(差异率 5.01%~17.71%,差异来自组件未精校)
- [ ] 3.2 对剩余 16 个页面运行逐屏对比,输出 diff 图到 `docs/h5_ui/compare/<page>/`
- [ ] 3.3 汇总差异率表(页面名 / 屏数 / 各屏差异率),按差异率降序排列
---
### 阶段 2 — 共性偏差批量修复
- [ ] 4. 跨页面共性偏差修复
- [ ] 4.1 根据阶段 1 差异率和 design.md §4.2 已识别的共性偏差Tab 导航 font-size/padding/line-height、筛选栏 border-radius、卡片 padding、标签 border-radius在各页面 WXSS 或共享组件中统一修正
- [ ] 4.2 共性修复后重新截图验证:对涉及修改的页面重新截图 + 对比,确认共性修正未引入新问题
---
### 阶段 3 — 逐页视觉还原
按批次组织,每页按「逐页视觉还原流程」执行(截图→审计→浮动元素检测→修正循环→验收)。
#### A 批次 — 看板页面
- [ ] 5. board-finance 视觉还原长页面10 屏 step 0-4848。含 sticky 区域检测safe-area-top 45px + filterBar 71px = 116px
- [ ] 6. board-coach 视觉还原短页面4 种排序维度 perf/salary/sv/task 需逐维度截图验证,通过排序筛选下拉切换)
- [ ] 7. board-customer 视觉还原短页面8 种客户维度 recall/potential/balance/recharge/recent/spend60/freq60/loyal 需逐维度截图验证,通过维度筛选下拉切换)
- [ ] 8. A 批次检查点3 页全部差异率 < 5%,共享组件跨页表现一致
#### B 批次 — 核心页面
- [ ] 9. task-list 视觉还原(约 3 屏)
- [ ] 10. my-profile 视觉还原(单屏页面)
- [ ] 11. B 批次检查点2 页全部差异率 < 5%
#### C 批次 — 任务详情页面
> task-detail 系列 4 个页面共享相同布局,仅 Banner 主题色不同。主页面做完整还原3 个变体只验证主题色差异。
- [ ] 12. task-detail 视觉还原(主页面,约 5 屏,完整审计→修正→验收)
- [ ] 13. task-detail 变体主题色验证callback=teal / priority=orange / relationship=pink逐个截图 → 仅对比含主题色的屏 → 确认主题色正确应用
- [ ] 14. C 批次检查点4 页全部差异率 < 5%
#### D 批次 — 详情页面
- [ ] 15. coach-detail 视觉还原(约 5 屏)
- [ ] 16. customer-detail 视觉还原(约 5 屏)
- [ ] 17. customer-service-records 视觉还原(约 2 屏)
- [ ] 18. D 批次检查点3 页全部差异率 < 5%
#### E 批次 — 绩效页面
- [ ] 19. performance 视觉还原(约 13 屏,本 spec 最长页面)
- [ ] 20. performance-records 视觉还原(约 5 屏)
- [ ] 21. E 批次检查点2 页全部差异率 < 5%
#### F 批次 — 对话页面
- [ ] 22. chat 视觉还原(约 2 屏)
- [ ] 23. chat-history 视觉还原(单屏页面)
- [ ] 24. F 批次检查点2 页全部差异率 < 5%
#### G 批次 — 其他页面
- [ ] 25. notes 视觉还原(约 3 屏)
- [ ] 26. G 批次检查点:差异率 < 5%
---
### 阶段 4 — 全局验收
- [ ] 27. 全局验收
- [ ] 27.1 汇总 17 页面差异率表(页面名 / 屏数 / 初始差异率 / 共性修复后差异率 / 最终差异率 / 修正轮数),共 79 个对照处理单元
- [ ] 27.2 确认所有 `compare/<page>/report.md` 已归档
- [ ] 27.3 TS 编译零诊断复查17 个页面)
- [ ] 27.4 标记 spec 完成

View File

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

View File

@@ -0,0 +1,552 @@
# 技术设计文档H5 → 微信小程序批量迁移
## 1. 概述
本设计文档基于 33 条需求(`requirements.md`),为 17 个 H5 原型页面迁移到微信小程序提供技术实现方案。权威参考:`docs/prd/MIGRATION-PLAYBOOK.md`
核心约束:
- 纯前端迁移,不涉及后端 API 或数据库变更
- 输入物分两批:第一批(结构迁移 Step 1-5、第二批像素精调 Step 6-7
- 迁移粒度:以"屏/交互态"为最小单位,非整页
## 2. 架构总览
### 2.1 目录结构(现有 + 新增)
```
apps/miniprogram/miniprogram/
├── app.json / app.ts / app.wxss # 全局配置与样式
├── assets/icons/ # SVG 图标(已有 + 新导出)
├── components/ # 共享组件
│ ├── ai-float-button/ # ✅ 已有
│ ├── board-tab-bar/ # ✅ 已有
│ ├── filter-dropdown/ # ✅ 已有
│ ├── heart-icon/ # ✅ 已有
│ ├── star-rating/ # ✅ 已有
│ ├── note-modal/ # ✅ 已有
│ ├── metric-card/ # ✅ 已有
│ ├── hobby-tag/ # ✅ 已有
│ ├── banner/ # ✅ 已有
│ └── dev-fab/ # ✅ 已有
├── pages/ # 页面目录17 个迁移目标)
│ ├── board-finance/ # A 批次 - 看板
│ ├── board-coach/
│ ├── board-customer/
│ ├── task-list/ # B 批次 - 核心
│ ├── my-profile/
│ ├── task-detail/ # C 批次 - 任务详情
│ ├── task-detail-callback/
│ ├── task-detail-priority/
│ ├── task-detail-relationship/
│ ├── coach-detail/ # D 批次 - 详情
│ ├── customer-detail/
│ ├── customer-service-records/
│ ├── performance/ # E 批次 - 绩效
│ ├── performance-records/
│ ├── chat/ # F 批次 - 对话
│ ├── chat-history/
│ └── notes/ # G 批次 - 其他
└── utils/ # 工具模块
├── ai-color.ts # 🆕 AI 图标随机配色
├── format.wxs # ✅ 已有 WXS 格式化
├── request.ts # ✅ 已有 网络请求
├── router.ts # ✅ 已有 路由工具
└── ... # 其他已有工具
```
### 2.2 页面四文件结构
每个页面输出标准四文件:
```
pages/<page>/
├── <page>.wxml # 视图模板
├── <page>.wxss # 样式
├── <page>.ts # 逻辑TypeScript
└── <page>.json # 页面配置usingComponents
```
## 3. 单页迁移工作流设计
### 3.1 工作流总览7 步 + 屏级粒度)
迁移以"屏/交互态"为最小工作单位,而非整页。每个页面的迁移流程:
```
Step 0: 页面分析(确认屏数、子页面、变种、工作量)
Step 1: 输入物冻结(第一批:结构材料)
Step 2: 迁移审计报告7 项审计,不写代码)
Step 3: 规则化转换(按屏逐个开发)
Step 4: 编译验证7 项检查)
Step 5: 结构还原验证9 项核对,按屏逐个验证)
── 第二批输入物补充(截图 + computed-styles──
Step 6: 像素级对比(按屏逐段对比 + 微调循环)
Step 7: 验收签收12 项清单)
```
### 3.2 Step 0页面分析新增步骤
在正式迁移前,先对目标页面做结构分析:
1. 打开 H5 原型截图 + 交互说明文档
2. 确认页面总长度(几个屏?)
3. 识别子页面/变种(如 task-detail 的 3 个主题色变体)
4. 列出所有交互态(弹窗、筛选展开、空状态等)
5. 输出工作量估算表:
```
| 单位 | 类型 | 描述 | 预估复杂度 |
|------|------|------|-----------|
| 屏-1 | 默认态首屏 | Banner + 筛选栏 + 第一板块 | 中 |
| 屏-2 | 默认态第二屏 | 第二~三板块 | 中 |
| 交互-1 | 筛选下拉 | 时间筛选 + 区域筛选 | 低 |
| 交互-2 | 指标弹窗 | 长按指标卡片 | 低 |
| ... | ... | ... | ... |
```
### 3.3 Step 3按屏逐个开发
规则化转换不是一次性写完整页,而是按屏/交互态逐个推进:
1. 先完成首屏结构 → 编译通过 → 截图粗看
2. 再完成第二屏 → 编译通过 → 截图粗看
3. 所有屏完成后 → 处理交互态(弹窗、筛选等)
4. 最后处理三态loading/empty/error
### 3.4 Step 5按屏逐个验证
结构还原验证同样按屏进行:
1. 截取小程序当前屏 → 与 H5 原型截图粗略比对
2. 9 项核对清单逐项确认
3. 通过 → 下一屏;未通过 → 修复后重新验证
4. 所有屏 + 所有交互态全部通过 → 进入 Step 6
### 3.5 差异率过大的处理策略
当 Step 6 像素对比差异率 > 15% 且多轮微调无法收敛时:
- 放弃修补,从零重写该页面(需求 2 第 5 条)
- 复杂 Banner 背景 → 导出为 SVG`<image>` 引用(需求 2 第 6 条)
- 复杂 Icon → 导出为 SVG`<image>` 引用(需求 2 第 7 条)
## 4. 样式转换系统设计
### 4.1 缩放公式
```
rpx = H5 px × 2 × 0.875
结果取偶数(向最近偶数取整)
```
特例:`borderRadius` 使用简单 ×2A/B 对比验证差异 < 0.02%)。
### 4.2 design-tokens 映射
全局设计令牌来自 `docs/h5_ui/design-tokens.json`,直接映射到 WXSS 变量:
| Token | 值 | 用途 |
|-------|-----|------|
| fontSize.xs | 22rpx | 辅助文字 |
| fontSize.sm | 24rpx | 次要文字 |
| fontSize.base | 28rpx | 正文 |
| fontSize.lg | 32rpx | 小标题 |
| fontSize.xl | 36rpx | 标题 |
| fontSize.2xl | 42rpx | 大标题 |
| borderRadius.sm | 8rpx | 小圆角 |
| borderRadius.md | 16rpx | 中圆角 |
| borderRadius.lg | 24rpx | 大圆角 |
| borderRadius.xl | 32rpx | 特大圆角 |
| borderRadius.3xl | 48rpx | 全圆角 |
颜色使用 `colors` 中定义的灰阶gray-1 ~ gray-13禁止使用 `#333``#666``#999` 等非标准灰色。
### 4.3 两阶段样式数据源
**结构迁移阶段Step 3**
1. H5 源码 Tailwind 类名 → 查速查表换算
2. design-tokens.json Token 值
3. 目测估算(必须标注 `/* 目测值,待校准 */`
**像素精调阶段Step 6**
1. computed-styles.json 精确 px 值(最高优先级)
2. H5 源码 Tailwind 类名
3. design-tokens.json
4. H5 截图目测(最低)
### 4.4 七维度核对
每个可见元素写 WXSS 时逐项确认:
1. font-size
2. font-weight
3. color
4. line-height必须显式写出
5. padding
6. margin / gap
7. border / border-radius
## 5. AI 图标配色系统设计
### 5.1 配色方案定义
6 种配色,每种 4 个 CSS 变量:
```typescript
// utils/ai-color.ts
const AI_COLOR_SCHEMES = {
red: { from: '#e74c3c', to: '#f39c9c', fromDeep: '#c0392b', toDeep: '#e74c3c' },
orange: { from: '#e67e22', to: '#f5c77e', fromDeep: '#ca6c17', toDeep: '#e67e22' },
yellow: { from: '#d4a017', to: '#f7dc6f', fromDeep: '#b8860b', toDeep: '#d4a017' },
blue: { from: '#2980b9', to: '#7ec8e3', fromDeep: '#1a5276', toDeep: '#2980b9' },
indigo: { from: '#667eea', to: '#a78bfa', fromDeep: '#4a5fc7', toDeep: '#667eea' },
purple: { from: '#764ba2', to: '#c084fc', fromDeep: '#5b3080', toDeep: '#764ba2' },
};
```
### 5.2 小程序实现方案
H5 通过 DOM `querySelectorAll` + `classList.add` 实现随机配色。小程序无 DOM API改用 `setData` + 条件样式:
```typescript
// 页面 onLoad 中调用
import { getRandomAiColor } from '../../utils/ai-color';
Page({
data: {
aiColorClass: '', // 'ai-color-red' | 'ai-color-orange' | ...
aiColorVars: {}, // CSS 变量值对象
},
onLoad() {
const color = getRandomAiColor();
this.setData({
aiColorClass: color.className,
aiColorVars: color.vars,
});
},
});
```
```xml
<!-- WXML 中使用 -->
<view class="ai-inline-icon {{aiColorClass}}">
<image src="/assets/icons/ai-robot-sm.svg" mode="aspectFit" />
</view>
<view class="ai-title-badge {{aiColorClass}}">
<view class="ai-title-badge-icon">
<image src="/assets/icons/ai-robot.svg" mode="aspectFit" />
</view>
<text>AI 推荐</text>
</view>
```
### 5.3 两个系列的 WXSS 实现
**ai-inline-icon**行首小图标28rpx
- 渐变背景 + 白色机器人 SVG
- 微光扫过动画12s 周期 `ai-shimmer`
- 尺寸28rpx × 28rpxH5 16px × 2 × 0.875 ≈ 28
**ai-title-badge**(标题行右侧标识):
- 浅色背景 + 主题色文字 + 主题色边框
- 呼吸脉冲动画3s 周期 `ai-pulse`
- 高光扫过动画14s 周期 `ai-shimmer`
### 5.4 ai-float-button 排除
`ai-float-button` 组件已有固定渐变动画(`#667eea → #764ba2 → #f093fb → #f5576c`),不参与页面级随机配色。无需修改。
### 5.5 机器人 SVG 复用
- 大系列ai-title-badge复用已有 `assets/icons/ai-robot.svg`
- 小系列ai-inline-icon从 H5 源码导出白色填充版本,保存为 `assets/icons/ai-robot-sm.svg`
## 6. 共享组件设计
### 6.1 已有组件(直接复用)
| 组件 | 路径 | 用途 | 使用页面 |
|------|------|------|---------|
| ai-float-button | components/ai-float-button/ | AI 悬浮按钮 | 所有业务页面 |
| board-tab-bar | components/board-tab-bar/ | 自定义底部导航 | board-coach, board-customer |
| filter-dropdown | components/filter-dropdown/ | 筛选下拉面板 | board-finance/coach/customer |
| heart-icon | components/heart-icon/ | 心形评分 | board-customer |
| star-rating | components/star-rating/ | 星级评价 | notes |
| note-modal | components/note-modal/ | 备注弹窗 | task-list/detail, coach-detail |
| metric-card | components/metric-card/ | 指标卡片 | board-finance, performance |
| hobby-tag | components/hobby-tag/ | 爱好标签 | board-customer, customer-detail |
| banner | components/banner/ | 顶部 Banner | task-list, performance |
| dev-fab | components/dev-fab/ | 开发调试按钮 | 所有页面(开发环境) |
### 6.2 组件注册规范
每个页面的 `.json` 文件中注册所需组件:
```json
{
"usingComponents": {
"t-button": "tdesign-miniprogram/button/button",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading",
"ai-float-button": "/components/ai-float-button/ai-float-button",
"filter-dropdown": "/components/filter-dropdown/filter-dropdown"
}
}
```
## 7. 事件与路由转换设计
### 7.1 事件映射表
| H5 | 小程序 | 说明 |
|----|--------|------|
| `onclick="fn()"` | `bindtap="fn"` | 基础点击 |
| `onclick="fn(id)"` | `data-id="{{id}}" bindtap="fn"` | dataset 传参 |
| `event.target.value` | `e.detail.value` | 表单取值 |
| `event.target.dataset` | `e.currentTarget.dataset` | dataset 取值 |
| `event.preventDefault()` | `catchtap` | 阻止冒泡 |
| `classList.toggle` | `setData` + 条件 class | 样式切换 |
| `innerHTML` | `setData` + WXML 绑定 | 视图更新 |
| `history.back()` | `wx.navigateBack()` | 返回 |
| `localStorage` | `wx.setStorageSync` | 本地存储 |
| `alert()/confirm()` | `wx.showToast()/wx.showModal()` | 弹窗 |
| `longpress` | `bindlongpress` | 长按 |
### 7.2 路由规则
| 目标页面类型 | API | 示例 |
|-------------|-----|------|
| TabBar 页面 | `wx.switchTab` | task-list, board-finance, my-profile |
| 普通页面 | `wx.navigateTo` | task-detail, coach-detail, chat |
| 重定向 | `wx.redirectTo` | 登录后跳转 |
| 返回 | `wx.navigateBack` | 详情页返回 |
| 重启 | `wx.reLaunch` | 切换身份 |
路径规则:以 `/` 开头,不带 `.wxml` 后缀。
## 8. 弹窗与 z-index 分层设计
### 8.1 全局 z-index 分层
```
10-29 sticky 元素Tab 栏 20, 筛选栏 15
30 AI 悬浮按钮
50 底部固定操作栏
100 自定义底部导航栏board-tab-bar
999 遮罩层
1000 弹窗内容
9999 Toast / Loading
```
### 8.2 弹窗实现模式
所有弹窗遵循统一模式:
- 同一时刻只允许一个弹窗打开(互斥)
- 遮罩 `bindtap` 关闭,内容区 `catchtap` 防穿透
- 背景滚动锁定:`catchtouchmove` 在遮罩层
- 底部弹出类添加 `padding-bottom: env(safe-area-inset-bottom)`
- 动画统一 200-220ms + `ease`
## 9. 三态处理设计
每个页面统一处理 4 种状态:
```xml
<!-- 通用三态模板 -->
<view wx:if="{{pageState === 'loading'}}">
<t-loading text="加载中..." />
</view>
<view wx:elif="{{pageState === 'error'}}">
<view class="error-state">
<text>加载失败,请点击重试</text>
<t-button bindtap="onRetry">重试</t-button>
</view>
</view>
<view wx:elif="{{pageState === 'empty'}}">
<text class="empty-text">{{emptyText}}</text>
</view>
<view wx:else>
<!-- 正常内容 -->
</view>
```
各页面空状态文案见需求 14。
## 10. 像素对比工具链设计
### 10.1 工具链流程
```
H5 截图DPR=3, 1290px 宽)
MP 截图DPR=1.5, 645px×2 缩放 → 1290px
pixelmatch 逐像素对比
按 150px 条带分析差异密度
定位差异区域 → WXSS 微调 → 循环
```
### 10.2 逐段对比v2 方案)
长页面使用 `scripts/ops/anchor_compare.py`
```bash
# 提取 H5 锚点 + 截图
python scripts/ops/anchor_compare.py extract-h5 <page>
# 生成 MP 截图指令
python scripts/ops/anchor_compare.py mp-inst <page>
# 执行 MP 截图(通过微信开发者工具 MCP
# 逐段配对 + 对比
python scripts/ops/anchor_compare.py compare <page>
```
### 10.3 scroll-view 页面截图
使用 `scroll-into-view` 模式:
1. `page.setData({ scrollIntoView: '' })` — 清空
2. `page.setData({ scrollIntoView: '<section-id>' })` — 设目标
3. 等待 1000ms → 截图
### 10.4 达标标准
- 前半屏差异率 < 5%:优秀
- 前半屏差异率 ≤ 10%:达标
- 前半屏差异率 > 15% 且无法收敛:触发重写
## 11. task-detail 变体策略
### 11.1 实现方式
1. 先完成 task-detail 主页面的完整迁移和验收
2. 复制 task-detail 四文件到变体目录
3. 替换主题色变量banner 背景色、按钮配色)
4. 保持数据结构和布局完全一致
### 11.2 变体清单
| 变体 | 差异点 |
|------|--------|
| task-detail-callback | banner 背景色 + 按钮配色(对照 H5 原型校准) |
| task-detail-priority | banner 背景色 + 按钮配色(对照 H5 原型校准) |
| task-detail-relationship | banner 背景色 + 按钮配色(对照 H5 原型校准) |
## 12. 认证与联调设计
### 12.1 认证守卫
每个业务页面 `onLoad` 检查登录态:
```typescript
onLoad() {
const token = wx.getStorageSync('token');
if (!token) {
wx.redirectTo({ url: '/pages/login/login' });
return;
}
// 正常加载逻辑
}
```
### 12.2 开发联调
- `utils/request.ts``BASE_URL` 指向 `http://localhost:8000`
- 后端 `WX_DEV_MODE=true` 支持 `/api/xcx/dev-login` Mock 登录
- Storage + header token 维持登录态
## 13. 不支持的 CSS 特性替代方案
| H5 特性 | 小程序替代 |
|---------|-----------|
| `backdrop-filter: blur()` | `background: rgba(255,255,255,0.95)` |
| `*` 通配符选择器 | 逐个元素设置 |
| `filter: blur()` | `radial-gradient` 模拟 |
| `url("data:image/svg+xml,...")` | CSS 渐变模拟或导出 PNG/base64 |
| `::before/::after`(复杂场景) | 额外 `<view>` 模拟 |
直接支持的特性无需替代CSS 变量 `var()``linear-gradient``animation`/`@keyframes``transition`
## 14. 批次执行顺序与依赖
```
A-看板board-finance → board-coach → board-customer
↓ 共享组件验证完毕
B-核心task-list → my-profile
C-任务task-detail → 3 个变体)
D-详情coach-detail → customer-detail → customer-service-records
E-绩效performance → performance-records
F-对话chat → chat-history
G-其他notes
```
A 批次优先验证共享组件filter-dropdown、board-tab-bar、metric-card在实际页面中的表现为后续批次建立基线。
## 15. 产出物与中间生成物归档
迁移过程中会产生大量截图、diff 图、逐段对比图等中间文件。所有生成物必须按类型分目录存放,禁止散放在项目根目录或临时位置。
### 15.1 目录结构
```
docs/h5_ui/
├── screenshots/ # H5 原型截图(输入物,已有)
│ ├── <page>.png # 默认态截图
│ └── <page>--<state>.png # 交互态截图
├── mp-screenshots/ # 🆕 小程序截图(迁移过程生成)
│ ├── <page>/ # 按页面分子目录
│ │ ├── <page>.png # 默认态全屏截图
│ │ ├── <page>--<state>.png # 交互态截图
│ │ └── seg-<N>-<section>.png # 逐段截图anchor_compare 生成)
│ └── ...
├── diffs/ # 🆕 像素对比结果(迁移过程生成)
│ ├── <page>/ # 按页面分子目录
│ │ ├── diff-<page>.png # 全屏 diff 图
│ │ ├── diff-seg-<N>-<section>.png # 逐段 diff 图
│ │ └── report.md # 该页面的对比结果摘要(差异率、问题区域)
│ └── ...
├── h5-segments/ # 🆕 H5 逐段截图anchor_compare 生成)
│ ├── <page>/
│ │ └── seg-<N>-<section>.png
│ └── ...
└── ...
```
### 15.2 归档规则
| 生成物类型 | 目标目录 | 命名规则 | 说明 |
|-----------|---------|---------|------|
| H5 原型截图 | `docs/h5_ui/screenshots/` | `<page>.png` / `<page>--<state>.png` | 输入物,已有,不动 |
| MP 全屏截图 | `docs/h5_ui/mp-screenshots/<page>/` | `<page>.png` / `<page>--<state>.png` | 每轮对比更新覆盖 |
| MP 逐段截图 | `docs/h5_ui/mp-screenshots/<page>/` | `seg-<N>-<section>.png` | anchor_compare 生成 |
| H5 逐段截图 | `docs/h5_ui/h5-segments/<page>/` | `seg-<N>-<section>.png` | anchor_compare 生成 |
| 全屏 diff 图 | `docs/h5_ui/diffs/<page>/` | `diff-<page>.png` | pixelmatch 输出 |
| 逐段 diff 图 | `docs/h5_ui/diffs/<page>/` | `diff-seg-<N>-<section>.png` | pixelmatch 输出 |
| 对比报告 | `docs/h5_ui/diffs/<page>/` | `report.md` | 差异率 + 问题区域摘要 |
| 新导出 SVG | `assets/icons/` | `icon-<用途>.svg` / `logo-<名称>.svg` | 小程序工程内 |
| 图标映射更新 | `docs/h5_ui/icon-mapping.md` | — | 追加新条目 |
| 小程序页面代码 | `apps/miniprogram/miniprogram/pages/<page>/` | 四文件组合 | 最终交付物 |
### 15.3 管理规则
1. 按页面分子目录MP 截图、H5 逐段截图、diff 图均按 `<page>/` 分目录,避免数百张图片平铺
2. 每轮覆盖更新像素精调循环中每轮新截图覆盖上一轮同名文件不保留历史版本git 有历史)
3. 逐段截图编号连续:`seg-0``seg-1``seg-2`...,与 anchor_compare.py 输出一致
4. report.md 格式统一:每个页面的 `diffs/<page>/report.md` 记录最终差异率和遗留问题,作为验收依据
5. .gitignore 不排除:这些中间文件需要入库,便于团队复查和回溯
6. H5 原型截图目录只读:`docs/h5_ui/screenshots/` 是输入物,迁移过程中不往里写 MP 截图或 diff 图

View File

@@ -0,0 +1,424 @@
# 需求文档H5 → 微信小程序批量迁移
## 简介
`docs/h5_ui/pages/` 下 17 个 HTML 原型页面迁移为原生微信小程序页面WXML/WXSS/TS/JSON。迁移范围覆盖 7 个批次A-看板、B-核心、C-任务、D-详情、E-绩效、F-对话、G-其他),每页需走完 7 步标准流程(输入物冻结 → 迁移审计 → 规则化转换 → 编译验证 → 结构还原验证 → 像素级对比 → 验收签收)。本工程为纯前端迁移,不涉及后端 API 开发或数据库变更。
权威参考文档:`docs/prd/MIGRATION-PLAYBOOK.md`(唯一迁移执行手册)。
## 术语表
- **Playbook**`docs/prd/MIGRATION-PLAYBOOK.md`H5→小程序迁移的唯一权威执行手册
- **H5_原型**`docs/h5_ui/pages/<page>.html`,基于 Tailwind CDN + 内联 SVG + 原生 JS 的单文件 HTML 原型
- **小程序页面**`apps/miniprogram/miniprogram/pages/<page>/` 下的 `.wxml`/`.wxss`/`.ts`/`.json` 四文件组合
- **WXML**:微信小程序标记语言,替代 HTML
- **WXSS**:微信小程序样式表,替代 CSS支持 rpx 单位
- **rpx**微信小程序响应式像素单位750rpx = 屏幕宽度
- **缩放公式**`rpx = H5 px × 2 × 0.875`,结果取偶数(向最近偶数取整)
- **design-tokens**`docs/h5_ui/design-tokens.json`,全局颜色/间距/字号/圆角/阴影定义
- **computed-styles**`docs/h5_ui/computed-styles.json`H5 元素的浏览器计算样式精确 px 值
- **交互说明**`docs/h5_ui/interactions/<page>.md`,每页的状态变量 + 操作响应 + 状态枚举
- **icon-mapping**`docs/h5_ui/icon-mapping.md`H5 图标到小程序实现的映射表
- **TDesign**:腾讯开源的微信小程序 UI 组件库
- **结构还原验证**:对照 H5 原型截图和交互说明,逐项确认区域划分、元素层级、文本内容、图标、导航、弹窗、滚动、三态占位等 9 项结构要素
- **像素级对比**:使用工具链(截图 → 尺寸统一 → pixelmatch → 差异分析)量化小程序与 H5 原型的视觉差异百分比
- **七维度核对**:每个可见元素写 WXSS 时必须逐一确认的 7 个属性维度(字号、字重、文字颜色、行高、内间距、外间距、边框与圆角)
- **三态处理**:每个页面必须处理的 loading / empty / error / normal 四种状态
- **TabBar_页面**task-list、board-finance、my-profile使用 `wx.switchTab` 跳转
- **迁移审计报告**Step 2 输出的 7 项审计内容页面结构、CSS 风险点、关键样式映射、图标处理、交互映射、外部依赖、缺失信息)
- **差异率**pixelmatch 对比输出的像素差异百分比,前半屏 < 5% 为优秀,≤ 10% 为达标
## 需求
### 需求 1迁移范围与批次管理
**用户故事:** 作为项目管理者,我希望 17 个页面按批次有序迁移,以便控制进度和质量。
#### 验收标准
1. THE 迁移工程 SHALL 覆盖以下 17 个页面,按 7 个批次组织A-看板board-finance、board-coach、board-customer、B-核心task-list、my-profile、C-任务task-detail、task-detail-callback、task-detail-priority、task-detail-relationship、D-详情coach-detail、customer-detail、customer-service-records、E-绩效performance、performance-records、F-对话chat、chat-history、G-其他notes
2. THE 迁移工程 SHALL 排除以下页面login、no-permission、reviewing、apply用户指定不迁移、home-settings无交互说明、ai-icon-demo无截图和交互说明
3. WHEN 迁移 board-coach、board-customer、board-finance、notes 页面时THE 迁移流程 SHALL 对已有历史实现按标准流程重新审计、对比 H5 原型、差异修复、像素级验收
4. WHEN 迁移其余 13 个页面时THE 迁移流程 SHALL 从零开始执行全流程迁移
### 需求 27 步标准迁移流程
**用户故事:** 作为迁移执行者,我希望每个页面都走完标准化的 7 步流程,以确保迁移质量一致且可追溯。
#### 验收标准
1. THE 迁移流程 SHALL 对每个页面依次执行以下 7 步Step 1 输入物冻结、Step 2 迁移审计、Step 3 规则化转换、Step 4 编译验证、Step 5 结构还原验证、Step 6 像素级视觉对比、Step 7 验收签收
2. THE 迁移流程 SHALL 禁止跳过任何步骤,后续步骤的执行以前置步骤通过为前提
3. WHEN Step 5 结构还原验证未全部通过时THE 迁移流程 SHALL 禁止进入 Step 6 像素级对比
4. WHEN 验收未通过时THE 迁移流程 SHALL 按问题类型回退到对应步骤结构错误→Step 5、样式偏差→Step 6、交互缺陷→Step 3、编译报错→Step 4、真机差异→Step 6并从回退步骤开始重新往下走完所有后续步骤
5. WHEN Step 6 像素级对比的差异率过大(前半屏 > 15%且多轮微调无法收敛时THE 迁移流程 SHALL 放弃修补,直接按后续规则(需求 5-9、需求 21从零重写该小程序页面
6. WHEN H5 原型页面包含渐变复杂、条纹复杂的 Banner 背景时THE 迁移流程 SHALL 将该背景生成为 SVG 文件,存放到 `assets/icons/` 目录,在 WXML 中通过 `<image src="/assets/icons/bg-<name>.svg">` 引用
7. WHEN H5 原型页面包含精美复杂的 Icon 或装饰性按钮(非标准 TDesign 图标可覆盖THE 迁移流程 SHALL 将其生成为 SVG 文件导出,通过 `<image>` 引用,而非尝试用 WXSS 手动还原
### 需求 3输入物分批提供
**用户故事:** 作为迁移执行者,我希望输入物按阶段分批提供——结构迁移阶段只需结构和行为材料,像素精调阶段再补充视觉校验材料——以避免因截图等材料未就绪而阻塞结构迁移工作。
#### 验收标准
1. THE 输入物 SHALL 分为两批提供:
- **第一批结构迁移Step 1-5**规则层Playbook→ 全局资源层design-tokens.json、icon-mapping.md→ 页面源码层H5 HTML `docs/h5_ui/pages/<page>.html`、自定义 CSS `docs/h5_ui/css/<page>.css`(如有))→ 行为层(`docs/h5_ui/interactions/<page>.md`
- **第二批像素精调Step 6-7**computed-styles.json 中该页面的精确 px 值、H5 默认态截图 `docs/h5_ui/screenshots/<page>.png`DPR=3, 1290px 宽)、交互态截图 `docs/h5_ui/screenshots/<page>--<state>.png`
2. WHEN 开始结构迁移Step 1-5THE 迁移流程 SHALL 仅要求第一批输入物齐全;第二批输入物缺失不阻塞结构迁移
3. WHEN 进入像素精调Step 6THE 迁移流程 SHALL 要求第二批输入物齐全IF 第二批输入物缺失THEN 标记缺失项并暂停像素精调,不猜测
4. IF 第一批中任何必需输入物缺失THEN THE 迁移流程 SHALL 标记缺失项并暂停该页面迁移,禁止猜测缺失内容
5. THE 结构迁移阶段的样式转换 SHALL 基于 H5 源码中的 Tailwind 类名和 design-tokens.json 进行换算,不依赖 computed-styles.jsoncomputed-styles.json 仅在像素精调阶段用于精确校准
### 需求 4迁移审计报告Step 2
**用户故事:** 作为迁移执行者,我希望在写代码前先输出审计报告,以识别风险点和制定转换策略。
#### 验收标准
1. WHEN 输入物冻结完成后THE 迁移流程 SHALL 输出《迁移审计报告》,包含 7 项A.页面结构(区域划分+组件化边界、B.CSS 风险点(不支持特性+替代方案、C.关键样式映射Tailwind→computed→WXSS 七维度核对、D.图标处理(每个 SVG 的处理决策、E.交互映射H5 DOM→setData+事件绑定、F.外部依赖CDN 本地化方案、G.缺失信息(需补充材料清单)
2. THE 迁移审计报告 SHALL 在写任何转换代码之前完成输出
3. THE 迁移审计报告中每个 CSS 风险点 SHALL 包含原因、影响、处理方案、验收方式四项说明
### 需求 5标签与结构转换规则
**用户故事:** 作为迁移执行者,我希望有明确的 HTML→WXML 标签映射规则,以确保转换的一致性和正确性。
#### 验收标准
1. THE 转换引擎 SHALL 按以下映射执行标签转换:`<div>``<view>``<span>/<p>``<text>``<img>``<image mode="">`(必须指定 mode 和宽高)、`<svg>``<image src="xx.svg">``<t-icon>``<button>``<t-button>``<input>``<t-input>``<textarea>``<t-textarea>``<select>``<t-picker>``<a>``bindtap`+`wx.navigateTo``<ul>/<li>``<view wx:for>`(必须加 `wx:key`)、`<table>``<view>` 手动布局、scroll 容器→`<scroll-view>`(必须设固定高度)
2. THE 转换引擎 SHALL 禁止在 WXML 中使用任何 HTML 标签
3. THE 转换引擎 SHALL 确保 `<text>` 内只嵌套 `<text>`,需要块级布局时外层使用 `<view>`
4. WHEN 列表渲染时THE 转换引擎 SHALL 为每个 `wx:for` 提供 `wx:key` 属性
### 需求 6样式转换与缩放规则
**用户故事:** 作为迁移执行者,我希望所有样式值都按统一公式换算,以确保像素级视觉还原。
#### 验收标准
1. THE 样式转换 SHALL 使用核心缩放公式 `rpx = H5 px × 2 × 0.875`,结果取偶数(向最近偶数取整)
2. THE 样式转换 SHALL 按以下数据源优先级取值,且区分两个阶段:
- **结构迁移阶段Step 3**H5 源码 Tailwind 类名(查速查表换算)→ design-tokens.json Token 值(颜色/圆角/阴影)→ 目测估算(必须标注 `/* 目测值,待校准 */`
- **像素精调阶段Step 6**computed-styles.json 精确 px 值(最高优先级,覆盖结构阶段的换算值)→ H5 源码 Tailwind 类名 → design-tokens.json → H5 截图目测估算(最低)
3. THE 样式转换 SHALL 对每个可见元素按七维度逐项核对字号font-size、字重font-weight、文字颜色color、行高line-height、内间距padding、外间距margin/gap、边框与圆角border/border-radius
4. THE 样式转换 SHALL 禁止使用不在 design-tokens.json 色阶表中的灰色值(禁止 `#333``#666``#999` 等非标准灰色)
5. THE 样式转换 SHALL 显式写出 `line-height` 值,禁止依赖小程序默认行高
6. THE 样式转换 SHALL 使用最小 2rpx 的边框宽度1rpx 在部分设备不显示)
7. THE 样式转换 SHALL 禁止不查任何数据源直接写"看起来差不多"的值
### 需求 7事件与路由转换规则
**用户故事:** 作为迁移执行者,我希望 H5 事件和路由逻辑有明确的小程序对应方案,以确保交互行为正确迁移。
#### 验收标准
1. THE 转换引擎 SHALL 按以下映射执行事件转换:`onclick="fn()"``bindtap="fn"``onclick="fn(id)"``data-id="{{id}}" bindtap="fn"`dataset 传参)、`event.target.value``e.detail.value``event.target.dataset``e.currentTarget.dataset``event.preventDefault()``catchtap``classList.toggle``setData`+条件 class 绑定、`innerHTML``setData`+WXML 数据绑定、`history.back()``wx.navigateBack()``localStorage``wx.setStorageSync``alert()/confirm()``wx.showToast()/wx.showModal()`
2. WHEN 跳转到 TabBar 页面task-list、board-finance、my-profileTHE 路由逻辑 SHALL 使用 `wx.switchTab`,禁止使用 `wx.navigateTo`
3. THE 路由逻辑 SHALL 确保所有路径以 `/` 开头且不带 `.wxml` 后缀
4. THE 转换引擎 SHALL 禁止使用 `addEventListener``document.getElementById` 等 DOM API所有视图更新通过 `this.setData()` 驱动
### 需求 8SVG 图标处理
**用户故事:** 作为迁移执行者,我希望每个 H5 页面中的 SVG 图标都有明确的处理决策,以避免图标缺失或显示异常。
#### 验收标准
1. WHEN H5 页面包含内联 SVG 时THE 图标处理流程 SHALL 按以下优先级决策TDesign 有语义等价图标→使用 `<t-icon>`、品牌/自定义图标→导出为 `apps/miniprogram/miniprogram/assets/icons/<name>.svg`
2. THE 图标处理流程 SHALL 按命名规则导出 SVG功能图标用 `icon-<用途>.svg`、Logo 类用 `logo-<名称>.svg`
3. THE 图标处理流程 SHALL 在导出 SVG 时保留原始 `viewBox``fill``path`
4. WHEN 在小程序中引用 SVG 时THE 图标引用 SHALL 使用 `<image src="/assets/icons/<name>.svg" mode="aspectFit" />`,并指定宽高
5. WHEN 迁移新页面导出新 SVG 时THE 图标处理流程 SHALL 将新导出的 SVG 追加到 icon-mapping.md 清单
### 需求 9不支持的 CSS 特性替代方案
**用户故事:** 作为迁移执行者,我希望所有小程序不支持的 CSS 特性都有明确的替代方案,以避免样式失效。
#### 验收标准
1. WHEN H5 使用 `backdrop-filter: blur()`THE 样式转换 SHALL 替换为 `background: rgba(255,255,255,0.95)` 半透明背景
2. WHEN H5 使用 `*` 通配符选择器时THE 样式转换 SHALL 逐个元素设置对应属性
3. WHEN H5 使用 `filter: blur()`THE 样式转换 SHALL 使用 `radial-gradient` 模拟扩散效果
4. WHEN H5 使用 `url("data:image/svg+xml,...")`THE 样式转换 SHALL 使用 CSS 渐变模拟或导出为 PNG/base64
5. WHEN H5 使用 `::before`/`::after` 伪元素且场景复杂时THE 样式转换 SHALL 使用额外 `<view>` 元素模拟
6. THE 样式转换 SHALL 确保 CSS 变量 `var()``linear-gradient``animation`/`@keyframes``transition` 直接迁移(小程序支持)
### 需求 10状态栏与安全区适配
**用户故事:** 作为用户,我希望小程序页面在各种机型(含刘海屏)上正确显示,内容不被遮挡。
#### 验收标准
1. WHEN 页面使用自定义导航栏(`navigationStyle: 'custom'`THE 页面 SHALL 通过 JS `wx.getSystemInfoSync().statusBarHeight` 获取状态栏高度,并设置 `padding-top`
2. THE 页面 SHALL 禁止使用 `env(safe-area-inset-top)` 获取顶部安全区(部分机型不生效),改用 JS 方案
3. WHEN 页面包含底部固定栏或底部弹窗时THE 页面 SHALL 添加 `padding-bottom: env(safe-area-inset-bottom)` 适配刘海屏底部
4. WHEN 页面为一屏布局不滚动THE 页面 SHALL 使用 `height: 100vh` + `box-sizing: border-box`,确保 padding-top 从 100vh 中扣除
### 需求 11长页面滚动与吸顶处理
**用户故事:** 作为用户,我希望长页面(超过一屏)能正常滚动,吸顶元素在滚动时保持固定位置。
#### 验收标准
1. THE 长页面 SHALL 优先使用页面自然滚动(`min-height: 100vh`),禁止用 `scroll-view` 包裹整个页面
2. WHEN 页面中某个区域需要独立滚动时(如 chat 页消息区THE 页面 SHALL 使用 `<scroll-view scroll-y>` 并设固定高度
3. THE 吸顶元素 SHALL 使用 `position: sticky`,且父容器不设 `overflow: hidden`
4. WHEN 多个 sticky 元素叠加时THE 页面 SHALL 确保 `z-index` 从上到下递减20→15→10
5. THE sticky 元素 SHALL 设置 `background-color`,防止内容穿透
6. WHEN 页面包含底部固定操作栏时THE 页面 SHALL 使用 `position: fixed` + `z-index: 50`,并在内容区末尾添加占位 `<view>` 防止内容被遮挡
7. WHERE 页面适用下拉刷新task-list、board-finance、board-coach、board-customer、notes、chat-historyTHE 页面 SHALL 启用下拉刷新功能
8. WHEN 页面包含 filter-bar筛选栏THE filter-bar SHALL 统一高度为 70 逻辑像素所有看板页面board-finance、board-coach、board-customer保持一致。此约束确保两端 sticky 区域高度一致(~116pxv2 逐段截图自然对齐。编辑小程序前端页面时须 check 并修正不符合此高度的 filter-bar。
### 需求 12交互弹窗与状态管理
**用户故事:** 作为用户,我希望所有弹窗、浮层、下拉面板的交互行为与 H5 原型一致,且不出现穿透、滚动异常等问题。
#### 验收标准
1. THE 弹窗系统 SHALL 遵循统一的 z-index 分层策略sticky 元素 10-29、AI 悬浮按钮 30、底部固定栏 50、自定义底部导航栏 100、遮罩 999、弹窗内容 1000、Toast/Loading 9999
2. THE 弹窗系统 SHALL 确保同一时刻只允许一个弹窗打开(互斥)
3. WHEN 弹窗打开时THE 弹窗 SHALL 使用 `catchtouchmove` 阻止背景页面滚动
4. THE 遮罩层 SHALL 使用 `bindtap` 关闭弹窗,弹窗内容区 SHALL 使用 `catchtap` 防止点击穿透
5. WHEN 弹窗为底部弹出类型时THE 弹窗 SHALL 添加 `padding-bottom: env(safe-area-inset-bottom)`
6. THE 弹窗动画 SHALL 统一使用 200-220ms 时长和 `ease` 缓动函数
7. WHEN 使用筛选下拉面板时THE 页面 SHALL 复用共享组件 `filter-dropdown`(规范见 Playbook 第八章 8.3
### 需求 13长按菜单交互
**用户故事:** 作为用户,我希望在 task-list 页面长按任务卡片时弹出操作菜单,且长按和点击手势互不干扰。
#### 验收标准
1. WHEN 用户长按任务卡片时THE task-list 页面 SHALL 使用 `bindlongpress` 触发长按菜单,菜单定位在触摸点附近
2. THE 长按菜单 SHALL 包含以下操作项:任务置底、问问助手、备注
3. THE 长按菜单 SHALL 使用黑底圆角样式(`rgba(0,0,0,0.85)` 背景、16rpx 圆角、白色文字)
4. WHEN 长按菜单显示时THE task-list 页面 SHALL 在 `onTaskTap` 中跳过跳转逻辑,防止长按后误触发点击跳转
5. WHEN 用户点击遮罩或菜单项时THE 长按菜单 SHALL 关闭
### 需求 14三态处理
**用户故事:** 作为用户,我希望每个页面在加载中、无数据、出错时都有明确的 UI 反馈,而不是空白页面。
#### 验收标准
1. THE 每个页面 SHALL 处理 loading、empty、error、normal 四种状态
2. WHEN 页面处于 loading 状态时THE 页面 SHALL 显示 `<t-loading>` 组件和"加载中..."文字
3. WHEN 页面处于 empty 状态时THE 页面 SHALL 显示对应的空状态文案task-list→"暂无任务"、board-coach→"暂无助教数据"、board-customer→"暂无客户数据"、board-finance→"暂无财务数据"、chat-history→"暂无对话记录"、notes→"暂无备注记录"、performance-records→"暂无业绩明细"、customer-service-records→"暂无服务记录"、其他→"暂无数据"
4. WHEN 页面处于 error 状态时THE 页面 SHALL 显示"加载失败,请点击重试"文字和重试按钮
5. WHEN 用户点击重试按钮时THE 页面 SHALL 重新加载数据
### 需求 15共享组件复用
**用户故事:** 作为迁移执行者,我希望跨页面复用的 UI 元素抽取为共享组件,以保持一致性并减少重复代码。
#### 验收标准
1. THE 迁移工程 SHALL 复用以下已有共享组件ai-float-buttonAI 悬浮按钮所有业务页面右下角、board-tab-bar自定义底部导航栏board-coach/board-customer 使用、filter-dropdown筛选下拉面板board-finance/coach/customer 使用、heart-icon心形评分图标board-customer 使用、dev-fab开发调试按钮
2. THE ai-float-button 组件 SHALL 在所有业务页面右下角显示,默认 bottom 220rpx使用固定渐变动画不参与页面级随机配色详见需求 32
3. THE board-tab-bar 组件 SHALL 用于非 TabBar 的看板子页面,高度 100rpx包含 safe-area 底部适配
4. THE filter-dropdown 组件 SHALL 实现全屏宽度面板 + 半透明遮罩 + 动态 top 计算
5. THE heart-icon 组件 SHALL 使用 TS `observers` 监听 `score` 属性变化计算 emojiWXS 不支持 emoji surrogate pair
### 需求 16TDesign 组件使用规范
**用户故事:** 作为迁移执行者,我希望 TDesign 组件的使用遵循统一规范,以避免样式冲突和事件绑定错误。
#### 验收标准
1. THE 迁移工程 SHALL 按以下替代关系使用 TDesign 组件:`<button>``<t-button>``<input>``<t-input>``<textarea>``<t-textarea>``<select>``<t-picker>`、SVG 图标→`<t-icon>`、加载动画→`<t-loading>`、确认弹窗→`<t-dialog>`、底部弹出→`<t-popup>`
2. WHEN TDesign 组件事件绑定时THE 迁移工程 SHALL 使用 `bind:change` 而非 `bindinput`
3. WHEN 需要覆盖 TDesign 组件样式时THE 迁移工程 SHALL 优先使用 CSS 变量,其次外部样式类,再次利用 `addGlobalClass`,最后使用 style 属性
4. WHEN 组件与 H5 原型差异过大时THE 迁移工程 SHALL 使用原生 view 实现而非强行定制 TDesign 组件
5. THE 迁移工程 SHALL 确保 `app.json` 中不包含 `"style": "v2"` 配置(会导致 TDesign 样式错乱)
6. WHEN 安装新 npm 包后THE 迁移工程 SHALL 在微信开发者工具中执行"构建 npm"
### 需求 17编译验证Step 4
**用户故事:** 作为迁移执行者,我希望每个页面转换后通过编译验证,以确保代码无语法错误和运行时问题。
#### 验收标准
1. WHEN 规则化转换完成后THE 编译验证 SHALL 检查以下 7 项WXML 编译无错误、WXSS 编译无警告(检查不支持的选择器)、控制台无 JS 运行时错误、图片加载无 404/500 错误(所有 `/assets/` 引用的文件必须存在)、组件注册无 "component not found" 警告、路由跳转无 "navigateTo:fail" 错误、TS 类型定义完整(`Page<IData>()` 中 data 所有字段有初始值)
2. THE 编译验证 SHALL 特别检查 WXML 中不包含 JS 方法调用(如 `.toFixed()`),需要格式化的逻辑使用 WXS 模块 `utils/format.wxs`
3. IF 编译验证发现错误THEN THE 迁移流程 SHALL 修复错误后重新验证,直到全部通过
### 需求 18结构还原验证Step 5
**用户故事:** 作为迁移执行者,我希望在像素对比前先确认页面结构与 H5 原型一致,以避免在结构错误的基础上做无意义的像素调整。
#### 验收标准
1. WHEN 编译验证通过后THE 结构还原验证 SHALL 逐项对照 H5 原型截图和交互说明,确认以下 9 项区域划分header/content/footer/tabbar 与 H5 一致)、元素层级(嵌套层级与 H5 DOM 结构对应)、列表/卡片数量Mock 数据条数与 H5 一致)、文本内容(标题/标签/按钮文案完全一致)、图标完整性(所有图标位置有对应实现)、导航结构(自定义导航栏/返回按钮/TabBar 行为正确)、弹窗/浮层所有弹窗能正常触发和关闭、滚动行为长页面可滚动、吸顶元素正确固定、三态占位loading/empty/error 三种状态有对应 UI 结构)
2. THE 结构还原验证 SHALL 9 项全部通过后才可进入 Step 6 像素级对比
3. IF 结构还原验证未通过THEN THE 迁移流程 SHALL 记录问题、修复、重新验证,循环直到全部通过
### 需求 19像素级视觉对比Step 6
**用户故事:** 作为迁移执行者,我希望通过工具链量化小程序与 H5 原型的视觉差异,以系统性地收敛渲染偏差。
#### 验收标准
1. WHEN 结构还原验证全部通过后THE 像素级对比 SHALL 按以下流程执行:截图(微信开发者工具)→ 尺寸统一H5 保持 1290px 宽MP ×2 缩放到 1290px→ pixelmatch 对比 → 差异分析(按 150px 条带分析差异密度)→ 定位差异区域 → WXSS 微调 → 循环
2. THE 像素级对比 SHALL 以前半屏差异率 < 5% 为优秀、≤ 10% 为达标的标准评估
3. THE 像素级对比 SHALL 每轮控制 2-5 处修改,避免一次改太多难以定位效果
4. THE 像素级对比 SHALL 优先使用 flex/盒模型的确定性方案,禁止使用"碰运气"的魔法数
5. THE 像素级对比 SHALL 确保 rpx 换算统一,禁止同一类间距混用 rpx 和 px
6. WHEN 需要对比交互态时THE 像素级对比 SHALL 对弹窗/筛选/空状态等交互态进行视觉检查(弹窗位置、遮罩透明度),交互态不要求像素精确
### 需求 20验收签收Step 7
**用户故事:** 作为项目管理者,我希望每个页面迁移完成后通过 12 项验收清单,以确保交付质量。
#### 验收标准
1. THE 验收签收 SHALL 逐项检查以下 12 项:编译零报错、默认态像素对比差异率 ≤ 10%、关键交互态截图齐全、87.5% 缩放一致(抽查 3 个关键元素)、颜色 Token 一致(使用 design-tokens.json 定义的颜色、TDesign 组件正确使用、共享组件复用、交互逻辑完整(对照 interactions/*.md、空/错误/加载态已处理、真机预览iOS + Android 各一台无异常)、导航正确(返回/跳转/TabBar 行为符合 PRD、认证守卫未登录自动跳转登录页
2. WHEN 验收未通过时THE 验收签收 SHALL 按问题严重度回退到对应步骤修复,修复后从回退步骤重新走完所有后续步骤
3. THE 验收签收 SHALL 确保每次返工修复后不引入新的编译错误(修 A 不坏 B
### 需求 21转换执行顺序
**用户故事:** 作为迁移执行者,我希望每个页面的代码转换按固定顺序执行,以确保依赖关系正确。
#### 验收标准
1. THE 规则化转换Step 3SHALL 按以下顺序执行:创建 4 文件骨架(.wxml/.wxss/.ts/.json→ .json 注册 usingComponentsTDesign + 自定义组件)→ 转换 WXML标签映射保持层级一致→ 转换 WXSSTailwind→手写 WXSS87.5% 缩放)→ 转换 TSDOM→setData事件→bindtap→ Mock 数据(贴近真实 API 格式,标记 TODO→ 三态处理loading/empty/normal/error
2. THE 每个页面 SHALL 输出到 `apps/miniprogram/miniprogram/pages/<page>/` 目录下
3. WHEN 页面需要新的 SVG 图标时THE 转换流程 SHALL 将 SVG 导出到 `apps/miniprogram/miniprogram/assets/icons/` 目录
### 需求 22task-detail 变体页面迁移策略
**用户故事:** 作为迁移执行者,我希望 task-detail 的 3 个主题色变体页面高效迁移,避免重复劳动。
#### 验收标准
1. THE 迁移流程 SHALL 先完成 task-detail 主页面的完整迁移和验收
2. WHEN task-detail 验收通过后THE 迁移流程 SHALL 通过复制 task-detail 并替换主题色变量的方式生成 task-detail-callback、task-detail-priority、task-detail-relationship 三个变体
3. THE 变体页面 SHALL 仅在以下方面与 task-detail 不同banner 背景色、按钮配色、页面主题色(对照各自 H5 原型截图校准色值)
4. THE 变体页面 SHALL 保持与 task-detail 完全相同的数据结构和页面布局
### 需求 23页面级功能需求 — A 批次(看板)
**用户故事:** 作为门店管理者,我希望在小程序中查看财务、助教、客户三个维度的看板数据,以便掌握门店经营状况。
#### 验收标准
1. THE board-finance 页面 SHALL 包含时间月份筛选9 选项+指定周期,最大 366 天)+ 区域筛选(全部/大厅ABC/麻将房/团建房/具体台桌)、财务汇总行(实际收入/支出/净利润三列)、四个板块(营业数据/收入构成/支出构成/利润构成,每行 3 个指标卡片)、长按指标卡片启动助手对话、指标说明弹窗、目录导航面板
2. THE board-coach 页面 SHALL 包含排序维度筛选7 选项)+ 擅长项目筛选 + 时间月份筛选、助教卡片列表(姓名+等级+擅长项目 | 关系最好客户前三 | 排序维度数值)、点击卡片跳转助教详情页
3. THE board-customer 页面 SHALL 包含客户类型筛选8 维度)+ 偏爱项目筛选、客户卡片列表(名称+等级+VIP标识 | 最喜欢助教前三 | 核心指标+最近到店、heart-icon 组件显示、最专一客户表格、点击卡片跳转客户详情页
4. THE board-coach 和 board-customer 页面 SHALL 使用 board-tab-bar 共享组件作为底部导航栏
5. THE 三个看板页面 SHALL 使用 filter-dropdown 共享组件实现筛选下拉
### 需求 24页面级功能需求 — B 批次(核心)
**用户故事:** 作为助教用户,我希望在小程序首页查看任务列表和个人信息,以便快速了解待办事项和管理账户。
#### 验收标准
1. THE task-list 页面 SHALL 包含:顶部自定义导航栏(标题"任务"无返回按钮、Banner 区(用户名+身份+业绩概览+预计收入,整块可点击跳转业绩详情页)、任务列表(单列,按紧急程度排序:红→橙→粉→蓝,不分组)、单条卡片(类型标签带颜色+客户姓名+右箭头+补充信息行)、长按卡片弹出黑底浮层菜单(任务置底/问问助手/备注)、备注弹窗、空状态"暂无任务"
2. THE my-profile 页面 SHALL 包含:顶部用户信息区、列表菜单(备注记录/助手对话记录/首页设置/退出账号)
3. THE task-list 和 my-profile 页面 SHALL 作为 TabBar 页面,使用 `wx.switchTab` 跳转
### 需求 25页面级功能需求 — C 批次(任务详情)
**用户故事:** 作为助教用户,我希望查看任务详情(含客户信息、消费习惯、关系等级、任务建议),以便有针对性地服务客户。
#### 验收标准
1. THE task-detail 页面 SHALL 包含:客户基本信息模块 → 消费习惯模块 → 与我的关系模块(等级+说明)→ 任务建议模块、底部固定栏(问问助手+备注按钮)、放弃任务确认弹窗、备注弹窗
2. THE task-detail-callback、task-detail-priority、task-detail-relationship 页面 SHALL 与 task-detail 保持相同的数据结构和布局,仅 banner 背景色和按钮配色不同
### 需求 26页面级功能需求 — D 批次(详情页)
**用户故事:** 作为门店管理者,我希望查看助教和客户的详细信息及服务记录,以便深入了解业务情况。
#### 验收标准
1. THE coach-detail 页面 SHALL 包含:基本信息模块 → 流水与业绩模块 → 工资与上课时长模块 → 前 10 客户指数列表、底部固定栏(问问助手+备注)、备注弹窗
2. THE customer-detail 页面 SHALL 包含:基本信息模块 → 消费习惯模块(标签+文本)→ 与我的关系模块(等级+说明)、底部固定栏(问问助手+备注)
3. THE customer-service-records 页面 SHALL 展示客户的服务记录列表
### 需求 27页面级功能需求 — E 批次(绩效)
**用户故事:** 作为助教用户,我希望查看本月业绩总览和明细,以便了解自己的工作成果和收入情况。
#### 验收标准
1. THE performance 页面 SHALL 包含:顶部 Banner用户名+身份+本月业绩进度+预计收入)、多组指标两列卡片网格(收入构成/台球助教业绩/充值业绩/酒水业绩)、指标卡片(名称+当前值+目标值+完成度%)、仅展示本月数据(无时间切换)
2. THE performance-records 页面 SHALL 展示业绩明细列表
### 需求 28页面级功能需求 — F 批次(对话)
**用户故事:** 作为用户,我希望与 AI 助手对话并查看历史对话记录,以便获取业务建议和回顾历史咨询。
#### 验收标准
1. THE chat 页面 SHALL 包含:仿微信对话界面(左助手气泡/右用户气泡)、引用内容(灰底小卡片:来源类型+标题+摘要)、输入区(文本框+按住说话语音转文字+发送按钮)、会话管理(超 1 小时提示"新对话主题/继续对话"
2. THE chat-history 页面 SHALL 包含:对话列表(对话标题+最近时间+消息条数,按更新时间倒序)、点击打开对应会话并滚动到最后一条
### 需求 29页面级功能需求 — G 批次(其他)
**用户故事:** 作为用户,我希望查看所有备注记录,以便回顾之前对客户和任务的备注。
#### 验收标准
1. THE notes 页面 SHALL 包含:备注列表按时间倒序平铺、每条备注显示:备注全文+关联对象+创建时间、不支持编辑/删除功能
2. THE notes 页面 SHALL 对已有历史实现按标准流程重新审计验收
### 需求 30认证守卫与开发联调
**用户故事:** 作为迁移执行者,我希望小程序页面在开发阶段能正常联调后端 API且未登录时自动跳转登录页。
#### 验收标准
1. THE 每个业务页面 SHALL 在加载时检查登录态,未登录时自动跳转登录页
2. WHEN 开发联调时THE 小程序 SHALL 通过 `utils/request.ts` 中的 `BASE_URL` 指向 `http://localhost:8000`
3. WHEN 开发联调时THE 后端 SHALL 启用 `WX_DEV_MODE=true` 开发模式,支持 `/api/xcx/dev-login` Mock 登录
4. THE 小程序 SHALL 使用 Storage + header token 方式维持登录态(小程序无 Cookie
### 需求 31全局设计规范
**用户故事:** 作为用户,我希望小程序的视觉风格和交互模式在所有页面保持一致。
#### 验收标准
1. THE 小程序 SHALL 使用简体中文,金额元取整,万元保留两位小数
2. THE 小程序 SHALL 使用底部 TabBar 导航任务task-list/ 看板board-finance/ 我的my-profile
3. THE 二级/详情页 SHALL 隐藏原生导航栏,使用自定义头部(左上角返回图标)
4. THE 所有业务页面 SHALL 在右下角显示 AI 悬浮助手按钮,点击进入助手对话页
5. THE 错误态 SHALL 统一显示"加载失败,请点击重试"+ 重试按钮
6. THE 空数据态 SHALL 使用纯文字提示(无插画)
7. THE 加载态 SHALL 使用"加载中..."纯文字(无骨架屏)
### 需求 32AI 图标配色系统
**用户故事:** 作为用户,我希望页面中的 AI 标识每次加载时随机呈现不同配色,整页统一,增加视觉新鲜感。
#### 验收标准
1. THE AI 图标配色系统 SHALL 支持 6 种配色方案(红/橙/黄/蓝/靛/紫),每种配色通过 CSS 变量定义渐变色对(`--ai-from`/`--ai-to`/`--ai-from-deep`/`--ai-to-deep`),色值参照 `docs/h5_ui/css/ai-icons.css` 中的定义
2. THE AI 图标配色系统 SHALL 包含两个系列的 AI 标识:
- `ai-inline-icon`行首小图标H5 16px → 28rpx渐变背景 + 白色机器人 SVG + 微光扫过动画12s 周期)
- `ai-title-badge`:标题行右侧标识(浅色背景 + 主题色文字 + 主题色边框 + 呼吸脉冲动画 3s 周期 + 高光扫过 14s 周期)
3. WHEN 页面加载时THE 配色系统 SHALL 从 6 种配色中随机选取一种,统一应用到该页面所有 `ai-inline-icon``ai-title-badge` 元素,确保同一页面内所有 AI 标识使用同一配色
4. THE ai-float-button悬浮按钮SHALL 不参与随机配色,保持固定渐变动画(`#667eea → #764ba2 → #f093fb → #f5576c`
5. THE 配色系统 SHALL 在小程序中通过页面级 `onLoad` 随机选色并 `setData` 传递 class 名的方式实现(替代 H5 的 DOM `querySelectorAll` + `classList.add`
6. THE 机器人 SVG 图标 SHALL 复用已有的 `assets/icons/ai-robot.svg`(大系列)和导出对应的小系列 SVG不重新生成
### 需求 33迁移产出物管理
**用户故事:** 作为项目管理者我希望迁移过程中的所有产出物审计报告、截图、diff 图)有序归档,以便追溯和复查。
#### 验收标准
1. THE 迁移工程 SHALL 将 H5 原型截图保留在 `docs/h5_ui/screenshots/` 目录(只读输入物),命名规则:默认态 `<page>.png`、交互态 `<page>--<state>.png`;迁移过程中禁止向此目录写入 MP 截图或 diff 图
2. THE 迁移工程 SHALL 将小程序截图按页面分子目录存放在 `docs/h5_ui/mp-screenshots/<page>/` 目录,命名规则:默认态 `<page>.png`、交互态 `<page>--<state>.png`、逐段截图 `seg-<N>-<section>.png`
3. THE 迁移工程 SHALL 将 H5 逐段截图按页面分子目录存放在 `docs/h5_ui/h5-segments/<page>/` 目录,命名规则:`seg-<N>-<section>.png`
4. THE 迁移工程 SHALL 将像素对比结果按页面分子目录存放在 `docs/h5_ui/diffs/<page>/` 目录,包含:全屏 diff 图 `diff-<page>.png`、逐段 diff 图 `diff-seg-<N>-<section>.png`、对比报告 `report.md`(差异率 + 问题区域摘要)
5. THE 迁移工程 SHALL 将新导出的 SVG 图标存放在 `apps/miniprogram/miniprogram/assets/icons/` 目录
6. WHEN 迁移新页面导出新 SVG 时THE 迁移工程 SHALL 更新 `docs/h5_ui/icon-mapping.md` 图标映射表
7. THE 迁移工程 SHALL 确保小程序代码输出到 `apps/miniprogram/miniprogram/pages/<page>/` 目录结构下
8. THE 像素精调循环中,每轮新截图 SHALL 覆盖上一轮同名文件,不保留历史版本(依赖 git 追溯历史)

View File

@@ -0,0 +1,474 @@
# 实现计划H5 → 微信小程序批量迁移
## 概述
基于 33 条需求和技术设计文档,将 17 个 H5 原型页面迁移为原生微信小程序页面。按 A-G 七个批次执行,每页走完 7 步标准流程(含 Step 0 页面分析)。输入物分两批提供:第一批(结构迁移 Step 0-5、第二批像素精调 Step 6-7
## 任务
- [x] 1. 全局基础设施搭建
- [x] 1.1 创建 AI 图标配色工具模块
- 文件:`utils/ai-color.ts`
- 实现 `AI_COLOR_SCHEMES` 常量6 种配色red/orange/yellow/blue/indigo/purple
- 实现 `getRandomAiColor()` 函数,返回 `{ className, vars }` 对象
- _需求: 32.1, 32.3, 32.5_
- [x] 1.2 创建 AI 图标全局 WXSS 样式
-`app.wxss` 中添加 `.ai-inline-icon``.ai-title-badge``.ai-color-*` 6 个配色类
- 实现 `ai-shimmer`12s`ai-pulse`3s两个 `@keyframes` 动画
- _需求: 32.1, 32.2_
- [x] 1.3 导出小系列机器人 SVG
- 从 H5 源码提取白色填充版机器人 SVG保存为 `assets/icons/ai-robot-sm.svg`
- 复用已有 `assets/icons/ai-robot.svg`(大系列)
- 更新 `docs/h5_ui/icon-mapping.md`
- _需求: 32.6, 33.2, 33.3_
- [x] 1.4 创建中间生成物目录结构
- 创建 `docs/h5_ui/mp-screenshots/`MP 截图,按页面分子目录)
- 创建 `docs/h5_ui/diffs/`(像素对比结果,按页面分子目录)
- 创建 `docs/h5_ui/h5-segments/`H5 逐段截图,按页面分子目录)
- 确认 `.gitignore` 不排除这些目录
- _需求: 33.1_
- [x] 1.5 验证全局基础设施
- 编译验证 `app.wxss` 无警告
- 在任意已有页面中测试 AI 配色工具模块可正常导入和调用
- _需求: 17.1_
- [ ] 2. A 批次 — board-finance看板-财务)
- [x] 2.1 Step 0: 页面分析
- 打开 H5 原型截图 + `interactions/board-finance.md`
- 确认屏数(预计 6 段:经营一览/预收资产/应计收入/现金流入/现金流出/助教分析)
- 列出所有交互态(时间筛选/区域筛选/指标弹窗/目录面板/长按菜单)
- 输出工作量估算表
- _需求: 1.1, 1.3_
- [x] 2.2 Step 1-2: 输入物冻结 + 迁移审计
- 冻结第一批输入物Playbook + design-tokens + icon-mapping + HTML + CSS + interactions
- 输出《迁移审计报告》7 项(页面结构/CSS 风险/样式映射/图标处理/交互映射/外部依赖/缺失信息)
- _需求: 3.1, 3.2, 4.1, 4.2, 4.3_
- [x] 2.3 Step 3: 规则化转换(按屏逐个开发)
- 创建四文件骨架 → .json 注册组件 → 按屏转换 WXML/WXSS/TS
- 包含filter-dropdown 复用、metric-card 复用、ai-float-button 集成
- filter-bar 高度统一 70 逻辑像素
- Mock 数据 + 三态处理
- _需求: 5, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 21, 23.1, 32_
- [x] 2.4 Step 4-5: 编译验证 + 结构还原验证
- 7 项编译检查WXML/WXSS/控制台/图片/组件/路由/TS 类型)
- 9 项结构核对(按屏逐个验证)
- _需求: 17, 18_
- [-] 2.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 1.1
- _需求: 3.3, 19, 20_
- [ ] 3. A 批次 — board-coach看板-助教)
- [x] 3.1 Step 0: 页面分析
- 确认屏数、交互态(排序筛选/擅长项目筛选/时间筛选)
- _需求: 1.1, 1.3_
- [x] 3.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3.1, 3.2, 4_
- [x] 3.3 Step 3: 规则化转换
- P0: error 状态补充WXML 分支 + TS onRetry + pageState 类型扩展)
- P1: 卡片按压反馈hover-class="coach-card--hover" 替代 :active
- P2: dev-fab 组件注册JSON 补注册)
- P3: 样式偏差修复 8 项Tab 24rpx/36rpx/22rpx、salary-amount 32rpx、right-sub #a6a6a6 22rpx、right-text 24rpx、right-highlight 28rpx、filter-item--wide flex:2
- _需求: 5-16, 21, 23.2, 23.4, 23.5, 32_
- [x] 3.4 Step 4-5: 编译验证 + 结构还原验证
- 7/7 编译检查通过9/9 结构核对通过
- _需求: 17, 18_
- [-] 3.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 1.2
- _需求: 19, 20_
- [x] 4. A 批次 — board-customer看板-客户)
- [x] 4.1 Step 0: 页面分析
- 确认屏数、交互态(客户类型筛选/偏爱项目筛选)
- _需求: 1.1, 1.3_
- [x] 4.2 Step 1-2: 输入物冻结 + 迁移审计
- _需求: 3.1, 3.2, 4_
- [x] 4.3 Step 3: 规则化转换
- P0: error 状态补充WXML + TS + WXSS
- P1: 卡片按压反馈hover-class 替代 :active
- P2: dev-fab 组件注册
- P3: 16 项 Step 3 级别样式偏差修复Tab/筛选栏/卡片圆角/头像/中间行/网格/柱状图/专一表等)
- _需求: 5-16, 21, 23.3, 23.4, 23.5, 32_
- [x] 4.4 Step 4-5: 编译验证 + 结构还原验证
- 7/7 编译检查通过11/11 结构核对通过
- _需求: 17, 18_
- [-] 4.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 1.3
- _需求: 19, 20_
- [x] 5. 检查点 A — 看板批次验收
- 3 个看板页面全部编译零错误
- 共享组件filter-dropdown、board-tab-bar、ai-float-button、dev-fab、heart-icon在所有页面中正确注册和引用
- 四态处理loading/empty/error/normal统一实现
- Tab 样式跨页面统一24rpx/36rpx/22rpx
- Step 6-7 像素精调待第二批输入物
- [ ] 6. B 批次 — task-list任务列表
- [x] 6.1 Step 0: 页面分析
- 确认屏数、交互态(长按菜单/备注弹窗/空状态)
- 历史实现差异极大接近全量重写Banner 17%、交互 23%、列表分区 0%
- _需求: 1.1_
- [x] 6.2 Step 1-2: 输入物冻结 + 迁移审计
- 输出审计报告含 7 节A-G+ C 节 13 子节样式映射
- 识别 8 个 Step 3 工作项P0-P72 个高复杂度
- _需求: 3, 4_
- [x] 6.3 Step 3: 规则化转换(全量重写 4 文件)
- P0: Banner 业绩进度卡片4 层:跳档提示 + 5 段进度条 + 课时红戳奖金 + 预计收入)
- P1: 卡片内容改造(第二行→最近到店+余额、第三行→AI 图标+建议、箭头→t-icon
- P2: 列表三区分组pinnedTasks/normalTasks/abandonedTasks + 分区标签)
- P3: 自定义长按菜单ctx-overlay + ctx-menu + 4 项 + 坐标定位 + 边界检测 + _longPressed 防冲突)
- P4: 放弃弹窗页面内实现textarea 必填校验 + 红色按钮)
- P5: error 状态 + dev-fab 注册 + hover-class
- P6: 盖戳动画(@keyframes stampDown + setTimeout 触发)
- P7: 样式偏差修复C9-1 padding 28rpx、C9-2 shadow-sm、C10-1/C10-2 标签渐变、C11-3 line-height
- 移除 hobby-tag新增 note-modal/dev-fab/t-icon 组件注册
- _需求: 5-16, 21, 24.1, 13, 32_
- [x] 6.4 Step 4-5: 编译验证 + 结构还原验证
- 7/7 编译检查通过TS 零诊断、JSON 合法、WXML 闭合、WXSS 语法、组件/数据/事件一致性)
- 13/13 结构核对通过P0-P7 全部验证 + 放弃卡片灰化 + 置顶 amber 阴影 + 长按冲突处理)
- _需求: 17, 18_
- [-] 6.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 2.1
- _需求: 19, 20_
- [ ] 7. B 批次 — my-profile个人中心
- [x] 7.1 Step 0: 页面分析
- 1 屏、最简页面、100% 功能覆盖、21 处 WXSS 数值偏差
- _需求: 1.1_
- [x] 7.2 Step 1-2: 输入物冻结 + 迁移审计
- 审计报告 7 节A-G21 处偏差全部为数值微调,无结构性问题
- _需求: 3, 4_
- [x] 7.3 Step 3: 规则化转换
- P1: WXSS 数值校准 21 处padding/gap/font-size/width/height/shadow/border
- P2: 补充 line-height: 1.5name/store-name/menu-text
- P3: 边框宽度 1rpx → 2rpx
- P4: 头像阴影参数修正为 design-tokens 标准值
- TabBar 页面路由配置已就绪
- _需求: 5-16, 21, 24.2, 24.3, 32_
- [x] 7.4 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断JSON 合法WXML 闭合WXSS 语法正确
- 21/21 偏差全部修正,图标 5/5交互 6/6外部依赖 0
- _需求: 17, 18_
- [-] 7.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 2.2
- _需求: 19, 20_
- [x] 8. 检查点 B — 核心批次验收
- ✅ task-list: 7/7 编译 + 13/13 结构验证通过
- ✅ my-profile: 0 诊断 + 21/21 偏差修正验证通过
- ✅ TabBar: 3 页面task-list/board-finance/my-profile路由+图标+文字配置正确
- ✅ 长按菜单: ctx-overlay + ctx-menu + _longPressed 防冲突机制已实现
- [ ] 9. C 批次 — task-detail任务详情主页面
- [x] 9.1 Step 0: 页面分析
- 6 个内容区域 + 1 固定栏 + 3 弹窗 = 10 功能单元
- 已有实现覆盖率 45%,缺失维客线索、近期服务记录、话术气泡、放弃弹窗等
- 输出 `docs/h5_ui/analysis/task-detail-step0.md`
- _需求: 1.1_
- [x] 9.2 Step 1-2: 输入物冻结 + 迁移审计
- 审计含在 Step 0 中,识别 11 个工作项P0-P10
- _需求: 3, 4_
- [x] 9.3 Step 3: 规则化转换11 项增量改造)
- P0: 维客线索区5 卡片4 色标签,展开/收起描述)
- P1: 近期服务记录区3 格汇总 + 3 条记录 + 查看全部链接)
- P2: 话术参考气泡5 条话术 + AI 图标 + 复制/已复制切换 + 气泡尖角 view 模拟)
- P3: 放弃弹窗改为自定义实现(遮罩 + textarea + 必填校验 + 红色确认)
- P4: 删除备注(垃圾桶图标 + wx.showModal 确认)
- P5: 错误态 + 重试pageState 扩展 'error'
- P6: 查看手机号phoneVisible 切换)
- P7: 储值等级标签(金色渐变)
- P8: 样式校准(全部按 design-tokens 校准2rpx 最小边框)
- P9: 备注星级评分star-rating readonly
- P10: 查看全部服务记录链接
- _需求: 5-16, 21, 25.1, 32_
- [x] 9.4 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断JSON 合法WXML 完整WXSS 语法正确
- P0-P10 全部验证通过
- _需求: 17, 18_
- [-] 9.5 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 3.1
- _需求: 19, 20_
- [x] 10. C 批次 — task-detail 三个变体
- [x] 10.1 复制 task-detail 生成 task-detail-callback
- teal 主题,竖线话术,无放弃按钮,无服务记录区,"📞 常规回访要点"
- 区域顺序:维客线索→关系→建议→备注
- _需求: 22.2, 22.3, 22.4, 25.2_
- [x] 10.2 复制 task-detail 生成 task-detail-priority
- orange 主题,气泡话术(同主页面),有放弃按钮+服务记录,"💡 建议执行"
- 区域顺序:关系→建议→维客线索→备注→服务记录(同 task-detail
- _需求: 22.2, 22.3, 22.4, 25.2_
- [x] 10.3 复制 task-detail 生成 task-detail-relationship
- pink 主题,竖线话术,无放弃按钮,无服务记录区,"💝 关系构建重点"
- 区域顺序:维客线索→关系→建议→备注
- _需求: 22.2, 22.3, 22.4, 25.2_
- [x] 10.4 三个变体编译验证
- 3 个变体 TS 零诊断12 文件全部创建
- _需求: 17_
- [x] 11. 检查点 C — 任务批次验收
- ✅ 文件完整性4 页面 × 4 文件 = 16 文件全部存在
- ✅ TS 编译4 个 TS 文件零诊断
- ✅ app.json 路由4 个页面路由已注册
- ✅ JSON 配置4 页面均有 `navigationStyle: "custom"` + 组件注册
- ✅ 主题色差异red/teal/orange/pink 四色正确区分
- ✅ 导航跳转task-list → DETAIL_ROUTE_MAP 按 tasktype 分发到 4 个变体
- ⚠️ 修复task-detail.wxss 内容丢失0 字节已恢复完整样式14,879 字节)
- [x] 12. D 批次 — coach-detail助教详情
- [x] 12.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 7 个内容区域 + 1 固定栏 + 2 弹窗 = 10 功能单元
- 已有实现覆盖率 45%,识别 P0-P9 共 10 个工作项
- 输出 `docs/h5_ui/analysis/coach-detail-step0.md`
- _需求: 1.1, 3, 4_
- [x] 12.2 Step 3: 规则化转换
- P0: 任务执行区6 可见 + 3 隐藏 + 2 已放弃,展开/收起,备注图标弹窗)
- P1: 客户关系 TOP55 卡片 + 渐变背景 + emoji + 跳转 customer-detail
- P2: 近期服务明细4 条记录 + 查看更多)
- P3: 更多信息(入职日期 + 5 行历史月份表格)
- P4: 绩效档位进度条
- P5: 备注列表弹窗(底部弹出 + 动态渲染)
- P6: 错误态 + 重试
- P7: hover-class 补充
- P8: safe-area-top + navigationStyle: custom
- P9: 样式校准design-tokens 对齐)
- _需求: 5-16, 21, 26.1, 32_
- [x] 12.3 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断WXML class 全覆盖JSON 组件注册完整app.json 路由已注册
- _需求: 17, 18_
- [-] 12.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 4.1
- _需求: 19, 20_
- [x] 13. D 批次 — customer-detail客户详情
- [x] 13.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 7 个内容区域 + 1 固定栏 = 8 功能单元
- 已有实现覆盖率高,消费记录三种样式(台桌/商城/充值)已实现
- 输出 `docs/h5_ui/analysis/customer-detail-step0.md`
- _需求: 1.1, 3, 4_
- [x] 13.2 Step 3: 规则化转换
- 补充 error 态 + onRetry
- 补充 safe-area-top + hover-class
- 消费记录三种样式完整(台桌结账/商城订单/充值)
- onReachBottom 懒加载分页
- note-modal 备注弹窗集成
- _需求: 5-16, 21, 26.2, 32_
- [x] 13.3 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断WXML class 全覆盖JSON 组件注册完整app.json 路由已注册
- _需求: 17, 18_
- [-] 13.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 4.2
- _需求: 19, 20_
- [x] 14. D 批次 — customer-service-records客户服务记录
- [x] 14.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 1 屏页面,已有实现覆盖率 ~80%,识别 P0-P5 共 6 个工作项
- 输出 `docs/h5_ui/analysis/customer-service-records-step0.md`
- _需求: 1.1, 3, 4_
- [x] 14.2 Step 3-5: 规则化转换 + 编译验证 + 结构还原验证
- P0: error 态 + onRetryWXML 分支 + TS pageState 扩展 'error'
- P1: hover-class 替代 :activerecord-card--hover + nav-back--hover + retry-btn--hover
- P2: dev-fab 组件注册JSON 补注册)
- P3: navigationStyle: custom + safe-area-top
- P4: WXSS 数值校准 12 处record-card border-radius/padding、record-date/duration/type/income font-size、summary-label/value、footer-text、month-label、customer-name、phone-text/sub-stat/stat-highlight
- P5: 边框 1rpx → 2rpxmonth-switcher、month-summary、summary-divider、record-card
- TS 零诊断JSON 合法WXML 完整app.json 路由已注册
- _需求: 5-18, 21, 26.3, 32_
- [-] 14.3 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 4.3
- _需求: 19, 20_
- [x] 15. 检查点 D — 详情批次验收
- ✅ 文件完整性3 页面 × 4 文件 = 12 文件全部存在
- ✅ TS 编译3 个 TS 文件零诊断
- ✅ app.json 路由coach-detail、customer-detail、customer-service-records 三个路由已注册
- ✅ 导航跳转board-coach → coach-detailonCoachTap、board-customer → customer-detailonCustomerTap、customer-detail → customer-service-recordsonViewAllRecords
- [x] 16. E 批次 — performance业绩总览
- [x] 16.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 已有实现覆盖率 ~85%,识别 P0-P6 共 7 个工作项
- 输出 `docs/h5_ui/analysis/performance-step0.md`
- _需求: 1.1, 3, 4_
- [x] 16.2 Step 3: 规则化转换
- P0: error 状态 + onRetryWXML 分支 + TS pageState 扩展 'error' + try-catch
- P1: hover-class 按压反馈customer-item + toggle-btn + view-all + income-card
- P2: dev-fab 组件注册
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
- P4: 日期分隔线增强dd-line + dd-stats 每日汇总)
- P5: WXSS 数值已合规,无需修改
- P6: 边框已是 2rpx无需修改
- _需求: 5-16, 21, 27.1, 32_
- [x] 16.3 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断JSON 合法WXML 完整app.json 路由已注册
- _需求: 17, 18_
- [-] 16.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 5.1
- _需求: 19, 20_
- [x] 17. E 批次 — performance-records业绩明细
- [x] 17.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 已有实现覆盖率 ~80%,识别 P0-P3 共 4 个工作项
- _需求: 1.1, 3, 4_
- [x] 17.2 Step 3-5: 规则化转换 + 编译验证 + 结构还原验证
- P0: error 状态 + onRetryWXML 分支 + TS pageState 扩展 + try-catch
- P1: hover-class 按压反馈month-btn
- P2: dev-fab 组件注册
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
- TS 零诊断JSON 合法WXML 完整app.json 路由已注册
- _需求: 5-21, 27.2, 32_
- [-] 17.3 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 5.2
- _需求: 19, 20_
- [x] 18. 检查点 E — 绩效批次验收
- ✅ 文件完整性2 页面 × 4 文件 = 8 文件全部存在
- ✅ TS 编译2 个 TS 文件零诊断
- ✅ app.json 路由performance 和 performance-records 两个路由已注册
- ✅ 导航跳转task-list → performanceonPerformanceTap、performance → performance-recordsgoToRecords
- [x] 19. F 批次 — chatAI 对话)
- [x] 19.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 已有实现覆盖率 ~85%,识别 6 个工作项
- _需求: 1.1, 3, 4_
- [x] 19.2 Step 3: 规则化转换
- P0: error 状态 + onRetrypageState 扩展 'error'、WXML 错误分支、TS try-catch
- P1: hover-class 按压反馈send-btn--hover
- P2: dev-fab 组件注册
- P3: navigationStyle: custom + safe-area-top + 自定义导航栏
- P4: border-top 1rpx → 2rpx.input-bar
- _需求: 5-16, 21, 28.1, 32_
- [x] 19.3 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断JSON 合法WXML 完整app.json 路由已注册
- _需求: 17, 18_
- [-] 19.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 6.1
- _需求: 19, 20_
- [x] 20. F 批次 — chat-history对话历史
- [x] 20.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 已有实现覆盖率 ~90%,识别 4 个工作项
- _需求: 1.1, 3, 4_
- [x] 20.2 Step 3: 规则化转换
- P0: error 状态 + onRetrypageState 扩展 'error'、WXML 错误分支、TS try-catch
- P1: hover-class 按压反馈chat-item--hover 替代 :active
- P2: dev-fab 组件注册 + navigationStyle: custom
- P3: safe-area-top + 自定义导航栏
- P4: border-bottom 1rpx → 2rpx.chat-item
- _需求: 5-16, 21, 28.2, 32_
- [x] 20.3 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断JSON 合法WXML 完整app.json 路由已注册
- _需求: 17, 18_
- [-] 20.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 6.2
- _需求: 19, 20_
- [x] 21. 检查点 F — 对话批次验收
- ✅ 文件完整性2 页面 × 4 文件 = 8 文件全部存在且非空
- ✅ TS 编译2 个 TS 文件零诊断
- ✅ app.json 路由chat 和 chat-history 两个路由已注册
- ✅ JSON 配置:两页面均有 navigationStyle: custom + dev-fab 注册
- ✅ 导航跳转ai-float-button → chat携带 customerId、chat-history → chat携带 historyId
- ✅ 四态处理:两页面均有 loading/empty/error/normal 四态分支
- ⚠️ 备注chat-history 传递 historyId 参数chat.ts 当前未消费Mock 阶段预期行为TODO 标记)
- [x] 22. G 批次 — notes备忘录
- [x] 22.1 Step 0-2: 页面分析 + 输入物冻结 + 迁移审计
- 已有实现覆盖率 ~80%,识别 P1-1~P1-5 + P2-7~P2-8 共 7 个工作项
- Tab 切换H5 HTML 原型无 Tab 结构(平铺列表),保持当前实现
- _需求: 1.1, 1.3, 3, 4_
- [x] 22.2 Step 3: 规则化转换
- P1-1/2: hover-class 按压反馈nav-back--hover、retry-btn--hover
- P1-3: hover 对应 WXSS 样式
- P1-4: 启用下拉刷新enablePullDownRefresh: true
- P1-5: onPullDownRefresh 方法
- P2-7: 统一 pageState 模式loading/empty/error/normal 替代 loading+error boolean
- P2-8: 缩放值微调padding 28rpx、margin-top 22rpx
- 补充error/empty 态导航栏、ai-float-button、t-empty/ai-float-button/dev-fab 组件注册
- _需求: 5-16, 21, 29, 32_
- [x] 22.3 Step 4-5: 编译验证 + 结构还原验证
- TS 零诊断JSON 合法WXML 完整app.json 路由已注册
- _需求: 17, 18_
- [-] 22.4 Step 6-7: 像素精调 + 验收签收 → 转入 `h5-miniprogram-migration-subsequent` specTask 7.1
- _需求: 19, 20_
- [x] 23. 检查点 G — 最终验收
- ✅ 文件完整性1 页面 × 4 文件 = 4 文件全部存在且非空
- ✅ TS 编译notes.ts 零诊断
- ✅ app.json 路由pages/notes/notes 已注册
- ✅ JSON 配置navigationStyle: custom + enablePullDownRefresh: true + 5 组件注册
- ✅ 导航跳转my-profile → notes通过 router.ts 映射)
- ✅ 四态处理pageState 统一模式loading/empty/error/normal
- ✅ 统一规范hover-class 4/4、safe-area-top 3 态、无 :active、无非标准灰色
- [x] 24. 全局收尾
- [x] 24.1 全量导航验证
- 验证所有页面间的跳转路径正确TabBar 切换、navigateTo、navigateBack
- 验证认证守卫(未登录自动跳转登录页)
- _需求: 7, 20, 30, 31_
- [x] 24.2 全量 AI 图标配色验证
- 抽查 3-5 个页面,确认 AI 图标随机配色正常
- 确认 ai-float-button 保持固定渐变(不参与随机)
- _需求: 32_
- [x] 24.3 icon-mapping.md 最终更新
- 确认所有新导出的 SVG 已记录在 icon-mapping.md 中
- _需求: 33.3_
- [x] 24.4 中间生成物归档验证
- 确认所有 MP 截图按页面分目录存放在 `docs/h5_ui/mp-screenshots/<page>/`
- 确认所有 diff 图和 report.md 按页面分目录存放在 `docs/h5_ui/diffs/<page>/`
- 确认所有 H5 逐段截图按页面分目录存放在 `docs/h5_ui/h5-segments/<page>/`
- 确认 `docs/h5_ui/screenshots/` 目录未被写入 MP 截图或 diff 图(只读输入物)
- 清理可能遗留在项目根目录或其他位置的临时截图文件
- _需求: 33_
## 备注
- 每个页面的 Step 0页面分析输出工作量估算表用户确认后再开始 Step 1
- 输入物分两批第一批Step 0-5 结构迁移、第二批Step 6-7 像素精调)
- 检查点任务用于批次间的质量门禁,确认通过后再进入下一批次
- 有历史实现的页面board-coach/customer/finance/notes走审计→对比→修复→验收流程
- task-detail 变体通过复制+替换主题色实现,不重复走完整流程
- 所有需求编号引用 `requirements.md` 中的需求序号
- 中间生成物MP 截图/H5 逐段截图/diff 图)按页面分子目录存放,每轮覆盖更新,不保留历史版本
- `docs/h5_ui/screenshots/` 为只读输入物目录,禁止写入迁移过程生成的文件

View File

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

View File

@@ -0,0 +1,359 @@
# 技术设计文档P4 前置依赖修复
## 概述
本设计覆盖 6 个定点修复,修正 P4 核心业务层与 Spec 的实现偏差,为 P6 前端任务模块扫清障碍。
修复范围:
- T1任务列表返回已放弃任务GAP-3**已实现,需验证**
- T2召回完成检测器过滤任务类型GAP-6**已实现,需验证**
- T3备注回溯重分类器冲突处理GAP-7— 需修改 `note_reclassifier.py`
- T4回访完成条件改为「有备注即完成」— 需修改 `note_service.py` + `note_reclassifier.py`
- T5trigger_scheduler last_run_at 事务安全GAP-9— 需修改 `trigger_scheduler.py`
- T6cron 默认值改为 07:00 — 需修改 `trigger_scheduler.py` 默认值
**关键发现**:代码审查显示 T1 和 T2 已在之前的修复中完成T6 的种子数据也已是 `0 7 * * *`,仅 `_calculate_next_run()` 的默认值仍为 `"0 4 * * *"`
## 架构
本次修复不引入新组件,仅修改现有服务层内部逻辑。涉及的调用链:
```mermaid
sequenceDiagram
participant ETL as ETL Pipeline
participant TS as TriggerScheduler
participant RD as RecallDetector
participant NR as NoteReclassifier
participant NS as NoteService
participant DB as PostgreSQL (biz schema)
ETL->>TS: fire_event("etl_data_updated")
TS->>RD: run(payload)
RD->>DB: 查询 active 召回任务 (T2: 仅 recall 类型)
RD->>DB: 标记 completed
RD->>TS: fire_event("recall_completed")
TS->>NR: run(payload)
NR->>DB: 查找 normal 备注 → 重分类为 follow_up
NR->>DB: 冲突检查 (T3: 查询已有 follow_up_visit)
NR->>DB: 创建/跳过/顶替回访任务 (T4: 有备注→completed)
Note-->>NS: 助教提交备注
NS->>DB: 创建备注
NS->>DB: 查询 active follow_up_visit 任务 (T4)
NS->>DB: 标记 completed不依赖 AI 评分)
```
事务边界变更T5
```mermaid
graph LR
subgraph "修复前:两个独立事务"
A[handler 事务] --> B[last_run_at 事务]
end
subgraph "修复后:合并为单一事务"
C[handler + last_run_at 同一事务]
end
```
## 组件与接口
本次修复不新增接口,仅修改现有服务层内部方法。以下按修复点列出变更:
### T1task_manager.get_task_list()(已实现 ✅)
代码审查确认 `get_task_list()` 已包含:
- `WHERE status IN ('active', 'abandoned')`
- `ORDER BY CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END ASC, is_pinned DESC, ...`
- SELECT 和返回结构包含 `abandon_reason` 字段
**本次无需代码变更,仅需验证测试覆盖。**
### T2recall_detector._process_service_record()(已实现 ✅)
代码审查确认 `_process_service_record()` 已包含:
- `AND task_type IN ('high_priority_recall', 'priority_recall')`
**本次无需代码变更,仅需验证测试覆盖。**
### T3note_reclassifier.run() — 冲突处理
**当前问题**`run()` 在步骤 4/5 直接 INSERT 回访任务,无冲突检查。当 AI 占位返回 None 时跳过任务创建,但修复 T4 后(有备注即完成)将不再依赖 AI 返回值。
**变更方案**
在创建 follow_up_visit 任务前,增加冲突检查逻辑:
```python
# 冲突检查:查询同 (site_id, assistant_id, member_id) 的 follow_up_visit 任务
cur.execute("""
SELECT id, status
FROM biz.coach_tasks
WHERE site_id = %s AND assistant_id = %s AND member_id = %s
AND task_type = 'follow_up_visit'
AND status IN ('active', 'completed')
ORDER BY CASE WHEN status = 'completed' THEN 0 ELSE 1 END
LIMIT 1
""", (site_id, assistant_id, member_id))
existing = cur.fetchone()
if existing:
existing_id, existing_status = existing
if existing_status == 'completed':
# 已完成 → 跳过创建
return
elif existing_status == 'active':
# 顶替:旧任务 → inactive创建新任务
cur.execute("""
UPDATE biz.coach_tasks
SET status = 'inactive', updated_at = NOW()
WHERE id = %s AND status = 'active'
""", (existing_id,))
_insert_history(cur, existing_id, action="superseded", ...)
```
**设计决策**
- 查询 `completed``active` 两种状态,优先检查 `completed`
- `inactive``abandoned` 状态的旧任务不阻止新建(语义上已失效)
- 顶替操作记录 `superseded` 历史,保留审计链
### T4回访完成条件 — note_service.create_note() + note_reclassifier.run()
**当前问题**
- `note_service.create_note()` 依赖 `ai_score >= 6` 判定回访完成,但 `ai_analyze_note()` 返回 None → 永远不触发完成
- `note_reclassifier.run()` 同样依赖 AI 返回值决定任务状态
**变更方案 — note_service.create_note()**
```python
# 修改后:有备注即完成,不依赖 AI 评分
if note_type == "follow_up" and task_id is not None:
# 保留 AI 占位调用P5 接入时调用链不变)
ai_score = ai_analyze_note(note["id"])
if ai_score is not None:
cur.execute("UPDATE biz.notes SET ai_score = %s ...", (ai_score, note["id"]))
note["ai_score"] = ai_score
# 不论 ai_score 如何,有备注即标记回访任务完成
if task_info and task_info["status"] == "active":
cur.execute("""
UPDATE biz.coach_tasks
SET status = 'completed', completed_at = NOW(),
completed_task_type = task_type, updated_at = NOW()
WHERE id = %s AND status = 'active'
""", (task_id,))
_record_history(cur, task_id, action="completed_by_note", ...)
```
**变更方案 — note_reclassifier.run()**
```python
# 修改后:不依赖 AI 返回值
# 保留 ai_analyze_note() 占位调用
ai_score = ai_analyze_note(note_id)
# 判定任务状态:有备注 → completed无备注 → active
# 此处 note_id 非 None 意味着已找到备注 → 直接 completed
task_status = "completed" # 回溯场景:已有备注 = 已完成
# 若未找到备注note_id is None创建 active 任务
# (此分支在上方 note_id is None 时已 return
```
**设计决策**
- `ai_analyze_note()` 调用保留,返回值仅用于更新 `ai_score` 字段,不影响完成判定
- P5 接入后AI 评分仍会写入 `notes.ai_score`,但不改变完成逻辑
- `note_reclassifier` 中:找到备注 = 回溯完成(`completed`),未找到备注 = 等待(`active`
### T5trigger_scheduler — last_run_at 事务安全
**当前问题**`fire_event()``check_scheduled_jobs()`handler 执行和 `last_run_at` 更新在不同事务中。handler 成功但 `last_run_at` commit 失败时,下次重跑会重复处理。
**变更方案 — 方案 Ahandler 内更新 last_run_at**
`last_run_at` 更新的 connection 传入 handler由 handler 在其事务内执行更新。但这要求修改所有 handler 签名,侵入性大。
**变更方案 — 方案 B传入 connhandler 结束后同事务更新)**
修改 `fire_event()``check_scheduled_jobs()`,将 `last_run_at` 更新纳入 handler 的事务范围:
```python
# fire_event() 修改后
def fire_event(event_name: str, payload: dict | None = None) -> int:
conn = _get_connection()
try:
# ... 查询 enabled event jobs ...
for job_id, job_type, job_name in rows:
handler = _JOB_REGISTRY.get(job_type)
if not handler:
continue
try:
handler(payload=payload)
# handler 成功后,在同一连接上更新 last_run_at
with conn.cursor() as cur:
cur.execute(
"UPDATE biz.trigger_jobs SET last_run_at = NOW() WHERE id = %s",
(job_id,),
)
conn.commit() # handler 数据变更 + last_run_at 一起提交
except Exception:
conn.rollback() # 一起回滚
finally:
conn.close()
```
**关键约束**handler`recall_detector.run()``note_reclassifier.run()`)各自管理独立连接和事务。`trigger_scheduler``last_run_at` 更新使用调度器自己的连接。这意味着 handler 事务和 `last_run_at` 事务仍然是独立的。
**实际可行方案**:由于 handler 使用独立连接,真正的"同一事务"需要 handler 接受外部 conn 参数。考虑到改动范围,采用折中方案:
1. handler 执行完毕后立即更新 `last_run_at`(当前已是如此)
2. 依赖 handler 的幂等性保证重复执行安全(`recall_detector` 只匹配 `status='active'`,已完成的不会重复处理)
3.`last_run_at` 更新从 handler 成功后的独立 commit 改为与查询 jobs 的同一连接上的事务
**设计决策**:采用用户确认的方案 A — 将 `last_run_at` 更新纳入 handler 同一事务。具体实现handler 接受可选的 `conn` 参数,在 handler 的最后一个事务中附带更新 `last_run_at`。对于 event 类型触发器(`recall_detector``note_reclassifier`),在 handler 的最终 commit 前插入 `last_run_at` 更新。
### T6cron 默认值 07:00
**当前问题**:种子数据已是 `"0 7 * * *"`,但 `_calculate_next_run()` 的 fallback 默认值仍为 `"0 4 * * *"`
**变更方案**
1. `_calculate_next_run()``trigger_config.get("cron_expression", "0 4 * * *")``"0 7 * * *"`
2. 新建迁移脚本 `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql` 作为幂等更新(确保生产环境一致)
## 数据模型
本次修复不新增表或字段。涉及的现有表:
| 表 | 修改内容 | 修复点 |
|---|---|---|
| `biz.coach_tasks` | 查询条件变更(无 DDL 变更) | T1, T2, T3, T4 |
| `biz.coach_task_history` | 新增 `superseded` action 类型记录 | T3 |
| `biz.trigger_jobs` | `last_run_at` 更新时机变更cron_expression 幂等更新 | T5, T6 |
| `biz.notes` | 查询条件变更(无 DDL 变更) | T4 |
`coach_tasks.status` 状态转换新增路径:
- `active → inactive`T3 顶替场景,由 `note_reclassifier` 触发)
此路径在原 Spec 状态机中已定义("新回访顶替旧回访"),本次为首次实现。
## 正确性属性Correctness Properties
*属性是系统在所有合法执行路径上都应保持为真的特征或行为——本质上是对系统行为的形式化陈述。属性是人类可读规格与机器可验证正确性保证之间的桥梁。*
### Property 1: 任务列表状态过滤
*For any* 任务集合(包含 active、abandoned、completed、inactive 四种状态的任务),`get_task_list` 的过滤逻辑应仅返回 status 为 `active``abandoned` 的任务,不包含 `completed``inactive` 状态的任务。
**Validates: Requirements 1.1**
### Property 2: 任务列表排序正确性
*For any* 包含 active 和 abandoned 任务的列表,排序后应满足:(1) 所有 abandoned 任务排在所有 active 任务之后;(2) active 任务内部按 `is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC` 排序。
**Validates: Requirements 1.2**
### Property 3: abandon_reason 与 status 一致性不变量
*For any* 返回的任务记录,若 `status = 'active'``abandon_reason` 为 null`status = 'abandoned'``abandon_reason` 为非空字符串。
**Validates: Requirements 1.3**
### Property 4: 召回检测器仅完成 recall 类型任务
*For any* 服务记录和任意任务集合(包含四种 task_type`_process_service_record` 仅将 `task_type IN ('high_priority_recall', 'priority_recall')` 的 active 任务标记为 completed`follow_up_visit``relationship_building` 类型的任务状态不变。
**Validates: Requirements 2.1, 2.2, 2.3**
### Property 5: 冲突处理 — 已完成回访任务阻止新建
*For any* (site_id, assistant_id, member_id) 组合,若已存在 `status = 'completed'``follow_up_visit` 任务,则 `note_reclassifier` 不创建新的回访任务,且重复触发相同 payload 不产生唯一约束冲突。
**Validates: Requirements 3.1, 3.4**
### Property 6: 冲突处理 — active 回访任务被顶替
*For any* (site_id, assistant_id, member_id) 组合,若已存在 `status = 'active'``follow_up_visit` 任务,则 `note_reclassifier` 将旧任务标记为 `inactive` 并创建新的回访任务,旧任务的变更记录包含 `superseded` action。
**Validates: Requirements 3.2**
### Property 7: 无冲突时正常创建回访任务
*For any* (site_id, assistant_id, member_id) 组合,若不存在任何 `follow_up_visit` 任务(或仅存在 `inactive`/`abandoned` 状态),则 `note_reclassifier` 正常创建新的回访任务。
**Validates: Requirements 3.3**
### Property 8: 有备注即完成回访任务,不依赖 AI 评分
*For any* `ai_analyze_note()` 的返回值None、0-5、6-10当助教为关联 `follow_up_visit` 任务的客户提交备注时,该 active 回访任务都应被标记为 `completed`。AI 评分仅更新 `notes.ai_score` 字段,不影响任务完成判定。
**Validates: Requirements 4.2, 4.3**
### Property 9: 回溯有备注时创建 completed 回访任务
*For any* 召回完成事件,若 `note_reclassifier` 在 service_time 之后找到了 normal 备注,则创建的回访任务 `status = 'completed'`
**Validates: Requirements 4.4**
### Property 10: 回溯无备注时创建 active 回访任务
*For any* 召回完成事件,若 `note_reclassifier` 在 service_time 之后未找到 normal 备注,则创建的回访任务 `status = 'active'`(等待助教提交备注)。
**Validates: Requirements 4.5**
### Property 11: 触发器 last_run_at 事务一致性
*For any* 触发器执行event/cron/interval 类型handler 成功时 `last_run_at` 应被更新handler 抛出异常时 `last_run_at` 应保持不变(整个事务回滚)。
**Validates: Requirements 5.1, 5.2**
## 错误处理
| 场景 | 处理方式 | 修复点 |
|------|---------|--------|
| T3 冲突查询失败 | 捕获异常rollback记录日志返回 `tasks_created: 0` | T3 |
| T3 顶替 UPDATE 失败 | 捕获异常rollback不创建新任务 | T3 |
| T4 备注创建成功但任务完成 UPDATE 失败 | 整个事务 rollback备注也不创建返回 500 | T4 |
| T4 ai_analyze_note() 抛出异常 | 捕获异常记录日志继续执行任务完成逻辑AI 失败不阻塞业务) | T4 |
| T5 handler 成功但 commit 失败 | 整个事务回滚handler 数据变更 + last_run_at下次重跑依赖幂等性 | T5 |
| T5 handler 抛出异常 | rollbacklast_run_at 不更新,下次重跑 | T5 |
## 测试策略
### 属性测试Property-Based Testing
使用 **Hypothesis** 库(项目已有依赖),每个属性测试最少 100 次迭代。
每个测试用 comment 标注对应的设计属性:
```python
# Feature: p4-prerequisite-fixes, Property 1: 任务列表状态过滤
```
属性测试重点覆盖:
- Property 1-3任务列表过滤、排序、字段不变量纯函数可提取测试
- Property 4召回检测器类型过滤SQL 条件验证)
- Property 5-7冲突处理三分支状态机测试
- Property 8AI 评分不影响完成判定(参数化 ai_score 返回值)
- Property 9-10回溯场景任务状态有/无备注两分支)
- Property 11事务一致性mock handler 成功/失败)
### 单元测试
单元测试聚焦于:
- T1 边界:全部 active无 abandoned、全部 abandoned无 active、混合场景
- T2 边界:仅有 follow_up_visit 任务时返回 0 completed
- T3 边界:同时存在 completed 和 active 的 follow_up_visit优先检查 completed → 跳过)
- T4 边界task_id 为 None 时不触发完成逻辑;非 follow_up_visit 任务不触发
- T5 边界handler 抛出特定异常类型时的回滚行为
- T6 具体值:`_calculate_next_run("cron", {})` 默认使用 `"0 7 * * *"`;迁移脚本 SQL 正确性
### 测试配置
```python
from hypothesis import given, settings, strategies as st
@settings(max_examples=100)
@given(...)
def test_property_name(...):
# Feature: p4-prerequisite-fixes, Property N: property_text
...
```
测试文件位置:`tests/` 目录Monorepo 级属性测试,与现有 P4 属性测试同级)。

View File

@@ -0,0 +1,94 @@
# 需求文档P4 前置依赖修复
## 简介
P4 核心业务层已实现并通过属性测试,但对比 Spec 发现 6 处实现偏差(来源:`docs/reports/P4-spec-vs-implementation-gap-analysis.md`)。这些偏差会影响 P6前端任务模块的正常开发必须前置修复。本需求覆盖 GAP-3、GAP-6、GAP-7、GAP-9 的代码修复以及两项新增修正回访完成条件、cron 时间)。
修复范围6 个定点修复,无新表、无新接口,仅修改现有服务层逻辑和一条迁移脚本。
## 术语表
- **Task_Manager**: 任务管理服务(`task_manager.py`),负责任务列表查询和状态操作
- **Recall_Detector**: 召回完成检测器(`recall_detector.py`ETL 数据更新后匹配服务记录完成召回任务
- **Note_Reclassifier**: 备注回溯重分类器(`note_reclassifier.py`),召回完成后回溯普通备注为回访备注并创建回访任务
- **Note_Service**: 备注服务(`note_service.py`),负责备注 CRUD 和回访任务自动完成
- **Trigger_Scheduler**: 触发器调度框架(`trigger_scheduler.py`),统一管理 cron/interval/event 三种触发方式
- **coach_tasks**: 助教任务表(`biz.coach_tasks`
- **trigger_jobs**: 触发器配置表(`biz.trigger_jobs`
- **active**: 任务有效状态
- **abandoned**: 任务已放弃状态
- **inactive**: 任务无效状态(被顶替)
- **completed**: 任务已完成状态
- **high_priority_recall**: 高优先召回任务类型
- **priority_recall**: 优先召回任务类型
- **follow_up_visit**: 客户回访任务类型
- **relationship_building**: 关系构建任务类型
- **abandon_reason**: 放弃原因字段
- **last_run_at**: 触发器上次运行时间戳
- **ai_analyze_note()**: AI 备注分析占位函数P5 实现,当前返回 None
## 需求
### 需求 1任务列表返回已放弃任务
**用户故事:** 作为助教,我希望在任务列表中看到已放弃的任务及其放弃原因,以便执行「取消放弃」操作恢复任务。
#### 验收标准
1. WHEN 助教请求任务列表, THE Task_Manager SHALL 返回 active 和 abandoned 两种状态的任务
2. THE Task_Manager SHALL 按以下顺序排列任务abandoned 排在所有 active 任务之后active 任务内部按 is_pinned DESC、priority_score DESC NULLS LAST、created_at ASC 排序
3. THE Task_Manager SHALL 在返回结构中始终包含 abandon_reason 字段active 状态任务的 abandon_reason 为 nullabandoned 状态任务的 abandon_reason 为非空字符串
4. WHEN 不存在 abandoned 任务时, THE Task_Manager SHALL 仅返回 active 任务返回结构不变abandon_reason 为 null
### 需求 2召回完成检测器过滤任务类型
**用户故事:** 作为系统运维人员,我希望召回完成检测器仅完成召回类任务,避免错误地将回访和关系构建任务标记为已完成。
#### 验收标准
1. WHEN ETL 检测到新增服务记录, THE Recall_Detector SHALL 仅匹配 task_type 为 high_priority_recall 或 priority_recall 的 active 任务进行完成标记
2. WHILE 存在 active 的 follow_up_visit 任务, THE Recall_Detector SHALL 跳过该任务,不执行完成标记
3. WHILE 存在 active 的 relationship_building 任务, THE Recall_Detector SHALL 跳过该任务,不执行完成标记
### 需求 3备注回溯重分类器冲突处理
**用户故事:** 作为系统运维人员,我希望备注回溯重分类器在创建回访任务时正确处理冲突,避免唯一约束违反和重复任务。
#### 验收标准
1. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 已存在 completed 的 follow_up_visit 任务, THEN THE Note_Reclassifier SHALL 跳过创建(回访完成语义已满足)
2. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 已存在 active 的 follow_up_visit 任务, THEN THE Note_Reclassifier SHALL 将旧任务标记为 inactive 并记录变更历史,然后创建新的 follow_up_visit 任务(顶替方案)
3. WHEN Note_Reclassifier 准备创建 follow_up_visit 任务且同 (site_id, assistant_id, member_id) 不存在任何 follow_up_visit 任务, THE Note_Reclassifier SHALL 正常创建新任务
4. IF Note_Reclassifier 重复触发相同 payload, THEN THE Note_Reclassifier SHALL 不产生唯一约束冲突错误
### 需求 4回访任务完成条件改为「有备注即完成」
**用户故事:** 作为助教,我希望为客户提交备注后对应的回访任务自动完成,无需等待 AI 评分。
#### 验收标准
1. WHEN 助教通过 Note_Service 为某客户创建备注, THE Note_Service SHALL 查询该客户是否存在 active 的 follow_up_visit 任务(通过 user_assistant_binding 获取 assistant_id再匹配 site_id、assistant_id、member_id
2. WHEN 存在 active 的 follow_up_visit 任务且助教提交了备注, THE Note_Service SHALL 将该任务标记为 completed 并记录变更历史,完成判定不依赖 ai_analyze_note() 的返回值
3. THE Note_Service SHALL 保留 ai_analyze_note() 占位调用P5 接入时调用链不变),但 ai_analyze_note() 的返回值不作为回访任务完成的判定条件
4. WHEN Note_Reclassifier 回溯发现已有备注, THE Note_Reclassifier SHALL 直接创建 status='completed' 的回访任务(回溯完成),不依赖 AI 评分
5. WHEN Note_Reclassifier 回溯未发现备注, THE Note_Reclassifier SHALL 创建 status='active' 的回访任务(等待助教提交备注)
### 需求 5trigger_scheduler last_run_at 事务安全
**用户故事:** 作为系统运维人员,我希望触发器的 last_run_at 更新与 handler 执行在同一事务中,避免 handler 成功但 last_run_at 更新失败导致重复处理。
#### 验收标准
1. WHEN Trigger_Scheduler 执行 event 类型触发器fire_event, THE Trigger_Scheduler SHALL 将 last_run_at 更新纳入 handler 执行的同一事务范围handler 成功与 last_run_at 更新要么一起提交要么一起回滚
2. WHEN Trigger_Scheduler 执行 cron/interval 类型触发器check_scheduled_jobs, THE Trigger_Scheduler SHALL 将 last_run_at 和 next_run_at 更新纳入 handler 执行的同一事务范围
3. IF handler 执行成功但事务提交失败, THEN THE Trigger_Scheduler SHALL 回滚整个事务(包括 handler 的数据变更和 last_run_at 更新),下次重跑时 handler 的幂等性保证不会产生副作用
### 需求 6任务生成器 cron 时间改为 07:00
**用户故事:** 作为运营人员,我希望任务生成器在每天早上 7 点运行,与门店营业节奏匹配。
#### 验收标准
1. THE 迁移脚本 SHALL 将 trigger_jobs 表中 task_generator 的 cron_expression 从 `0 4 * * *` 更新为 `0 7 * * *`
2. THE Trigger_Scheduler SHALL 在 _calculate_next_run() 中使用 `0 7 * * *` 作为 cron 默认值
3. WHEN task_generator 触发器下次运行时间被计算, THE Trigger_Scheduler SHALL 基于 `0 7 * * *` 计算正确的 next_run_at

View File

@@ -0,0 +1,104 @@
# Implementation Plan: P4 前置依赖修复
## Overview
6 个定点修复,修正 P4 核心业务层与 Spec 的实现偏差。T1/T2 已在之前修复中完成仅需属性测试验证T3/T4/T5/T6 需要代码变更。所有修改限于现有服务层内部逻辑,无新表、无新接口。
测试框架Hypothesis项目已有依赖测试文件位于 `tests/` 目录。
## Tasks
- [x] 1. 验证 T1任务列表和 T2召回检测器已有实现 + 属性测试
- [x] 1.1 编写 T1 属性测试任务列表状态过滤、排序、abandon_reason 一致性
- 创建 `tests/test_p52_task_list_properties.py`
- 提取 `get_task_list` 的过滤和排序逻辑为纯函数(或 mock DB 层),用 Hypothesis 生成随机任务集合
- **Property 1: 任务列表状态过滤** — 仅返回 active/abandoned不含 completed/inactive
- **Property 2: 任务列表排序正确性** — abandoned 排在 active 之后active 内部按 is_pinned DESC, priority_score DESC NULLS LAST, created_at ASC
- **Property 3: abandon_reason 与 status 一致性不变量** — active → null, abandoned → 非空字符串
- **Validates: Requirements 1.1, 1.2, 1.3, 1.4**
- [x] 1.2 编写 T2 属性测试:召回检测器仅完成 recall 类型任务
-`tests/test_p52_recall_detector_properties.py` 中编写
- 用 Hypothesis 生成包含四种 task_type 的任务集合和服务记录
- **Property 4: 召回检测器仅完成 recall 类型任务** — high_priority_recall/priority_recall 被完成follow_up_visit/relationship_building 状态不变
- **Validates: Requirements 2.1, 2.2, 2.3**
- [x] 2. Checkpoint — 验证 T1/T2 属性测试通过
- Ensure all tests pass, ask the user if questions arise.
- [x] 3. 实现 T3备注回溯重分类器冲突处理
- [x] 3.1 修改 `apps/backend/app/services/note_reclassifier.py``run()` 方法
- 在创建 follow_up_visit 任务前,查询同 (site_id, assistant_id, member_id) 是否已有 follow_up_visit 任务
- 已有 `completed` → 跳过创建(回访完成语义已满足)
- 已有 `active` → 旧任务标记 `inactive`,记录 `superseded` 历史,创建新任务
- 不存在(或仅有 inactive/abandoned→ 正常创建
- 确保重复触发相同 payload 不产生唯一约束冲突
- _Requirements: 3.1, 3.2, 3.3, 3.4_
- [x] 3.2 编写 T3 属性测试:冲突处理三分支
-`tests/test_p52_note_reclassifier_properties.py` 中编写
- **Property 5: 已完成回访任务阻止新建** — 存在 completed 的 follow_up_visit 时跳过创建
- **Property 6: active 回访任务被顶替** — 旧任务 → inactive + superseded 历史,新任务创建
- **Property 7: 无冲突时正常创建** — 不存在 follow_up_visit或仅 inactive/abandoned时正常创建
- **Validates: Requirements 3.1, 3.2, 3.3, 3.4**
- [x] 4. 实现 T4回访完成条件改为「有备注即完成」
- [x] 4.1 修改 `apps/backend/app/services/note_service.py``create_note()` 方法
-`note_type == "follow_up"``task_id is not None` 时:
- 保留 `ai_analyze_note()` 占位调用,返回值仅用于更新 `ai_score` 字段
- 不论 `ai_score` 如何,有备注即标记关联的 active follow_up_visit 任务为 `completed`
- 记录 `completed_by_note` 历史
- _Requirements: 4.1, 4.2, 4.3_
- [x] 4.2 修改 `apps/backend/app/services/note_reclassifier.py``run()` 方法T4 部分)
- 去掉 AI 评分判定逻辑(`ai_score >= 6` 条件)
- 保留 `ai_analyze_note()` 占位调用
- 找到备注(`note_id is not None`)→ 创建 `status='completed'` 的回访任务(回溯完成)
- 未找到备注(`note_id is None`)→ 创建 `status='active'` 的回访任务(等待备注)
- 注意:此步骤需与 3.1 的冲突处理逻辑协同,冲突检查在任务创建前执行
- _Requirements: 4.4, 4.5_
- [x] 4.3 编写 T4 属性测试:有备注即完成
-`tests/test_p52_note_service_properties.py` 中编写
- **Property 8: 有备注即完成回访任务,不依赖 AI 评分** — 对任意 ai_scoreNone/0-5/6-10提交备注后 active follow_up_visit 任务都标记 completed
- **Property 9: 回溯有备注时创建 completed 回访任务** — note_reclassifier 找到备注 → status='completed'
- **Property 10: 回溯无备注时创建 active 回访任务** — note_reclassifier 未找到备注 → status='active'
- **Validates: Requirements 4.2, 4.3, 4.4, 4.5**
- [x] 5. Checkpoint — 验证 T3/T4 实现和测试通过
- Ensure all tests pass, ask the user if questions arise.
- [x] 6. 实现 T5trigger_scheduler last_run_at 事务安全
- [x] 6.1 修改 `apps/backend/app/services/trigger_scheduler.py`
- `fire_event()`handler 接受可选 `conn` 参数,在 handler 最终 commit 前附带更新 `last_run_at`handler 失败时整个事务回滚last_run_at 不更新)
- `check_scheduled_jobs()`:同理,将 `last_run_at``next_run_at` 更新纳入 handler 事务范围
- 需同步修改 `recall_detector.run()``note_reclassifier.run()` 的签名,接受可选 `conn` 参数
- _Requirements: 5.1, 5.2, 5.3_
- [x] 6.2 编写 T5 属性测试:事务一致性
-`tests/test_p52_trigger_scheduler_properties.py` 中编写
- **Property 11: 触发器 last_run_at 事务一致性** — handler 成功 → last_run_at 更新handler 异常 → last_run_at 不变(整个事务回滚)
- **Validates: Requirements 5.1, 5.2**
- [x] 7. 实现 T6cron 默认值改为 07:00 + 迁移脚本
- [x] 7.1 修改 `apps/backend/app/services/trigger_scheduler.py``_calculate_next_run()`
-`trigger_config.get("cron_expression", "0 4 * * *")` 改为 `"0 7 * * *"`
- _Requirements: 6.2, 6.3_
- [x] 7.2 创建迁移脚本 `db/zqyy_app/migrations/2026-03-15__p52_update_cron_0700.sql`
- 幂等 UPDATE`UPDATE biz.trigger_jobs SET trigger_config = jsonb_set(trigger_config, '{cron_expression}', '"0 7 * * *"') WHERE job_name = 'task_generator'`
- 包含回滚注释
- _Requirements: 6.1_
- [x] 8. Final checkpoint — 全部测试通过
- Ensure all tests pass, ask the user if questions arise.
- 运行 `pytest tests/test_p52_*.py -v` 验证所有 P5.2 属性测试通过
## Notes
- Tasks marked with `*` are optional and can be skipped for faster MVP
- T1/T2 已在之前修复中实现,任务 1 仅编写属性测试验证覆盖
- T3 和 T4 对 `note_reclassifier.py` 有交叉修改,任务 3.1 和 4.2 需协同实施
- T5 涉及 handler 签名变更,需同步修改 recall_detector 和 note_reclassifier
- T6 种子数据已是 `0 7 * * *`,迁移脚本为幂等更新确保生产环境一致
- 属性测试使用 Hypothesis每个属性最少 100 次迭代

View File

@@ -96,29 +96,29 @@
- `default_registry.register("DWS_SPENDING_POWER_INDEX", SpendingPowerIndexTask, requires_db_config=False, layer="INDEX", depends_on=["DWS_MEMBER_CONSUMPTION"])` - `default_registry.register("DWS_SPENDING_POWER_INDEX", SpendingPowerIndexTask, requires_db_config=False, layer="INDEX", depends_on=["DWS_MEMBER_CONSUMPTION"])`
- _Requirements: 9.1, 9.2_ - _Requirements: 9.1, 9.2_
- [-] 6. 配置种子数据 - [x] 6. 配置种子数据
- [x] 6.1 在 `db/etl_feiqiu/seeds/seed_index_parameters.sql` 中追加 SPI 参数 - [x] 6.1 在 `db/etl_feiqiu/seeds/seed_index_parameters.sql` 中追加 SPI 参数
- 插入 `index_type='SPI'` 的全部参数行(窗口、基数、权重、映射、稳定性) - 插入 `index_type='SPI'` 的全部参数行(窗口、基数、权重、映射、稳定性)
- 金额压缩基数使用合理初始默认值 - 金额压缩基数使用合理初始默认值
- _Requirements: 7.1, 7.4, 8.5_ - _Requirements: 7.1, 7.4, 8.5_
- [~] 6.2 在测试库执行种子数据脚本 - [x] 6.2 在测试库执行种子数据脚本
- 通过 TEST_DB_DSN 连接测试库执行 INSERT - 通过 TEST_DB_DSN 连接测试库执行 INSERT
- _Requirements: 7.1_ - _Requirements: 7.1_
- [~] 7. 检查点 - 确保单元测试和集成验证通过 - [x] 7. 检查点 - 确保单元测试和集成验证通过
- 运行 `cd apps/etl/connectors/feiqiu && pytest tests/unit/test_spi_task.py -v` - 运行 `cd apps/etl/connectors/feiqiu && pytest tests/unit/test_spi_task.py -v`
- 确保所有测试通过,如有问题请询问用户。 - 确保所有测试通过,如有问题请询问用户。
- [ ] 8. 文档更新 - [x] 8. 文档更新
- [~] 8.1 编写数据库手册 `docs/database/BD_Manual_dws_member_spending_power_index.md` - [x] 8.1 编写数据库手册 `docs/database/BD_Manual_dws_member_spending_power_index.md`
- 包含表结构、字段说明、索引、验证 SQL至少 3 条)、兼容性说明、回滚策略 - 包含表结构、字段说明、索引、验证 SQL至少 3 条)、兼容性说明、回滚策略
- _Requirements: 11.1_ - _Requirements: 11.1_
- [~] 8.2 更新 ETL 任务文档 `apps/etl/connectors/feiqiu/docs/etl_tasks/index_tasks.md` - [x] 8.2 更新 ETL 任务文档 `apps/etl/connectors/feiqiu/docs/etl_tasks/index_tasks.md`
- 新增 DWS_SPENDING_POWER_INDEX 章节,包含算法公式、参数清单、数据来源、计算流程 - 新增 DWS_SPENDING_POWER_INDEX 章节,包含算法公式、参数清单、数据来源、计算流程
- 更新概述表格和继承体系图 - 更新概述表格和继承体系图
- _Requirements: 11.2, 11.3_ - _Requirements: 11.2, 11.3_
- [~] 9. 最终检查点 - 确保所有测试通过 - [x] 9. 最终检查点 - 确保所有测试通过
- 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_spi_properties.py -v` - 运行属性测试:`cd C:\NeoZQYY && pytest tests/test_spi_properties.py -v`
- 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_spi_task.py -v` - 运行单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit/test_spi_task.py -v`
- 确保所有测试通过,如有问题请询问用户。 - 确保所有测试通过,如有问题请询问用户。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,63 @@
{
"audit_required": true,
"db_docs_required": false,
"reasons": [
"root-file",
"dir:miniprogram"
],
"changed_files": [
"NeoZQYY.code-workspace",
"VI-COLOR-SYSTEM-PROJECT-SUMMARY.md",
"apps/miniprogram/doc/progress-bar-animation.md",
"apps/miniprogram/miniprogram/app.wxss",
"apps/miniprogram/miniprogram/assets/icons/feature-ai.svg",
"apps/miniprogram/miniprogram/assets/icons/feature-board.svg",
"apps/miniprogram/miniprogram/assets/icons/feature-task.svg",
"apps/miniprogram/miniprogram/assets/icons/menu-chat.svg",
"apps/miniprogram/miniprogram/assets/icons/menu-logout.svg",
"apps/miniprogram/miniprogram/assets/icons/menu-notes.svg",
"apps/miniprogram/miniprogram/assets/icons/send-arrow-gray.svg",
"apps/miniprogram/miniprogram/assets/icons/send-arrow-white.svg",
"apps/miniprogram/miniprogram/assets/icons/send-arrow.svg",
"apps/miniprogram/miniprogram/assets/images/login-bg-animated.svg",
"apps/miniprogram/miniprogram/components/ai-inline-icon/ai-inline-icon.wxml",
"apps/miniprogram/miniprogram/components/ai-title-badge/ai-title-badge.wxml",
"apps/miniprogram/miniprogram/components/clue-card/",
"apps/miniprogram/miniprogram/components/coach-level-tag/",
"apps/miniprogram/miniprogram/components/note-modal/note-modal.ts",
"apps/miniprogram/miniprogram/components/note-modal/note-modal.wxml",
"apps/miniprogram/miniprogram/components/perf-progress-bar/",
"apps/miniprogram/miniprogram/components/service-record-card/",
"apps/miniprogram/miniprogram/pages/apply/apply.wxss",
"apps/miniprogram/miniprogram/pages/board-coach/board-coach.json",
"apps/miniprogram/miniprogram/pages/board-coach/board-coach.ts",
"apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxml",
"apps/miniprogram/miniprogram/pages/board-coach/board-coach.wxss",
"apps/miniprogram/miniprogram/pages/board-customer/board-customer.json",
"apps/miniprogram/miniprogram/pages/board-customer/board-customer.ts",
"apps/miniprogram/miniprogram/pages/board-customer/board-customer.wxml",
"apps/miniprogram/miniprogram/pages/board-finance/board-finance.json",
"apps/miniprogram/miniprogram/pages/board-finance/board-finance.wxml",
"apps/miniprogram/miniprogram/pages/chat-history/chat-history.json",
"apps/miniprogram/miniprogram/pages/chat-history/chat-history.ts",
"apps/miniprogram/miniprogram/pages/chat-history/chat-history.wxml",
"apps/miniprogram/miniprogram/pages/chat-history/chat-history.wxss",
"apps/miniprogram/miniprogram/pages/chat/chat.json",
"apps/miniprogram/miniprogram/pages/chat/chat.ts",
"apps/miniprogram/miniprogram/pages/chat/chat.wxml",
"apps/miniprogram/miniprogram/pages/chat/chat.wxss",
"apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.json",
"apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.ts",
"apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxml",
"apps/miniprogram/miniprogram/pages/coach-detail/coach-detail.wxss",
"apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.json",
"apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.ts",
"apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxml",
"apps/miniprogram/miniprogram/pages/customer-detail/customer-detail.wxss",
"apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.json",
"apps/miniprogram/miniprogram/pages/customer-service-records/customer-service-records.ts"
],
"change_fingerprint": "d0c44d030a16a1abb7a69b1aeb2e2478253b3d9c",
"marked_at": "2026-03-18T05:11:36.241874+08:00",
"last_reminded_at": null
}

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