# Taro 前端对接指南

更新时间：2026-06-30

目标：用一套 Taro 工程同时服务微信小程序和 H5 Web 题库，并采用“Supabase Auth/JWT + `apps/api` 业务 API 优先”的混合架构，支持多租户、品牌主题、地区题库、会员权益、销售追踪和对象存储资源。

建议新建：

```text
F:\project\apps\taro
```

旧前端参考：

```text
F:\project\参考\旧题库项目\src
```

## 启动流程

### H5

1. 从 `window.location.host` 获取当前域名。
2. 调用 `GET /api/tenant/resolve?host=<host>`。
3. 保存 `tenant.id`、`tenant.slug`、`branding`、`features`、`publicConfig`。
4. 使用 `branding.theme` 和 `branding.publicAssets` 初始化主题、Logo、分享图、页面标题、功能开关。
5. 检查本地 session token，调用 `GET /api/auth/me`。
6. 如果未登录，进入登录页；如果已登录，加载个人中心和首页数据。

### 微信小程序

1. 从编译环境或小程序启动参数读取 `tenantCode`。
2. 推广码、销售码、分享码从 `options` 或 `scene` 中解析。
3. 调用 `GET /api/tenant/resolve?tenantCode=<tenantCode>`。
4. 如存在 referral 参数，先调用 `/api/referral/resolve` 和 `/api/referral/track-event`。
5. 登录后再调用 `/api/referral/bind` 完成首绑保护。

## 请求封装

本项目不采用“前端直接写 Supabase 表替代业务命令层”的模式。Supabase 官方允许前端在 RLS 和最小权限下使用 Data API，但本系统的订单、支付、权益、租户后台、内容导入、CRM、对象存储签名等都需要服务端事务、密钥、审计和幂等，所以复杂业务命令默认调用 RPC、`apps/api`、Edge Function 或 worker。

前端可以使用 Supabase client 的范围：

- H5 Auth session/JWT。
- 小程序端在兼容性验证通过后的 Auth session/JWT。
- 低风险公开只读数据，且必须已经有 RLS、grant、跨租户测试。
- Realtime 非敏感通知。

前端必须调用 `apps/api` 的范围：

- 题库练习、答题、错题、收藏。
- 订单、支付、激活码、优惠券、权益。
- 私有 PDF、资料、视频、对象存储签名。
- 租户后台、平台后台、内容导入、CRM、销售/代理、数据看板。

前端应封装一个统一 API client，所有页面禁止直接散写 `Taro.request`。当前统一入口是：

```text
apps/taro/src/services/api.ts
apps/taro/src/services/api-auth.ts
```

`apiRequest` 的默认鉴权模式是 `authMode='auto'`：

| authMode | 行为 | 适用场景 |
| --- | --- | --- |
| `auto` | H5 先读取 Supabase Auth access token；没有 Supabase token 时才兜底迁移期 `tk_` session | 绝大多数登录后业务接口 |
| `supabase` | 只发送 Supabase access token，没有 token 也不回退 `tk_` | 云端 JWT/RLS 回归、需要提前发现迁移 token 依赖的页面 |
| `legacy` | 只发送迁移期 `tk_` session | 本地迁移、旧数据导入演练、临时内网联调 |
| `none` | 不发送 Authorization | 租户解析、短信发送/验证、公开目录、公开套餐等接口 |

公共接口必须显式传 `authMode: 'none'`，例如 `tenant/resolve`、`catalog/regions`、`catalog/content-entries`、`catalog/svip-plans`、`auth/sms/send`。平台全局接口或租户解析如不应带租户上下文，必须显式传 `tenantId: null`；不能依赖当前本地缓存的租户。

本地迁移期仍可兼容旧请求头，但新的 Taro 请求封装必须按下面目标实现：

```text
Authorization: Bearer <tk_session>
x-tenant-id: <tenantId>   # 仅作为登录前/公开目录租户上下文；登录后必须与 session 租户一致
```

生产目标：

```text
Authorization: Bearer <supabase_access_token>
x-tenant-id: <tenantId>   # 作为租户上下文，不能作为身份依据
```

前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 Supabase JWT 和迁移 session 优先解析：如果 Authorization 存在，用户态接口以 token 映射出的业务用户为准；如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`，伪造不同租户会返回 `AUTH_TENANT_MISMATCH` 或 `AUTH_SESSION_INVALID`。

H5 使用 Supabase Auth 时，不要在页面里手写 `Authorization`，推荐请求流程是由统一 client 完成：

```ts
await apiRequest('/api/profile/me');
```

`apps/taro/src/services/api-auth.ts` 会读取 Supabase session 并生成 `Authorization: Bearer <supabase_access_token>`。页面层禁止通过 `headers.Authorization` 或 `headers['x-tenant-id']` 覆盖身份和租户上下文；如确实要切换租户上下文，必须使用 `tenantId` 显式参数。该规则由 `scripts/taro-api-auth-mode-test.js` 纳入 `npm run test:readiness`。

后端会通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射用户身份。`x-tenant-id` 只能帮助确定当前租户上下文，不能让用户访问自己没有 membership 的租户。

生产或云端测试建议设置：

```text
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
```

这样旧式 `x-user-id` 和平台管理 key 会被拒绝，前端可以提前发现未按 session 接入的页面。

前端构建变量或 H5 `runtime-config.json` 只允许包含：

```text
TARO_APP_PORTAL / portal
TARO_APP_API_BASE_URL / apiBaseUrl
TARO_APP_SUPABASE_URL / supabaseUrl
TARO_APP_SUPABASE_PUBLISHABLE_KEY / supabasePublishableKey
TARO_APP_TENANT_CODE / tenantCode
```

H5 线上优先使用每个静态目录根部的 `runtime-config.json` 覆盖公开配置，避免 API/Auth 域名变化时重打包。完整部署、Nginx、CSP、缓存和 CORS 规则见 `docs/refactor/taro-h5-deployment.md`。禁止把 Supabase secret key、service role key、数据库连接串、对象存储密钥、支付私钥放进 Taro 构建变量或 `runtime-config.json`。

统一错误处理：

| HTTP | 前端动作 |
| --- | --- |
| 400 | 展示表单错误或参数错误 |
| 401 | 清 session，跳登录 |
| 403 | 展示无权限或会员升级 |
| 404 | 展示空状态 |
| 409 | 展示业务冲突，例如激活码已用 |
| 413 | 提示上传/导入文件过大 |
| 429 | 倒计时重试，例如短信冷却 |
| 500 | 展示系统异常并上报日志 |

## 租户主题与品牌契约

学生端、租户后台、平台后台启动时都通过 `GET /api/tenant/resolve` 获取已发布主题。响应中的 `branding.theme` 是已发布 token，`branding.publicAssets` 是公开素材引用，前端可以安全消费；租户后台草稿不会出现在公开解析响应里。

主题 token 示例：

```json
{
  "primaryColor": "#2563eb",
  "accentColor": "#0f766e",
  "backgroundColor": "#f8fafc",
  "surfaceColor": "#ffffff",
  "textColor": "#0f172a",
  "borderRadius": 8,
  "buttonRadius": 8,
  "layoutDensity": "comfortable"
}
```

公开素材示例：

```json
{
  "logoUrl": "/assets/tenant/logo.png",
  "shareImageUrl": "https://static.example.com/share.png",
  "iconSet": "focus",
  "shareCardStyle": "study"
}
```

前端处理规则：

- 只读取 `/api/tenant/resolve` 返回的已发布主题来渲染学生端和公开页面。
- 租户后台主题草稿只调用 `GET /api/tenant-admin/theme` 展示，不能让学生端读取草稿。
- 不允许前端把任意 CSS、HTML、JS 或远程脚本当作主题执行；后端已经限制主题为颜色、半径、安全 CSS 变量、图标 token 和 HTTPS/站内公开素材。
- Logo、分享图、启动图等长期素材后续应从后台上传进入 `content_assets` 或静态公共资源，再把公开 URL/路径写入主题 `publicAssets`。
- 小程序端用 `branding.publicAssets.shareImageUrl` 作为分享图时，仍要遵守微信平台对图片尺寸、域名和 HTTPS 的要求。

租户后台主题配置接口：

```text
GET  /api/tenant-admin/theme-templates
GET  /api/tenant-admin/theme
POST /api/tenant-admin/theme/preview
POST /api/tenant-admin/theme/publish
```

权限：

```text
tenant:theme:read
tenant:theme:write
```

`POST /api/tenant-admin/theme/preview` 只保存草稿：

```json
{
  "templateCode": "focus",
  "theme": {
    "primaryColor": "#123abc",
    "accentColor": "#f59e0b"
  },
  "publicAssets": {
    "logoUrl": "/assets/tenant/logo.png",
    "iconSet": "focus",
    "shareCardStyle": "study"
  }
}
```

`POST /api/tenant-admin/theme/publish` 发布草稿：

```json
{
  "useDraft": true
}
```

发布成功后，下一次 `GET /api/tenant/resolve` 会返回新主题。主题预览和发布都会写入租户审计日志，越权角色会返回 `TENANT_PERMISSION_REQUIRED`。

## 全局状态建议

| Store | 内容 |
| --- | --- |
| tenantStore | tenant、branding、theme、features、publicConfig |
| authStore | session、user、roles、permissions、loginState |
| regionStore | 当前地区、可选地区、地区权益 |
| catalogStore | content entries、nodes、collections、blueprints |
| entitlementStore | SVIP 权益、视频权益、资料下载权益 |
| referralStore | inviteCode、referrer、scene、bindState |
| uiStore | 当前主题、tab、loading、toast、modal |

注意缓存必须带租户维度，例如：

```text
tenant:<tenantId>:catalog:entries
tenant:<tenantId>:profile
tenant:<tenantId>:theme
```

切换租户或切换小程序环境时必须清理旧租户缓存。

## 页面/API 映射

| 页面 | 主要接口 |
| --- | --- |
| 启动页 | `GET /api/tenant/resolve` |
| 登录页 | `POST /api/auth/sms/send`、`POST /api/auth/sms/verify`、`POST /api/auth/oauth/wechat-miniapp`、`POST /api/auth/oauth/wechat`、`POST /api/auth/oauth/qq` |
| 首页 | `/api/catalog/content-entries`、`/api/catalog/banners`、`/api/catalog/announcements`、`/api/catalog/exam-dates`、`/api/profile/me` |
| 选地区 | `/api/catalog/regions`、`/api/commerce/entitlements/check` |
| 题库入口 | `/api/catalog/content-entries` |
| 分类树 | `/api/catalog/content-nodes?entryId=...&parentId=root` |
| 题目列表 | `/api/catalog/question-collections`、`/api/catalog/question-collections/questions` |
| 开始练习 | `POST /api/learning/practice-sessions` |
| 恢复练习 | `GET /api/learning/practice-sessions/detail?practiceSessionId=...` |
| 提交答案 | `POST /api/learning/answers` |
| 交卷/报告 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports` |
| 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` |
| 错题复习 | `GET /api/learning/wrong-questions/review-plan`、`POST /api/learning/practice-sessions` with `mode=wrong_review` |
| 收藏夹 | `GET/POST /api/learning/favorites/questions` |
| 练习历史/统计 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend` |
| 学习排行榜 | `GET /api/learning/leaderboard?metric=questions&period=all`，仅当租户 `features.enableLeaderboard=true` 时启用；默认不请求、不展示 |
| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` |
| 题目反馈 | `POST /api/profile/feedbacks`、`GET /api/profile/feedbacks` |
| 分佣结算 | `GET /api/commission/settings`、`PUT /api/commission/settings`、`PUT /api/commission/member-rate`、`GET /api/commission/summary`、`GET /api/commission/orders`、`GET /api/commission/settlements`、`POST /api/commission/settlements/generate`、`POST /api/commission/settlements/status` |
| 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` |
| 单词进度/计划 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats`、`/api/learning/vocabulary/review-plan`、`POST /api/learning/vocabulary/review` |
| 单词收藏 | `/api/learning/vocabulary/favorites` |
| 知识手册 | `/api/catalog/handbook-subjects`、`handbook-chapters`、`handbook-entries` |
| 分数线 | `/api/scoreline/fields`、`schools`、`majors`、`records`、`trend`、`years`；动态字段筛选使用 `field.<fieldKey>`、数值范围使用 `min.<fieldKey>` / `max.<fieldKey>`，字段必须以后端 `fields.items[].isFilter=true` 为准 |
| 资料下载/预览 | `/api/catalog/assets`、`/api/catalog/assets/preview`、`/api/catalog/assets/download` |
| 商城/收银台 | `/api/catalog/svip-plans`、`POST /api/commerce/coupons/claim`、`POST /api/commerce/orders`、`POST /api/commerce/payments/create` |
| 订单/权益 | `/api/commerce/orders`、`/api/commerce/orders/detail`、`/api/commerce/orders/status`、`/api/commerce/entitlements` |
| 激活码 | `POST /api/commerce/activation-codes/check`、`POST /api/commerce/activation-codes/redeem` |
| 个人中心 | `GET/PATCH /api/profile/me`、`POST /api/profile/check-in`、`GET /api/profile/score-events`、`GET /api/profile/exam-countdowns`、`GET /api/profile/badges`、`GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem`、`GET /api/profile/notifications`、`POST /api/profile/notifications/status` |
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
| 租户数据看板 | `GET /api/tenant-admin/dashboard?timeRange=30d&regionId=...` |
| 租户主题模板 | `GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish` |
| 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` |
| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` |
| 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` |
| 学生备注 | `GET/PUT /api/tenant-admin/students/notes` |
| 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` |
| 学生跟进看板 | `GET /api/tenant-admin/students/followups/report?timeRange=30d&classId=...` |
| 学习督导自动化 | `GET /api/tenant-admin/students/supervision/preview?classId=...`、`POST /api/tenant-admin/students/supervision/generate`、`GET/PUT /api/tenant-admin/students/supervision/rules` |
| 租户教师 | `GET /api/tenant-admin/teachers` |
| 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` |
| 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`GET /api/tenant-admin/feedbacks/report`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` |
| 租户勋章 | `GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants` |
| 租户积分任务/兑换 | `GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders` |
| 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve`、`POST /api/tenant-content/public-question-banks/conflicts/resolve-batch` |
| 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` |
| 资料/视频运营审计 | `GET /api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` |
| 平台权限目录 | `GET /api/platform-admin/permissions` |

## 资料、PDF 和视频资源契约

前端必须把 `content_assets` 当成资源唯一台账。学生端资料、PDF 预览和题目视频播放都不能直接拼接私有 OSS/COS/Supabase Storage URL，也不能把后台配置的 `cdnUrl` 持久缓存成长期可访问地址。

学生端资料流程：

1. 列表页调用 `GET /api/catalog/assets`，只展示后端返回的 active 资源。
2. 预览 PDF/图片时调用 `GET /api/catalog/assets/preview?assetId=...`。
3. 下载资料时调用 `GET /api/catalog/assets/download?assetId=...`。
4. 使用响应里的 `preview.url` 或 `download.url` 立即打开；不要写入本地长期缓存。若响应包含 `watermark.required=true`，必须先渲染可见水印覆盖层，再打开或展示签名资源。

签名有效期规则：

- 学生 inline 预览、SVIP/会员资料、视频和资料包通常只有 300 秒左右有效期。
- 后台预览有效期也不是永久 URL，租户后台应在用户点击时重新请求签名。
- 响应里的 `expiresInSec/expiresAt/signatureMode` 只用于 UI 提示和排查，不要自行延长有效期。

动态水印响应：

```json
{
  "watermark": {
    "mode": "visible_overlay",
    "required": true,
    "text": "仅限本人学习 账号:AB12CD34 7D2A9C3E1B0F",
    "traceId": "7D2A9C3E1B0F",
    "position": "diagonal",
    "opacity": 0.16,
    "repeat": true,
    "expiresAt": "2026-06-29T10:00:00.000Z",
    "renderHint": "render_visible_overlay_before_opening_signed_url"
  }
}
```

前端处理规则：

- `mode=visible_overlay` 时，PDF/图片预览、H5 视频播放器和资料打开页都要显示覆盖水印。
- 水印必须包含 `text` 和 `traceId`，不能只显示品牌名。
- `repeat=true` 建议做斜向重复水印；`position=bottom-right` 或 `center` 可作为单水印模式。
- 不要把 `traceId` 当隐私信息隐藏；它是外泄追踪码，会同步写入后端访问事件。
- 小程序端如果原生 PDF/video 组件覆盖层能力受限，应使用自定义容器包裹组件，至少在可视区域显示固定水印和 traceId。
- 当前 `apps/taro/src/pages/student/assets/index.tsx` 已按该契约接入：预览和下载都先向后端申请短期签名，页面展示过期时间、签名模式、`watermark.traceId` 和可见水印。若资源要求 `watermark.required=true`，H5 预览不提供脱离水印容器的外部打开入口；下载会先展示水印确认面板，再由用户确认打开/复制签名链接。小程序端如无法保证原生组件覆盖层，应提示使用 H5 资料页或只展示水印确认，不直接嵌入私有文件。

锁定资源 CDN 规则：

- `visibility=members/svip/private` 的外部 `cdnUrl` 默认会被后端拒绝，返回 `ASSET_CDN_ACCESS_NOT_ALLOWED`。
- 只有后台明确登记 `metadata.providerManagedAccess=true` 或 `metadata.cdnAccessMode='signed_by_provider'`，后端才允许把外部 URL 作为 provider-managed 资源返回。
- 商用环境更推荐把锁定资料登记为 `objectKey`，由后端生成 OSS/COS/Supabase Storage 私有签名 URL。

租户后台排查：

```text
GET /api/tenant-content/assets/access-events?assetId=<assetId>&limit=100
```

该接口返回资源访问事件，包括学生下载、学生预览、后台下载、后台预览、上传签名、上传确认以及 denied 原因。租户后台可以在资源详情页增加“访问记录/异常记录”面板。

访问事件 `metadata.watermark.traceId` 可用于后台按截图上的追踪码回查访问记录。视频播放不走 `content_asset_access_events`，但 `POST /api/videos/play` 返回同样的 `watermark` 对象，后端会把 traceId 写入 `video_play_events.metadata.watermark.traceId`。

租户后台运营报表：

```text
GET /api/tenant-content/media-analytics/summary?timeRange=30d&limit=10
GET /api/tenant-content/media-analytics/asset-events?traceId=7D2A9C3E1B0F&limit=100
GET /api/tenant-content/media-analytics/video-events?videoId=<videoId>&limit=100
```

这些接口用于租户后台资料/视频运营面板，权限为 `content:analytics:read`，租户 owner/admin/operator 默认可访问。普通教师默认不可见，除非绑定了带该权限的角色模板。

前端展示建议：

- `summary.assetAccess` 展示下载、预览、拒绝访问、水印事件数。
- `summary.videoPlay` 展示视频播放、SVIP 播放、次数播放、消耗次数。
- `assetTop` / `videoTop` 做热门资料和热门视频排行。
- `daily` 做资料访问和视频播放趋势。
- 搜索框支持输入截图上的 `traceId`，同时请求 `asset-events` 和 `video-events` 回查用户、时间、IP、UA、资源或视频。
- 这些接口不会返回签名 URL、播放 token、云厂商密钥或支付密钥；前端不要把它们当成下载/播放接口。

## 练习访问控制契约

前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时，统一调用 `POST /api/learning/practice-sessions`，后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。

请求示例：

```json
{
  "mode": "sequential",
  "collectionId": "00000000-0000-0000-0000-000000000615",
  "questionLimit": 50
}
```

响应关键字段：

```json
{
  "item": {
    "id": "...",
    "mode": "sequential",
    "questionIds": ["..."],
    "questionCount": 25,
    "accessMode": "free",
    "consumedFreeQuota": 25,
    "accessSnapshot": {
      "grantedBy": "free_quota",
      "requestedCount": 50,
      "grantedCount": 25,
      "truncated": true,
      "dailyLimit": 25
    }
  }
}
```

前端处理规则：

- 以返回的 `questionIds` 为准渲染本次练习，不要自行追加题目。
- `accessSnapshot.truncated=true` 时，可提示“今日免费额度有限，已为你开放 N 题”并引导开通 SVIP。
- `PRACTICE_FREE_LIMIT_REACHED`：弹出会员购买/激活码兑换入口。
- `PRACTICE_SVIP_REQUIRED`：提示该内容需要对应地区/科目/题库 SVIP。
- `PRACTICE_SESSION_QUESTION_FORBIDDEN`：说明提交答案的题目不在本次 session 快照内，应清理本地异常进度并重新开始。
- 提交答案必须传 `practiceSessionId`；后端会拒绝不属于本人有效 session 的题目。

### 题干、解析和知识手册富文本

学生端已经新增统一渲染组件：

```text
apps/taro/src/components/RichContent.tsx
apps/taro/src/components/rich-content.css
```

官方参考：

- KaTeX options：`https://katex.org/docs/options`
- Taro RichText：`https://docs.taro.zone/en/docs/components/base/rich-text`

