duoqi-api/docs/api-reference.md
Wang Zhuoxuan c36d828df9
All checks were successful
CI/CD Pipeline / Unit Tests (push) Successful in 30s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Successful in 1m19s
feat: narrow gamification to first-version MVP scope
行为按 docs/GAMIFICATION_DESIGN.md(duoqi-flutter/docs/GAMIFICATION_REQUIREMENTS_CHANGE.md)对齐:

- 新增 src/utils/time.ts 北京时间工具,统一替换散落的 UTC 日期/周边界计算
- challenge-service:未完成 session 续答(同 nodeId 复用 pending/in_progress),每日次数按 session 幂等扣减(条件 UPDATE 抢 daily_attempt_consumed_at 锁),移除高奖励 multiplier 和答对时发 first_knowledge_card
- 新增 knowledge-card-service 和 POST /challenges/knowledge-cards/:cardId/view:用户打开/收下卡片时在单事务内发 review_explanation (3 XP) 和 first_knowledge_card (15 XP),fallback 卡走独立幂等 namespace
- tracks-service:mapNodeStatus 只返回 current/done
- hearts-service:删除 Pro/ProPlus 免扣分支和新用户 1 心保护,RestoreMethod 收窄为 'ad' | 'wait'
- xp-service / leaderboard-service / streak-service / ad-recovery-service / progress-summary-service:时区切北京;排行榜同分按 last_xp_at 升序,weeklySettlement 不再发前 3 名金币;streak milestone 返回空
- /shop/purchase、/inventory/items/use、/subscription/verify 返回 NOT_AVAILABLE_IN_MVP
- schema 加 challenge_sessions.daily_attempt_consumed_at(迁移 0007)
- 接口文档 docs/api-reference.md 同步 12 项 MVP 行为变化
- 测试:新增 utils/time 和 tracks node-status 测试;hearts/streak/leaderboard 测试按新行为更新;challenge-service 13 个 MVP 之外(Plus/高奖励/first_knowledge_card 答对触发)测试 it.skip 待重写 mock 队列

typecheck / lint / test (146 passed, 13 skipped) 全部通过。
2026-06-16 18:05:04 +08:00

