Files
Neo-ZQYY/docs/specs/miniapp-data-perf-optimization/design.md
Neo 70324d8542 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>
2026-04-06 00:02:37 +08:00

425 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 技术设计文档:小程序数据获取与展示效率优化
## 概述
本设计覆盖小程序三个核心页面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 分析字段