当前接入页面：

```text
pages/student/practice/index      题干、选项、子题、参考答案、解析
pages/student/reports/index       逐题复盘、子题明细、参考答案、解析
pages/student/handbook/index      知识点摘要和正文
```

第一版支持：

- 纯文本和换行。
- Markdown 图片 `![alt](https://...)`、站内 `/...` 路径，或私有资源引用 `asset:<uuid>`、`content_asset:<uuid>`、`/asset/<uuid>`。
- 基础表格。
- `**加粗**`、行内代码和代码块。
- H5 端用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]`；渲染失败时降级显示公式原文。
- 长题干、长单词、长公式自动换行或横向滚动。

安全边界：

- 组件会剥离 `<script>`、`<style>` 和普通 HTML 标签，不执行后端或导入内容中的 HTML/JS。
- 图片只允许 HTTPS、站内相对路径、本地开发 localhost HTTP，或明确的 `content_assets` 资源 ID 引用；拒绝 `javascript:`、`data:`、协议相对 URL 等危险来源。
- 私有题图通过 `GET /api/catalog/assets/preview?assetId=...` 申请短期签名后展示，不把私有 OSS/COS/Supabase Storage URL 长期写进题干或本地缓存。
- 公式 HTML 只来自 KaTeX `renderToString`，不要把题库导入的原始 HTML 直接交给 `RichText`。小程序端还要真机验收 KaTeX 生成 HTML 的兼容性；如兼容性不足，保持同一 parser，替换为服务端公式图片或小程序专用公式组件。

### 断点续练

Taro 可以缓存当前题号和答题卡用于刷新恢复体验，但跨设备、清缓存、小程序重启后的权威恢复必须调用：

```text
GET /api/learning/practice-sessions/detail?practiceSessionId=<sessionId>
```

响应会返回：

```json
{
  "item": {
    "id": "...",
    "status": "active",
    "questionIds": ["..."],
    "questions": [],
    "answersByQuestion": {
      "<questionId>": {
        "selectedOptions": ["1"],
        "answerText": null,
        "answerPayload": {
          "mode": "composite",
          "subAnswers": [],
          "subResults": []
        },
        "isCorrect": true,
        "answeredAt": "..."
      }
    },
    "expiresAt": "..."
  }
}
```

前端处理规则：

- 继续练习入口优先从 `GET /api/learning/practice-sessions/history?status=active` 获取未完成 session，再带 `practiceSessionId` 进入练习页。
- 练习页如果 URL 有 `practiceSessionId`，先调用 detail 恢复后端题目快照和最新答案，不要新建 session。
- `answersByQuestion` 是同一题的最新答题记录，答题卡、正确/错误统计和解析展示以它为准。
- 阅读理解、案例分析等复合题会在 `answerPayload.subAnswers/subResults` 中返回子题作答、判分、解析和分值；继续练习时按该结构恢复每个子题状态。
- 倒计时以 `expiresAt` 计算剩余时间；不要用本地启动时间重新生成考试时长。
- detail 只返回当前用户自己的 session；跨用户或跨租户读取会返回 `PRACTICE_SESSION_NOT_FOUND`。

## 提交答案契约

客观题、主观题都统一调用：

```text
POST /api/learning/answers
```

单选/判断题示例：

```json
{
  "practiceSessionId": "...",
  "questionId": "...",
  "selectedOptions": ["1"]
}
```

多选题示例：

```json
{
  "practiceSessionId": "...",
  "questionId": "...",
  "selectedOptions": ["0", "2"]
}
```

填空、简答、翻译、案例分析等无客观选项的主观题，前端可以先展示参考答案，再让学生自评：

```json
{
  "practiceSessionId": "...",
  "questionId": "...",
  "answerText": "学生自己的作答或备注",
  "selfJudgedCorrect": true
}
```

阅读理解、案例分析、组合题等带 `subQuestions` 的复合题必须使用 `subAnswers`，不能混用顶层 `selectedOptions/answerText/selfJudgedCorrect`：

```json
{
  "practiceSessionId": "...",
  "questionId": "...",
  "subAnswers": [
    {
      "subQuestionId": "main-idea",
      "selectedOptions": ["1"]
    },
    {
      "subQuestionId": "reason",
      "answerText": "学生自己的作答或备注",
      "selfJudgedCorrect": true
    }
  ]
}
```

复合题响应会额外返回：

```json
{
  "item": {
    "isCorrect": true,
    "answerPayload": {
      "mode": "composite",
      "subAnswers": [
        { "subQuestionId": "main-idea", "selectedOptions": ["1"], "answerText": null },
        { "subQuestionId": "reason", "selectedOptions": [], "answerText": "学生自己的作答或备注", "selfJudgedCorrect": true }
      ],
      "subResults": [
        {
          "subQuestionId": "main-idea",
          "order": 1,
          "type": "choice",
          "selectedOptions": ["1"],
          "isCorrect": true,
          "correctOptionIndices": [1],
          "explanation": "..."
        }
      ],
      "summary": {
        "answeredCount": 2,
        "correctCount": 2,
        "wrongCount": 0,
        "unansweredCount": 0
      }
    },
    "subResults": []
  }
}
```

响应关键字段：

```json
{
  "item": {
    "id": "...",
    "questionId": "...",
    "selectedOptions": [],
    "answerText": "学生自己的作答或备注",
    "isCorrect": true,
    "answeredAt": "2026-06-29T00:00:00.000Z",
    "selfJudged": true
  }
}
```

前端处理规则：

- 客观题不要传 `selfJudgedCorrect`。后端会用题库标准答案判分，传了会返回 `SELF_JUDGMENT_NOT_ALLOWED`。
- 客观子题同样不要传 `selfJudgedCorrect`；主观子题可以传 `selfJudgedCorrect`。
- 复合题如果缺少 `subAnswers` 会返回 `SUB_ANSWERS_REQUIRED`；空提交会返回 `SUB_ANSWERS_EMPTY`；未知子题 id 会返回 `UNKNOWN_SUB_ANSWER`。
- 主观题自评也由后端落库为 `answer_records.is_correct`，错题本、练习统计、模考报告都以后端返回为准。
- 前端可以在本地缓存当前 session 的答题卡和当前题号，用于刷新恢复体验；但交卷报告只以后端 `answer_records` 和 session 快照计算。
- `answerText` 只保存学生作答或备注，不要为了让后端判对而把参考答案塞进去。
- 重复答题时，报告会取同一题最新一条 `answer_records`，页面应以最近一次提交结果展示。

## 学生端支付与售后契约

学生端 `pages/student/checkout/index` 和 `pages/student/order-detail/index` 已接第一版。前端只传递套餐、地区、优惠券和支付 provider；最终金额、优惠抵扣、订单状态、支付记录、权益发放都以后端返回为准。

推荐流程：

1. `GET /api/catalog/svip-plans` 加载可购买套餐。
2. 如有优惠券，先调 `POST /api/commerce/coupons/claim`，仅用于领取、占用一个未核销 redemption 和展示预计抵扣。
3. 调 `POST /api/commerce/orders` 创建订单，后端会重新计算最终金额和抵扣。
4. 非零元订单调 `POST /api/commerce/payments/create` 获取支付参数。
5. H5 支付可跳转 provider 返回的 URL；微信小程序支付用 provider 返回参数调用 `Taro.requestPayment`。
6. 支付后调 `/api/commerce/orders/status` 轮询状态，已支付订单的权益由后端 webhook/补偿 worker 幂等发放。

普通学生端不直接调用退款接口。退款申请、审核、供应商退款、全额退款权益撤销均在租户后台权限流中完成；学生端只展示订单详情、状态和售后联系入口。

## 勋章

学生个人中心或学习成就页调用：

```text
GET /api/profile/badges?includeLocked=true&category=practice
```

说明：

- `includeLocked=true` 时返回已解锁和未解锁勋章；不传时只返回已解锁。
- `category` 可选：`learning`、`practice`、`vocabulary`、`mock_exam`、`activity`、`feedback`、`sales`、`system`、`custom`。
- 前端只展示后端返回的 `unlocked/grantId/grantedAt`，不要在本地自行认定用户已经获得勋章。

租户后台勋章管理：

```text
GET  /api/tenant-admin/badges?category=practice&includeInactive=true
PUT  /api/tenant-admin/badges
GET  /api/tenant-admin/badge-grants?userId=...&badgeId=...
POST /api/tenant-admin/badge-grants
```

`PUT /api/tenant-admin/badges` 支持同租户内 `legacyId` 幂等更新；如果 `id` 与 `legacyId` 指向不同记录会返回 `BADGE_ID_CONFLICT`。`POST /api/tenant-admin/badge-grants` 对同一用户同一勋章幂等，不会重复生成多条发放记录。

当前后端已支持第一批自动发放规则：

| unlockType | 推荐 conditionField | 触发时机 |
| --- | --- | --- |
| `check_in` | `checkInStreak` | `POST /api/profile/check-in` 真实签到成功后 |
| `score` | `score` | 签到加分、反馈奖励或积分活动奖励成功后 |
| `feedback_resolved` | `feedbackResolvedCount` | 租户后台把反馈处理为 `resolved` 后 |
| `activity_reward` | `activityRewardCount` 或 `score` | 学生领取积分活动任务且后端证据校验通过后 |
| `practice_count` | `practiceCount` 或 `practiceReportCount` | `POST /api/learning/practice-sessions/submit` 生成练习报告后 |
| `vocabulary_mastered` | `vocabularyMasteredCount` 或 `masteredWordsCount` | `POST /api/learning/vocabulary/progress` 或 `POST /api/learning/vocabulary/review` 使单词状态达到 `mastered` 后 |
| `mock_exam_score` | `mockExamScore`、`mockExamAccuracy` 或 `mockExamBestScore` | `POST /api/learning/practice-sessions/submit` 提交 `mock_exam` 报告后 |

规则使用 `conditionOperator` 的合法值 `gte`、`gt`、`lte`、`lt`、`eq`；`conditionValue` 为数字。触发成功的接口会返回 `autoBadges`，前端可据此弹出“获得勋章”提示；如果是重复签到、重复处理反馈、重复交卷、重复单词复习或已获得过同一勋章，后端不会重复返回同一发放记录。

前端接入注意：

- 刷题/模考交卷、单词进度更新、签到、反馈处理、积分任务领取都可能返回 `autoBadges`；统一交给一个成就提示组件处理。
- 勋章是否已解锁仍以 `GET /api/profile/badges` 为准，`autoBadges` 只用于即时提示和刷新个人中心。
- 租户后台配置自动勋章时，只展示上表中的 `unlockType` 和推荐字段，避免运营输入任意字段导致规则永远无法命中。

### 模考交卷与报告

全真模拟、试卷模式、顺序练习的最终报告都走后端交卷接口。前端不得传分数、正确数或题目范围；后端只信任 `practice_sessions.question_ids` 快照和 `answer_records` 最新答题记录。

交卷请求：

```json
{
  "practiceSessionId": "00000000-0000-0000-0000-000000000000"
}
```

响应关键字段：

```json
{
  "item": {
    "id": "...",
    "practiceSessionId": "...",
    "mode": "mock_exam",
    "totalQuestions": 3,
    "answeredCount": 2,
    "correctCount": 1,
    "wrongCount": 1,
    "unansweredCount": 1,
    "score": 2,
    "totalScore": 100,
    "accuracy": 0.3333,
    "sectionStats": [
      {
        "key": "choice",
        "title": "单选题",
        "questionCount": 3,
        "correctCount": 1,
        "score": 2,
        "totalScore": 6
      }
    ],
    "wrongQuestionIds": ["..."],
    "questionResults": [
      {
        "questionId": "...",
        "sectionKey": "choice",
        "answered": true,
        "isCorrect": false,
        "score": 0,
        "totalScore": 2,
        "selectedOptions": ["0"],
        "correctOptionIndices": [1],
        "explanation": "..."
      }
    ]
  }
}
```

复合题报告规则：

- `totalQuestions` 仍按顶层大题计数，阅读理解/案例分析不会按子题拆成多题。
- `questionResults[].subResults` 返回每个子题的 `selectedOptions/answerText/isCorrect/explanation/score/totalScore`。
- 若导入数据没有给子题分值，后端默认把该大题分值平均分给所有子题；如果后续导入模板提供子题 `score`，报告会按子题分值再缩放到大题配置分。
- 顶层 `isCorrect=true` 表示所有子题都判为正确；若部分正确，顶层为 `false`，但 `score` 会保留部分得分。

前端处理规则：

- 重复交卷是幂等的，后端会返回同一份报告。
- 报告页刷新时调用 `GET /api/learning/practice-sessions/report?practiceSessionId=...`。
- 个人中心/模考历史调用 `GET /api/learning/practice-reports?mode=mock_exam&limit=20`，也可以传 `blueprintId` 筛选某套模拟卷。
- `score` 是逐题得分合计；`totalScore` 保留后台配置的卷面总分。测试或预发数据题量不足时，两者不一定按百分制等比换算，前端展示时不要自行重算。
- 错题复盘优先使用 `wrongQuestionIds` 和 `questionResults`，题目详情仍可按现有题目接口或 session 快照加载。

### 练习历史、统计和错题复习

个人中心和学习报告页优先使用后端聚合接口，不要让前端遍历全部答题记录自行统计。

接口用途：

| 页面/组件 | 接口 | 说明 |
| --- | --- | --- |
| 练习历史列表 | `GET /api/learning/practice-sessions/history?limit=20` | 返回 session、报告、已答数量、正确数、状态 |
| 学习概览卡片 | `GET /api/learning/stats?days=30` | 返回总答题、正确率、报告数、错题数、收藏数、题型分布 |
| 正确率趋势图 | `GET /api/learning/trend?days=14` | 返回每日答题数、正确数、错题数、session 数、报告数 |
| 错题复习入口 | `GET /api/learning/wrong-questions/review-plan?limit=20` | 返回建议复习题和后端组卷 nextAction |
| 排行榜 | `GET /api/learning/leaderboard?metric=questions&period=7d&regionId=...&classId=...` | 仅租户 `features.enableLeaderboard=true` 时请求；返回排名、用户展示信息、当前用户排名、范围信息和 `avatarPreset` 默认头像路径 |

错题复习创建 session：

```json
{
  "mode": "wrong_review",
  "questionLimit": 20
}
```

前端处理规则：

- 不要把错题 ID 列表从前端传回后端组卷；`wrong_review` 会由后端按当前用户错题本安全组卷。
- `review-plan.nextAction` 可直接用于按钮配置，但仍需使用当前登录 session 调用。
- 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`，body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`。
- 趋势图以接口返回日期桶为准，缺失日期后端会补 0，不需要前端补点。

### 排行榜

排行榜由后端统一聚合，但当前产品默认关闭，租户 `feature_flags.enableLeaderboard` 未开启时接口返回 `LEADERBOARD_DISABLED`。前端默认不要在个人中心请求或展示排行榜；只有租户明确开启并完成压测后，再进入独立排行榜页或活动页。前端不要读取答题记录、单词进度或模考报告后自行排名，避免越权、口径漂移和跨租户数据泄露。

可选参数：

| 参数 | 可选值 | 说明 |
| --- | --- | --- |
| `metric` | `questions`、`score`、`vocabulary`、`mock_exam` | 分别表示累计答题、积分、掌握单词、模考最高分 |
| `period` | `all`、`7d`、`30d` | 统计周期 |
| `regionId` | UUID | 地区范围，可选 |
| `classId` | UUID | 班级范围，可选，后端按当前租户校验 |
| `limit` / `page` | 正整数 | 分页 |

响应会包含 `items` 和 `currentUser`。即使当前用户未进入前 N 名，也应优先展示 `currentUser` 作为“我的排名”。排行榜用户头像只返回系统默认头像路径和 `avatarPreset`，前端不能尝试展示微信/QQ 头像或旧库头像 URL。日/周榜预聚合、防刷策略和运营看板不是默认待办，只在租户显式购买/开启排行榜活动并完成压测后再进入开发，前端只消费后端接口返回口径。

### 租户数据看板

租户后台数据看板由后端统一聚合，前端不要直接读取订单、答题记录、学生列表后自行统计，避免权限越权、敏感信息外泄和各页面口径不一致。

请求：

```http
GET /api/tenant-admin/dashboard?timeRange=30d&regionId=<可选地区ID>&limit=10
```

可选参数：

| 参数 | 可选值 | 说明 |
| --- | --- | --- |
| `timeRange` | `7d`、`30d`、`90d` | 统计区间，默认 `30d` |
| `regionId` | UUID | 可选地区筛选，后端会校验地区属于当前租户 |
| `limit` | 1-50 | 题型、科目、地区、套餐和运营动态的返回条数 |

响应主要结构：

```json
{
  "item": {
    "scope": {
      "tenantId": "...",
      "regionId": "...",
      "timeRange": "30d",
      "timezone": "Asia/Shanghai"
    },
    "cards": {
      "students": {},
      "learning": {},
      "content": {},
      "activationCodes": {},
      "feedback": {}
    },
    "paymentStats": {},
    "trends": [],
    "activeHours": [],
    "questionDistribution": [],
    "subjectTop": [],
    "regionStats": [],
    "planSales": [],
    "recentActivities": []
  }
}
```

前端处理规则：

- 管理台菜单显示可按 `/api/tenant-admin/permissions` 的 `dashboard:read` 判断，但真正权限以后端返回为准。
- `trends` 已补齐自然日桶，`activeHours` 固定 24 项，前端不需要补点。
- `revenueCents`、`amountCents` 都是分，前端统一格式化成人民币展示，不要自行重算订单金额。
- `recentActivities.details` 只包含可展示的低敏汇总信息，不包含手机号、支付密钥、对象存储 key 等敏感字段。
- 大租户正式上线后会补预聚合 worker，前端不应依赖任何临时 SQL 口径或自己维护缓存口径。

### 销售/代理分佣结算

分佣结算由后端统一计算，前端不要读取订单、激活码或客资后自行算佣金。当前后端已支持订单和激活码两类来源，并且只统计客资首绑保护后的成交，避免后绑抢单。

常用接口：

| 页面/动作 | 接口 | 权限 |
| --- | --- | --- |
| 查看租户分佣设置 | `GET /api/commission/settings` | `commission:read` |
| 修改默认分佣设置 | `PUT /api/commission/settings` | `commission:write` |
| 设置销售/代理个人比例 | `PUT /api/commission/member-rate` | `commission:write` |
| 分佣汇总 | `GET /api/commission/summary?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `commission:read` 或 `commission:self` |
| 分佣来源明细 | `GET /api/commission/orders?...` | `commission:read` 或 `commission:self` |
| 销售/代理转化报表 | `GET /api/referral/conversion-report?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD&referrerUserId=...` | `referral:read` 或 `referral:self` |
| 结算单列表 | `GET /api/commission/settlements?...` | `commission:read` 或 `commission:self` |
| 导出结算明细 | `GET /api/commission/settlements/export?settlementId=...&format=csv` | `commission:read` 或 `commission:self` |
| 生成结算单 | `POST /api/commission/settlements/generate` | `commission:write` |
| 审核/打款状态 | `POST /api/commission/settlements/status` | `commission:review` |
| 查看凭证 | `GET /api/commission/settlements/proofs?settlementId=...` | `commission:read` 或 `commission:self` |
| 登记凭证 | `POST /api/commission/settlements/proofs` | `commission:review` |
| 凭证复核 | `POST /api/commission/settlements/proofs/status` | `commission:review` |

