# 前后端联调规范手册

> 最后更新：2026-04-01
> 适用范围：小程序（`apps/miniprogram/`）与 FastAPI 后端（`apps/backend/`）的联调
> 约束力：高度建议参考。特殊需求可偏离但需注明原因。
> 速查索引：`.kiro/steering/frontend-backend-integration.md`（fileMatch 自动加载）

---

## 一、数据契约

### 1.1 ResponseWrapper
后端 `ResponseWrapperMiddleware` 自动包装所有 2xx JSON 响应为 `{"code": 0, "data": <原始body>}`。脚本/测试调用 API 时必须从 `resp.json()["data"]` 取实际数据，不能直接用 `resp.json()`（踩坑记录 2026-03-28）。

### 1.2 字段命名与 CamelCase 转换
- 后端统一 `snake_case`，Pydantic `CamelModel` 自动转 `camelCase` 输出
- 前端用 camelCase 读取，兼容写法：`rec.memberId ?? rec.member_id ?? 0`
- 踩坑：`me.avatar` 应为 `me.avatarUrl`（`avatar_url` 转换后）

### 1.3 条件显示放后端
字段需要根据业务条件决定显示/隐藏时，后端满足条件返回值、不满足返回 `null`。前端只判 `wx:if="{{field}}"`，禁止做数值比较。

组件 property 收到 `null` 不走默认值，传入前必须清洗：`r.durationRaw ?? 0`、`r.drinks ?? ''`。

### 1.4 Schema 与 Service 同步规则

Pydantic response schema 必须与 service 返回字段严格一致，否则 500。排查 500 优先检查：
1. 数据库迁移是否已执行
2. Schema 字段是否与 service 一致
3. 环境变量是否缺失
4. 鉴权依赖是否匹配

#### 踩坑记录汇总（Pydantic 系列）

| 问题 | 日期 | 表现 | 规则 |
|------|------|------|------|
| 静默丢弃未声明字段 | 2026-03-28 | 新增字段（`desc`、`discount_total`）前端收不到 | 新增后端返回字段时必须同步更新 Schema |
| `list[dict]` 不转换内部 key | 2026-03-28 | 前端拿到 snake_case key | 用 CamelModel 子类替代 `dict`，或 service 层手动递归转换 |
| Optional 与 None 不同步 | 2026-03-29 | service 返回 None 但 Schema 非 Optional → 500 | 修改 service 返回 None 时必须同步 Schema 为 `XxxModel | None` |
| response_model 类型不匹配 | 2026-03-29 | Schema 期望 `list[CoachSkillItem]` 但返回 `list[str]` → 500 | 修改 service 返回字段时必须同步检查 Schema 类型 |
| 嵌套 CamelModel 字段名不匹配 | 2026-03-29 | dict key 与 Schema 字段名不一致 → 500 | `_build_*` 方法必须逐字段对照 Schema |
| 含数字字段名转换 | 2026-03-29 | `consumption_60d` → `consumption60D`（大写 D） | 前端用 `d.consumption60D ?? d.consumption60d` 兼容 |

### 1.5 接口返回结构
```json
// 列表：{ "records": [...], "total_count": 42, "has_more": true }
// 概览：{ "coach_name": "...", "income_items": [...], "this_month_records": [...] }
```
收入明细始终返回所有项（即使为 0），前端不做过滤。

---

## 二、展示映射

### 2.1 头像颜色
后端只传 `member_id` + `avatar_char`，前端 `nameToAvatarColor(String(memberId))` 计算颜色 key。散客（`member_id ≤ 0`）→ `'default'`（灰色），`avatar_char` 返回 `"?"`。

### 2.2 课程标签
后端返回数据库原始 `skill_name`（中文），WXSS 不支持中文类名，前端映射：
```typescript
const COURSE_TAG_MAP: Record<string, string> = {
  '陪打': 'basic', '基础课': 'basic',
  '包厢': 'room', '包厢课': 'room',
  '超休': 'incentive', '激励课': 'incentive', '打赏课': 'incentive',
}
```

### 2.3 角色标签
后端返回英文 code（`coach`/`staff`/`head_coach`/`manager`），前端 `ROLE_LABELS` 映射中文。助教拼接等级：`${coachLevel}助教`。

### 2.4 助教姓名显示规则（2026-03-28）
所有助教必须使用昵称/花名（`nickname`），不得显示真实姓名（`real_name`）。SQL 中统一用 `COALESCE(da.nickname, da.real_name, '')`，nickname 优先。

### 2.5 助教离职过滤（2026-03-29）
所有查询助教的 SQL 必须加 `da.leave_status = 0` 过滤离职助教。LEFT JOIN 场景用 `(da.leave_status IS NULL OR da.leave_status = 0)` 兼容无匹配行。`dim_assistant` 中 `leave_status=1` 为离职（占 74%），不过滤会展示大量已离职助教。

### 2.6 预估规则（全小程序统一）
当月且当前日期 ≤ 5号时显示"预估"标签。业务含义：每月 1-5 日为工资审核期。

