Files
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

16 KiB
Raw Permalink Blame History

技术设计文档:小程序数据获取与展示效率优化

概述

本设计覆盖小程序三个核心页面Performance、Task-List、Task-Detail的数据获取与展示效率优化。优化分为两个维度

  1. 后端查询优化:消除 N+1 查询、SQL 层过滤替代内存过滤、ETL 连接合并
  2. 前端体验优化:内存缓存 + 版本校验、跳转预加载、条件显示逻辑

核心设计原则:数据正确性优先于性能——所有优化必须通过基线快照回归验证,确保优化前后数据完全一致。

架构

当前架构问题

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_indexRS 指数)
  • 降级策略:每个子查询用 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_infoget_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_infobalancerelation_index、任务预加载数据
  • 缓存 key 格式:{dataType}:{id}(如 member_info:12345task_preload:678
  • 生命周期:onHide 或下拉刷新时 clear()

8. Task-List 页跳转传参(修改)

  • 点击任务卡片时,将已有数据写入 cacheStore.setTaskPreload()
  • 传递字段:customer_nameheart_scorebalancetask_typetask_type_label

9. Task-Detail 页骨架渲染(修改)

  • onLoad 时先从 cacheStore.getTaskPreload() 读取预加载数据
  • 有缓存立即渲染骨架内容客户名、RS 分数、余额等),同时异步请求完整数据
  • 无缓存回退为当前全量加载模式loading → 请求 → 渲染)

10. Performance 页跳转传参(修改)

  • 点击客户卡片跳转 Task-Detail 时,将 customer_nameheart_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: 2964673443302213site_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_detailtop_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 过滤条件和分页参数(pagepage_sizeget_task_list_v2 返回的 total 值应等于遍历所有页(page=1ceil(total/page_size))累计的 items 总数。

Validates: Requirements 2.4

Property 4: 接口返回包含版本时间戳

For any 调用 get_task_list_v2get_task_detailget_coach_detail 返回的数据每个数据项任务项、任务详情、TOP 客户项)都应包含有效的版本时间戳字段(updated_atdata_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_idassistant_idmember_id 组合,batch_query_for_task_detail 返回的 {member_info, balance, rs_score} 应与分别调用 get_member_infoget_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 后端使用 hypothesisTypeScript 前端使用 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_atdata_updated_at 等时间戳字段的 diff
  • 覆盖到手金额、RS 指数、会员余额、服务记录条数/排序、酒水明细、TOP 客户列表、AI 分析字段