金额字段统一为分：

```text
grossAmountCents
commissionAmountCents
minSettlementCents
```

比例字段统一为 0 到 1 的数字：

```text
defaultRate = 0.2
commissionRate = 0.35
```

结算状态：

```text
draft -> pending_review -> approved -> paid
pending_review -> rejected/cancelled
approved -> cancelled
```

前端处理规则：

- 销售/代理默认只有 `commission:self`，只能查看自己的分佣；租户运营/管理员拥有 `commission:read` 才能查看全局。
- `startDate/endDate` 使用 `YYYY-MM-DD`，后端按 `Asia/Shanghai` 业务日计算账期。
- 分佣比例优先级由后端处理：激活码批次比例 > 成员个人比例 > 租户默认比例。
- `sourceType=order` 表示学生订单；`sourceType=activation_code` 表示激活码兑换。
- 已进入结算单的来源会返回 `settlementId/settlementStatus`，前端不要重复发起生成。
- 已打款结算单不可再修改状态；遇到 `COMMISSION_SETTLEMENT_LOCKED` 展示“已打款，不可变更”。
- `COMMISSION_NO_UNSETTLED_SOURCES` 表示当前账期无未结算来源，不是系统异常。
- 导出接口返回 `contentBase64/sha256/filename/mimeType`，H5 可转成下载，微信小程序端建议后续使用文件系统保存；前端不要直接查询 `commission_settlement_items` 拼文件。
- 凭证支持 `assetId` 或 `externalUrl`。如果使用 `assetId`，必须先通过资料/对象存储台账上传凭证，后端会校验资源属于当前租户；`externalUrl` 只接受 http/https。
- `referrerUserId` 是受首绑保护的推广/分佣归属；CRM 的 `assignedToUserId` 只是跟进负责人，不能作为分佣结算依据。
- 当前版本支持线下打款状态登记、结算导出、凭证登记和凭证复核；真实银行/微信/支付宝打款 provider、发票和批量凭证上传后续增强。

### 销售/代理转化报表

销售/代理转化报表由后端聚合客资、订单、激活码、CRM 队列和跟进任务，前端不要自行读取这些表后拼统计。接口：

```http
GET /api/referral/conversion-report?startDate=2026-06-01&endDate=2026-06-30&referrerUserId=<可选>&limit=30
```

返回结构包含：

```text
item.range
item.summary
item.funnel
item.byReferrer
item.dailyTrend
item.recentUnconvertedLeads
```

前端展示建议：

- 租户管理员、运营等有 `referral:read` 的账号可看全局或指定成员；销售/代理通常只有 `referral:self`，后端会强制只返回本人数据。
- `summary.leadCount/convertedLeadCount/paidSourceCount/grossAmountCents/commissionAmountCents/crmFailedCount/openFollowupCount` 可作为顶部指标。
- `funnel` 直接展示触达、客资、已分配、有效成交、成交来源漏斗，不要在前端重新计算。
- `byReferrer` 展示成员排行，`conversionRate/paidLeadRate` 是 0 到 1 的比例。
- `recentUnconvertedLeads` 用于运营跟进，但只能展示后端返回字段；不要把它当成修改客资归属的入口。
- 区分 `referrerUserId` 和 `assignedToUserId`：前者是首绑保护下的推广/分佣归属，后者只是 CRM 跟进负责人。
- 大租户上线后会补日/小时预聚合；前端只消费该接口，不依赖当前 SQL 的中间口径。

### 背单词计划与复习上报

背单词页面分三类数据：单元列表、每日计划、单词进度。前端不需要计算下次复习日期，只提交“认识/不认识”，由后端统一更新 `nextReviewDate`、连续正确、掌握状态和每日复习计划。

当前 Taro 页面：

```text
apps/taro/src/pages/student/vocabulary/index.tsx
apps/taro/src/services/pronunciation.ts
```

已支持三种队列：

- `今日计划`：调用 `review-plan`，优先学习后端计划中的待复习/新词。
- `单元学习`：调用 `vocabulary-words`，用于按单元顺序学习。
- `收藏练习`：调用 `vocabulary/favorites`，用于复习本人收藏单词。

页面交互已包含学习概览、掌握率、已开始/未开始、学习中、今日计划拆分、收藏数、卡片翻转、上一个/下一个、单词列表跳转、发音、美/英音切换、收藏/取消收藏和按 `unitId + mode` 保存本地当前位置。权威掌握状态仍以后端 `vocabulary/review` 返回和后续统计为准。

取今日计划：

```http
GET /api/learning/vocabulary/review-plan?unitId=<unitId>&reviewLimit=30&newLimit=20
```

响应关键字段：

```json
{
  "item": {
    "dueCount": 3,
    "newCount": 20,
    "totalPlanned": 23,
    "dueWords": [{ "wordId": "...", "status": "reviewing", "dueLevel": "soon" }],
    "newWords": [{ "wordId": "...", "status": "new", "dueLevel": "new" }],
    "words": []
  }
}
```

上报单词复习结果：

```json
{
  "wordId": "00000000-0000-0000-0000-000000000812",
  "result": "known"
}
```

`result` 可传：

- `known`：认识/答对。
- `unknown`：不认识/答错。

前端处理规则：

- `review-plan.words` 是本轮学习队列；卡片翻转、上一个、跳转、收藏状态属于前端交互。
- 每个单词点击“认识/不认识”后调用 `POST /api/learning/vocabulary/review`。
- 返回的 `status`、`dueLevel`、`nextReviewDate` 作为后续展示依据，不在前端重算间隔。
- 收藏列表调用 `GET /api/learning/vocabulary/favorites?unitId=<unitId>`；收藏/取消收藏调用 `POST /api/learning/vocabulary/favorites`。
- 发音当前使用前端 `services/pronunciation.ts`：H5 优先播放有道 dictvoice HTTPS 音频并用 Web Speech 兜底，小程序优先使用 `Taro.createInnerAudioContext`。这里不保存任何密钥；如果后续租户需要自定义发音源，应改为后端返回可配置的公开 provider URL 或资源台账引用。
- 旧的 `POST /api/learning/vocabulary/progress` 保留给兼容和后台手工修正；普通学习流优先用 `vocabulary/review`。

## 视频播放契约

题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数；除免费公开视频外，列表和搜索接口不会返回可播放 URL。

播放步骤：

1. 进入题目页后调用 `GET /api/questions/{questionId}/videos` 或批量预加载 `POST /api/questions/videos/batch`。
2. 用户点击播放时调用 `POST /api/videos/play`。
3. 后端校验当前 session 用户、租户、题目绑定关系、SVIP 权益或视频次数权益。
4. 后端返回短期签名 URL、播放 token、权益来源和过期时间。
5. 前端播放器只使用本次返回的 `playback.url`，不要缓存为长期资源地址。
6. 播放器开始、周期心跳和播放完成时调用 `POST /api/videos/progress` 上报进度。

请求示例：

```json
{
  "videoId": "00000000-0000-0000-0000-000000000821",
  "questionId": "00000000-0000-0000-0000-000000000401"
}
```

响应关键字段：

```json
{
  "item": {
    "id": "...",
    "title": "...",
    "accessMode": "svip",
    "freePreviewSeconds": 15
  },
  "playToken": "vp_...",
  "playback": {
    "url": "https://...",
    "expiresAt": "2026-06-28T12:00:00.000Z",
    "signatureMode": "signed"
  },
  "access": {
    "mode": "svip",
    "entitlementId": "...",
    "quotaAccountId": null,
    "consumedQuota": 0
  }
}
```

前端处理规则：

- `VIDEO_SVIP_REQUIRED`：弹出开通或升级会员。
- `VIDEO_QUOTA_REQUIRED`：提示购买视频次数包或套餐。
- `VIDEO_ASSET_REQUIRED`：展示“视频暂不可播放”，同时上报前端日志。
- 签名 URL 过期后必须重新调用 `/api/videos/play`，不要重试旧 URL。
- 小程序/H5 不保存对象存储真实 key，不把播放 URL 写入本地持久缓存。
- `playToken` 只用于当前播放会话进度上报，不写入长期缓存，不暴露到页面 URL。
- H5 `video` 组件建议在 `play` 上报 `eventType=start`，每 15-30 秒或进度变化明显时上报 `heartbeat`，`ended` 或观看进度超过 90% 时上报 `complete`。

进度上报示例：

```json
{
  "playToken": "vp_...",
  "eventType": "heartbeat",
  "progressSeconds": 45,
  "watchedSeconds": 48,
  "durationSeconds": 90
}
```

后端会校验 `playToken` 必须属于当前登录用户和当前租户，其他用户不能拿 token 改播放状态。返回的 `item.playback` 会包含 `watchedSeconds`、`completionRate`、`startedAt`、`completedAt`，租户后台媒体运营报表会读取这些字段计算完成率和观看时长。

## 资料上传、预览和下载契约

学生端资料只读取目录、预览和下载签名，不接触对象存储真实密钥，也不自行拼接私有 bucket 地址。

学生端展示资料列表：

```http
GET /api/catalog/assets?assetType=pdf&regionId=<regionId>&includeLocked=true
```

学生端 PDF/图片预览：

```http
GET /api/catalog/assets/preview?assetId=<assetId>
```

学生端下载：

```http
GET /api/catalog/assets/download?assetId=<assetId>
```

前端处理规则：

- `preview.url` 是短期 inline URL，只给预览组件使用，不持久化。
- `download.url` 是短期 attachment URL，只给下载动作使用。
- `ASSET_SVIP_REQUIRED`：提示开通对应地区/科目权益。
- `ASSET_UPLOAD_NOT_VERIFIED`：展示“资料正在处理中”，并上报前端日志。
- `ASSET_SECURITY_SCAN_REQUIRED`：展示“资料安全扫描中，请稍后再试”，并重新拉取资源列表或提示后台处理。
- `ASSET_SECURITY_SCAN_FAILED`：展示“资料安全校验未通过，已下架”，学生端不要继续重试旧签名。
- `ASSET_NOT_FOUND` 或列表中资源从 `active` 消失：展示“资源异常已下架”或刷新列表，不要继续使用旧签名 URL。
- `ASSET_PREVIEW_NOT_SUPPORTED`：隐藏预览按钮，仅保留下载或提示不支持预览。
- `previewUrl` 字段只作为公开/托管预览提示，不代表可以绕过接口直接访问。

租户后台上传资料必须走六步：

```text
sign-upload -> 直传对象存储 -> PUT assets 登记草稿 -> confirm-upload -> 等待 assets worker 安全扫描 -> PUT assets 发布 -> sign-preview 验收
```

后台上传确认：

```json
{
  "assetId": "<assetId>",
  "fileSizeBytes": 4096,
  "mimeType": "application/pdf",
  "checksumSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "publish": true
}
```

托管对象在确认前会保持 `status=draft`、`uploadStatus=pending`、`securityScanStatus=pending`，学生端不会看到。确认成功后仍保持 `status=draft`，并进入 `uploadStatus=verified`、`securityScanStatus=pending`；即使传 `publish=true`，后端也不会直接发布。确认失败时后端返回 `UPLOAD_VERIFICATION_FAILED`，后台必须展示失败原因并允许重新上传，不能前端强行改为已发布。

后台资源列表建议展示这些状态：

| 状态 | 前端展示 | 可执行动作 |
| --- | --- | --- |
| `draft/pending/pending` | 待上传确认 | 重新上传、确认上传 |
| `draft/verified/pending` | 安全扫描排队中 | 刷新状态、查看扫描事件 |
| `draft/verified/scanning` | 安全扫描中 | 刷新状态、查看扫描事件 |
| `draft/verified/passed` | 可发布 | 发布、预览、下载 |
| `active/verified/passed` | 已发布 | 预览、下载、下架 |
| `draft/verified/failed` | 安全扫描失败 | 查看扫描事件、重新上传 |
| `draft/failed/skipped` | 上传复检失败 | 查看复检/扫描事件、重新上传 |

租户后台可通过下面接口排查扫描过程：

```http
GET /api/tenant-content/assets/security-scan-events?assetId=<assetId>&limit=100
```

