Files
gongxue-base/docs/refactor/taro-frontend-integration.md
2026-06-30 02:50:13 +08:00

2561 lines
105 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Taro 前端对接指南
更新时间2026-06-29
目标:用一套 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` |
| 题目视频 | `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` |
| 资料下载/预览 | `/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` |
| 销售分享 | `/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/teachers` |
| 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` |
| 租户反馈处理 | `GET /api/tenant-admin/feedbacks``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 /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` |
## 资料、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` 后 |
规则使用 `conditionOperator` 的合法值 `gte``gt``lte``lt``eq``conditionValue` 为数字。触发成功的接口会返回 `autoBadges`,前端可据此弹出“获得勋章”提示;如果是重复签到、重复处理反馈或已获得过同一勋章,后端不会重复返回同一发放记录。
### 模考交卷与报告
全真模拟、试卷模式、顺序练习的最终报告都走后端交卷接口。前端不得传分数、正确数或题目范围;后端只信任 `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=...` | 返回排名、用户展示信息、当前用户排名和范围信息 |
错题复习创建 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不需要前端补点。
### 排行榜
排行榜由后端统一聚合,前端不要读取答题记录、单词进度或模考报告后自行排名,避免越权、口径漂移和跨租户数据泄露。
可选参数:
| 参数 | 可选值 | 说明 |
| --- | --- | --- |
| `metric` | `questions``score``vocabulary``mock_exam` | 分别表示累计答题、积分、掌握单词、模考最高分 |
| `period` | `all``7d``30d` | 统计周期 |
| `regionId` | UUID | 地区范围,可选 |
| `classId` | UUID | 班级范围,可选,后端按当前租户校验 |
| `limit` / `page` | 正整数 | 分页 |
响应会包含 `items``currentUser`。即使当前用户未进入前 N 名,也应优先展示 `currentUser` 作为“我的排名”。后台后续会补日/周榜预聚合和防刷策略,前端只消费接口返回口径。
### 租户数据看板
租户后台数据看板由后端统一聚合,前端不要直接读取订单、答题记录、学生列表后自行统计,避免权限越权、敏感信息外泄和各页面口径不一致。
请求:
```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/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、发票和批量凭证上传后续增强。
### 背单词计划与复习上报
背单词页面分三类数据:单元列表、每日计划、单词进度。前端不需要计算下次复习日期,只提交“认识/不认识”,由后端统一更新 `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` 返回匹配倒计时。
签到入口调用:
```http
POST /api/profile/check-in
```
返回关键字段:
```json
{
"item": {
"checkedIn": true,
"alreadyCheckedIn": false,
"pointsAdded": 10,
"streak": 1,
"score": 10,
"lastCheckInDate": "2026-06-29",
"autoBadges": [
{
"badgeId": "...",
"badge": {
"name": "连续签到",
"category": "activity",
"iconUrl": "https://..."
}
}
]
}
}
```
前端处理规则:
- `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。
- 积分明细调用 `GET /api/profile/score-events`
- 积分最终余额以后端 `score` 和流水为准,前端只做展示。
- 如响应包含 `autoBadges`,可展示获得勋章弹层;不要在前端根据连续天数或积分自行判断勋章是否解锁。
题目页、资料页或视频页可提交反馈:
```json
{
"questionId": "...",
"type": "question_error",
"category": "answer",
"title": "题目解析有误",
"description": "请填写具体问题",
"attachments": []
}
```
前端处理规则:
- `questionId` 如存在,后端会校验题目必须属于当前租户。
- 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。
- 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加;状态处理响应可能返回 `autoBadges`,后台或学生消息中心后续可据此做获得勋章提醒。
## 题库新模型接入方式
旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染:
```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`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
- 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。
视频导入前端注意:
- 列表和搜索接口不会给付费视频可播放 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
```
采纳请求:
```json
{
"grantId": "<授权ID>",
"entryName": "天津专升本公共题库",
"collectionName": "天津专升本公共题目",
"copyLimit": 500
}
```
前端处理规则:
- 租户只能看到后端判定为已授权的公共题库,不要在前端用套餐码自行过滤。
- 采纳成功后后端会生成本租户自己的 `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 和操作入口,不保存题目答案或解析。
- 租户后台可调用 `GET /api/tenant-content/notifications?notificationType=public_question_bank_conflict&status=unread&limit=20` 展示待处理同步消息;也可带 `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 自动同步后的通知、冲突查询结果、单条处理和批量处理按钮。后续继续补更完整运营消息、失败告警和生产定时调度。
## 登录对接
### 短信登录
开发环境可以先使用 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": "<userIdassign 时必填>",
"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 平台后台租户、套餐账单、用量、公共题库授权
```
验证命令:
```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 工程既有警告,不阻断联调。
下一批前端开发重点:
- 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情、售后入口、题干/解析/知识手册 RichContent 安全渲染、逐题复盘、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;下一批继续补真正 KaTeX/小程序公式方案、私有题图签名资源映射、背单词更细统计、小程序支付容器和分享场景。
- 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 CRM 配置保存、CRM 队列按状态查看、分佣默认规则、成员分佣比例、分佣订单明细、结算单生成、审核通过/驳回、标记线下打款、优惠券规则表单、筛选、核销明细和核销报表第一版;财务运营页已接退款申请/审核/供应商提交与查询、官方账单任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已接主题模板、草稿预览、发布、角色模板创建、编辑、停用、权限点、菜单、模块、字段、基础数据范围、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版;下一批继续补更精细的学生导入模板体验、真实生产账单抽样验收、真实打款 provider、发票、更细数据范围 UI 和主题素材库。
- 平台后台:租户创建、状态变更、订阅开通、账单生成、人工收款确认、用量录入、公共题库授权编辑已接第一版;继续补租户详情/编辑、平台审计、自动计费和批量账单操作。
- 小程序:验证 `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 报告;后续真实 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>
```
前端渲染建议:
- `resultPayload.schemaVersion` 必须等于 `school-recommendation-report-v1`,未知版本先降级为只展示 summary 和原始 JSON。
- `recommendedSchools` 是服务端已排序结果,前端不要重新按分数线做业务排序。
- `disclaimers` 必须展示在报告底部或导出 PDF 中。
- 报告详情只能展示当前登录学生自己的报告,遇到 `AI_REPORT_NOT_FOUND` 按“报告不存在或无权访问”处理。
- 当前 Taro 基础页在 `pages/student/ai-school/index`service 在 `src/services/ai.ts`
## 租户后台前端建议
租户后台可以先做 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`
- 角色模板与成员:`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/badges``GET/POST /api/tenant-admin/badge-grants`
- 考试日期:`GET/PUT /api/tenant-admin/exam-dates`
- 题目反馈:`GET /api/tenant-admin/feedbacks``POST /api/tenant-admin/feedbacks/status``GET /api/tenant-admin/feedbacks/events`
- 销售/代理/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` 可用于展示推送目标,但前端不能自行改写客资归属或分配游标。
租户后台不应在前端自行决定权限;隐藏菜单只是体验优化,接口仍会校验权限。角色模板用于让租户配置“运营、教师、销售、代理”等自定义后台体验,成员绑定模板后,前端按模板的菜单/模块/字段权限渲染,后端按 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。