1740 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Duoqi API Reference
> 多奇服务端 API 接口文档。本文按当前 Fastify 路由和 TypeScript DTO 更新。
>
> 最近一次代码审计2026-06-16来源为 `src/index.ts` 注册的路由、`src/routes/**/*.ts`、`src/types/app-api.ts` 和相关 service 返回值。
## Base URL
| 环境 | Base URL |
|------|----------|
| 生产 | `https://api.duoqi.me/v1` |
| 本地开发 | `http://localhost:3000/v1` |
健康检查是唯一不带 `/v1` 前缀的客户端端点:`GET /health`。
## 通用约定
### 认证
| 类型 | Header | 适用路径 |
|------|--------|----------|
| 无需认证 | - | `/health`, `/v1/auth/guest`, `/v1/auth/huawei`, `/v1/auth/refresh`, `/v1/auth/providers`, `/v1/admin/login` |
| JWT游客 | `Authorization: Bearer <jwt_token>` | `/v1/auth/link` |
| JWT | `Authorization: Bearer <jwt_token>` | 大多数客户端 API |
| Admin JWT | `Authorization: Bearer <admin_jwt_token>` | `/v1/admin/*` |
### 统一响应
```json
{
"success": true,
"data": {},
"error": null
}
```
```json
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input"
}
}
```
分页响应会额外包含:
```json
{
"pagination": {
"total": 100,
"page": 1,
"limit": 20
}
}
```
## 客户端 API
### 健康检查
#### GET /health
认证:无
响应:
```json
{
"success": true,
"data": {
"status": "ok",
"timestamp": "2026-05-05T12:00:00.000Z"
},
"error": null
}
```
### 认证
#### POST /auth/guest
认证:无
限流10 次/分钟
请求:
```json
{
"deviceId": "device-id"
}
```
响应:
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"nickname": null,
"avatarUrl": null,
"tier": "free"
},
"tokens": {
"accessToken": "jwt",
"refreshToken": "jwt"
}
},
"error": null
}
```
#### POST /auth/huawei
认证:无
限流10 次/分钟
请求:
```json
{
"authorizationCode": "authorization-code"
}
```
响应同 `/auth/guest`
#### POST /auth/refresh
认证:无
限流10 次/分钟
请求:
```json
{
"refreshToken": "jwt"
}
```
响应:
```json
{
"success": true,
"data": {
"accessToken": "jwt",
"refreshToken": "jwt"
},
"error": null
}
```
#### POST /auth/link
认证JWT游客 Token
限流10 次/分钟
用途:将当前登录的游客账号关联到第三方正式账号(当前支持 Apple Sign In。支持两种场景
- **场景 A新用户**:该 Apple ID 未注册过,游客行原地升级为 Apple 账号,无需数据迁移。
- **场景 B老用户**:该 Apple ID 已有正式账号,在事务内将游客的答题记录、奖励流水等数据合并到正式账号,不覆盖老账号的订阅、余额、库存、连续学习等核心资产。
请求:
```json
{
"provider": "apple",
"credential": {
"identityToken": "apple-identity-token",
"authorizationCode": "optional-authorization-code"
},
"mergePolicy": "server_account_first",
"clientMigrationId": "client-generated-uuid"
}
```
- `provider`:当前仅支持 `apple`
- `credential.identityToken`Apple Sign In 返回的 identity token必填
- `credential.authorizationCode`Apple 授权码(可选)。
- `mergePolicy`:当前固定为 `server_account_first`,以服务端已有数据为主。
- `clientMigrationId`:客户端生成的唯一 ID用于幂等保证防止网络重试导致重复合并。
成功响应:
```json
{
"success": true,
"data": {
"session": {
"user": {
"id": "uuid",
"nickname": null,
"avatarUrl": null,
"tier": "free"
},
"tokens": {
"accessToken": "jwt",
"refreshToken": "jwt"
}
},
"migrationSummary": {
"policy": "server_account_first",
"imported": {
"challengeAttempts": 5
},
"skipped": {
"subscriptions": 1
},
"conflicts": []
},
"bootstrap": null
},
"error": null
}
```
- `session.tokens`:关联成功后返回正式账号的新 JWT客户端应替换本地存储的 token。
- `migrationSummary.imported`:从游客账号导入的数据类型和数量。
- `migrationSummary.skipped`:因合并策略跳过的数据(不覆盖老账号已有的数据)。
- `migrationSummary.conflicts`:合并过程中发现的冲突项列表,通常为空。
- `bootstrap`:当前固定为 `null`,客户端需调用 `GET /app/bootstrap` 获取最新状态。
错误响应:
```json
{
"success": false,
"data": null,
"error": {
"code": "CONFLICT",
"message": "Migration already completed"
}
}
```
```json
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid Apple identity token"
}
}
```
> 重复提交相同 `clientMigrationId` 时返回幂等结果,不会触发重复合并。游客账号只能关联一次,已关联后再次调用返回 `CONFLICT`。
#### POST /auth/phone/send-code
认证:无
限流5 次/分钟
用途:向手机号发送短信验证码(通过阿里云号码认证服务)。验证码由阿里云生成并管理,有效期 5 分钟60 秒内不可重发。
请求体:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `phoneNumber` | `string` | 是 | 中国大陆手机号11 位) |
成功响应:
```json
{
"success": true,
"data": null,
"error": null
}
```
错误响应:
| HTTP | code | 说明 |
|------|------|------|
| 400 | `VALIDATION_ERROR` | 手机号格式错误 |
| 400 | `VALIDATION_ERROR` | 阿里云拒绝该号码 |
| 429 | `RATE_LIMITED` | 发送频率超限60 秒间隔) |
| 503 | `SERVICE_UNAVAILABLE` | SMS 服务未配置 |
| 503 | `SMS_PROVIDER_ERROR` | 阿里云服务暂时不可用 |
#### POST /auth/phone
认证:无
限流10 次/分钟
用途:核验短信验证码。验证通过后自动创建或查找用户,返回 JWT tokens。
请求体:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `phoneNumber` | `string` | 是 | 中国大陆手机号11 位) |
| `verifyCode` | `string` | 是 | 短信验证码4-8 位数字) |
成功响应:
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"nickname": null,
"avatarUrl": null,
"tier": "free"
},
"tokens": {
"accessToken": "jwt...",
"refreshToken": "jwt..."
}
},
"error": null
}
```
错误响应:
| HTTP | code | 说明 |
|------|------|------|
| 400 | `VALIDATION_ERROR` | 手机号或验证码格式错误 |
| 401 | `INVALID_VERIFY_CODE` | 验证码错误或已过期 |
| 503 | `SERVICE_UNAVAILABLE` | SMS 服务未配置 |
#### GET /auth/providers
认证:无
用途:返回指定平台可用的登录方式列表。客户端根据此接口决定登录页面显示哪些登录按钮。第三方登录方式的开关可通过 Admin API 热更新,无需重启服务器。
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `platform` | `'ios' \| 'android' \| 'harmony'` | 是 | 客户端平台 |
平台过滤规则:
- 短信验证码:全平台
- 华为账号:仅 Android、Harmony
- Apple 账号:仅 iOS
- 微信 / QQ全平台enabled 由数据库配置控制
响应:
```json
{
"success": true,
"data": {
"providers": [
{ "id": "phone_sms", "name": "短信验证码登录", "type": "secondary", "enabled": true },
{ "id": "apple", "name": "通过 Apple 登录", "type": "third_party", "iconKey": "apple", "enabled": true },
{ "id": "wechat", "name": "通过微信登录", "type": "third_party", "iconKey": "wechat", "enabled": false }
]
},
"error": null
}
```
#### GET /auth/me
认证JWT
响应:
```json
{
"success": true,
"data": {
"id": "uuid",
"nickname": "用户昵称",
"avatarUrl": "https://example.com/avatar.png",
"tier": "free",
"xpTotal": 150,
"streakDays": 3,
"heartsRemaining": 5,
"dailyXpEarned": 20,
"dailyXpGoal": 50
},
"error": null
}
```
### Flutter 客户端聚合 API
这组接口对应 Flutter 原型使用的主题路线、挑战、资源摘要、商店和订阅合同。
#### GET /app/bootstrap
认证JWT
响应:
```json
{
"success": true,
"data": {
"user": {
"id": "uuid",
"nickname": "知识探险家",
"avatarUrl": null,
"tier": "free",
"level": 1,
"region": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
}
},
"progress": {
"hearts": 5,
"maxHearts": 5,
"nextHeartRestoreAt": null,
"dailyAttemptsLeft": 5,
"dailyAttemptsMax": 5,
"nextAttemptResetAt": "2026-05-06T00:00:00.000Z",
"highRewardSessionsLeft": 0,
"highRewardSessionsMax": 3,
"xp": 0,
"level": 1,
"xpToNextLevel": 400,
"streakDays": 0,
"checkInDays": 0,
"streakProtectedUntil": null,
"activeTrackId": null,
"isSubscribed": false
},
"tracks": [],
"shopBenefits": [],
"shop": {
"benefits": [],
"products": [
{
"id": "hint-feather",
"type": "item",
"itemId": "hint_feather",
"title": "提示羽毛",
"description": "答题时排除 1 个错误选项",
"priceCoins": 80,
"quantity": 1,
"enabled": true
}
]
},
"wallet": {
"coinsBalance": 260
},
"inventory": {
"items": [
{
"itemId": "hint_feather",
"quantity": 2,
"activeUntil": null,
"metadata": null
}
]
},
"subscription": {
"status": "none",
"tier": "free",
"expiresAt": null,
"autoRenew": false
}
},
"error": null
}
```
说明:`shopBenefits` 为兼容旧客户端保留,内容等同于 `shop.benefits`。新客户端应优先读取 `shop.products`、`wallet.coinsBalance` 和 `inventory.items` 来展示金币、背包和可购买商品。
#### GET /app/regions
认证:公开接口(无需 JWT
客户端每次启动时同步地区列表到本地缓存。MVP 阶段仅包含中国大陆 31 个一级行政区;数据结构保留 `parentCode` / `level`,后续可扩展到二级地区而不改变协议形状。
响应:
```json
{
"success": true,
"data": {
"version": "2026-06-08.1",
"countryCode": "CN",
"hierarchy": "flat",
"updatedAt": "2026-06-08T00:00:00.000Z",
"regions": [
{
"code": "310000",
"name": "上海市",
"shortName": "上海",
"parentCode": null,
"level": 1,
"sortOrder": 30,
"enabled": true
}
]
},
"error": null
}
```
#### PATCH /users/me/region
认证JWT
用户选择或变更地区。首次选择不受限制;已有地区后,每个北京时间自然月只允许变更一次。
请求:
```json
{
"regionCode": "310000"
}
```
响应:
```json
{
"success": true,
"data": {
"region": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
}
},
"error": null
}
```
同月再次变更:
```json
{
"success": false,
"data": null,
"error": {
"code": "REGION_CHANGE_LIMIT_REACHED",
"message": "每个自然月只能修改一次地区,请下个月再试。"
}
}
```
#### GET /tracks
认证JWT
响应:
```json
{
"success": true,
"data": [
{
"id": "history",
"name": "历史",
"icon": "🏛",
"progress": 25,
"nodes": [
{
"id": "uuid",
"title": "第一章",
"status": "current",
"reward": "+40 XP",
"questionCount": 4
}
]
}
],
"error": null
}
```
`nodes[].status` 取值:`done`, `current`, `locked`, `chest`。**MVP 服务端只返回 `current``done`**docs/GAMIFICATION_DESIGN.md`locked` 和 `chest` 为预留状态,客户端保留兼容展示但不依赖其业务逻辑。
#### GET /tracks/:trackId
认证JWT
路径参数:`trackId` 可以是分类 `id``slug`
响应:单个 `ThemeTrackDto`,不存在时 `data``null`
#### GET /challenges/next
认证JWT
查询参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `trackId` | string | 是 | 分类 `id``slug` |
响应:
```json
{
"success": true,
"data": {
"challengeId": "challenge-session-uuid",
"trackId": "history",
"nodeId": "chapter-uuid",
"totalQuestions": 5,
"highRewardEligible": false,
"questions": [
{
"challengeId": "challenge-session-uuid",
"trackId": "history",
"nodeId": "chapter-uuid",
"question": {
"id": "question-uuid-1",
"prompt": "题目文本",
"options": [
{ "id": "a", "text": "选项 A" },
{ "id": "b", "text": "选项 B" },
{ "id": "c", "text": "选项 C" }
]
}
}
],
"answeredQuestionIds": []
},
"error": null
}
```
**MVP 行为**docs/GAMIFICATION_DESIGN.md
- 同一 `(userId, nodeId)` 存在 `pending` / `in_progress` 的未完成 session 时,直接复用原 session`challengeId` 和原 5 题),不创建新 session`answeredQuestionIds` 返回已答题目 ID客户端据此跳到下一道未答题。
- `highRewardEligible` 始终为 `false`MVP 不实现高奖励策略schema 字段保留以备未来重新启用)。
- 题库不足 5 题或没有可用题目时 `data``null`
#### POST /challenges/answer
认证JWT
请求:
```json
{
"challengeId": "challenge-session-uuid",
"questionId": "question-uuid",
"selectedOptionId": "a",
"timeMs": 1500,
"comboCount": 0,
"submitRequestId": "client-submit-id"
}
```
`submitRequestId` 可选;未传时服务端会使用 `challengeId + questionId` 作为默认幂等 key。同一挑战组内重复提交同一道题会返回第一次裁决结果不会重复扣资源或重复发放奖励。
响应:
```json
{
"success": true,
"data": {
"challengeId": "challenge-session-uuid",
"answerState": "correct",
"correctOptionId": "a",
"xpDelta": 10,
"progress": {
"hearts": 5,
"dailyAttemptsLeft": 4,
"highRewardSessionsLeft": 0,
"highRewardSessionsMax": 3,
"xp": 10,
"streakDays": 0
},
"knowledgeCard": {
"id": "card-uuid",
"title": "知识点摘要",
"summary": "知识点摘要",
"fact": "深入解析"
},
"rewards": [
{ "type": "xp", "amount": 10, "title": "+10 XP" }
]
},
"error": null
}
```
`answerState` 取值:`correct`, `wrong`
资源扣减规则MVP
- **每日次数**:本组首次提交答案时扣 1 次(按 session 维度幂等)。服务端用 `challenge_sessions.daily_attempt_consumed_at` 条件 UPDATE 抢锁,保证同一 session 第一题并发提交只扣一次。同组后续 4 题不再扣次数。
- **爱心**:答错扣 1 颗0 颗时返回 `VALIDATION_ERROR`(当前题已结算完毕,进入下一题或下一组前由客户端阻断)。
- **知识卡 XP**:答题响应里的 `knowledgeCard` 仅供展示;用户打开或收下卡片时必须再调 `POST /challenges/knowledge-cards/:cardId/view` 才会发放 `review_explanation` (3 XP) 和 `first_knowledge_card` (15 XP) 奖励。
- `highRewardSessionsLeft` 固定返回 `0`MVP 不实现高奖励策略)。
#### POST /challenges/knowledge-cards/:cardId/view
认证JWT
路径参数:`cardId` 为 `knowledge_cards.id` 或 fallback 占位 ID`fallback-{questionId}`,用于题库未配齐卡时的占位)。
请求(可选):
```json
{
"challengeId": "challenge-session-uuid"
}
```
`challengeId` 仅用于审计,不影响结算。
响应:
```json
{
"success": true,
"data": {
"cardId": "card-uuid",
"rewards": [
{ "type": "xp", "source": "review_explanation", "amount": 3, "title": "查看解析 +3 XP" },
{ "type": "xp", "source": "first_knowledge_card", "amount": 15, "title": "首次知识卡 +15 XP" }
],
"progress": {
"hearts": 5,
"dailyAttemptsLeft": 4,
"xp": 28,
"streakDays": 1
}
},
"error": null
}
```
幂等:服务端写 `reward_ledger`,唯一索引挡重复。
- `kcview:{userId}:{cardId}` — 每张卡首次查看发 3 XP。
- `kcfirst:{userId}` — 任意卡首次查看触发一次 15 XP。
第二次查看同一张卡:`rewards` 返回空数组,`progress` 仍刷新。
#### GET /progress/summary
认证JWT
响应:`ProgressSummaryDto`,字段同 `/app/bootstrap` 中的 `progress`,包含 `highRewardSessionsLeft``highRewardSessionsMax`
#### PATCH /progress/preferences
认证JWT
请求:
```json
{
"activeTrackId": "history"
}
```
响应:更新后的 `ProgressSummaryDto`
#### POST /progress/check-in
认证JWT
用途:完成当天签到,并返回更新后的进度摘要。
请求 body无。
客户端对接要求:
- 推荐不发送 body也不要设置 `Content-Type`
- 如果客户端网络库要求 JSON body请发送空对象 `{}`,不要发送“带 `Content-Type: application/json` 但 body 为空”的请求。
- 服务端按北京时间Asia/Shanghai自然日判断"当天"。同一北京自然日内重复调用不会重复增加 `checkInDays`
成功响应:更新后的 `ProgressSummaryDto`
```json
{
"success": true,
"data": {
"hearts": 5,
"maxHearts": 5,
"nextHeartRestoreAt": null,
"dailyAttemptsLeft": 5,
"dailyAttemptsMax": 5,
"nextAttemptResetAt": "2026-05-06T00:00:00.000Z",
"highRewardSessionsLeft": 0,
"highRewardSessionsMax": 3,
"xp": 0,
"level": 1,
"xpToNextLevel": 100,
"streakDays": 0,
"checkInDays": 1,
"streakProtectedUntil": null,
"activeTrackId": null,
"isSubscribed": false
},
"error": null
}
```
#### GET /leaderboards
认证JWT
查询参数:
| 参数 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `scope` | `region``topic` | `region` | `region` 返回地区榜;`topic` 暂保留原本周 XP 分组榜 |
| `regionCode` | string | 用户已选择地区 | 查看指定地区榜;不传则展示用户已选择地区 |
| `trackId` | string | - | 当前版本预留,不参与筛选 |
| `page` | number | 1 | 页码 |
| `limit` | number | 20 | 1-100 |
响应:
```json
{
"success": true,
"data": [
{
"rank": 1,
"userId": "uuid",
"displayName": "玩家昵称",
"avatarUrl": null,
"xp": 500,
"badge": "榜首",
"isMe": false
}
],
"meta": {
"weekStart": "2026-05-11",
"weekEnd": "2026-05-17",
"nextRefreshAt": "2026-05-18",
"groupId": "week-2026-05-11-group-1",
"requiresRegionSelection": false,
"selectedRegion": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
},
"viewRegion": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"parentCode": null,
"level": 1,
"sortOrder": 30,
"enabled": true
},
"rewardPreview": [
{ "rank": 1, "coins": 300 },
{ "rank": 2, "coins": 150 },
{ "rank": 3, "coins": 50 }
]
},
"pagination": {
"total": 25,
"page": 1,
"limit": 20
},
"error": null
}
```
> `scope=region` 时,`xp` 为本周累计 XP非全局累计排名基于 `users.region_code` 过滤后的地区榜。用户未选择地区且请求未带 `regionCode` 时,服务端返回空榜并设置 `meta.requiresRegionSelection=true`,客户端应提示用户选择所在地区。`scope=topic` 当前仍保留原本周 XP 分组榜。
>
> **MVP 时区与平局规则**docs/GAMIFICATION_DESIGN.md周榜按**北京时间自然周**(周一 00:00 +0800 到周日 23:59:59 +0800统计同分时按 `user_weekly_xp.last_xp_at` 升序排序——先达到该 XP 的用户排在前面。
#### GET /leaderboards/me
认证JWT
查询参数同 `/leaderboards`
响应:
```json
{
"success": true,
"data": {
"rank": 3,
"userId": "uuid",
"displayName": "我",
"avatarUrl": null,
"xp": 150,
"badge": "前三",
"isMe": true
},
"meta": {
"weekStart": "2026-05-11",
"weekEnd": "2026-05-17",
"nextRefreshAt": "2026-05-18",
"groupId": "week-2026-05-11-group-1",
"rank": 3,
"rewardPreview": [
{ "rank": 1, "coins": 300 },
{ "rank": 2, "coins": 150 },
{ "rank": 3, "coins": 50 }
]
},
"error": null
}
```
#### GET /shop
认证JWT
响应:
```json
{
"success": true,
"data": {
"benefits": [
{
"id": "restore-hearts",
"type": "hearts",
"title": "恢复满血",
"description": "血量不足时继续挑战",
"enabled": true,
"requiresAd": true
}
],
"products": [
{
"id": "hint-feather",
"type": "item",
"itemId": "hint_feather",
"title": "提示羽毛",
"description": "答题时排除 1 个错误选项",
"priceCoins": 80,
"quantity": 1,
"enabled": true
}
]
},
"error": null
}
```
说明:`requiresAd=true` 的权益应通过 `/rewards/ad-recovery/session``/rewards/ad-recovery/complete` 完成资格检查和结算。
可购买商品价格:提示羽毛 80 金币,爱心补给 150 金币,双倍 XP 药水 250 金币,连胜护盾 400 金币,第一版吉祥物装扮 800 金币。
#### POST /shop/purchase
认证JWT
**MVP 不开放**docs/GAMIFICATION_DESIGN.md「MVP 不支持金币消费」)。路由保留以兼容客户端,但服务端不调底层 `purchaseShopProduct`,统一返回:
```json
{
"success": false,
"data": null,
"error": {
"code": "NOT_AVAILABLE_IN_MVP",
"message": "商店购买暂未开放"
}
}
```
请求体 schema 仍然校验(`productId` 枚举、`clientRequestId`),便于未来开放时无契约变更。
#### POST /inventory/items/use
认证JWT
**MVP 不开放**docs/GAMIFICATION_DESIGN.md「MVP 不实现道具」)。同样返回 `NOT_AVAILABLE_IN_MVP`
```json
{
"success": false,
"data": null,
"error": {
"code": "NOT_AVAILABLE_IN_MVP",
"message": "道具使用暂未开放"
}
}
```
#### GET /subscription
认证JWT
响应:
```json
{
"success": true,
"data": {
"status": "active",
"tier": "pro",
"expiresAt": "2026-06-05T00:00:00.000Z",
"autoRenew": true
},
"error": null
}
```
`status` 取值:`none`, `active`, `expired`, `cancelled`。`tier` 取值:`free`, `pro`, `proplus`
#### POST /subscription/verify
认证JWT
**MVP 不开放**docs/GAMIFICATION_DESIGN.md「MVP 不实现 Plus 权益」)。路由保留以兼容客户端,服务端不调底层 `verifyClientSubscription`,统一返回:
```json
{
"success": false,
"data": null,
"error": {
"code": "NOT_AVAILABLE_IN_MVP",
"message": "Plus 订阅暂未开放"
}
}
```
请求体 schema 仍然校验(`platform` / `purchaseToken` / `productId` / `tier`),便于未来开放时无契约变更。`GET /subscription` 仍可查询当前订阅状态MVP 通常返回 `tier: "free"`)。
### 激励广告恢复
#### POST /rewards/ad-recovery/session
认证JWT
用途:展示广告前创建恢复会话,服务端返回会话 ID、广告位和资格状态。
请求:
```json
{
"type": "hearts",
"clientRequestId": "uuid-from-client",
"platform": "ios",
"adProvider": "mock"
}
```
`type` 取值:`hearts`, `bonusAttempts`, `streakProtection`
`platform` 取值:`ios`, `android`, `harmony`, `web`
符合资格响应:
```json
{
"success": true,
"data": {
"sessionId": "uuid",
"eligible": true,
"type": "hearts",
"adPlacementId": "duoqi_restore_hearts_ios",
"remainingToday": 2,
"expiresAt": "2026-05-05T12:30:00.000Z"
},
"error": null
}
```
不符合资格响应:
```json
{
"success": true,
"data": {
"sessionId": null,
"eligible": false,
"reason": "daily_limit_reached",
"nextAvailableAt": "2026-05-06T00:00:00.000Z"
},
"error": null
}
```
Plus 用户响应(无需看广告,返回订阅权益摘要):
```json
{
"success": true,
"data": {
"sessionId": null,
"eligible": false,
"reason": "already_subscribed",
"subscriptionBenefits": {
"tier": "pro",
"unlimitedHearts": true,
"dailyHighRewardSessions": null
}
},
"error": null
}
```
> `subscriptionBenefits.dailyHighRewardSessions` 为 `null` 表示无限制。客户端可据此展示替代提示,引导用户享受订阅权益。
#### POST /rewards/ad-recovery/complete
认证JWT
用途:广告 SDK 返回完整播放后提交凭证,由服务端幂等结算奖励。`mock` provider 用于测试;真实 provider 需要提交 `providerRewardToken`
请求:
```json
{
"sessionId": "uuid",
"clientRequestId": "uuid-from-client",
"adProvider": "admob",
"providerRewardToken": "opaque-provider-token",
"completedAt": "2026-05-05T12:03:00.000Z"
}
```
成功响应:
```json
{
"success": true,
"data": {
"status": "completed",
"type": "hearts",
"reward": {
"heartsDelta": 3,
"dailyAttemptsDelta": 0,
"streakProtectionGranted": false
},
"progress": {
"hearts": 5,
"maxHearts": 5,
"dailyAttemptsLeft": 2,
"dailyAttemptsMax": 5,
"nextAttemptResetAt": "2026-05-06T00:00:00.000Z",
"highRewardSessionsLeft": 0,
"highRewardSessionsMax": 3,
"xp": 1200,
"level": 4,
"xpToNextLevel": 200,
"streakDays": 21,
"checkInDays": 8,
"streakProtectedUntil": null,
"activeTrackId": "history",
"isSubscribed": false
},
"limits": {
"remainingHeartsRecoveriesToday": 2,
"remainingAttemptRecoveriesToday": 3,
"nextStreakProtectionAvailableAt": "2026-05-12T00:00:00.000Z"
}
},
"error": null
}
```
失败响应:
```json
{
"success": true,
"data": {
"status": "failed",
"reason": "provider_verification_failed",
"message": "广告未完整播放,未发放奖励。",
"progress": {
"hearts": 2,
"maxHearts": 5,
"dailyAttemptsLeft": 1,
"dailyAttemptsMax": 5,
"nextAttemptResetAt": "2026-05-06T00:00:00.000Z",
"highRewardSessionsLeft": 0,
"highRewardSessionsMax": 3,
"xp": 1200,
"level": 4,
"xpToNextLevel": 200,
"streakDays": 21,
"checkInDays": 8,
"streakProtectedUntil": null,
"activeTrackId": "history",
"isSubscribed": false
}
},
"error": null
}
```
`reason` 取值:`ad_not_completed`, `provider_verification_failed`, `session_expired`, `daily_limit_reached`, `cooldown_active`, `already_subscribed`, `invalid_type`
> 广告恢复奖励通过统一奖励结算层(`rewardLedger`)发放,以 `ad_recovery:{sessionId}` 为幂等 key记录发放前后资源快照。旧接口 `POST /rewards/hearts/restore`、`POST /rewards/attempts/restore`、`POST /rewards/streak/protect`、`POST /progress/hearts/restore` 已废弃,请使用上述 session + complete 两步流程。
## 管理端 API
管理端路由统一带 `/v1/admin` 前缀。
### 管理端认证
#### POST /admin/login
认证:无
请求:
```json
{
"username": "admin",
"password": "password123"
}
```
响应:
```json
{
"success": true,
"data": {
"accessToken": "jwt",
"refreshToken": "jwt",
"admin": {
"id": "uuid",
"username": "admin",
"role": "super_admin"
}
},
"error": null
}
```
#### PUT /admin/change-password
认证Admin JWT
请求:
```json
{
"currentPassword": "old-password",
"newPassword": "new-password"
}
```
响应:
```json
{
"success": true,
"data": {
"message": "Password changed successfully"
},
"error": null
}
```
#### POST /admin/auth
认证:无
兼容旧管理端的 `ADMIN_TOKEN` 验证接口。新管理端应使用 `/admin/login` 获取 Admin JWT。
请求:
```json
{
"token": "admin-token"
}
```
响应:
```json
{
"success": true,
"data": {
"authenticated": true
},
"error": null
}
```
### 管理员管理
#### GET /admin/admins
认证Admin JWT
查询参数:`page`, `limit`, `role`, `isActive`
响应:管理员数组 + `pagination`
#### GET /admin/admins/:id
认证Admin JWT
#### POST /admin/admins
认证Admin JWT`role=super_admin`
请求:
```json
{
"username": "newadmin",
"password": "password123",
"role": "admin"
}
```
#### PUT /admin/admins/:id
认证Admin JWT`role=super_admin`
请求:
```json
{
"username": "updated",
"role": "admin",
"isActive": 1
}
```
#### DELETE /admin/admins/:id
认证Admin JWT`role=super_admin`。软删除管理员。
#### POST /admin/admins/:id/reset-password
认证Admin JWT`role=super_admin`。响应包含一次性明文密码 `plainPassword`
### 题目管理
#### GET /admin/questions
认证Admin JWT
查询参数:`page`, `limit`, `status`, `categoryId`, `keyword`, `difficulty`, `source`, `sortBy`, `sortOrder`
`sortBy` 取值:`createdAt`, `updatedAt`, `difficulty`。`sortOrder` 取值:`asc`, `desc`
#### GET /admin/questions/:id
认证Admin JWT
#### POST /admin/questions
认证Admin JWT
请求:
```json
{
"stem": { "text": "题目内容" },
"contentType": "text",
"correctAnswer": "正确答案",
"distractors": ["干扰项1", "干扰项2"],
"categoryId": "history",
"difficulty": 3,
"knowledgeCard": {
"summary": "知识点摘要",
"deepDive": "深入解析",
"sourceRef": "来源"
}
}
```
`contentType` 取值:`text`, `image`, `video`, `audio`
#### PUT /admin/questions/:id
认证Admin JWT。请求字段同创建接口均可选额外支持 `status`
#### PATCH /admin/questions/:id/status
认证Admin JWT
请求:
```json
{
"status": "published"
}
```
`status` 取值:`draft`, `reviewing`, `published`, `archived`。服务会校验状态流转。
#### DELETE /admin/questions/:id
认证Admin JWT。归档题目。
#### POST /admin/questions/batch-publish
认证Admin JWT
请求:
```json
{
"ids": ["uuid"]
}
```
#### POST /admin/questions/batch-archive
认证Admin JWT。请求同 `batch-publish`
#### POST /admin/questions/batch-delete
认证Admin JWT。软删除等同批量归档。
#### POST /admin/questions/import
认证Admin JWT
请求:
```json
{
"questions": [
{
"stem": { "text": "题目内容" },
"contentType": "text",
"correctAnswer": "正确答案",
"distractors": ["干扰项1", "干扰项2"],
"categoryId": "history",
"difficulty": 3,
"knowledgeCard": {
"summary": "知识点摘要",
"deepDive": "深入解析",
"sourceRef": "来源"
}
}
]
}
```
单次 1-200 条,全有或全无。
#### POST /admin/questions/import-csv
认证Admin JWT
`Content-Type`: `text/plain`
CSV 表头:
```csv
categoryId,contentType,difficulty,stemText,correctAnswer,distractor1,distractor2,distractor3,distractor4,distractor5,cardSummary,cardDeepDive,cardSourceRef
```
### 分类管理
#### GET /admin/categories
认证Admin JWT
查询参数:`page`, `limit`
响应:分类数组 + `pagination`
#### POST /admin/categories
认证Admin JWT
请求:
```json
{
"id": "history",
"name": "历史",
"slug": "history",
"parentId": null,
"sortOrder": 1
}
```
#### PUT /admin/categories/:id
认证Admin JWT
请求:
```json
{
"name": "历史",
"slug": "history",
"parentId": null,
"sortOrder": 1,
"status": "active"
}
```
#### DELETE /admin/categories/:id
认证Admin JWT。归档分类。
### 知识点卡片
#### GET /admin/knowledge-cards
认证Admin JWT
查询参数:`page`, `limit`
响应:知识点卡片数组 + `pagination`
#### GET /admin/knowledge-cards/by-question/:questionId
认证Admin JWT
#### PUT /admin/knowledge-cards/:id
认证Admin JWT
请求:
```json
{
"summary": "摘要",
"deepDive": "深入解析",
"sourceRef": "来源"
}
```
所有字段均可选,但至少应传一个需要更新的字段。
### 技能树管理
#### GET /admin/skill-tree
认证Admin JWT
查询参数:`categoryId`。
#### POST /admin/skill-tree
认证Admin JWT
请求:
```json
{
"categoryId": "history",
"title": "第一章",
"parentId": null,
"sortOrder": 1,
"questionsRequired": 4,
"passThreshold": 2
}
```
#### PUT /admin/skill-tree/:id
认证Admin JWT。请求字段同创建接口均可选。
#### DELETE /admin/skill-tree/:id
认证Admin JWT。
### 用户管理
#### GET /admin/users
认证Admin JWT
查询参数:`page`, `limit`, `search`
响应:用户数组 + `pagination`
#### GET /admin/users/:id
认证Admin JWT
#### PUT /admin/users/:id/ban
认证Admin JWT
#### PUT /admin/users/:id/unban
认证Admin JWT
### 统计数据
#### GET /admin/stats
认证Admin JWT
响应:
```json
{
"success": true,
"data": {
"totalUsers": 1000,
"activeUsers": 150,
"totalQuestions": 500,
"publishedQuestions": 450,
"averageXp": 200
},
"error": null
}
```
### 反馈管理
#### GET /admin/feedback
认证Admin JWT
查询参数:`page`, `limit`
响应:反馈数组 + `pagination`
### 游戏化审计
#### GET /admin/gamification/users/:id/wallet
认证Admin JWT
响应:指定用户金币钱包,未创建钱包时 `data``null`
#### GET /admin/gamification/users/:id/inventory
认证Admin JWT
响应:指定用户道具库存数组。
#### GET /admin/gamification/users/:id/rewards
认证Admin JWT
查询参数:`page`, `limit`
响应:指定用户奖励流水数组 + `pagination`
#### GET /admin/gamification/users/:id/ad-recovery
认证Admin JWT
查询参数:`page`, `limit`
响应:指定用户广告恢复会话数组 + `pagination`
#### GET /admin/gamification/users/:id/transactions
认证Admin JWT
查询参数:`page`, `limit`
响应:指定用户资源变更流水数组 + `pagination`
### 定时任务
#### GET /admin/jobs
认证Admin JWT
响应:
```json
{
"success": true,
"data": [
{
"name": "weekly-settlement",
"description": "周榜结算按组快照上周排名。MVP 不发放前 3 名金币rewards 始终为空),仅写入 leaderboard_snapshots 和标记 user_weekly_xp.settled=1",
"schedule": "每周一北京时间 00:30"(原 UTC 00:30MVP 已切北京时间)
},
{
"name": "expire-subscriptions",
"description": "订阅过期检查:检查并过期到期的订阅",
"schedule": "每日北京时间 01:00"(原 UTC 01:00MVP 已切北京时间)
}
],
"error": null
}
```
#### POST /admin/jobs/trigger
认证Admin JWT
请求:
```json
{
"job": "weekly-settlement",
"dryRun": true
}
```
`job` 取值:`weekly-settlement`, `expire-subscriptions`。`dryRun` 可选,默认 `false`
响应:
```json
{
"success": true,
"data": {
"job": "weekly-settlement",
"dryRun": true,
"executedAt": "2026-05-18T00:30:00.000Z",
"result": {}
},
"error": null
}
```
## 已注册兼容接口
以下接口仍在 `src/index.ts` 中注册,但不是新客户端的推荐主合同。新客户端优先使用上文的 Flutter 客户端聚合 API、订阅 API 和广告恢复两步流程。
| 路径 | 状态 | 推荐替代 |
|------|------|----------|
| `GET /quiz/categories` | 兼容旧出题接口 | `GET /tracks` |
| `GET /quiz/categories/:id/chapters` | 兼容旧出题接口 | `GET /tracks/:trackId` |
| `GET /quiz/chapters/:id/questions` | 兼容旧出题接口 | `GET /challenges/next` |
| `POST /quiz/answer` | 兼容旧出题接口 | `POST /challenges/answer` |
| `POST /quiz/rate` | 兼容旧反馈接口 | 仍可用于题目评分 |
| `GET /progress/dashboard` | 兼容旧进度接口 | `GET /progress/summary` |
| `GET /progress/streak` | 兼容旧进度接口 | `GET /progress/summary` |
| `GET /progress/hearts` | 兼容旧进度接口 | `GET /progress/summary` |
| `POST /progress/hearts/restore` | 已废弃恢复接口 | `POST /rewards/ad-recovery/session` + `POST /rewards/ad-recovery/complete` |
| `GET /progress/chapters` | 兼容旧进度接口 | `GET /tracks/:trackId` |
| `POST /feedback` | 兼容旧反馈接口 | 仍可用于用户反馈 |
| `GET /leaderboard` | 兼容旧排行榜接口 | `GET /leaderboards` |
| `GET /leaderboard/me` | 兼容旧排行榜接口 | `GET /leaderboards/me` |
| `GET /achievements` | 兼容旧成就接口 | 当前无聚合替代 |
| `POST /achievements/check` | 兼容旧成就接口 | 当前无聚合替代 |
| `POST /payment/verify-huawei` | 兼容旧支付接口 | `POST /subscription/verify` |
| `GET /payment/subscription` | 兼容旧订阅接口 | `GET /subscription` |
| `POST /rewards/hearts/restore` | 已废弃恢复接口 | `POST /rewards/ad-recovery/session` + `POST /rewards/ad-recovery/complete` |
| `POST /rewards/attempts/restore` | 已废弃恢复接口 | `POST /rewards/ad-recovery/session` + `POST /rewards/ad-recovery/complete` |
| `POST /rewards/streak/protect` | 已废弃恢复接口 | `POST /rewards/ad-recovery/session` + `POST /rewards/ad-recovery/complete` |
## 错误代码
| 代码 | 说明 |
|------|------|
| `VALIDATION_ERROR` | 请求参数验证失败 |
| `VALIDATION_FAILED` | 批量导入中部分题目校验失败 |
| `CSV_PARSE_ERROR` | CSV 解析失败 |
| `UNAUTHORIZED` | 未认证或认证失败 |
| `FORBIDDEN` | 权限不足 |
| `NOT_FOUND` | 资源不存在 |
| `CONFLICT` | 资源冲突(如重复迁移、已关联账号) |
| `INVALID_STATUS_TRANSITION` | 题目状态流转不合法 |
| `INVALID_RECEIPT` | 支付收据验证失败 |
| `UNSUPPORTED_PLATFORM` | 订阅平台暂不支持 |
| `NOT_IMPLEMENTED` | 路由已注册但功能未实现 |
| `INTERNAL_ERROR` | 服务器内部错误 |
客户端应把 `error.code` 当作稳定的机器分支键,`error.message` 只用于调试或兜底展示。广告恢复等业务失败会以 `success=true` 返回,并把状态放在 `data.status` / `data.reason` 中;这类流程不要只依赖 `error.code` 判断。