`GET /api/tenant-content/assets` 和学生端 `GET /api/catalog/assets` 都会返回 `securityScanStatus`。前端可以展示状态，但最终能否下载、预览、播放仍以后端签名接口为准。

生产环境会定时运行 assets worker 复检对象存储元数据并执行内置 `metadata_rules` 安全扫描；上线配置应启用 `WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http`，由 worker 调用外部 HTTP 杀毒/内容安全服务。复检发现对象丢失、跨租户 objectKey、大小/MIME/checksum 不一致时，后端会把资源置为 `uploadStatus=failed`、`securityScanStatus=skipped` 并从 `active` 退回 `draft`，同时写入 `securityFlags.assetRecheckFailed=true`。扫描发现 MIME 不允许、扩展名/MIME 不匹配、外部 scanner 判定风险，或外部 scanner 不可用且 fail-closed 时，会把资源置为 `securityScanStatus=failed`，并写入 `securityFlags.assetSecurityScanFailed=true`。

前端处理规则：

- 租户后台资源列表应展示 `securityScanStatus=pending/scanning/failed/skipped/passed`，其中 `failed/skipped` 展示异常原因和重新上传入口。
- 租户后台不能用前端状态绕过发布；即使 UI 显示处理中，发布/下载/预览仍以后端接口返回为准。
- 学生端不要缓存资料列表和签名 URL 作为长期状态；每次预览/下载/播放前重新请求后端签名。
- 前端不调用外部扫描服务，也不接触 scanner endpoint/token；扫描证据只通过 `GET /api/tenant-content/assets/security-scan-events` 给后台排查。

## 考试倒计时、签到积分和反馈

首页可用 `GET /api/catalog/exam-dates?regionId=<regionId>` 展示地区公开考试日期；个人中心优先用 `GET /api/profile/exam-countdowns`，后端会按学生当前 `regionId/selectedSchoolId` 返回匹配倒计时。

## 个人中心预设头像

学生端不支持用户上传头像。系统只提供男女两种默认头像预设，避免头像上传带来的存储、内容安全、隐私和审核复杂度。前端不要展示文件选择器，不要调用对象存储上传链路，也不要把微信/QQ 返回的头像 URL 写入学生资料。后端登录链路和 PocketBase 导入脚本会丢弃第三方头像字段；租户后台学生创建、更新、批量导入和批量分班也会拒绝 `avatarUrl/avatar_url/avatar/headimgurl/figureurl` 等字段，学生展示头像必须来自 `avatarPreset`。

当前 Taro 学生个人中心已在 `apps/taro/src/pages/student/profile/index.tsx` 接入男女预设头像二选一 UI，保存时只调用 `PATCH /api/profile/me` 的 `avatarPreset` 字段。Taro 构建会把 `apps/taro/src/assets/avatars` 复制到 H5 产物的 `assets/avatars`，与后端返回的默认路径保持一致。

当前 Taro service 使用：

```text
apps/taro/src/services/profile.ts

loadProfile
updateProfile({ avatarPreset: 'male' | 'female' })
```

`GET /api/profile/me` 会返回：

```json
{
  "item": {
    "avatarPreset": "male",
    "avatar": {
      "preset": "male",
      "displayUrl": "/assets/avatars/default-male.svg"
    }
  }
}
```

前端展示规则：

- 直接使用 `item.avatar.displayUrl` 展示头像。
- 允许用户在 UI 中二选一切换 `male` / `female`，调用 `PATCH /api/profile/me` 的 `avatarPreset` 字段保存。
- `PATCH /api/profile/me` 传 `avatarUrl` 会返回 `PROFILE_AVATAR_URL_DIRECT_UPDATE_REJECTED`，前端不应提供该输入。
- `avatarPreset` 只能是 `male` 或 `female`；其它值会返回 `INVALID_AVATAR_PRESET`。
- `PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/classes/members/bulk-assign` 针对学生数据传头像字段会返回或逐行记录 `STUDENT_AVATAR_URL_UNSUPPORTED`；传 `primaryRole/primary_role` 会返回或逐行记录 `STUDENT_PRIMARY_ROLE_UNSUPPORTED`。
- 默认头像资源由前端静态资产或 CDN 提供，路径保持 `/assets/avatars/default-male.svg` 和 `/assets/avatars/default-female.svg`；当前仓库内置资源位于 `apps/taro/src/assets/avatars`。

签到入口调用：

```http
POST /api/profile/check-in
```

返回关键字段：

```json
{
  "item": {
    "checkedIn": true,
    "alreadyCheckedIn": false,
    "pointsAdded": 17,
    "streak": 1,
    "score": 17,
    "lastCheckInDate": "2026-06-29",
    "rewardMode": "configured_tasks",
    "rewardBreakdown": [
      {
        "taskId": "<daily-check-in-task-id>",
        "taskCode": "daily-check-in",
        "title": "每日签到基础奖励",
        "points": 12,
        "periodKey": "2026-06-29"
      },
      {
        "taskId": "<streak-task-id>",
        "taskCode": "check-in-streak-day-one",
        "title": "连续签到首日奖励",
        "points": 5,
        "periodKey": "2026-06-29"
      }
    ],
    "ledgers": [
      {
        "eventType": "check_in",
        "sourceType": "point_activity_tasks",
        "sourceId": "<daily-check-in-task-id>"
      }
    ],
    "claims": [
      {
        "taskId": "<daily-check-in-task-id>",
        "periodKey": "2026-06-29",
        "sourceType": "student_profiles"
      }
    ],
    "autoBadges": [
      {
        "badgeId": "...",
        "badge": {
          "name": "连续签到",
          "category": "activity",
          "iconUrl": "https://..."
        }
      }
    ]
  }
}
```

前端处理规则：

- `alreadyCheckedIn=true` 时展示今日已签到，不要本地再加分。
- 积分明细调用 `GET /api/profile/score-events`。
- 积分最终余额以后端 `score` 和流水为准，前端只做展示。
- 租户如配置了 `taskType=daily_check_in` 的积分任务，签到会返回 `rewardMode=configured_tasks` 和 `rewardBreakdown`；前端可展示每项奖励，但不要重新计算连续签到奖励。
- 租户未配置每日签到任务时，后端会使用兼容旧逻辑的固定积分公式，并返回 `rewardMode=legacy_formula`。
- 如响应包含 `autoBadges`，可展示获得勋章弹层；不要在前端根据连续天数或积分自行判断勋章是否解锁。

积分活动任务和积分兑换入口：

```http
GET  /api/profile/activity-tasks
POST /api/profile/activity-tasks/claim
GET  /api/profile/exchange-items
POST /api/profile/exchange-items/redeem
```

`POST /api/profile/activity-tasks/claim` 示例：

```json
{
  "taskId": "<taskId>",
  "sourceId": "<practiceSessionId 或 vocabularyReviewId，可选>",
  "idempotencyKey": "activity-claim-20260629-001"
}
```

`POST /api/profile/exchange-items/redeem` 示例：

```json
{
  "itemId": "<exchangeItemId>",
  "quantity": 1,
  "idempotencyKey": "exchange-20260629-001"
}
```

前端处理规则：

- 积分任务领取、积分扣减、库存扣减、个人限购、优惠券兑换履约都由后端事务处理。
- `practice_complete`、`mock_exam_submit`、`vocabulary_review` 等任务需要传 `sourceId`，后端会校验证据属于当前学生当前租户。
- `daily_check_in`、`feedback_resolved` 等系统流程任务禁止学生自领，前端应展示为“系统发放”或不展示领取按钮；签到只调用 `POST /api/profile/check-in`。
- 兑换返回 `order` 和最新积分余额后，再刷新 `profile/me`、`score-events` 和兑换列表；不要在本地先扣积分。
- 如果响应包含 `autoBadges`，按获得勋章提示处理；重复领取、库存不足、积分不足、限购等错误按后端 `code` 展示业务提示。

当前 Taro 第一版已经把积分任务、积分兑换和积分明细嵌入 `apps/taro/src/pages/student/profile/index.tsx`。个人中心只对 `manual` 且仍有领取次数的任务展示直接领取按钮；练习、单词、模考等需要证据的任务只展示状态，由对应学习流程携带后端 `sourceId` 触发，避免前端伪造学习完成证据。兑换商品会先按后端返回的库存、限购和当前积分做按钮状态提示，但最终仍以后端事务校验为准。

租户后台积分任务/兑换操作台：

```text
GET /api/tenant-admin/point-activity-tasks?status=active
PUT /api/tenant-admin/point-activity-tasks
GET /api/tenant-admin/point-activity-claims?taskId=<task-id>&userId=<student-id>
GET /api/tenant-admin/point-exchange-items?status=active
PUT /api/tenant-admin/point-exchange-items
GET /api/tenant-admin/point-exchange-orders?itemId=<item-id>&status=pending_fulfillment
GET /api/tenant-admin/points-risk-report?timeRange=30d&limit=20
```

积分风控报表是只读运营视角：

```json
{
  "item": {
    "range": { "timeRange": "30d", "startDate": "2026-06-01", "endDate": "2026-06-30" },
    "summary": {
      "eventCount": 120,
      "suspiciousUserCount": 3,
      "suspiciousEventCount": 4,
      "riskScore": 28
    },
    "suspiciousUsers": [],
    "suspiciousEvents": [],
    "suspiciousTasks": [],
    "suspiciousExchanges": []
  }
}
```

当前 Taro 第一版已经把租户积分运营嵌入 `apps/taro/src/pages/tenant-admin/marketing/index.tsx`，支持任务配置、兑换商品配置、任务领取记录、兑换订单查看和 30 天只读风控摘要。前端只提交配置和筛选条件，权限、优惠券/资源归属、库存、限购、积分扣减、风控阈值统计和审计仍由后端处理。积分风控只用于提示运营人工排查，不在前端直接冻结用户、扣回积分、取消权益或隐藏内容。

每日签到奖励配置复用同一组积分任务接口：

```json
{
  "code": "daily-check-in",
  "title": "每日签到基础奖励",
  "taskType": "daily_check_in",
  "rewardPoints": 12,
  "periodType": "daily",
  "metadata": {}
}
```

连续签到规则写在 `metadata`，后端按当前连续签到天数匹配，命中后自动写 `user_score_events` 和 `user_point_activity_claims`。常用字段：

```json
{
  "requiredStreak": 7,
  "minStreak": 3,
  "maxStreak": 30,
  "streakMultipleOf": 7
}
```

规则说明：`requiredStreak` 表示仅第 N 天命中，`minStreak/maxStreak` 表示连续天数区间，`streakMultipleOf` 表示连续天数为 N 的倍数时命中。多个 `daily_check_in` 任务可同时生效，后端会逐项发放并返回 `rewardBreakdown`。

题目页、资料页或视频页可提交反馈：

```json
{
  "questionId": "...",
  "type": "question_error",
  "category": "answer",
  "title": "题目解析有误",
  "description": "请填写具体问题",
  "attachments": []
}
```

前端处理规则：

- `questionId` 如存在，后端会校验题目必须属于当前租户。
- 反馈状态由租户后台处理，学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
- 租户后台处理反馈时，奖励积分由后端 `idempotency_key` 保证不会重复发放，前端不要重复叠加；状态处理响应可能返回 `autoBadges`。反馈状态、反馈奖励和自动勋章提醒会由后端写入学生站内通知，前端消息中心只负责展示和标记状态。

租户后台反馈运营报表：

```text
GET /api/tenant-admin/feedbacks/report?timeRange=30d&limit=20
```

`timeRange` 仅支持 `7d`、`30d`、`90d`。该接口是只读运营聚合，不会修改反馈、积分、勋章或通知：

```json
{
  "item": {
    "range": { "timeRange": "30d", "startDate": "2026-06-01", "endDate": "2026-06-30" },
    "summary": {
      "total": 18,
      "pendingBacklog": 4,
      "handledInRange": 12,
      "rewardPoints": 60,
      "handledRate": 0.6667,
      "avgHandleHours": 5.25
    },
    "byStatus": [],
    "byType": [],
    "byCategory": [],
    "topQuestions": [],
    "topHandlers": [],
    "recentUnhandled": []
  }
}
```

当前 Taro 租户营销中心已接 30 天反馈运营摘要。前端可以展示反馈积压、处理率、奖励积分、高优先级数量、高频题目和待处理反馈；不要在报表页直接改反馈状态，处理动作仍走 `POST /api/tenant-admin/feedbacks/status`。

## 用户站内通知

站内通知用于承接反馈处理、反馈奖励、勋章发放、积分兑换完成或待履约等学生可见事件。它不是权限来源，也不替代订单、权益、积分或勋章接口的真实状态。

学生端消息中心：

```text
GET /api/profile/notifications?status=unread&limit=50
GET /api/profile/notifications?notificationType=badge_granted
POST /api/profile/notifications/status
```

当前 Taro 第一版已经把学生消息中心嵌入 `apps/taro/src/pages/student/profile/index.tsx`：展示未读/已处理统计，支持全部、未读、已读、归档筛选，并允许学生把自己的通知标记为已读或归档。后续如果拆成独立消息中心页面，仍复用同一组接口，不要在前端根据通知 metadata 推导权益、积分或订单状态。

状态更新请求：

```json
{
  "notificationIds": ["<notification-id>"],
  "status": "read | dismissed | archived"
}
```

返回项包含：

```json
{
  "items": [
    {
      "id": "<uuid>",
      "notificationType": "badge_granted",
      "status": "unread",
      "severity": "success",
      "title": "获得新勋章",
      "message": "你获得了「连续签到」勋章。",
      "actionLabel": "查看勋章",
      "actionPath": "/student/profile?tab=badges",
      "sourceType": "badges",
      "sourceId": "<uuid>",
      "metadata": {},
      "readAt": null,
      "createdAt": "2026-06-29T00:00:00.000Z"
    }
  ],
  "summary": {
    "unread": 1,
    "read": 0,
    "dismissed": 0,
    "archived": 0
  }
}
```

租户后台通知查看：

```text
GET /api/tenant-admin/user-notifications?status=unread&limit=100
GET /api/tenant-admin/user-notifications?userId=<student-id>&notificationType=point_exchange_pending_fulfillment
```

- 租户后台需要 `notifications:read` 权限。
- 后台只能查看租户内用户通知，不代学生改已读状态。
- 前端可以按 `actionPath` 做站内跳转，但跳转后的页面仍要重新请求对应业务接口，不能信任通知 metadata 作为最终业务数据。
- 当前支持的通知类型包括 `feedback_status_updated`、`feedback_reward_granted`、`badge_granted`、`point_exchange_completed`、`point_exchange_pending_fulfillment`。
- 当前 Taro 第一版已经把租户用户通知查看嵌入 `apps/taro/src/pages/tenant-admin/marketing/index.tsx`，支持按状态、类型和学生用户 ID 筛选。该页面只读展示，不提供代学生标记已读、归档或删除的操作。

## 题库新模型接入方式

旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级，按下面模型渲染：

```text
content_entries
  -> content_nodes 任意深度分类树
    -> question_collections 题目列表/试卷/章节/题型集合
      -> practice_blueprints 顺序/随机/全真模拟规则
```

前端建议：

- `entryType=question_practice` 渲染为刷题入口。
- `entryType=vocabulary` 渲染为背单词入口。
- `entryType=handbook` 渲染为知识手册入口。
- `markerType=school` 或 `markerType=exam_track` 可作为学生目标院校/专业意向采集。
- 不同地区节点层级可以不同，页面组件必须支持递归树和面包屑。

## 多租户前端优化

- Logo、标题、主题色、客服信息全部来自 `tenant/resolve`。
- 功能开关控制菜单显示，但接口权限仍以后端为准。
- 私有图片、PDF、视频不要直接拼 URL，一律通过后端签名。
- 支付渠道从后端返回或租户配置读取，不在页面硬编码。
- 小程序分享路径必须带 tenantCode 和 referral code。
- 用户首绑归属由后端保护，前端不要提供“换绑销售”入口。
- 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions`、`current.templatePermissions`、`current.menuPermissions`、`current.modulePermissions` 渲染；接口权限仍以后端校验为准。
- 教师、班主任、助教类账号进入租户后台时，学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准；前端不要自行用本地班级 ID 放大查询范围。
- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示；字段被后端返回为 `null` 时前端展示脱敏占位，不要从其它接口补取。
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`，前端必须展示逐行错误，不要在浏览器端静默丢弃失败行。学生导入模板不要提供头像 URL 或 `primaryRole` 字段；后端会拒绝这类字段，学生头像统一由个人中心 `avatarPreset` 控制。
- 学生批量 CRM 推送统一调用 `POST /api/tenant-admin/students/crm-push`，请求包含 `studentUserIds/title/message/followupType/priority/assignedToUserId/classId/dueAt/idempotencyKey`。前端不要直接写 `crm_webhook_queue`，也不要持有 webhook 密钥；后端会校验 `crm:write`、学生范围、班级范围、指派人租户成员和幂等键，并返回逐行 `queueId/followupId/errors`。
- 学生跟进看板统一调用 `GET /api/tenant-admin/students/followups/report`，不要由前端直接读取 `tenant_student_followups` 或 `crm_webhook_queue` 后自行聚合。该接口支持 `timeRange=7d|30d|90d` 或 `startDate/endDate`、`assignedToUserId`、`followupType`、`classId` 和 `limit`，返回 `summary/crmQueue/byStatus/byType/byPriority/byAssignee/byClass/dailyTrend/overdueItems`；后端会沿用当前账号的租户权限、教师班级范围和手机号脱敏规则。
- 学习督导自动化统一走后端 `preview -> generate -> rules`：先调用 `GET /api/tenant-admin/students/supervision/preview` 预览候选学生和风险原因，再调用 `POST /api/tenant-admin/students/supervision/generate` 幂等生成 `learning` 跟进任务；需要周期执行时用 `GET/PUT /api/tenant-admin/students/supervision/rules` 查询或保存督导规则模板。规则支持 `manual/daily/weekly`、阈值配置、班级范围、负责人和每次生成上限，后端会为 scoped 教师/班主任强制班级范围；`apps/worker --job student-supervision` 会按启用规则定时生成跟进任务并写回 `lastResult/nextRunAt`。前端不要直接读取 `answer_records`、`wrong_questions`、`user_word_progress`、`practice_sessions`、`tenant_student_supervision_rules` 或 `tenant_student_followups.metadata` 自行计算风险或改规则状态；后端会统一执行 `students:supervision:read/write` 权限、教师班级范围、学生手机号字段脱敏和审计。
- 教师可以为范围内学生创建备注和跟进任务，但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定；前端只按返回值渲染。
- H5 自定义域名下要注意缓存隔离，不能把 A 租户主题缓存用到 B 租户。

