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

38 KiB
Raw Blame History

Duoqi API Reference

多奇服务端 API 接口文档。本文按当前 Fastify 路由和 TypeScript DTO 更新。

最近一次代码审计2026-06-16来源为 src/index.ts 注册的路由、src/routes/**/*.tssrc/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.identityTokenApple Sign In 返回的 identity token必填
  • credential.authorizationCodeApple 授权码(可选)。
  • 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.productswallet.coinsBalanceinventory.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, chestMVP 服务端只返回 currentdonedocs/GAMIFICATION_DESIGN.mdlockedchest 为预留状态,客户端保留兼容展示但不依赖其业务逻辑。

GET /tracks/:trackId

认证JWT 路径参数:trackId 可以是分类 idslug

响应:单个 ThemeTrackDto,不存在时 datanull

GET /challenges/next

认证JWT

查询参数:

参数 类型 必填 说明
trackId string 分类 idslug

响应:

{
  "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 时,直接复用原 sessionchallengeId 和原 5 题),不创建新 sessionansweredQuestionIds 返回已答题目 ID客户端据此跳到下一道未答题。
  • highRewardEligible 始终为 falseMVP 不实现高奖励策略schema 字段保留以备未来重新启用)。
  • 题库不足 5 题或没有可用题目时 datanull

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 固定返回 0MVP 不实现高奖励策略)。

POST /challenges/knowledge-cards/:cardId/view

认证JWT

路径参数:cardIdknowledge_cards.id 或 fallback 占位 IDfallback-{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,包含 highRewardSessionsLefthighRewardSessionsMax

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 regiontopic 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, cancelledtier 取值: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, streakProtectionplatform 取值: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.dailyHighRewardSessionsnull 表示无限制。客户端可据此展示替代提示,引导用户享受订阅权益。

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/restorePOST /rewards/attempts/restorePOST /rewards/streak/protectPOST /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 JWTrole=super_admin

请求:

{
  "username": "newadmin",
  "password": "password123",
  "role": "admin"
}

PUT /admin/admins/:id

认证Admin JWTrole=super_admin

请求:

{
  "username": "updated",
  "role": "admin",
  "isActive": 1
}

DELETE /admin/admins/:id

认证Admin JWTrole=super_admin。软删除管理员。

POST /admin/admins/:id/reset-password

认证Admin JWTrole=super_admin。响应包含一次性明文密码 plainPassword

题目管理

GET /admin/questions

认证Admin JWT

查询参数:page, limit, status, categoryId, keyword, difficulty, source, sortBy, sortOrdersortBy 取值:createdAt, updatedAt, difficultysortOrder 取值: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 响应:指定用户金币钱包,未创建钱包时 datanull

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:30MVP 已切北京时间)
    },
    {
      "name": "expire-subscriptions",
      "description": "订阅过期检查:检查并过期到期的订阅",
      "schedule": "每日北京时间 01:00"(原 UTC 01:00MVP 已切北京时间)
    }
  ],
  "error": null
}

POST /admin/jobs/trigger

认证Admin JWT

请求:

{
  "job": "weekly-settlement",
  "dryRun": true
}

job 取值:weekly-settlement, expire-subscriptionsdryRun 可选,默认 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 判断。