### 2.7 WXSS 类名拼接
后端值用于 `class="xxx-{{value}}"` 时必须是合法 CSS 标识符（纯英文），禁止中文/hex/特殊字符。

### 2.8 前端筛选枚举同步（踩坑记录 2026-03-28）
前端下拉/筛选组件的 `value` 必须与后端 Enum 值完全一致（大小写、命名）。修改后端枚举时，必须全局搜索所有使用该枚举的前端页面同步更新。

### 2.9 WXS 格式化规范

- WXS 数值方法防护：`format.wxs` 中所有调用 `.toFixed()` 的函数必须先 `parseFloat` 转数字
- TS 与 WXS 格式化互斥（2026-03-27）：WXML 用 `{{fmt.money(field)}}` 时，TS 层 `setData` 必须传原始数字（`?? 0`），禁止 `formatMoney()` 预格式化
- 后端 service 层同理（2026-03-29）：前端用 WXS 格式化的字段，后端必须返回原始数字（float/int），禁止预格式化为字符串。Schema 类型需同步从 `str` 改为 `float/int`
- 环比显示统一规范（2026-03-28）：所有环比文本必须使用 `fmt.compareText(compare, isDown)` + `fmt.compareClass(compare, isDown, size)` WXS 函数
- WXS 零值语义（2026-03-27）：`value === 0` 不一定是空值。`days(0)` 表示"今天到店"是有效值。新增 WXS 函数时必须区分"零值有业务含义"和"零值等同空值"

---

## 三、SQL 查询规范

### 3.1 ETL 连接复用（必须）
同一 service 方法内创建一个 `etl_conn` 传给所有 `fdw_queries.*`。

### 3.2 多页面复用组件时 SQL 统一
后端为同一组件提供数据的查询必须同口径（共享 JOIN/聚合），各页面只负责过滤和分页。

### 3.3 一对多明细用 LATERAL 避免行膨胀
```sql
LEFT JOIN LATERAL (
    SELECT string_agg(gs.ledger_name || '×' || gs.total_count, '、') AS drinks
    FROM (...GROUP BY ledger_name) gs
) gs_agg ON true
```

### 3.4 助教收入口径
- `sl.ledger_amount`：行级毛收入，不是到手
- 到手：`hours × (base_course_price - base_deduction)`
- `sh.assistant_pd_money + cx_money`：整单级汇总，禁止 JOIN 到行级

### 3.5 快照值 vs 流量值（2026-03-27）
- 流量值（充值/消费/收入）：可以 SUM
- 快照值（卡余额 `cash_card_balance`/`gift_card_balance`/`total_card_balance`）：日末快照，多天聚合取最后一天的值，禁止 SUM

### 3.6 `dim_member_card_account` 多卡膨胀（2026-03-29）
同一 `tenant_member_id` 在 `scd2_is_current=1` 下可能有多条记录（多张卡），必须先 `GROUP BY tenant_member_id` + `SUM(balance)` 聚合后再 JOIN。

### 3.7 台费原价推算（2026-03-29）
台费正价 = `table_charge_money + adjust_amount`。`adjust_amount > 0` 时显示原价删除线。

### 3.8 环比同期对比规则（2026-03-28）
当前周期 end_date cap 到今天，上期取同类周期的同期天数。已完成周期取完整周期对比再上一个完整周期。

### 3.9 优惠拆分恒等式（2026-03-28）
`discount_total = discount_groupbuy + discount_manual + discount_other + discount_vip + discount_gift_card + discount_rounding`。拆分展示必须包含全部 6 个子字段。

### 3.10 团购金额双口径（2026-03-28）
DWS `groupbuy_pay_amount` 是团购交易金额，`daily_revenue_report.py` 用 `sale_price × 0.75` 估算回款。财务看板用 DWS 口径，经营报告用估算回款口径。

### 3.11 助教财务三列口径（2026-03-28）
pay/share/hourly 全部从 DWS `dws_assistant_salary_calc` 计算。禁止从 DWD `ledger_amount` 取客户支付再 JOIN DWS 等级（同一助教同月可有多等级记录导致行膨胀）。

### 3.12 客源储值口径（纠正 2026-03-29）
客源储值 = 该助教关联客户的卡余额合计，不是充值提成。数据源：`v_dws_member_assistant_relation_index`（`session_count > 0`）+ `v_dim_member_card_account`（先 GROUP BY 聚合）。

### 3.13 `DATE_TRUNC` 返回 timestamp 不是 date（2026-03-29）
用作 dict key 时必须先 `.date()` 转换。

### 3.14 关系指数表字段名（2026-03-29）
`dws_member_assistant_relation_index` 的字段是 `session_count`（非 service_count）、`total_duration_minutes`（非 total_hours）、`ml_allocated_amount`（非 total_income）。

### 3.15 `dim_table` 主键列名（2026-03-29）
主键是 `table_id`（不是 `site_table_id`）。JOIN 时用 `ON s.site_table_id = dt.table_id`。

---

## 四、数据库操作陷阱

