# 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=`。 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=`。 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`。 本地迁移期仍可兼容旧请求头,但新的 Taro 请求封装必须按下面目标实现: ```text Authorization: Bearer x-tenant-id: # 仅作为登录前/公开目录租户上下文;登录后必须与 session 租户一致 ``` 生产目标: ```text Authorization: Bearer x-tenant-id: # 作为租户上下文,不能作为身份依据 ``` 前端不应再传 `x-user-id`、query/body `userId` 来表示当前用户。后端已经实现 Supabase JWT 和迁移 session 优先解析:如果 Authorization 存在,用户态接口以 token 映射出的业务用户为准;如果请求里伪造了不同的 `userId` 会返回 `AUTH_USER_MISMATCH`,伪造不同租户会返回 `AUTH_TENANT_MISMATCH` 或 `AUTH_SESSION_INVALID`。 H5 使用 Supabase Auth 时,推荐请求流程: ```ts const { data } = await supabase.auth.getSession(); const accessToken = data.session?.access_token; await api.request('/api/profile/me', { headers: { Authorization: `Bearer ${accessToken}`, 'x-tenant-id': tenantStore.tenantId, }, }); ``` 后端会通过 `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 接入的页面。 前端环境变量只允许包含: ```text TARO_APP_API_BASE_URL TARO_APP_SUPABASE_URL TARO_APP_SUPABASE_PUBLISHABLE_KEY ``` 禁止把 Supabase secret key、service role key、数据库连接串、对象存储密钥、支付私钥放进 Taro。 统一错误处理: | 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::catalog:entries tenant::profile tenant::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。 锁定资源 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=&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=&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 的题目。 ### 断点续练 Taro 可以缓存当前题号和答题卡用于刷新恢复体验,但跨设备、清缓存、小程序重启后的权威恢复必须调用: ```text GET /api/learning/practice-sessions/detail?practiceSessionId= ``` 响应会返回: ```json { "item": { "id": "...", "status": "active", "questionIds": ["..."], "questions": [], "answersByQuestion": { "": { "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` 对同一用户同一勋章幂等,不会重复生成多条发放记录。当前后端支持手动发放,自动发放规则后续由 worker/事件流补齐。 ### 模考交卷与报告 全真模拟、试卷模式、顺序练习的最终报告都走后端交卷接口。前端不得传分数、正确数或题目范围;后端只信任 `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`、连续正确、掌握状态和每日复习计划。 取今日计划: ```http GET /api/learning/vocabulary/review-plan?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` 作为后续展示依据,不在前端重算间隔。 - 旧的 `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=&includeLocked=true ``` 学生端 PDF/图片预览: ```http GET /api/catalog/assets/preview?assetId= ``` 学生端下载: ```http GET /api/catalog/assets/download?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": "", "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=&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=` 展示地区公开考试日期;个人中心优先用 `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" } } ``` 前端处理规则: - `alreadyCheckedIn=true` 时展示今日已签到,不要本地再加分。 - 积分明细调用 `GET /api/profile/score-events`。 - 积分最终余额以后端 `score` 和流水为准,前端只做展示。 题目页、资料页或视频页可提交反馈: ```json { "questionId": "...", "type": "question_error", "category": "answer", "title": "题目解析有误", "description": "请填写具体问题", "attachments": [] } ``` 前端处理规则: - `questionId` 如存在,后端会校验题目必须属于当前租户。 - 反馈状态由租户后台处理,学生可用 `GET /api/profile/feedbacks` 查看自己的反馈历史。 - 租户后台处理反馈时,奖励积分由后端 `idempotency_key` 保证不会重复发放,前端不要重复叠加。 ## 题库新模型接入方式 旧项目常按“地区 -> 科目 -> 章节/试卷”固定层级处理。新项目不要写死层级,按下面模型渲染: ```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": "", "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": "", "format": "json", "exportType": "questions", "includeAnswers": false, "includeExplanations": false, "options": { "title": "天津专升本题库导出" } } ``` 试卷 payload 导出: ```json { "scopeType": "content_node", "scopeId": "", "format": "paper_json", "exportType": "paper", "includeAnswers": true, "includeExplanations": true, "options": { "title": "全真模拟试卷", "durationMinutes": 120, "watermarkText": "仅供内部使用" } } ``` PDF/Word 异步导出: ```json { "scopeType": "collection", "scopeId": "", "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": "", "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": "", "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": "", "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 x-tenant-id: ``` ### 绑定或更换手机号 微信/QQ 登录后强制绑定手机号、个人中心更换手机号,都走同一个后端命令。前端先发送 `bind_phone` 用途验证码,再提交绑定: ```text POST /api/auth/sms/send body: { "phone": "13800000000", "purpose": "bind_phone" } POST /api/auth/phone/bind Authorization: Bearer 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": "", "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": "", "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": "", "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": "", "quantity": 1, "payProvider": "wechat_pay | alipay", "payMethod": "jsapi | wap", "regionId": "", "couponCode": "<可选,优惠券码>", "couponRedemptionId": "<可选,已领取优惠券 redemptionId>" } ``` 前端可以先领取优惠券,再下单: ```text POST /api/commerce/coupons/claim body: { "code": "", "planId": "", "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": [""], "allowedRegionIds": [""], "maxUses": 500, "metadata": { "channel": "poster" } } ``` 租户后台核销和报表: ```text GET /api/tenant-admin/coupons?status=active&campaignName=暑期活动 GET /api/tenant-admin/coupons/redemptions?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": "", "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= GET /api/commerce/orders/detail?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= 权限:tenant:refund:read ``` 创建退款申请: ```text POST /api/commerce/refunds 权限:tenant:refund:write body: { "orderNo": "", "refundNo": "<可选,前端幂等键>", "amountCents": 500, "reason": "用户协商退款", "entitlementAction": "revoke_on_success | none" } ``` 退款状态流转: ```text POST /api/commerce/refunds/status body: { "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=`、`POST /api/commerce/refunds/notify/alipay?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": "", "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=&matchStatus=missing_provider GET /api/commerce/reconciliation/anomalies?provider=wechat_pay ``` 从异常明细创建差错工单: ```text POST /api/commerce/reconciliation/issues/create 权限:tenant:reconciliation:write body: { "itemId": "", "assignedTo": "", "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=&batchId=&orderNo= 权限:tenant:reconciliation:read ``` 工单状态流转: ```text POST /api/commerce/reconciliation/issues/status 权限:tenant:reconciliation:write body: { "issueId": "", "action": "start | assign | resolve | ignore | escalate | reopen", "assignedTo": "", "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= 权限: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": "", "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": "", "adjustmentType": "write_off", "direction": "decrease", "amountCents": 990, "title": "供应商缺失账单人工核销凭证", "description": "仅作为财务复核证据", "assetId": "", "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= 权限:tenant:reconciliation:read ``` 复核凭证: ```text POST /api/commerce/adjustment-vouchers/status 权限:tenant:reconciliation:review body: { "voucherId": "", "status": "approved | rejected | voided", "reviewNote": "财务复核意见", "metadata": { "reviewChannel": "tenant-admin" } } ``` 查看凭证轨迹和报表: ```text GET /api/commerce/adjustment-vouchers/events?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": "", "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= /api/commerce/payments/notify/alipay?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/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/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 工程既有警告,不阻断联调。 下一批前端开发重点: - 学生端:地区选择、题目视频播放、题目反馈、错题/收藏专题页、模考交卷报告、收银台、订单详情和售后入口已接第一版;下一批继续补刷题细节 UI、小程序支付容器和分享场景。 - 租户后台:工作台已接权限驱动模块入口;学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、学生备注、跟进任务和完成跟进第一版;题库内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、模板预览/下载、异步任务轮询和导入后复检第一版;营销中心已接 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": "", "estimatedScore": 210, "riskPreference": "balanced", "constraints": "优先考虑计算机相关专业", "recommendationLimit": 5 } ``` `riskPreference` 支持 `safe`、`balanced`、`sprint`。`regionId` 不传时后端会使用 `/api/profile/me` 中学生档案的目标地区。后端会校验该地区属于当前租户,并要求当前学生有有效 SVIP;无权限时返回 `AI_SVIP_REQUIRED`。 响应核心结构: ```json { "item": { "id": "", "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": "", "schoolName": "烟测学院", "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=&limit=20 GET /api/ai/school-recommendations/detail?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": ["", ""] } ``` `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。