forked from wangziqi/gongxue-base
2561 lines
105 KiB
Markdown
2561 lines
105 KiB
Markdown
# 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®ionId=...` |
|
||
| 租户主题模板 | `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 图片 ``、站内 `/...` 路径,或私有资源引用 `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®ionId=...&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®ionId=<可选地区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®ionId=<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": "<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 平台后台租户、套餐账单、用量、公共题库授权
|
||
```
|
||
|
||
验证命令:
|
||
|
||
```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。
|