## 租户内容导入对接

租户后台导入统一使用 preview -> issues -> import 流程，前端不要直接写 Supabase 表或绕过 `apps/api`。当前 JSON、CSV 和 Excel 都进入同一套后端规范化、逐行 issue、幂等和审计管线。

当前可联调：

```text
POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions
POST /api/tenant-content/imports/preview/vocabulary
POST /api/tenant-content/imports/vocabulary
POST /api/tenant-content/imports/preview/handbook
POST /api/tenant-content/imports/handbook
POST /api/tenant-content/imports/preview/scoreline
POST /api/tenant-content/imports/scoreline
POST /api/tenant-content/imports/preview/videos
POST /api/tenant-content/imports/videos
GET  /api/tenant-content/imports
GET  /api/tenant-content/imports/issues
GET  /api/tenant-content/imports/field-mapping
GET  /api/tenant-content/imports/templates
POST /api/tenant-content/imports/post-check
GET  /api/tenant-content/imports/post-check
```

前端流程：

1. 页面初始化调用 `field-mapping`，渲染字段说明、别名、必填项和示例。
2. 下载模板调用 `templates?importType=...&format=csv|json`，用 `contentBase64` 生成文件。
3. 上传或粘贴 JSON/CSV/Excel，先调用对应 preview。
4. 展示 `job.totalCount/validCount/errorCount/warningCount`。
5. 展示 `job.sourceFormat`、`job.parserMetadata`、逐行 `issues`，错误行必须让运营修正；如果后端允许 `allowPartial`，也要二次确认。
6. 小批量确认后直接调用 import；大批量确认时传 `executionMode=async` 排队，前端轮询 job 状态。
7. 导入进入 `completed/completed_with_errors` 后调用 `POST /api/tenant-content/imports/post-check`。
8. 展示 `summary.importPostCheck` 或 `GET /api/tenant-content/imports/post-check?jobId=...` 返回的复检结果，再刷新内容列表、分数线列表或题目视频列表。

当前 `apps/taro/src/pages/tenant-admin/content/index.tsx` 已接第一版 H5 操作台：可切换导入类型和 JSON/CSV/Excel 格式，选择本地文件或粘贴内容，预览/下载模板，编辑本次字段别名，调用后端 preview，再同步执行或传 `executionMode=async` 入队；选中任务后调用 `/api/tenant-content/imports/detail` 查看 worker 状态、item 状态统计、最近 issue 和复检结果，并会对 `pending/importing` 异步任务自动轮询。下一步继续补目标入口/集合选择的完整表单。

`fieldMappingOverrides` 只影响 CSV/Excel 表头归一化，不改变 JSON 导入 schema。后端会按导入类型校验目标字段白名单并拒绝危险对象键；前端可以用它提高旧表格兼容性，但不能用它制造新业务字段或绕过后端校验。

CSV 请求示例：

```json
{
  "sourceFormat": "csv",
  "sourceName": "questions.csv",
  "csvText": "legacyId,题型,题干,选项A,选项B,答案\nq1,choice,题干,A,B,B",
  "fieldMappingOverrides": {
    "content": ["自定义题干"],
    "answer": ["正确项"]
  },
  "subjectId": "...",
  "categoryId": "...",
  "entryId": "...",
  "contentNodeId": "...",
  "collectionId": "..."
}
```

Excel 请求示例：

```json
{
  "sourceFormat": "excel",
  "sourceName": "scoreline.xlsx",
  "fileBase64": "<xlsx base64>",
  "sheetName": "records",
  "regionId": "..."
}
```

异步确认导入示例：

```json
{
  "previewJobId": "uuid",
  "executionMode": "async",
  "allowPartial": false
}
```

异步导入状态：

```text
pending/importing：展示处理中，不允许重复同步执行同一 job。
completed：刷新目标内容列表。
completed_with_errors：刷新成功内容，并提示查看 issues。
failed/rejected：展示 errorMessage 和 issues，允许运营修正后重新 preview。
```

导入复检状态：

```text
passed：可以展示为导入验收通过。
warning：导入已落库，但存在可运营确认的风险，例如 allowPartial 导入。
failed：导入结果和目标表不一致，必须提示管理员排查，不要静默刷新页面。
```

前端不要自行判断导入成功率，也不要只看 `completed` 就认为可上线；以复检结果和目标内容刷新结果共同作为运营提示。

前端文件限制应与后端一致：单文件最大 8MB，最多 5000 行、160 列。后端不会保存原始 `fileBase64`，但前端仍不要把含隐私的导入文件写入长期缓存。

分数线导入前端注意：

- 后端支持 `fields/schools/majors/records` 分桶，也支持 `items` 列表；Excel 可用 `fields`、`schools`、`majors`、`records` 多 Sheet。
- 页面筛选字段仍以 `/api/scoreline/fields` 为准，不要从导入 JSON 临时生成筛选 UI。
- `record` 至少需要 `schoolId`、`schoolLegacyId` 或 `schoolName`，否则 preview 会返回 issue。
- 学生端查询记录时，普通动态字段传 `field.<fieldKey>=value`；数值下限/上限传 `min.<fieldKey>=value`、`max.<fieldKey>=value`。后端会按租户和地区校验字段白名单，前端不要自行拼未在 `fields` 返回中的字段。

视频导入前端注意：

- 列表和搜索接口不会给付费视频可播放 URL；播放统一调 `POST /api/videos/play`。
- 绑定题目必须提供 `questionId` 或 `legacyQuestionId`。
- 生产建议把私有视频先入 `content_assets`，导入时传 `assetId`，避免长期暴露源站 URL。

## 题库导出对接

租户后台题库导出统一走后端生成结构化 payload，前端不要直接查 Supabase 表拼导出文件。当前后端支持三种交付形态：

- 同步结构化导出：`json`、`paper_json`、`print_payload`，接口直接返回 base64 JSON/payload。
- 异步二进制导出：`pdf`、`docx`，接口先返回 `pending` job，由 `apps/worker --job exports` 渲染 PDF/Word、水印并写入 `content_assets`，前端轮询 job 后再走资源签名下载或预览。
- 异步运营素材包：`daily_practice_zip`，仅支持 `exportType=daily_practice`，worker 会生成九宫格 PNG/SVG 卡片、拼图 PNG/SVG、`manifest.json` 和脱敏后的 `payload.json`，并作为 `asset_type=package` 写入 `content_assets`。

可用接口：

```text
POST /api/tenant-content/exports/questions
GET  /api/tenant-content/exports/jobs
```

导出范围：

| scopeType | scopeId | 用途 |
| --- | --- | --- |
| `collection` | `question_collections.id` | 导出某个题目列表或试卷集合 |
| `entry` | `content_entries.id` | 导出某个题库入口下全部已发布题目 |
| `content_node` | `content_nodes.id` | 导出某个分类节点及其子节点下全部已发布题目 |

普通题库 JSON 导出：

```json
{
  "scopeType": "collection",
  "scopeId": "<questionCollectionId>",
  "format": "json",
  "exportType": "questions",
  "includeAnswers": false,
  "includeExplanations": false,
  "options": {
    "title": "天津专升本题库导出"
  }
}
```

试卷 payload 导出：

```json
{
  "scopeType": "content_node",
  "scopeId": "<contentNodeId>",
  "format": "paper_json",
  "exportType": "paper",
  "includeAnswers": true,
  "includeExplanations": true,
  "options": {
    "title": "全真模拟试卷",
    "durationMinutes": 120,
    "watermarkText": "仅供内部使用"
  }
}
```

PDF/Word 异步导出：

```json
{
  "scopeType": "collection",
  "scopeId": "<questionCollectionId>",
  "format": "pdf",
  "exportType": "paper",
  "includeAnswers": false,
  "includeExplanations": false,
  "options": {
    "title": "天津专升本模拟试卷",
    "watermarkText": "仅供内部使用",
    "publishToAssets": true,
    "assetVisibility": "tenant"
  }
}
```

`format` 可为 `pdf`、`docx`。`publishToAssets=true` 表示导出文件可作为资料资源展示给对应可见范围用户；不传时默认生成后台私有资源，仅后台可下载。`assetVisibility` 支持 `public`、`tenant`、`members`、`svip`、`private`，生产默认建议用 `tenant/private/svip`，不要轻易公开带题目的文件。

每日一练运营素材导出 PDF/Word：

```json
{
  "scopeType": "collection",
  "scopeId": "<questionCollectionId>",
  "format": "pdf",
  "exportType": "daily_practice",
  "includeAnswers": true,
  "includeExplanations": false,
  "limit": 8,
  "options": {
    "title": "每日一练 第 1 期",
    "issue": "每日一练 第 1 期",
    "date": "2026-06-29",
    "theme": "ink",
    "cardFormat": "1:1",
    "watermarkText": "恭学教育",
    "publishToAssets": true,
    "assetVisibility": "tenant",
    "brand": {
      "name": "恭学教育",
      "english": "GONGXUE EDU",
      "slogan": "专注高职升本",
      "ctaLine": "每日一练 · 精选八题 · 稳步上岸"
    },
    "centerSlot": {
      "type": "cta",
      "title": "恭学教育",
      "subtitle": "每日一练 · 稳步上岸"
    }
  }
}
```

每日一练图片 ZIP 素材包：

```json
{
  "scopeType": "collection",
  "scopeId": "<questionCollectionId>",
  "format": "daily_practice_zip",
  "exportType": "daily_practice",
  "includeAnswers": false,
  "includeExplanations": false,
  "limit": 8,
  "options": {
    "title": "每日一练 第 2 期",
    "issue": "每日一练 第 2 期",
    "date": "2026-06-29",
    "theme": "default",
    "cardFormat": "1:1",
    "publishToAssets": true,
    "assetVisibility": "tenant",
    "brand": {
      "name": "恭学教育",
      "english": "GONGXUE EDU",
      "slogan": "专注高职升本",
      "ctaLine": "每日一练 · 精选八题 · 稳步上岸"
    }
  }
}
```

`daily_practice` 会自动限制最多 8 道题，并生成第 5 格中央品牌/CTA 位。同步 `json` 导出会返回 `export.dailyPractice.slots`，适合前端做九宫格预览；异步 `pdf/docx` 会由 worker 生成运营素材文件并进入 `content_assets`；异步 `daily_practice_zip` 会生成运营图片包，适合租户后台“一键下载每日一练素材”。

`daily_practice_zip` 产物结构：

```text
manifest.json          导出任务、品牌、主题、文件清单和脱敏策略
payload.json           后端导出 payload，遵守 includeAnswers/includeExplanations
collage.png            3x3 九宫格拼图
collage.svg            拼图 SVG 源文件
cards/card-01.png      单张卡片 PNG
cards/card-01.svg      单张卡片 SVG 源文件
...
cards/card-09.png
cards/card-09.svg
```

前端只需要轮询导出 job 并下载 ZIP，不需要在 Taro 端重写图片渲染。后台可读取 `manifest.json.files.cards` 做下载后预览；若导出时 `includeAnswers=false`，`payload.json` 和卡片都不会包含答案/解析。

响应关键结构：

```json
{
  "job": {
    "id": "...",
    "status": "completed",
    "questionCount": 1,
    "outputHash": "..."
  },
  "export": {
    "_tikuExport": "3.0",
    "jobId": "...",
    "summary": {
      "questionCount": 1,
      "sectionCount": 1
    },
    "sections": [],
    "questions": [],
    "files": [
      {
        "filename": "天津专升本题库导出.json",
        "mimeType": "application/json",
        "encoding": "base64",
        "contentBase64": "..."
      }
    ],
    "renderHints": {
      "pdfLayout": "paper",
      "pageSize": "A4",
      "answerPlacement": "inline_or_appendix"
    }
  }
}
```

异步二进制导出初始响应：

```json
{
  "job": {
    "id": "...",
    "status": "pending",
    "questionCount": 20,
    "outputHash": "..."
  },
  "export": null
}
```

worker 完成后，`GET /api/tenant-content/exports/jobs` 返回：

```json
{
  "items": [
    {
      "id": "...",
      "format": "daily_practice_zip",
      "status": "completed",
      "assetId": "...",
      "attemptCount": 1,
      "outputMetadata": {
        "delivery": "content_asset",
        "fileName": "每日一练-第-2-期.zip",
        "mimeType": "application/zip",
        "sizeBytes": 123456,
        "checksumSha256": "...",
        "assetId": "..."
      }
    }
  ]
}
```

前端处理规则：

- 导出按钮只给具备租户内容编辑权限的后台成员展示；接口仍以后端 `TENANT_CONTENT_EDITOR_REQUIRED` 为准。
- 下载 JSON 时使用 `files[0].contentBase64` 生成 Blob，文件名使用后端返回的 `filename`。
- `includeAnswers=false` 时，顶层答案字段和阅读理解/案例分析的子题答案都会被后端脱敏；前端不要在本地重新合并答案。
- `includeExplanations=false` 时，不展示解析，也不要从题目详情接口额外补解析。
- `paper_json` 可用于后台试卷预览和打印；`pdf/docx` 用于正式文件下载、资料发布和带水印留档；`daily_practice_zip` 用于每日一练运营图片包。
- `GET /api/tenant-content/exports/jobs?scopeType=collection&scopeId=...` 用于后台导出历史；结构化导出只记录 metadata 和输出 hash，二进制导出记录 `assetId`、文件名、大小和 checksum。
- `status=pending/rendering` 时展示生成中；`completed` 且存在 `assetId` 后，后台可调用 `POST /api/tenant-content/assets/sign-download` 下载，PDF/图片资源也可调用 `POST /api/tenant-content/assets/sign-preview` 预览。ZIP 包通常直接下载，不做 inline 预览。
- 学生资料页只展示 `content_assets.status=active` 且非 `private` 的资源；后台私有导出不会出现在学生资料列表。
- 跨租户导出会返回 404 或 403，前端不要重试其它租户 ID。

## 公共题库采纳对接

平台超级管理员后台使用：

```text
GET /api/platform-admin/question-banks
GET /api/platform-admin/question-bank-grants
PUT /api/platform-admin/question-bank-grants
```

授权参数建议：

```json
{
  "sourceQuestionBankId": "<平台公共题库ID>",
  "grantScope": "plans",
  "allowedPlanCodes": ["starter_yearly", "pro_yearly"],
  "status": "active"
}
```

`grantScope` 可选：

- `plans`：按 SaaS 套餐授权。
- `tenants`：指定租户授权。
- `mixed`：套餐和指定租户同时生效。
- `all_active_tenants`：所有有效订阅租户可见。

租户内容后台使用：

```text
GET /api/tenant-content/public-question-banks
POST /api/tenant-content/public-question-banks/adopt
POST /api/tenant-content/public-question-banks/sync
GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...
POST /api/tenant-content/public-question-banks/conflicts/resolve
POST /api/tenant-content/public-question-banks/conflicts/resolve-batch
GET /api/tenant-content/notifications
POST /api/tenant-content/notifications/status
```

平台公共题库运营台使用：

```text
GET /api/platform-admin/question-bank-sync-status
GET /api/platform-admin/question-bank-sync-status?tenantId=<tenantId>&syncStatus=failed&onlyOpenIssues=true
GET /api/platform-admin/question-bank-sync-status?sourceQuestionBankId=<questionBankId>&limit=100
```

该接口需要 `platform:question_bank:ops` 权限。它用于平台超级管理员查看各租户采纳公共题库后的同步状态、失败/冲突通知数量和 worker 最近执行摘要；不返回题目正文、答案、解析或对象存储签名。

采纳请求：

```json
{
  "grantId": "<授权ID>",
  "entryName": "天津专升本公共题库",
  "collectionName": "天津专升本公共题目",
  "copyLimit": 500
}
```

前端处理规则：

- 租户只能看到后端判定为已授权的公共题库，不要在前端用套餐码自行过滤。后端会同时校验 `question_bank_grants`、有效 `tenant_subscriptions`、`platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess`；基础版默认只允许订阅 metadata 中 `allowedRegionIds` 覆盖的单地区公共题库，专业版默认 `national` 可见全国地区题库。列表响应中的 `accessPlanCode` 和 `accessMode` 仅用于展示“由哪个 SaaS 套餐授予访问”，不能作为前端权限来源。
- 采纳成功后后端会生成本租户自己的 `questionBankId`、`entryId`、`collectionId` 和题目快照，学生端直接按普通 `/api/catalog/content-entries`、`question-collections`、`practice-sessions` 接入。
- 重复采纳返回 `QUESTION_BANK_ALREADY_ADOPTED`，前端展示“已采纳”即可。
- 已采纳公共题库可以手动同步平台后续新增/更新题目；同步会重新校验当前租户仍有授权，且只写入租户自己的题目副本。
- 后端也可以由 `apps/worker --job public-banks` 自动同步待更新的采纳题库；前端不需要轮询平台源库，只需要在租户后台展示同步状态、最近同步时间和冲突数量。