### 4.1 psycopg2 Windows GBK 编码（2026-03-29）
`psycopg2.connect()` 用关键字参数时，libpq 拼接系统 locale 信息触发 `UnicodeDecodeError`。解决：用显式 DSN 字符串 + `client_encoding=UTF8` + `os.environ.setdefault("PGCLIENTENCODING", "UTF8")`。

### 4.2 软删除 + ON CONFLICT
`ON CONFLICT DO UPDATE SET is_removed=false`，禁止 `DO NOTHING`。

### 4.3 ON CONFLICT 精确匹配 partial unique index
列列表和 WHERE 条件必须与索引定义完全对应。

### 4.4 跨库过滤分页
先构建排除列表，SQL 层统一过滤，禁止内存过滤修正 total。

### 4.5 共享连接子流程
需事务隔离时用独立连接，避免异常被外层 except 吞掉。

### 4.6 FDW 查询禁止 N+1 串行（2026-03-29）
必须改为 `WHERE assistant_id = ANY(%s)` 批量查询 + Python 层 dict 映射。

### 4.7 FDW 查询必须传 etl_conn（2026-03-29）
所有 `_build_*` 子函数都应接收并传递 `etl_conn`，否则 `_fdw_context` 在业务库连接上设置 RLS 导致空结果。

---

## 五、前端交互规范

### 5.1 页面加载态（2026-03-29）
统一用 `wx.showLoading` / `wx.hideLoading` 原生遮罩，禁止自定义加载组件。

### 5.2 跨页面跳转
URL 参数必须包含可查询 ID（`memberId`/`taskId`），不能只传展示字段。

### 5.3 dataset 同步
TS `e.currentTarget.dataset.xxx` 必须与 WXML `data-xxx` 属性同步。

### 5.4 自定义组件静默不渲染（2026-03-29）
未在页面 JSON `usingComponents` 中注册的组件不报错但完全不渲染。

### 5.5 多状态列表页
后端按 status 过滤，前端需并行请求所有需要展示的状态并合并。

### 5.6 微信头像
`chooseAvatar` 组件 + `wx.uploadFile` 上传服务器，临时路径会过期。

### 5.7 Vite 代理
跨前缀接口必须在 `vite.config.ts` proxy 中添加规则。

### 5.8 权限守卫
fallback 目标页必须对当前用户无条件可访问，避免跳转死循环。

### 5.9 前后端权限一致性
后端 `/api/xcx/me` 返回 `permissions[]`，前端根据权限码动态控制可见性。新增权限码只需更新数据库 `auth.role_permissions`。

### 5.10 custom-tab-bar 异步刷新
更新 `globalData.visibleTabs` 后必须主动调用 `tabBar._refreshTabs()`。看板 boardTabs 刷新必须放在 `checkPageAccess().then()` 回调中。

### 5.11 禁止页面滚动（2026-03-28）
`wx.setPageScrollEnabled` 不存在。用 `<page-meta page-style="overflow:hidden" />` 控制。

### 5.12 登录与 /me 接口字段差异（2026-03-28）
`/api/xcx/login` 返回 `WxLoginResponse`（不含 permissions），`/api/xcx/me` 返回 `UserStatusResponse`（含 permissions）。登录后需权限码时必须额外请求 `/me`。

### 5.13 分页接口 API 函数必须返回完整响应（2026-03-29）
必须返回完整 `data`（含 `items`/`total`/`page`/`pageSize`），禁止只返回 `data.items`。

### 5.14 懒加载分页追加必须去重 + 双条件判底（2026-03-29）
① 追加数据按 id 去重（`Set` 过滤）；② `hasMore` 用双条件：`items.length >= pageSize && merged.length < total`。

---

## 六、事件驱动触发器

- `biz.trigger_jobs` 中 `trigger_condition='event'` 的任务必须有明确的 `fire_event(event_name)` 发射端
- 任务引擎全流程由 ETL 任务 `DWS_TASK_ENGINE` 编排，通过 HTTP 调用后端 `POST /api/internal/run-job`
- `task_expiry_check` 的 interval 触发器保留（每小时），`DWS_TASK_ENGINE` 是补充而非替代
- `recall_detector` 直接生成回访任务（CHANGE 2026-03-31）
- 任务生成器使用客户级别升级/转移（CHANGE 2026-03-31）：基于 `t_v / ideal_interval` ratio 决定分配范围
- 任务统计写入（CHANGE 2026-03-31）：`task_generator.run()` 末尾调用 `_update_task_stats()`

---

## 七、ETL 调用后端 API 注意事项

- ETL 的 `AppConfig` 不设置 `os.environ`，ETL 任务中用 `os.environ.get()` 前必须先 `load_dotenv`
- 后端 `ResponseWrapperMiddleware` 包装响应，ETL 调用时需从 `resp.json()["data"]` 取数据

---

## 八、列表与分页

- 客户列表：默认 5 条，展开最多 20 条
- 服务记录：按日期分组（DateGroup），默认 2 组，可展开
- 新客：本月有服务但之前无历史
- 常客：本月服务 ≥ 2 次，展示近 90 天聚合
