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

## 概述

本设计覆盖小程序三个核心页面（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 分析字段