同步请求：

```json
{
  "adoptionId": "<tenant_question_bank_adoptions.id>",
  "copyLimit": 1000
}
```

同步响应关键字段：

```json
{
  "item": {
    "id": "...",
    "syncStatus": "synced | failed",
    "copiedQuestionCount": 120,
    "targetQuestionBankId": "...",
    "targetEntryId": "...",
    "targetCollectionId": "..."
  },
  "sync": {
    "status": "synced | conflict",
    "counts": {
      "inserted": 1,
      "updated": 3,
      "skipped": 116,
      "conflicts": 0
    },
    "results": [
      {
        "sourceQuestionId": "...",
        "targetQuestionId": "...",
        "action": "inserted | updated | skipped | conflict",
        "sourceHash": "...",
        "previousSourceHash": "...",
        "targetHash": "..."
      }
    ]
  }
}
```

前端处理规则：

- `sync.status=synced`：刷新公共题库列表、题目集合和题目列表。
- `sync.status=conflict` 或 `item.syncStatus=failed`：展示冲突数量和冲突题目，不要把它当系统异常。冲突表示租户已经改过这道采纳题，后端已跳过并保留租户内容。
- `action=conflict` 的记录可以进入冲突处理区域：展示平台源题 ID、租户目标题 ID、上次平台 hash、当前平台 hash、租户当前 hash。当前后端已支持单条和批量处理。
- 页面初始化或 worker 后台同步完成后，可以调用 `GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...` 查询最近一次同步状态、`counts` 和 `conflicts`。这个接口只返回当前租户自己的采纳记录，跨租户会返回 `QUESTION_BANK_ADOPTION_NOT_FOUND`。
- 单条冲突处理调用 `POST /api/tenant-content/public-question-banks/conflicts/resolve`，body 为 `{ "adoptionId": "...", "sourceQuestionId": "...", "resolution": "accept_platform | keep_local" }`。`accept_platform` 会把租户副本写成平台当前版本并生成新题目版本；`keep_local` 会记录本地保留决策，同一平台 hash 和本地 hash 后续同步不再反复提示。两种操作都会写审计日志。
- 批量冲突处理调用 `POST /api/tenant-content/public-question-banks/conflicts/resolve-batch`，body 为 `{ "adoptionId": "...", "sourceQuestionIds": ["..."], "resolution": "accept_platform | keep_local", "limit": 50 }`。后端最多处理 100 条，仍会重新校验租户授权、锁定采纳记录和目标题，逐条写审计；前端只提交当前冲突列表中明确展示给操作者的 source id。
- 同步产生新增/更新时，后端会写入 `public_question_bank_synced` 通知；同步产生冲突时，会写入 `public_question_bank_conflict` 通知。通知只包含同步摘要、题库 ID、题目 hash 和操作入口，不保存题目答案或解析。
- worker 自动同步因授权失效、目标题库缺失等原因失败时，后端会写入 `public_question_bank_sync_failed` 通知，`severity=error`，metadata 只包含 `errorCode/errorMessage/workerId/failedAt` 等脱敏运维摘要。后续同步恢复成功后，后端会自动把未 dismissed 的失败通知标记为 `resolved`。
- 租户后台可调用 `GET /api/tenant-content/notifications?notificationType=public_question_bank_conflict&status=unread&limit=20` 展示待处理同步消息；也可用 `public_question_bank_sync_failed` 展示自动同步失败消息，并带 `adoptionId` 查看某个采纳记录的通知。
- 通知状态更新调用 `POST /api/tenant-content/notifications/status`，body 为 `{ "notificationIds": ["..."], "status": "read | dismissed | resolved" }`。冲突被单条或批量全部处理后，后端会自动把相关冲突通知标记为 `resolved`。
- `QUESTION_BANK_GRANT_NOT_AVAILABLE`：说明 SaaS 套餐/授权已失效，提示联系平台或升级套餐。
- `QUESTION_BANK_ADOPTION_NOT_FOUND`：说明不是当前租户的采纳记录或记录已归档，前端不要跨租户重试。
- 当前租户后台可以提供手动“同步平台更新”按钮，并展示 worker 自动同步后的通知、失败消息、冲突查询结果、单条处理和批量处理按钮。平台后台可用 `question-bank-sync-status` 做跨租户运营看板，优先展示失败、pending 和开放冲突。

## 登录对接

### 短信登录

开发环境可以先使用 mock 短信，接口会返回 `debugCode`。生产环境禁止依赖 `debugCode`。

```text
POST /api/auth/sms/send
body: { "phone": "13800000000", "purpose": "login" }

POST /api/auth/sms/verify
body: { "phone": "13800000000", "code": "123456", "purpose": "login" }
```

成功后保存：

```text
session.token
session.expiresAt
user
```

后续请求统一带：

```text
Authorization: Bearer <session.token>
x-tenant-id: <tenantId>
```

### 绑定或更换手机号

微信/QQ 登录后强制绑定手机号、个人中心更换手机号，都走同一个后端命令。前端先发送 `bind_phone` 用途验证码，再提交绑定：

```text
POST /api/auth/sms/send
body: { "phone": "13800000000", "purpose": "bind_phone" }

POST /api/auth/phone/bind
Authorization: Bearer <session.token>
body: { "phone": "13800000000", "code": "123456" }
```

前端规则：

- 绑定接口必须带当前登录态，不能用 `x-user-id` 伪造用户。
- 绑定接口只接受 `bind_phone` 验证码，不接受 `login` 验证码。
- 新手机号如果已属于其它账号，后端返回 `PHONE_ALREADY_BOUND`。
- 换绑成功后旧手机号登录身份会被移除；迁移期 `tk_` 其它设备 session 会被撤销，当前 session 继续可用。
- 微信手机号授权后也应由后端 adapter 换取手机号，再复用同一类绑定命令；不要在页面里持久化明文手机号授权中间数据。

### 微信小程序登录

微信小程序端调用 `Taro.login()` 获取 code，然后交给后端：

```text
POST /api/auth/oauth/wechat-miniapp
body: {
  "code": "<wx.login code>",
  "profile": {
    "nickName": "...",
    "avatarUrl": "..."
  }
}
```

成功响应包含：

```text
provider
user
isNewUser
session.token
session.expiresAt
identity.openId
identity.unionId
```

注意：

- 前端不接触 `appSecret`。
- 前端不会拿到微信 `session_key`。
- 如果登录前已经解析到推广码，登录成功后再调用 `/api/referral/bind` 完成首绑保护。
- 如果用户没有手机号，跳转到上面的“绑定或更换手机号”流程。

### 微信网页登录

H5 端在微信开放平台授权回调页拿到 `code` 后，交给后端：

```text
POST /api/auth/oauth/wechat
body: {
  "code": "<wechat oauth code>",
  "lang": "zh_CN"
}
```

成功响应与小程序登录一致，包含 `provider=user/identity/session`。后端会使用租户 `wechat-web/wechat_web/wechat` provider 配置换取 `access_token` 和 `openid`，再拉取用户资料；如果返回 `unionid`，会和同一开放平台下的小程序身份合并。

前端注意：

- H5 回调页只短暂读取 `code/state`，不要持久化微信 `access_token`。
- `state` 应在前端本地或服务端中转页校验，避免跨站授权回调混淆。
- 多租户自定义域名下，授权回调域名必须与租户微信开放平台配置一致；如果未来使用统一授权中转域名，需要在回调后再解析目标租户。

### QQ 登录

H5 端在 QQ 互联授权回调页拿到 `code` 后，交给后端：

```text
POST /api/auth/oauth/qq
body: {
  "code": "<qq oauth code>",
  "redirectUri": "https://h5.example.com/auth/qq/callback"
}
```

后端会完成 `code -> access_token -> openid -> userinfo`，并签发本项目 session。

前端注意：

- `redirectUri` 必须与 QQ 互联后台登记地址一致；也可以由租户后台 provider 配置固定，前端不传。
- 前端不要接触 QQ `clientSecret/AppKey` 或 `access_token`。
- 登录后如果没有手机号，同样进入“绑定或更换手机号”流程。

## 支付对接

支付流程必须以后端订单金额和后端回调为准，前端只负责拉起支付。

### 创建订单

```text
POST /api/commerce/orders
body: {
  "planId": "<svipPlanId>",
  "quantity": 1,
  "payProvider": "wechat_pay | alipay",
  "payMethod": "jsapi | wap",
  "regionId": "<regionId>",
  "couponCode": "<可选，优惠券码>",
  "couponRedemptionId": "<可选，已领取优惠券 redemptionId>"
}
```

前端可以先领取优惠券，再下单：

```text
POST /api/commerce/coupons/claim
body: {
  "code": "<couponCode>",
  "planId": "<svipPlanId>",
  "regionId": "<regionId>"
}
```

`coupons/claim` 对同一用户同一优惠券的未核销记录是幂等的；如果优惠券允许 `perUserLimit > 1`，前一次 redemption 已经下单核销后，用户可以再次领取直到达到限额。已超过单用户限额会返回 `COUPON_ALREADY_USED` 或 `COUPON_USER_LIMIT_REACHED`。下单时后端会重新计算套餐原价、优惠金额和最终应付，前端展示金额只能使用接口返回的 `originalAmountCents`、`discountCents`、`amountCents`。

优惠券规则由后端执行，前端只做展示和提示：

```text
COUPON_DISABLED                   优惠券已停用或归档
COUPON_NOT_STARTED                未到开始时间
COUPON_EXPIRED                    已过期
COUPON_QUOTA_EXHAUSTED            总库存已用完
COUPON_PLAN_MISMATCH              不适用当前套餐
COUPON_REGION_MISMATCH            不适用当前地区
COUPON_MIN_ORDER_AMOUNT_NOT_MET   未达到最低订单金额
COUPON_FIRST_ORDER_ONLY           仅限首单
COUPON_USER_LIMIT_REACHED         已达到单用户可用次数
```

租户后台优惠券配置字段：

```json
{
  "code": "SUMMER80",
  "planId": "<默认绑定套餐，可选>",
  "discountType": "fixed | percent",
  "discountValue": 800,
  "status": "active | disabled | archived",
  "campaignName": "暑期活动",
  "minOrderAmountCents": 3000,
  "maxDiscountCents": 1000,
  "perUserLimit": 2,
  "firstOrderOnly": false,
  "allowedPlanIds": ["<svipPlanId>"],
  "allowedRegionIds": ["<regionId>"],
  "maxUses": 500,
  "metadata": {
    "channel": "poster"
  }
}
```

租户后台核销和报表：

```text
GET /api/tenant-admin/coupons?status=active&campaignName=暑期活动
GET /api/tenant-admin/coupons/redemptions?couponId=<couponId>&status=used
GET /api/tenant-admin/coupons/report?startDate=2026-06-01&endDate=2026-06-29&campaignName=暑期活动
权限：`coupons:read` 可查看配置，`coupons:write` 可维护配置，核销明细和报表需要 `coupons:redemptions:read`
```

`coupons/report` 返回 `claimCount/usedCount/discountCents/paidAmountCents/conversionRate/byCoupon/byCampaign/daily`。金额均为分，报表只读；前端不要用报表数据反向修改订单、支付、权益或优惠券使用次数。

如果优惠后 `amountCents=0`，后端会立即把订单置为 `paid` 并发放权益，前端不要再调用 `payments/create`。

返回未支付 `orderNo` 后，再创建支付参数：

```text
POST /api/commerce/payments/create
body: {
  "orderNo": "<orderNo>",
  "provider": "wechat_pay",
  "openId": "<微信小程序登录后的 openId>"
}
```

微信小程序返回的 `paymentParams` 可直接映射到 `Taro.requestPayment`：

```text
appId
timeStamp
nonceStr
package
signType
paySign
```

支付宝 H5/WAP 返回：

```text
paymentParams.url
```

H5 可以跳转到该 URL。小程序端如果后续要接支付宝小程序，需要新增独立 provider/method，不要复用 H5 WAP URL。

支付完成后前端不要自行开通会员。前端应轮询或重新请求：

```text
GET /api/commerce/orders/status?orderNo=<orderNo>
GET /api/commerce/orders/detail?orderNo=<orderNo>
GET /api/commerce/entitlements
```

订单详情会返回 `pricing`、`payments`、`items`、`couponRedemptions`，可用于收银台、订单详情页和售后排查。订单状态轮询页只需消费 `status/payment`，避免频繁拉取全量明细。

后端已提供 commerce worker 作为兜底补偿：如果微信/支付宝支付成功但 webhook 漏通知，worker 会按租户商户配置查询供应商订单并幂等更新订单、支付和权益。前端仍然只轮询 `orders/status` 或 `orders/detail`，不要直接调用供应商查询接口，也不要在页面里自行开通会员。

### 退款和售后

学生端不直接发起后台退款命令。普通用户订单页只展示 `GET /api/commerce/orders/status` 和 `GET /api/commerce/orders/detail` 返回的订单状态、支付状态、`refundedAmountCents`，并提供客服/工单入口。租户后台或运营后台才接退款接口。

租户后台退款列表：

```text
GET /api/commerce/refunds?status=requested&orderNo=<orderNo>
权限：tenant:refund:read
```

创建退款申请：

```text
POST /api/commerce/refunds
权限：tenant:refund:write
body: {
  "orderNo": "<orderNo>",
  "refundNo": "<可选，前端幂等键>",
  "amountCents": 500,
  "reason": "用户协商退款",
  "entitlementAction": "revoke_on_success | none"
}
```

退款状态流转：

```text
POST /api/commerce/refunds/status
body: {
  "refundId": "<refundId>",
  "action": "approve | reject | submit_provider_refund | query_provider_refund | mark_processing | mark_succeeded | mark_failed | cancel",
  "providerRefundNo": "<支付平台退款单号，可选>",
  "providerNotifyUrl": "<微信退款通知地址，可选>",
  "note": "<处理备注>"
}
```

状态说明：

```text
requested -> approved -> processing -> succeeded
requested -> approved -> submit_provider_refund -> processing/succeeded
processing -> query_provider_refund -> processing/succeeded/failed
provider refund notify -> processing/succeeded/failed
requested/approved -> rejected
requested/approved -> cancelled
approved/processing -> failed
```

注意：

- 金额单位一律是分，前端不要传元。
- `refundNo` 是幂等键；同一订单同一金额重复提交会返回原退款申请。
- 后端会限制累计退款金额不能超过实付金额。
- 全额退款成功后订单和支付会进入 `refunded`，相关订单权益会被置为 `revoked`；部分退款进入 `partially_refunded`，默认不撤销权益。
- `submit_provider_refund` 会由后端使用租户支付账户密钥调用微信/支付宝；前端不要保存商户私钥、API v3 key 或支付宝应用私钥。
- 微信退款通常先进入 `processing`，租户后台可以调用 `query_provider_refund` 主动向微信查询，确认成功后后端才会更新订单退款金额和权益。
- 支付宝普通退款如果响应 `fund_change=Y` 会同步进入 `succeeded`；处于 `processing` 的退款也可以用 `query_provider_refund` 调用 `alipay.trade.fastpay.refund.query` 确认。
- 退款通知地址由支付账户或 `submit_provider_refund.providerNotifyUrl` 配置，后端公开接收路径为 `POST /api/commerce/refunds/notify/wechat_pay?tenantId=<tenantId>`、`POST /api/commerce/refunds/notify/alipay?tenantId=<tenantId>`。这是支付平台回调地址，Taro 前端不要主动调用。
- 退款通知只会推进已经审核/处理中的退款申请；未审核的 `requested` 退款不能被外部通知直接落账。
- 已经 `succeeded` 的退款不能再次查询或再次标记成功，避免订单退款金额重复累加。前端应按接口返回状态展示，不要假设点击后立即到账。
- 自动补偿 worker 已接入：支付漏通知和处理中退款会由后端定时查询供应商并幂等落账。资金对账已支持租户后台手工/API 导入供应商账单、微信/支付宝官方账单下载任务、查询差异、差错工单处理、异常订单运营台、人工调整凭证和复核报表。生产联调时仍需保留人工确认/失败登记入口。

### 租户后台资金对账

资金对账是租户后台/财务运营能力，学生端不要接。对账接口只生成差异台账、差错工单和审计，不会自动修改订单、支付、退款或权益。前端不能根据对账结果或工单状态自行开通、退款或撤销权益。

预览账单：

```text
POST /api/commerce/reconciliation/preview
权限：tenant:reconciliation:read
body: {
  "provider": "wechat_pay | alipay | manual",
  "billDate": "2026-06-29",
  "billType": "payment | refund | combined",
  "sourceName": "wechat-bill-20260629.csv",
  "rows": [
    {
      "transactionType": "payment",
      "orderNo": "<本地 orderNo 或 out_trade_no>",
      "providerTradeNo": "<微信/支付宝交易号>",
      "amountCents": 990,
      "providerStatus": "SUCCESS"
    },
    {
      "transactionType": "refund",
      "orderNo": "<orderNo>",
      "refundNo": "<本地 refundNo 或 out_refund_no>",
      "providerRefundNo": "<支付平台退款单号>",
      "refundAmountCents": 100,
      "providerStatus": "REFUND_SUCCESS"
    }
  ],
  "previewLimit": 200
}
```

