- .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>
16 KiB
技术设计文档:小程序数据获取与展示效率优化
概述
本设计覆盖小程序三个核心页面(Performance、Task-List、Task-Detail)的数据获取与展示效率优化。优化分为两个维度:
- 后端查询优化:消除 N+1 查询、SQL 层过滤替代内存过滤、ETL 连接合并
- 前端体验优化:内存缓存 + 版本校验、跳转预加载、条件显示逻辑
核心设计原则:数据正确性优先于性能——所有优化必须通过基线快照回归验证,确保优化前后数据完全一致。
架构
当前架构问题
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
优化后架构
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()(新增)
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()(新增)
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(新增)
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 表变更
-- 新增 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 返回项新增字段
{
# ... 现有字段不变 ...
"updated_at": "2026-03-25T10:30:00+08:00", # 新增
}
get_task_detail 返回新增字段
{
# ... 现有字段不变 ...
"updated_at": "2026-03-25T10:30:00+08:00", # 新增
}
get_coach_detail → top_customers 列表项新增字段
{
# ... 现有字段不变 ...
"data_updated_at": "2026-03-25T10:30:00+08:00", # 新增,取查询时 now()
}
前端缓存数据结构
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 分析字段