- .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>
425 lines
16 KiB
Markdown
425 lines
16 KiB
Markdown
# 技术设计文档:小程序数据获取与展示效率优化
|
||
|
||
## 概述
|
||
|
||
本设计覆盖小程序三个核心页面(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 分析字段
|