确认导入：

```text
POST /api/commerce/reconciliation/import
权限：tenant:reconciliation:write
```

查询批次、明细和异常：

```text
GET /api/commerce/reconciliation/batches?provider=wechat_pay&billDate=2026-06-29
GET /api/commerce/reconciliation/items?batchId=<batchId>&matchStatus=missing_provider
GET /api/commerce/reconciliation/anomalies?provider=wechat_pay
```

从异常明细创建差错工单：

```text
POST /api/commerce/reconciliation/issues/create
权限：tenant:reconciliation:write
body: {
  "itemId": "<reconciliationItemId>",
  "assignedTo": "<tenantStaffUserId，可选>",
  "dueAt": "2026-06-30T10:00:00.000Z",
  "summary": "微信账单金额不一致核对",
  "note": "先交给财务核对供应商流水",
  "metadata": {
    "source": "tenant-admin"
  }
}
```

重复对同一未关闭异常明细创建工单时，后端会返回原工单并带 `idempotent=true`。`matched/ignored` 明细不可创建工单。

查询工单：

```text
GET /api/commerce/reconciliation/issues?status=open&assignedTo=<userId>&batchId=<batchId>&orderNo=<orderNo>
权限：tenant:reconciliation:read
```

工单状态流转：

```text
POST /api/commerce/reconciliation/issues/status
权限：tenant:reconciliation:write
body: {
  "issueId": "<issueId>",
  "action": "start | assign | resolve | ignore | escalate | reopen",
  "assignedTo": "<userId，assign 时必填>",
  "resolutionType": "provider_confirmed | local_corrected | manual_adjustment | false_positive | duplicate | write_off",
  "note": "处理备注",
  "metadata": {
    "voucherNo": "ADJ-20260629-001"
  }
}
```

查看事件轨迹：

```text
GET /api/commerce/reconciliation/issues/events?issueId=<issueId>
权限：tenant:reconciliation:read
```

官方账单下载：

```text
POST /api/commerce/reconciliation/provider-bills/request
权限：tenant:reconciliation:download
body: {
  "provider": "wechat_pay | alipay",
  "billDate": "2026-06-29",
  "billType": "payment | refund | combined",
  "metadata": {
    "remark": "财务手动申请"
  }
}
```

返回：

```json
{
  "item": {
    "id": "<jobId>",
    "provider": "wechat_pay",
    "billDate": "2026-06-29",
    "billType": "payment",
    "status": "queued",
    "sourceName": "provider-bill:wechat_pay:2026-06-29:payment",
    "sourceHash": null,
    "rowCount": 0,
    "downloadUrlHost": null,
    "reconciliationBatchId": null
  },
  "idempotent": false
}
```

轮询任务：

```text
GET /api/commerce/reconciliation/provider-bills/jobs?provider=wechat_pay&billDate=2026-06-29
权限：tenant:reconciliation:read
```

任务状态：

```text
queued      已排队，等待 provider-bills worker
running     worker 正在申请和下载官方账单
completed   已下载、校验、导入对账批次
failed      下载、hash 校验、解析或导入失败
cancelled   已取消
```

前端处理规则：

- 前端只创建任务和轮询状态，不直接请求微信/支付宝账单 URL。
- 后端响应只会返回 `downloadUrlHost`，不会返回完整下载 URL、商户私钥、API v3 key、支付宝应用私钥。
- `completed` 后用 `reconciliationBatchId` 跳转到对账批次明细。
- `failed` 时展示 `errorCode/errorMessage`，让财务重新发起或联系技术处理。
- 官方账单下载由服务器定时运行 `apps/worker --job provider-bills`，前端不要自行触发供应商接口。

工单状态：

```text
open             新建待处理
investigating    处理中
escalated        已升级
resolved         已解决
ignored          已忽略
```

处理结论：

```text
none                未处理
provider_confirmed  已按供应商确认
local_corrected     已通过专门业务命令修正本地记录
manual_adjustment   已登记人工调整凭证
false_positive      误报
duplicate           重复账单/重复工单
write_off           财务核销
```

`matchStatus` 取值：

```text
matched             本地和供应商账单匹配
amount_mismatch     金额不一致
status_mismatch     状态不一致
missing_local       供应商账单有，本地没有
missing_provider    本地已支付/退款成功，供应商账单没有
duplicate           供应商账单重复行
ignored             无效行或不符合本次 billType
```

前端处理规则：

- 财务导入页建议使用 preview -> 人工确认 -> import -> anomalies 的流程。
- `sourceHash` 可作为同一文件内容的识别线索，但当前接口不会阻止重复导入；前端应展示最近同名/同 hash 批次提醒。
- 金额统一是分，前端不要传元。
- 对账差异和差错工单只是运营判断依据，`resolve/ignore` 不会落账。最终订单修正必须走退款、补偿、人工确认或后续专门的人工调整接口。
- 当前后端支持 JSON 行手工导入；官方账单下载 worker 支持微信/支付宝账单 JSON/CSV/ZIP 解析，并复用同一套对账导入逻辑。真实生产接入时仍要用真实账单文件抽样验收字段映射。

### 异常订单运营台和调整凭证

租户后台财务/售后页可以用异常运营台作为入口。该页只展示待处理风险和凭证复核状态，不允许前端直接修改订单、支付、退款或权益。

异常运营台：

```text
GET /api/commerce/operations/anomalies?provider=wechat_pay&limit=50
权限：tenant:reconciliation:read
```

返回中 `items[].type` 可能是：

```text
reconciliation_issue    未关闭对账差错工单
provider_bill_job       官方账单下载失败或运行超时
payment_event_error     支付/退款通知处理异常
stuck_pending_payment   长时间 pending 支付
stuck_refund            长时间待处理/处理中退款
```

创建人工调整凭证：

```text
POST /api/commerce/adjustment-vouchers
权限：tenant:reconciliation:write
body: {
  "voucherNo": "ADJ-20260629-001",
  "reconciliationIssueId": "<issueId>",
  "adjustmentType": "write_off",
  "direction": "decrease",
  "amountCents": 990,
  "title": "供应商缺失账单人工核销凭证",
  "description": "仅作为财务复核证据",
  "assetId": "<contentAssetId，可选>",
  "externalUrl": "https://finance.example.com/proofs/ADJ-20260629-001",
  "metadata": {
    "operatorRemark": "后台上传凭证"
  }
}
```

可关联的来源字段：

```text
reconciliationIssueId   对账差错工单
reconciliationItemId    对账明细
orderId / orderNo       订单
paymentId               支付记录
refundRequestId/refundNo 退款申请
sourceType=manual       纯人工凭证
```

凭证字段：

```text
adjustmentType:
  manual_payment_confirm | refund_correction | provider_confirmed |
  local_corrected | write_off | duplicate | other

direction:
  increase | decrease | none

status:
  draft | submitted | approved | rejected | voided
```

查询凭证：

```text
GET /api/commerce/adjustment-vouchers?status=submitted&orderNo=<orderNo>
权限：tenant:reconciliation:read
```

复核凭证：

```text
POST /api/commerce/adjustment-vouchers/status
权限：tenant:reconciliation:review
body: {
  "voucherId": "<voucherId>",
  "status": "approved | rejected | voided",
  "reviewNote": "财务复核意见",
  "metadata": {
    "reviewChannel": "tenant-admin"
  }
}
```

查看凭证轨迹和报表：

```text
GET /api/commerce/adjustment-vouchers/events?voucherId=<voucherId>
GET /api/commerce/adjustment-vouchers/report?startDate=2026-06-01&endDate=2026-06-29
权限：tenant:reconciliation:read
```

前端处理规则：

- 学生端不要接这些接口。
- `tenant:reconciliation:write` 可创建凭证，`tenant:reconciliation:review` 才能审批、驳回或作废凭证。
- 后端会校验凭证来源和附件 `content_assets` 必须属于当前租户。
- 已 `approved/rejected/voided` 的凭证不能翻转到其它关闭状态。
- 凭证审批不会自动改订单、支付、退款和权益；真正落账仍要走退款状态机、支付补偿、手工支付确认或后续专门落账命令。
- 前端可在差错工单 `resolve` 时把 `metadata.voucherNo` 一并传入，用于人读检索，但不要把它当成落账动作。

### 激活码预检查与兑换

兑换前建议先调用：

```text
POST /api/commerce/activation-codes/check
body: {
  "code": "<activationCode>",
  "regionId": "<regionId>"
}
```

可根据返回的 `valid/reasonCode/days/regionName/saleType` 展示确认弹窗。常见 `reasonCode`：

```text
ACTIVATION_CODE_NOT_FOUND
ACTIVATION_CODE_USED
ACTIVATION_CODE_SELF_REDEEM_FORBIDDEN
ACTIVATION_CODE_REGION_MISMATCH
```

用户确认后再调用 `POST /api/commerce/activation-codes/redeem`。兑换成功后重新请求 `/api/commerce/entitlements` 和个人中心，不要在前端本地伪造会员状态。

后端支付回调地址由租户支付账户配置：

```text
/api/commerce/payments/notify/wechat_pay?tenantId=<tenantId>
/api/commerce/payments/notify/alipay?tenantId=<tenantId>
```

前端禁止：

- 传入自定义金额。
- 伪造支付成功状态。
- 调用 `/api/commerce/payments/manual-confirm`；这个接口只给租户后台线下收款/迁移期使用，后端要求 `tenant:payment:write`。
- 保存商户号私钥、API v3 key、支付宝应用私钥。
- 在页面里实现 webhook 验签或权益开通。

## 第一阶段页面建议

1. `pages/bootstrap/index`
   - 租户解析、主题初始化、登录态恢复。
2. `pages/login/index`
   - 先接短信登录；后续接微信小程序登录。
3. `pages/home/index`
   - Banner、公告、题库入口、会员入口、资料入口。
4. `pages/region/index`
   - 地区选择和权益提示。
5. `pages/catalog/index`
   - entry/node/collection/blueprint 通用导航。
6. `pages/practice/index`
   - 刷题、答题、解析、错题、收藏。
7. `pages/vocabulary/index`
   - 单词单元、学习、收藏。
8. `pages/handbook/index`
   - 手册目录和阅读。
9. `pages/scoreline/index`
   - 动态字段筛选和趋势。
10. `pages/ai-school/index`
   - SVIP AI 择校推荐、报告历史和 JSON 报告渲染。
11. `pages/profile/index`
   - 会员、订单、激活码、学习数据、勋章。

## 当前 Taro 实现进度

截至 2026-06-29，`apps/taro` 已完成学生端第一阶段页面：

```text
pages/student/login/index
pages/student/home/index
pages/student/region/index
pages/student/catalog/index
pages/student/practice/index
pages/student/review/index
pages/student/reports/index
pages/student/video/index
pages/student/checkout/index
pages/student/order-detail/index
pages/student/vocabulary/index
pages/student/handbook/index
pages/student/scoreline/index
pages/student/ai-school/index
pages/student/assets/index
pages/student/profile/index
```

已新增服务层：

```text
src/components/RichContent.tsx 安全富文本渲染：题干、选项、解析、知识手册、报告复盘
src/services/catalog.ts    目录、题库、单词、手册、分数线、资料
src/services/learning.ts   练习 session、答题、收藏、错题/收藏复习、交卷报告、单词复习和单词收藏列表
src/services/commerce.ts   套餐、优惠券、下单、支付参数、订单详情、权益、激活码
src/services/profile.ts    个人中心、地区目标、签到、反馈、勋章、倒计时、积分活动任务、积分兑换
src/services/video.ts      题目视频列表、播放签名
src/services/ai.ts         AI 择校推荐生成、报告列表、报告详情
src/services/pronunciation.ts H5/小程序单词发音适配
src/services/tenantAdmin.ts 租户后台看板、权限矩阵、成员、学生创建/批量导入/分班/备注/跟进、内容、营销、设置、角色模板写操作、公共题库采纳/同步/单条和批量冲突处理、导入详情/复检、CRM 配置/队列、分佣规则/成员比例/订单/结算、优惠券规则/核销报表、积分任务/兑换配置和记录
src/services/tenantFinance.ts 租户财务运营：退款状态机、官方账单任务、对账批次/明细、差错工单、异常订单和人工调整凭证
src/services/platformAdmin.ts 平台后台租户、租户详情、账务资料、平台审计查询/CSV 导出、平台审计告警规则/列表/状态更新、审计告警外部通知渠道/发送事件、套餐账单、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、用量超额账单候选/dry-run/生成、逾期预览/内部催缴记录、催缴外部通知渠道/发送事件、用量、公共题库授权
```

验证命令：

```bash
npm run check:taro
npm run build:taro:h5:student
npm run build:taro:h5:tenant
npm run build:taro:h5:platform
```

已通过。构建仍有 Taro H5 入口体积 warning，属于当前 Taro 工程既有警告，不阻断联调。

## 平台审计导出

平台审计属于超级管理员后台能力，学生端和租户后台不要接。前端只能调用后端命令式接口，不允许直接读 Supabase 表或拼 SQL 导出审计。

查询审计：

```text
GET /api/platform-admin/audit-logs?tenantId=<tenantId>&action=platform.tenant&limit=100
```

导出审计：

```text
GET /api/platform-admin/audit-logs/export?format=csv&limit=1000
GET /api/platform-admin/audit-logs/export?format=json&tenantId=<tenantId>&startDate=2026-06-01&endDate=2026-06-30
```

可用筛选：

```text
tenantId
action       前缀匹配，例如 platform.tenant
targetType
actorUserId
q            匹配 action/targetType/targetId
startDate    YYYY-MM-DD
endDate      YYYY-MM-DD
limit        查询最大 500，导出最大 5000
format       csv | json
```

导出响应：

```json
{
  "item": {
    "filename": "platform-audit-20260630T120000Z.csv",
    "format": "csv",
    "mimeType": "text/csv",
    "rowCount": 25,
    "exportedAt": "2026-06-30T12:00:00.000Z",
    "sha256": "<content-sha256>",
    "sizeBytes": 4096,
    "contentBase64": "<base64>",
    "filters": {
      "tenantId": "<tenantId>",
      "action": "platform.tenant",
      "startDate": "2026-06-01",
      "endDate": "2026-06-30"
    }
  }
}
```

前端处理规则：

- 只允许平台管理员页面调用，普通学生或租户成员会返回 `PLATFORM_ADMIN_REQUIRED`。
- 后端会把导出动作再次写入 `platform.audit.exported`，前端不需要额外记日志。
- 导出内容中的 `details` 会递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段。
- H5 可用 `contentBase64` 生成 `data:<mimeType>;base64,...` 下载；下载后可以显示 `sha256` 方便人工校验。
- 小程序端如果无法稳定写入文件系统，建议展示“已生成，请到 H5 平台后台下载”，不要把完整 base64 长文本复制到剪贴板。
- 平台后台不要把 `contentBase64` 持久存入本地缓存、日志或埋点。

## 平台审计告警

平台审计告警属于超级管理员后台能力。内部告警由 `apps/worker --job platform-audit-alerts` 根据 `platform_audit_alert_rules` 从平台审计日志生成，外部通知由 `apps/worker --job platform-audit-notifications` 根据 `platform_audit_notification_channels` 入队并发送到 generic、钉钉、飞书或企业微信 webhook。前端不要直接读写 Supabase 表。

## 平台后台权限

平台后台不再假设所有 `platform_admin` 都是全权限。Taro 平台后台启动后应先调用：

```text
GET /api/platform-admin/permissions
```

响应结构：

```json
{
  "item": {
    "primaryRole": "platform_admin",
    "permissions": {
      "platform:tenant:read": true,
      "platform:billing:read": true
    },
    "effective": {
      "platform:tenant:read": true,
      "platform:tenant:write": false
    },
    "catalog": [
      {
        "key": "platform:tenant:read",
        "group": "tenant",
        "label": "查看租户"
      }
    ]
  }
}
```

前端处理规则：

- `effective` 只用于隐藏菜单、按钮和表单，不是安全边界。
- 真实权限由后端在每个 `/api/platform-admin/*` 接口强制校验；越权会返回 `PLATFORM_PERMISSION_REQUIRED`。
- 平台菜单推荐映射：租户中心用 `platform:tenant:read`，创建租户用 `platform:tenant:write`，状态变更用 `platform:tenant:status`，账务资料用 `platform:tenant:billing_profile`，账务中心用 `platform:billing:read/write/payment/dunning`，审计中心用 `platform:audit:read/export/alert/notification`，公共题库用 `platform:question_bank:read/grant`。
- `permissions` 里可能出现 `{"*":true}` 或 `platform:billing:*` 这类通配权限，前端只消费后端返回的 `effective`，不要自己重新实现权限匹配规则。
- 平台后台 H5 不允许保存 `x-platform-admin-key`；生产必须使用 Supabase Auth access token。

查询启用规则：

```text
GET /api/platform-admin/audit-alert-rules?enabled=true
```

查询开放告警：

```text
GET /api/platform-admin/audit-alerts?status=open&limit=50
```

可用筛选：

```text
tenantId=<tenant uuid>
status=open|acknowledged|resolved|ignored
severity=low|medium|high|critical
ruleCode=platform_tenant_status_changed
q=<keyword>
limit=1..500
```

更新告警状态：

