chore: 文档与 IDE 配置整理

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

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

View File

@@ -0,0 +1,424 @@
# 技术设计文档:小程序数据获取与展示效率优化
## 概述
本设计覆盖小程序三个核心页面Performance、Task-List、Task-Detail的数据获取与展示效率优化。优化分为两个维度
1. **后端查询优化**:消除 N+1 查询、SQL 层过滤替代内存过滤、ETL 连接合并
2. **前端体验优化**:内存缓存 + 版本校验、跳转预加载、条件显示逻辑
核心设计原则:**数据正确性优先于性能**——所有优化必须通过基线快照回归验证,确保优化前后数据完全一致。
## 架构
### 当前架构问题
```mermaid
graph TD
subgraph "Performance 页 — N+1 问题"
A[_build_top_customers] -->|循环 N 次| B[get_relation_index]
B -->|每次新建 FDW 连接| C[v_dws_member_assistant_relation_index]
end
subgraph "Task-Detail 页 — 3 次独立连接"
D[get_task_detail] --> E[get_member_info — 连接1]
D --> F[get_member_balance — 连接2]
D --> G[RS 指数内联查询 — 连接3]
end
subgraph "前端 — 无缓存"
H[Task-List 页] -->|跳转| I[Task-Detail 页]
I -->|重新请求全部数据| J[后端 API]
end
```
### 优化后架构
```mermaid
graph TD
subgraph "Performance 页 — 批量查询"
A2[_build_top_customers] -->|1 次调用| B2[get_relation_index_batch]
B2 -->|单连接 WHERE member_id = ANY| C2[v_dws_member_assistant_relation_index]
end
subgraph "Task-Detail 页 — 单连接批量"
D2[get_task_detail] --> E2[batch_query_for_task_detail]
E2 -->|单 _fdw_context| F2["member_info + balance + RS 一次查完"]
end
subgraph "前端 — Cache Store"
G2[Task-List 页] -->|写入 Cache Store| H2[全局内存缓存]
H2 -->|读取缓存 + 骨架渲染| I2[Task-Detail 页]
I2 -->|异步请求完整数据| J2[后端 API]
end
```
## 组件与接口
### 后端新增/修改组件
#### 1. `fdw_queries.get_relation_index_batch()`(新增)
```python
def get_relation_index_batch(
conn: Any,
site_id: int,
assistant_id: int,
member_ids: list[int],
) -> dict[int, float]:
"""
批量查询多个客户的 RS 指数。
返回 {member_id: rs_display} 映射。
member_ids 为空时直接返回空字典,不执行 SQL。
"""
```
- 数据源:`app.v_dws_member_assistant_relation_index`
- 查询条件:`WHERE assistant_id = %s AND member_id = ANY(%s)`
- 复用已有 `_fdw_context` 连接管理
#### 2. `fdw_queries.batch_query_for_task_detail()`(新增)
```python
def batch_query_for_task_detail(
conn: Any,
site_id: int,
assistant_id: int,
member_id: int,
) -> dict:
"""
单连接批量查询任务详情所需的 ETL 数据。
返回 {member_info, balance, rs_score}。
每个子查询独立降级(返回默认值)。
"""
```
- 模式:复用 `batch_query_for_task_list``_fdw_context` 单连接模式
- 子查询:`v_dim_member`(会员信息)、`v_dim_member_card_account`(余额)、`v_dws_member_assistant_relation_index`RS 指数)
- 降级策略:每个子查询用 try/except 包裹,失败返回默认值
#### 3. `coach_service._build_top_customers()`(修改)
-`for mid in member_ids` 循环调用 `get_relation_index` 替换为单次 `get_relation_index_batch` 调用
- 返回数据结构不变
#### 4. `task_manager.get_task_detail()`(修改)
- 将 3 个独立 ETL 查询(`get_member_info``get_member_balance`、内联 RS 查询)替换为 `batch_query_for_task_detail` 单次调用
- 返回数据结构不变,新增 `updated_at` 字段
#### 5. `task_manager.get_task_list_v2()`(修改)
- 当前已在 SQL 层排除 RS 范围外的 `relationship_building` 任务CHANGE 2026-03-25
- 需求 2 要求的 SQL 层过滤已基本实现,需验证 `total` 计数与实际过滤结果一致性
- 返回每个任务项新增 `updated_at` 字段
#### 6. `coach_service.get_coach_detail()`(修改)
- `top_customers` 列表中每个客户新增 `data_updated_at` 字段(取查询时服务器时间戳 `now()`
### 前端新增组件
#### 7. `utils/cache-store.ts`(新增)
```typescript
interface CacheEntry<T> {
data: T
updatedAt: string // ISO 时间戳,来自后端 updated_at
cachedAt: number // 本地缓存时间 Date.now()
}
class CacheStore {
private store: Map<string, CacheEntry<any>>
/** 读取缓存,返回 null 表示缓存不存在 */
get<T>(key: string): CacheEntry<T> | null
/** 写入缓存 */
set<T>(key: string, data: T, updatedAt: string): void
/** 清空全部缓存 */
clear(): void
/** 批量写入Task-List → Task-Detail 预加载) */
setTaskPreload(taskId: number, data: TaskPreloadData): void
/** 读取任务预加载数据 */
getTaskPreload(taskId: number): TaskPreloadData | null
}
export const cacheStore = new CacheStore()
```
- 缓存范围:`member_info``balance``relation_index`、任务预加载数据
- 缓存 key 格式:`{dataType}:{id}`(如 `member_info:12345``task_preload:678`
- 生命周期:`onHide` 或下拉刷新时 `clear()`
#### 8. Task-List 页跳转传参(修改)
- 点击任务卡片时,将已有数据写入 `cacheStore.setTaskPreload()`
- 传递字段:`customer_name``heart_score``balance``task_type``task_type_label`
#### 9. Task-Detail 页骨架渲染(修改)
- `onLoad` 时先从 `cacheStore.getTaskPreload()` 读取预加载数据
- 有缓存立即渲染骨架内容客户名、RS 分数、余额等),同时异步请求完整数据
- 无缓存回退为当前全量加载模式loading → 请求 → 渲染)
#### 10. Performance 页跳转传参(修改)
- 点击客户卡片跳转 Task-Detail 时,将 `customer_name``heart_score` 写入 `cacheStore`
### 数据库变更
#### 11. `biz.coach_tasks` 新增 `updated_at` 列
- DDL 迁移文件:`db/zqyy_app/migrations/YYYY-MM-DD__coach_tasks_updated_at.sql`
- 列定义:`updated_at TIMESTAMPTZ NOT NULL DEFAULT now()`
- 触发器:任务状态变更时自动更新 `updated_at`
### 酒水聚合优化(低优先级)
#### 12. `fdw_queries.get_service_records_for_task()`(条件修改)
- 当服务记录 > 50 条时,使用 CTE 预聚合替代 `LEFT JOIN LATERAL`
- ≤ 50 条时保持现有 `LEFT JOIN LATERAL` 方式
- 结果数据完全一致
### 回归验证
#### 13. `tests/test_perf_optimization_baseline.py`(新增)
- pytest 形式,使用小燕的真实数据(`assistant_id: 2964673443302213``site_id: 2790685415443269`
- 基线快照存储:`tests/fixtures/perf_optimization/`
- 覆盖三个核心接口的逐字段 diff 对比
## 数据模型
### `biz.coach_tasks` 表变更
```sql
-- 新增 updated_at 列
ALTER TABLE biz.coach_tasks
ADD COLUMN updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
-- 触发器:状态变更时自动更新
CREATE OR REPLACE FUNCTION biz.coach_tasks_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_coach_tasks_updated_at
BEFORE UPDATE ON biz.coach_tasks
FOR EACH ROW
EXECUTE FUNCTION biz.coach_tasks_set_updated_at();
```
### 接口返回结构变更
#### `get_task_list_v2` 返回项新增字段
```python
{
# ... 现有字段不变 ...
"updated_at": "2026-03-25T10:30:00+08:00", # 新增
}
```
#### `get_task_detail` 返回新增字段
```python
{
# ... 现有字段不变 ...
"updated_at": "2026-03-25T10:30:00+08:00", # 新增
}
```
#### `get_coach_detail` → `top_customers` 列表项新增字段
```python
{
# ... 现有字段不变 ...
"data_updated_at": "2026-03-25T10:30:00+08:00", # 新增,取查询时 now()
}
```
### 前端缓存数据结构
```typescript
interface TaskPreloadData {
customer_name: string
heart_score: number
balance: number
task_type: string
task_type_label: string
}
// Cache key 示例
// "task_preload:123" → TaskPreloadData
// "member_info:456" → { nickname, mobile }
// "balance:456" → number
// "rs:789:456" → number (assistant_id:member_id)
```
### 基线快照 JSON 结构
```
tests/fixtures/perf_optimization/
├── baseline_coach_detail.json # get_coach_detail 完整返回
├── baseline_task_list_v2.json # get_task_list_v2 完整返回
└── baseline_task_detail.json # get_task_detail 完整返回
```
## 正确性属性Correctness Properties
*属性是一种在系统所有有效执行中都应成立的特征或行为——本质上是对系统应做什么的形式化陈述。属性是人类可读规格说明与机器可验证正确性保证之间的桥梁。*
### Property 1: 批量 RS 查询与逐条查询等价性
*For any* `assistant_id` 和任意非空 `member_ids` 列表,`get_relation_index_batch(conn, site_id, assistant_id, member_ids)` 返回的 `{member_id: rs_display}` 映射,应与对每个 `member_id` 逐条调用 `get_relation_index(conn, site_id, member_id)` 并提取 `assistant_id` 匹配行的 `relation_index` 值完全一致。
**Validates: Requirements 1.4**
### Property 2: relationship_building 任务 RS 范围过滤
*For any* 任务列表返回结果,其中 `task_type = 'relationship_building'` 的任务,其对应 `member_id` 的 RS 指数值应在 `(rs_min, rs_max)` 开区间范围内(当 RS 排除列表查询成功时)。
**Validates: Requirements 2.2**
### Property 3: 分页 total 与实际结果集一致性
*For any* 过滤条件和分页参数(`page``page_size``get_task_list_v2` 返回的 `total` 值应等于遍历所有页(`page=1``ceil(total/page_size)`)累计的 `items` 总数。
**Validates: Requirements 2.4**
### Property 4: 接口返回包含版本时间戳
*For any* 调用 `get_task_list_v2``get_task_detail``get_coach_detail` 返回的数据每个数据项任务项、任务详情、TOP 客户项)都应包含有效的版本时间戳字段(`updated_at``data_updated_at`),且该字段为合法的 ISO 8601 时间戳。
**Validates: Requirements 3.2, 6.1, 6.2, 6.3**
### Property 5: 缓存版本校验行为
*For any* 缓存条目和对应的后端 `updated_at` 值,当缓存中的 `updatedAt` 与后端返回的 `updated_at` 相同时,`CacheStore.get()` 应返回缓存数据;当后端 `updated_at` 更新(值变大)时,缓存应被判定为过期。
**Validates: Requirements 3.3, 3.4**
### Property 6: 缓存 clear 后为空
*For any* 非空的 `CacheStore` 状态,调用 `clear()` 后,对任意 key 调用 `get()` 应返回 `null`
**Validates: Requirements 3.6**
### Property 7: 预加载数据 round-trip
*For any* `TaskPreloadData` 对象,调用 `cacheStore.setTaskPreload(taskId, data)` 后,`cacheStore.getTaskPreload(taskId)` 应返回与写入数据字段值完全一致的对象。
**Validates: Requirements 4.1**
### Property 8: Task-Detail 批量查询与独立查询等价性
*For any* `site_id``assistant_id``member_id` 组合,`batch_query_for_task_detail` 返回的 `{member_info, balance, rs_score}` 应与分别调用 `get_member_info``get_member_balance`、内联 RS 查询的结果在数据内容上完全一致。
**Validates: Requirements 5.4**
### Property 9: 任务更新后 updated_at 自动变化
*For any* `biz.coach_tasks` 中的任务记录,执行 UPDATE 操作后,该记录的 `updated_at` 值应严格大于更新前的 `updated_at` 值。
**Validates: Requirements 6.4**
### Property 10: 折前/折后条件显示逻辑
*For any* 服务记录,当 `duration_raw` 不为 null 且 `duration_raw ≠ duration` 时,显示函数应返回包含"折前"标注的字符串;当 `duration_raw` 为 null 或 `duration_raw == duration` 时,显示函数应不包含"折前"标注。
**Validates: Requirements 7.1, 7.2, 7.3**
### Property 11: 折前小时数格式化
*For any* 非负浮点数 `hours`,格式化函数应输出恰好一位小数的字符串(如 `"2.5h"`),且 `parseFloat(formatted) ≈ round(hours, 1)`
**Validates: Requirements 7.4**
### Property 12: CTE 预聚合与 LEFT JOIN LATERAL 等价性
*For any* 服务记录集合(记录数 > 50使用 CTE 预聚合方式查询的 `drinks` 字段内容应与使用 `LEFT JOIN LATERAL` 方式查询的结果完全一致。
**Validates: Requirements 8.2**
## 错误处理
### 后端降级策略
| 场景 | 降级行为 | 影响范围 |
|------|----------|----------|
| `get_relation_index_batch` 查询失败 | 返回空字典 `{}`,所有客户 RS 显示为 0 | Performance 页 TOP 客户心形图标 |
| `batch_query_for_task_detail` 某子查询失败 | 该子查询返回默认值,其他子查询正常 | Task-Detail 页部分字段显示默认值 |
| RS 排除列表查询失败 | 不排除任何任务,返回全量(已有行为) | Task-List 页可能显示 RS 范围外的保底任务 |
| `updated_at` 字段缺失(旧数据) | 前端视为缓存过期,发起网络请求 | 无功能影响,仅缓存失效 |
| CacheStore 异常 | 降级为直接网络请求 | 无功能影响,仅失去缓存加速 |
| CTE 预聚合查询失败 | 回退到 LEFT JOIN LATERAL 方式 | 无功能影响,仅性能回退 |
### 前端错误处理
- CacheStore 的所有读写操作用 try/catch 包裹,异常时静默降级
- 预加载数据不存在时Task-Detail 页回退为全量加载模式
- `updated_at` 比较异常时,视为缓存过期,重新请求
## 测试策略
### 双轨测试方法
本项目采用单元测试 + 属性测试的双轨方法:
- **单元测试**:验证具体示例、边界条件、错误降级行为
- **属性测试**验证跨所有输入的通用属性等价性、不变性、round-trip
### 属性测试配置
- **库**Python 后端使用 `hypothesis`TypeScript 前端使用 `fast-check`
- **迭代次数**:每个属性测试最少 100 次迭代
- **标签格式**`Feature: miniapp-data-perf-optimization, Property {N}: {property_text}`
- 每个正确性属性由单个属性测试实现
### 后端属性测试hypothesis
| Property | 测试文件 | 生成器 |
|----------|----------|--------|
| P1: 批量 RS 等价性 | `tests/test_perf_optimization_properties.py` | `st.lists(st.integers(min_value=1))` 生成 member_ids |
| P2: RS 范围过滤 | 同上 | 生成随机 RS 值和 rs_min/rs_max 范围 |
| P3: 分页 total 一致性 | 同上 | 生成随机 page/page_size 参数 |
| P4: 版本时间戳存在性 | 同上 | 验证接口返回结构 |
| P8: Task-Detail 批量等价性 | 同上 | 生成随机 member_id |
| P9: updated_at 自动更新 | 同上 | 生成随机任务状态变更 |
| P12: CTE vs LATERAL 等价性 | 同上 | 生成随机服务记录集合 |
### 前端属性测试fast-check
| Property | 测试文件 | 生成器 |
|----------|----------|--------|
| P5: 缓存版本校验 | `apps/miniprogram/tests/cache-store.test.ts` | `fc.string()` 生成 updatedAt 时间戳 |
| P6: 缓存 clear | 同上 | `fc.dictionary()` 生成随机缓存条目 |
| P7: 预加载 round-trip | 同上 | `fc.record()` 生成 TaskPreloadData |
| P10: 折前/折后显示 | `apps/miniprogram/tests/duration-display.test.ts` | `fc.float()` 生成 duration 和 duration_raw |
| P11: 格式化一位小数 | 同上 | `fc.float({min: 0, max: 1000})` |
### 单元测试覆盖
- **边界条件**`member_ids` 为空时 `get_relation_index_batch` 返回空字典AC 1.3
- **降级行为**RS 排除列表查询失败时不排除任何任务AC 2.3
- **降级行为**`batch_query_for_task_detail` 子查询独立降级AC 5.3
- **降级行为**CacheStore 异常时降级为网络请求AC 3.5
- **降级行为**无预加载数据时回退全量加载AC 4.4
- **null 处理**`duration_raw` 为 null 时视为与 `duration` 相等AC 7.3
### 回归验证(集成测试)
- `tests/test_perf_optimization_baseline.py`:基线快照 diff 对比AC 9.1-9.6
- 使用真实测试数据库(`test_zqyy_app` / `test_etl_feiqiu`
- 排除 `updated_at``data_updated_at` 等时间戳字段的 diff
- 覆盖到手金额、RS 指数、会员余额、服务记录条数/排序、酒水明细、TOP 客户列表、AI 分析字段