# 技术设计文档:小程序数据获取与展示效率优化 ## 概述 本设计覆盖小程序三个核心页面(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 { data: T updatedAt: string // ISO 时间戳,来自后端 updated_at cachedAt: number // 本地缓存时间 Date.now() } class CacheStore { private store: Map> /** 读取缓存,返回 null 表示缓存不存在 */ get(key: string): CacheEntry | null /** 写入缓存 */ set(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 分析字段