行为按 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) 全部通过。
38 KiB
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/* |
统一响应
{
"success": true,
"data": {},
"error": null
}
{
"success": false,
"data": null,
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input"
}
}
分页响应会额外包含:
{
"pagination": {
"total": 100,
"page": 1,
"limit": 20
}
}
客户端 API
健康检查
GET /health
认证:无
响应:
{
"success": true,
"data": {
"status": "ok",
"timestamp": "2026-05-05T12:00:00.000Z"
},
"error": null
}
认证
POST /auth/guest
认证:无 限流:10 次/分钟
请求:
{
"deviceId": "device-id"
}
响应:
{
"success": true,
"data": {
"user": {
"id": "uuid",
"nickname": null,
"avatarUrl": null,
"tier": "free"
},
"tokens": {
"accessToken": "jwt",
"refreshToken": "jwt"
}
},
"error": null
}
POST /auth/huawei
认证:无 限流:10 次/分钟
请求:
{
"authorizationCode": "authorization-code"
}
响应同 /auth/guest。
POST /auth/refresh
认证:无 限流:10 次/分钟
请求:
{
"refreshToken": "jwt"
}
响应:
{
"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 已有正式账号,在事务内将游客的答题记录、奖励流水等数据合并到正式账号,不覆盖老账号的订阅、余额、库存、连续学习等核心资产。
请求:
{
"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,用于幂等保证,防止网络重试导致重复合并。
成功响应:
{
"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获取最新状态。
错误响应:
{
"success": false,
"data": null,
"error": {
"code": "CONFLICT",
"message": "Migration already completed"
}
}
{
"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 位) |
成功响应:
{
"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 位数字) |
成功响应:
{
"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 由数据库配置控制
响应:
{
"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
响应:
{
"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
响应:
{
"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,后续可扩展到二级地区而不改变协议形状。
响应:
{
"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
用户选择或变更地区。首次选择不受限制;已有地区后,每个北京时间自然月只允许变更一次。
请求:
{
"regionCode": "310000"
}
响应:
{
"success": true,
"data": {
"region": {
"code": "310000",
"name": "上海市",
"shortName": "上海",
"selectedAt": "2026-06-08T12:00:00.000Z",
"nextChangeAllowedAt": "2026-06-30T16:00:00.000Z"
}
},
"error": null
}
同月再次变更:
{
"success": false,
"data": null,
"error": {
"code": "REGION_CHANGE_LIMIT_REACHED",
"message": "每个自然月只能修改一次地区,请下个月再试。"
}
}
GET /tracks
认证:JWT
响应:
{
"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 |
响应:
{
"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
请求:
{
"challengeId": "challenge-session-uuid",
"questionId": "question-uuid",
"selectedOptionId": "a",
"timeMs": 1500,
"comboCount": 0,
"submitRequestId": "client-submit-id"
}
submitRequestId 可选;未传时服务端会使用 challengeId + questionId 作为默认幂等 key。同一挑战组内重复提交同一道题会返回第一次裁决结果,不会重复扣资源或重复发放奖励。
响应:
{
"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},用于题库未配齐卡时的占位)。
请求(可选):
{
"challengeId": "challenge-session-uuid"
}
challengeId 仅用于审计,不影响结算。
响应:
{
"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
请求:
{
"activeTrackId": "history"
}
响应:更新后的 ProgressSummaryDto。
POST /progress/check-in
认证:JWT
用途:完成当天签到,并返回更新后的进度摘要。
请求 body:无。
客户端对接要求:
- 推荐不发送 body,也不要设置
Content-Type。 - 如果客户端网络库要求 JSON body,请发送空对象
{},不要发送“带Content-Type: application/json但 body 为空”的请求。 - 服务端按北京时间(Asia/Shanghai)自然日判断"当天"。同一北京自然日内重复调用不会重复增加
checkInDays。
成功响应:更新后的 ProgressSummaryDto。
{
"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 |
响应:
{
"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。
响应:
{
"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
响应:
{
"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,统一返回:
{
"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:
{
"success": false,
"data": null,
"error": {
"code": "NOT_AVAILABLE_IN_MVP",
"message": "道具使用暂未开放"
}
}
GET /subscription
认证:JWT
响应:
{
"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,统一返回:
{
"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、广告位和资格状态。
请求:
{
"type": "hearts",
"clientRequestId": "uuid-from-client",
"platform": "ios",
"adProvider": "mock"
}
type 取值:hearts, bonusAttempts, streakProtection。
platform 取值:ios, android, harmony, web。
符合资格响应:
{
"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
}
不符合资格响应:
{
"success": true,
"data": {
"sessionId": null,
"eligible": false,
"reason": "daily_limit_reached",
"nextAvailableAt": "2026-05-06T00:00:00.000Z"
},
"error": null
}
Plus 用户响应(无需看广告,返回订阅权益摘要):
{
"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。
请求:
{
"sessionId": "uuid",
"clientRequestId": "uuid-from-client",
"adProvider": "admob",
"providerRewardToken": "opaque-provider-token",
"completedAt": "2026-05-05T12:03:00.000Z"
}
成功响应:
{
"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
}
失败响应:
{
"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
认证:无
请求:
{
"username": "admin",
"password": "password123"
}
响应:
{
"success": true,
"data": {
"accessToken": "jwt",
"refreshToken": "jwt",
"admin": {
"id": "uuid",
"username": "admin",
"role": "super_admin"
}
},
"error": null
}
PUT /admin/change-password
认证:Admin JWT
请求:
{
"currentPassword": "old-password",
"newPassword": "new-password"
}
响应:
{
"success": true,
"data": {
"message": "Password changed successfully"
},
"error": null
}
POST /admin/auth
认证:无
兼容旧管理端的 ADMIN_TOKEN 验证接口。新管理端应使用 /admin/login 获取 Admin JWT。
请求:
{
"token": "admin-token"
}
响应:
{
"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
请求:
{
"username": "newadmin",
"password": "password123",
"role": "admin"
}
PUT /admin/admins/:id
认证:Admin JWT,且 role=super_admin
请求:
{
"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
请求:
{
"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
请求:
{
"status": "published"
}
status 取值:draft, reviewing, published, archived。服务会校验状态流转。
DELETE /admin/questions/:id
认证:Admin JWT。归档题目。
POST /admin/questions/batch-publish
认证:Admin JWT
请求:
{
"ids": ["uuid"]
}
POST /admin/questions/batch-archive
认证:Admin JWT。请求同 batch-publish。
POST /admin/questions/batch-delete
认证:Admin JWT。软删除,等同批量归档。
POST /admin/questions/import
认证:Admin JWT
请求:
{
"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 表头:
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
请求:
{
"id": "history",
"name": "历史",
"slug": "history",
"parentId": null,
"sortOrder": 1
}
PUT /admin/categories/:id
认证:Admin JWT
请求:
{
"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
请求:
{
"summary": "摘要",
"deepDive": "深入解析",
"sourceRef": "来源"
}
所有字段均可选,但至少应传一个需要更新的字段。
技能树管理
GET /admin/skill-tree
认证:Admin JWT
查询参数:categoryId。
POST /admin/skill-tree
认证:Admin JWT
请求:
{
"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
响应:
{
"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
响应:
{
"success": true,
"data": [
{
"name": "weekly-settlement",
"description": "周榜结算:按组快照上周排名。MVP 不发放前 3 名金币(rewards 始终为空),仅写入 leaderboard_snapshots 和标记 user_weekly_xp.settled=1",
"schedule": "每周一北京时间 00:30"(原 UTC 00:30,MVP 已切北京时间)
},
{
"name": "expire-subscriptions",
"description": "订阅过期检查:检查并过期到期的订阅",
"schedule": "每日北京时间 01:00"(原 UTC 01:00,MVP 已切北京时间)
}
],
"error": null
}
POST /admin/jobs/trigger
认证:Admin JWT
请求:
{
"job": "weekly-settlement",
"dryRun": true
}
job 取值:weekly-settlement, expire-subscriptions。dryRun 可选,默认 false。
响应:
{
"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 判断。