```http
POST /api/platform-admin/audit-alerts/status
Content-Type: application/json

{
  "alertId": "<alert uuid>",
  "status": "acknowledged",
  "resolutionNote": "已人工确认"
}
```

`status` 只允许 `acknowledged`、`resolved`、`ignored`。前端不能通过这个接口把告警改回 `open`；需要重新打开时后续另设计复核接口，避免随意撤销安全处理痕迹。

安全边界：

- 只允许平台管理员调用，普通学生、租户管理员和租户成员会返回 `PLATFORM_ADMIN_REQUIRED`。
- 后端会对告警 `details` 递归脱敏 token、secret、password、key、authorization、cookie、session、cert、signature 等敏感字段；前端仍不要把 details 原样写入日志或埋点。
- 确认、解决和忽略都会写入 `platform.audit.alert_status_updated` 审计。
- 当前工作台展示开放告警并提供“确认/解决”按钮，同时展示已启用外部通知渠道和最近发送事件摘要；更完整筛选、批量处理和升级策略属于下一阶段。

### 平台审计告警外部通知

查询通知渠道：

```text
GET /api/platform-admin/audit-notification-channels?enabled=true&limit=50
```

渠道响应不会返回原始 `webhookUrl`，只返回安全摘要：

```json
{
  "items": [
    {
      "id": "...",
      "channelCode": "security_ops",
      "name": "安全值班群",
      "enabled": true,
      "provider": "wecom",
      "secretRef": "app_private.platform_secrets:webhook:security_ops",
      "minSeverity": "high",
      "statusFilter": ["open"],
      "actionPatterns": ["platform.*"],
      "webhook": {
        "protocol": "https",
        "host": "qyapi.weixin.qq.com",
        "pathname": "/cgi-bin/webhook/send"
      }
    }
  ]
}
```

保存通知渠道：

```http
PUT /api/platform-admin/audit-notification-channels
Content-Type: application/json

{
  "channelCode": "security_ops",
  "name": "安全值班群",
  "provider": "wecom",
  "webhookUrl": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
  "secret": "optional-signing-secret",
  "minSeverity": "high",
  "statusFilter": ["open"],
  "actionPatterns": ["platform.*"],
  "timeoutSec": 10
}
```

查询发送事件：

```text
GET /api/platform-admin/audit-notification-events?status=failed&limit=50
```

前端安全边界：

- `secret` 只允许在保存渠道时一次性提交给后端，后端写入 `app_private.platform_secrets`；页面、日志、状态管理和本地缓存都不能保存原文。
- API 响应只展示 `secretRef`、webhook host/path、发送状态、HTTP code 和脱敏后的 request payload。前端不要自行拼 webhook 请求。
- 生产环境 readiness 会阻断启用的非 HTTPS webhook、localhost webhook，以及缺少平台私密 secret 的钉钉/飞书签名渠道。
- 小程序端平台后台如果后续开放，只建议做查看和告警处理；通知渠道配置建议优先放在 H5 平台后台完成。

## 平台账务逾期催缴

平台后台账务页只调用命令式 API，不直接改 `tenant_invoices.status`、`tenants.billing_status` 或 `tenant_invoice_reminders`。后端会做平台管理员鉴权、行锁、每日催缴去重和审计。

预览逾期账单：

```http
POST /api/platform-admin/invoices/process-overdue
```

```json
{
  "dryRun": true,
  "limit": 100
}
```

执行内部催缴：

```json
{
  "dryRun": false,
  "channel": "internal",
  "limit": 100
}
```

响应核心字段：

```json
{
  "item": {
    "dryRun": false,
    "processed": 3,
    "markedOverdue": 2,
    "reminderCreated": 3,
    "skippedReminder": 0
  }
}
```

查询催缴记录：

```http
GET /api/platform-admin/invoices/reminders?tenantId=<tenantId>&invoiceId=<invoiceId>&limit=100
```

前端展示建议：

- dry-run 只展示“将标记逾期/将生成提醒”，不要写本地状态。
- 执行成功后重新加载 `/api/platform-admin/invoices` 和 `/api/platform-admin/invoices/reminders`。
- `platform-dunning` worker 可每天在 `platform-billing` 之后运行一次；它不会自动停用租户，停用仍走平台管理员状态变更流程。

### 平台账务催缴外部通知

平台催缴外部通知用于把 `tenant_invoice_reminders` 中的内部催缴记录推送到平台自己的值班群、财务群或运营 webhook。它属于平台超级管理员能力，前端只能调用后端 API 查看和配置渠道，不要直接写 Supabase 表，也不要在页面状态、日志、埋点或本地缓存保存 webhook secret。

查询催缴通知渠道：

```text
GET /api/platform-admin/dunning-notification-channels?enabled=true&limit=50
```

渠道响应不会返回原始 `webhookUrl`，只返回安全摘要：

```json
{
  "items": [
    {
      "id": "...",
      "channelCode": "finance_ops",
      "name": "平台财务催缴群",
      "enabled": true,
      "provider": "wecom",
      "secretRef": "app_private.platform_secrets:webhook:platform_dunning_finance_ops",
      "reminderTypes": ["overdue", "final_notice"],
      "reminderChannels": ["internal"],
      "minReminderLevel": 1,
      "tenantIds": [],
      "webhook": {
        "protocol": "https",
        "host": "qyapi.weixin.qq.com",
        "pathname": "/cgi-bin/webhook/send"
      }
    }
  ]
}
```

保存催缴通知渠道：

```http
PUT /api/platform-admin/dunning-notification-channels
Content-Type: application/json

{
  "channelCode": "finance_ops",
  "name": "平台财务催缴群",
  "provider": "wecom",
  "webhookUrl": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
  "secret": "optional-signing-secret",
  "reminderTypes": ["overdue", "final_notice"],
  "reminderChannels": ["internal"],
  "minReminderLevel": 1,
  "tenantIds": [],
  "timeoutSec": 10
}
```

查询发送事件：

```text
GET /api/platform-admin/dunning-notification-events?status=failed&limit=50
GET /api/platform-admin/dunning-notification-events?invoiceId=<invoiceId>&limit=50
```

前端展示建议：

- 工作台可展示启用渠道数、最近发送事件、失败数和待重试数；当前 `apps/taro/src/pages/platform-admin/workbench` 已接摘要第一版。
- 账务中心后续可在催缴记录旁展示外部通知状态，但不要把事件状态当作账单真实付款状态。
- `secret` 只在保存渠道时提交一次；后端写入 `app_private.platform_secrets`，响应只回显 `secretRef`。
- API 会对事件 `requestPayload` 递归脱敏，worker 会对联系人电话、邮箱做掩码。前端仍不要把 payload 原样写入日志。
- 生产 readiness 会阻断启用的非 HTTPS/localhost webhook；钉钉/飞书签名渠道必须有平台私密 secret。
- `platform-dunning-notifications` worker 适合在 `platform-dunning` 后每 5 到 15 分钟运行一次；发送成功后会把该催缴记录标记为 `sent`，发送失败会按退避策略重试并在终止失败后标记 `failed`。

下一批前端开发重点：

- 学生端：地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、逐题复盘、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认、个人中心消息摘要、独立消息中心、积分任务、积分兑换和积分明细第一版已接；下一批继续补真正 KaTeX/小程序公式方案、私有题图签名资源映射、小程序支付容器和分享场景。
- 租户后台：工作台已接权限驱动模块入口；学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务、完成跟进、跟进看板和 CRM 批量推送第一版；题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版；营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细、核销报表、积分任务/兑换操作台和用户通知查看第一版；财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版；租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版；下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台：租户创建、租户详情、状态变更、账务资料维护、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件状态摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、用量超额账单候选预览、超额 dry-run、超额账单生成、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑已接第一版；后端会跳过已开票订阅并记录 `platform.invoice.subscription_batch_created` 审计，`platform-billing` worker 会自动生成即将到期订阅账单并记录 `platform.invoice.subscription_auto_created` 审计，平台超额账单由后端按 `platform-usage` 用量快照、套餐额度和订阅 metadata 计算并记录 `platform.invoice.usage_overage_created` / `platform.invoice.usage_overage_batch_created` 审计，`platform-dunning` worker 会标记已过期未结清服务费账单、生成 `tenant_invoice_reminders` 并记录 `platform.invoice.overdue_processed` 审计，`platform-dunning-notifications` worker 会把内部催缴记录按渠道发送外部通知，`platform-audit-alerts` worker 会把高风险平台审计动作转换为内部告警，`platform-audit-notifications` worker 会把开放告警按渠道发送外部通知；前端只展示候选、预览结果、跳过结果、逾期处理结果、开放告警、通知事件和生成后的账单/审计，不要直接更新账单状态、租户 `billing_status`、告警表或通知事件表，也不要自行计算超额服务费；继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
- 小程序：验证 `Taro.login`、微信支付、分享 scene/referral、Supabase client 兼容性；如不稳定，保留 `apps/api/auth/*` 作为小程序登录适配层。

## AI 择校推荐接入

AI 择校推荐是学生端 SVIP 功能，前端只调用 `apps/api`，不要在 H5/小程序内保存任何 AI provider key、prompt secret 或服务端模型配置。当前后端默认使用 deterministic `local_rules` provider，基于学生目标地区和 `scoreline_records` 生成稳定 JSON 报告；已提供 Markdown/HTML 报告导出第一版。后续真实 AI provider 仍保持同一接口和 JSON schema。

生成报告：

```http
POST /api/ai/school-recommendations/generate
```

请求体：

```json
{
  "regionId": "<regionId>",
  "estimatedScore": 210,
  "riskPreference": "balanced",
  "constraints": "优先考虑计算机相关专业",
  "recommendationLimit": 5
}
```

`riskPreference` 支持 `safe`、`balanced`、`sprint`。`regionId` 不传时后端会使用 `/api/profile/me` 中学生档案的目标地区。后端会校验该地区属于当前租户，并要求当前学生有有效 SVIP；无权限时返回 `AI_SVIP_REQUIRED`。

响应核心结构：

```json
{
  "item": {
    "id": "<reportId>",
    "status": "generated",
    "provider": "local_rules",
    "model": "local-scoreline-rules-v1",
    "promptVersion": "school-recommendation-v1",
    "resultPayload": {
      "schemaVersion": "school-recommendation-report-v1",
      "summary": "基于当前地区历年分数线...",
      "riskLevel": "balanced",
      "recommendedSchools": [
        {
          "schoolId": "<schoolId>",
          "schoolName": "烟测学院",
          "majorId": "<majorId>",
          "majorName": "计算机科学与技术",
          "latestYear": 2026,
          "latestScore": 188,
          "scoreGap": 22,
          "riskLevel": "safe",
          "confidence": 0.9,
          "reason": "预估分与最新参考线差值约 22 分...",
          "scorelineTrend": {
            "years": [2026],
            "scores": [188],
            "direction": "unknown"
          },
          "tags": ["稳妥", "趋势不足", "样本较少"]
        }
      ],
      "actionPlan": [],
      "disclaimers": [],
      "dataCoverage": {}
    }
  }
}
```

报告历史：

```http
GET /api/ai/school-recommendations?regionId=<regionId>&limit=20
GET /api/ai/school-recommendations/detail?reportId=<reportId>
GET /api/ai/school-recommendations/export?reportId=<reportId>&format=markdown
GET /api/ai/school-recommendations/export?reportId=<reportId>&format=html
```

前端渲染建议：

- `resultPayload.schemaVersion` 必须等于 `school-recommendation-report-v1`，未知版本先降级为只展示 summary 和原始 JSON。
- `recommendedSchools` 是服务端已排序结果，前端不要重新按分数线做业务排序。
- `disclaimers` 必须展示在报告底部或导出 PDF 中。
- 报告详情只能展示当前登录学生自己的报告，遇到 `AI_REPORT_NOT_FOUND` 按“报告不存在或无权访问”处理。
- 导出接口只允许本人已生成报告，返回 `fileName/mimeType/contentText/contentBase64/sha256/sizeBytes`；H5 可用 `Blob` 下载，微信小程序可复制 `contentText` 或后续交给文件系统保存。
- 当前 Taro 基础页在 `pages/student/ai-school/index`，service 在 `src/services/ai.ts`，已接 Markdown/HTML 导出按钮。
- 当前导出是轻量报告交付，不是最终 PDF；PDF 建议后续走 worker 渲染并发布到 `content_assets`，继续沿用本人报告权限和审计。

## 租户后台前端建议

租户后台可以先做 H5 管理台，也可以后续使用 Taro H5 复用部分组件。优先页面：

- 概览：`/api/tenant-admin/overview`
- 数据看板：`/api/tenant-admin/dashboard`，展示收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态
- 品牌/主题/域名/公开设置
- 支付账户/登录 provider/密钥引用
- 用户与成员权限
- 班级/教师/学生：`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups`、`students/followups/report`、`students/supervision/preview`、`students/supervision/generate`、`students/crm-push`
- 角色模板与成员：`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable`、`GET/PUT /api/tenant-admin/members`、`POST /api/tenant-admin/members/disable`；当前 Taro 租户设置页已提供第一版创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员绑定模板和成员状态配置。
- 主题模板：`GET /api/tenant-admin/theme-templates`、`GET /api/tenant-admin/theme`、`POST /api/tenant-admin/theme/preview`、`POST /api/tenant-admin/theme/publish`；当前 Taro 租户设置页已提供第一版模板选择、主色/强调色、Logo/分享图、安全草稿预览和发布。
- 内容入口/分类树/题目集合/练习蓝图
- 题目/单词/知识手册/分数线/视频维护
- 题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 导入 preview/import/issues、字段映射、模板下载和导入后复检
- Banner/FAQ/公告/激活码/优惠券/积分任务/积分兑换，当前 Taro 租户营销中心已接 `GET/PUT /api/tenant-admin/coupons`、`GET /api/tenant-admin/coupons/redemptions`、`GET /api/tenant-admin/coupons/report`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`，用于配置复杂规则、查看核销明细、活动效果、任务领取和兑换记录。
- 勋章：`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`
- 考试日期：`GET/PUT /api/tenant-admin/exam-dates`
- 题目反馈：`GET /api/tenant-admin/feedbacks`、`GET /api/tenant-admin/feedbacks/report`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events`
- 销售/代理/CRM 队列、CRM 配置、CRM 跟进分配策略、CRM 死信失败池、脱敏日志、手动重试/忽略、销售/代理转化报表、分佣规则、成员分佣比例、分佣订单、结算单审核和线下打款登记；当前 Taro 租户营销中心已接 CRM 配置、队列、死信失败池、日志、重试/忽略、销售/代理转化、分佣、优惠券规则和核销报表第一版，财务运营页已接退款、官方账单、对账异常、差错工单和调整凭证第一版。真实打款 provider、发票、生产账单抽样验收和更完整财务复核体验后续增强。

CRM 分配策略由后端执行，前端只提交配置：

```json
{
  "assignmentMode": "round_robin",
  "assignmentPool": ["<salesUserId>", "<agentUserId>"]
}
```

`assignmentMode` 支持 `none`、`direct`、`round_robin`、`referrer`。后端会校验 `assignmentPool` 中的用户必须是当前租户内 active 的销售、代理、运营、教师或管理员；跨租户成员会返回 `CRM_ASSIGNMENT_POOL_INVALID`。首绑客资成功后，`lead.item.assignedToUserId` 是 CRM 跟进负责人，`lead.item.referrerUserId` 仍是受首绑保护的推广/分佣归属，两者不要在前端混用。CRM 队列 `payload.assignee` 可用于展示推送目标，但前端不能自行改写客资归属或分配游标。

CRM 死信运营统一走后端命令层：

```text
GET  /api/crm/dead-letters
GET  /api/crm/queue/logs?queueId=<crmQueueId>
POST /api/crm/queue/action
```

前端只展示后端返回的脱敏 payload/log，不能从日志里拼 webhook URL、secret 或 token。`retry` 用于 webhook 恢复后重新入队，`ignore` 用于租户确认不再处理的失败任务；按钮需要二次确认并要求填写原因，后端会记录操作者和审计。`pending/processing/sent` 任务不能被忽略，前端应按接口错误提示展示，不做本地状态覆盖。

租户后台不应在前端自行决定权限；隐藏菜单只是体验优化，接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验，成员绑定模板后，前端按模板的菜单/模块/字段权限渲染，后端按 permission keys 执行真正的访问控制。班级/学生范围权限由后端根据角色、模板 `dataScope.classIds` 和 `tenant_class_members` 计算，教师默认只能看到自己负责班级。

## 联调顺序

1. 启动页和租户解析。
2. 短信登录和 `auth/me`。
3. 首页、地区、内容入口、题库树。
4. 练习 session、答题、错题、收藏。
5. 背单词、知识手册、分数线。
6. 会员套餐、订单、激活码。
7. 资料下载、视频解析。
8. 销售追踪和分享链路。
9. 租户后台内容维护和导入。
10. 正式鉴权、真实支付、对象存储生产联调。

## Supabase Client 验证任务

前端 scaffold 后先做一个最小兼容性验证：

- H5：`@supabase/supabase-js` 初始化、session 持久化、token refresh、logout。
- 微信小程序：验证自定义 storage/fetch/URL polyfill 是否稳定。
- API：用 Supabase access token 调 `apps/api`，后端解析出可信用户。
- 安全：确认前端 bundle 中不存在 secret/service role/database/payment/storage 私钥。

如果微信小程序端 `supabase-js` 兼容性不稳定，小程序端改走 `apps/api/auth/*` 登录适配层，H5 继续使用 Supabase client 管理 Auth。
