duoqi-api/docs/api-reference.md
Wang Zhuoxuan 5e7b7b1cda
Some checks failed
CI/CD Pipeline / Unit Tests (push) Failing after 34s
CI/CD Pipeline / Build & Deploy Test (push) Has been skipped
CI/CD Pipeline / Build & Deploy Production (push) Has been skipped
Add region-based leaderboard support
2026-06-08 15:43:54 +08:00

36 KiB
Raw Blame History

Duoqi API Reference

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

最近一次代码审计2026-05-18来源为 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": 3,
      "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, chest

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": true,
    "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" }
          ]
        }
      }
    ]
  },
  "error": null
}

服务端会创建挑战组会话并一次返回 5 题,题目选项不包含正确答案标记。highRewardEligible=false 表示今日高奖励挑战次数已用尽,本轮 XP 和宝箱掉落按降级规则结算。题库不足 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": 3,
      "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

资源扣减规则:

  • 每次单题提交成功裁决后,dailyAttemptsLeft 扣 1重复提交同一题或同一 submitRequestId 返回第一次裁决快照,不重复扣减。
  • highRewardSessionsLeft 按 5 题挑战组消耗;只有本组最后一题触发挑战完成结算后,才会从 3/3 变为 2/3。

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 为空”的请求。
  • 服务端以 UTC 日期判断“当天”。同一 UTC 日期内重复调用不会重复增加 checkInDays

成功响应:更新后的 ProgressSummaryDto

{
  "success": true,
  "data": {
    "hearts": 5,
    "maxHearts": 5,
    "nextHeartRestoreAt": null,
    "dailyAttemptsLeft": 5,
    "dailyAttemptsMax": 5,
    "nextAttemptResetAt": "2026-05-06T00:00:00.000Z",
    "highRewardSessionsLeft": 3,
    "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 分组榜。

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

请求:

{
  "productId": "hint-feather",
  "clientRequestId": "request-uuid"
}

productId 取值:hint-feather, heart-supply, double-xp-potion, streak-shield, mascot-outfit-starter

响应:

{
  "success": true,
  "data": {
    "product": {
      "id": "hint-feather",
      "type": "item",
      "itemId": "hint_feather",
      "title": "提示羽毛",
      "description": "答题时排除 1 个错误选项",
      "priceCoins": 80,
      "quantity": 1,
      "enabled": true
    },
    "coinsSpent": 80,
    "coinsBalance": 220,
    "item": {
      "itemId": "hint_feather",
      "quantity": 3,
      "activeUntil": null,
      "metadata": null
    },
    "rewards": [
      {
        "type": "item",
        "source": "inventory",
        "itemId": "hint_feather",
        "quantity": 1,
        "title": "提示羽毛 x1"
      }
    ]
  },
  "error": null
}

购买使用 clientRequestId 作为幂等边界;金币不足时返回统一错误格式,error.codeVALIDATION_ERROR

POST /inventory/items/use

认证JWT

请求:

{
  "itemId": "hint_feather",
  "clientRequestId": "request-uuid",
  "questionId": "question-uuid"
}

itemId 取值:heart_supply, double_xp_potion, hint_feather, streak_shield。使用 hint_feather 时必须传 questionId

响应:

{
  "success": true,
  "data": {
    "itemId": "hint_feather",
    "quantityRemaining": 2,
    "effect": {
      "type": "hint",
      "excludedOptions": ["错误选项 A"]
    }
  },
  "error": null
}

效果说明:heart_supply 恢复当前用户爱心到上限;double_xp_potion 返回 15 分钟有效期 activeUntilhint_feather 返回可排除选项;streak_shield 返回 streakProtectedUntilclientRequestId 用于道具消耗幂等。

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

请求:

{
  "platform": "huawei",
  "purchaseToken": "purchase-token",
  "productId": "duoqi_plus_monthly",
  "tier": "pro"
}

响应:更新后的订阅 DTO。当前仅支持 platform=huawei,其他平台返回 UNSUPPORTED_PLATFORM

激励广告恢复

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": 3,
      "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": "周榜结算:按组快照上周排名,给每组前 3 名发金币奖励",
      "schedule": "每周一 UTC 00:30"
    },
    {
      "name": "expire-subscriptions",
      "description": "订阅过期检查:检查并过期到期的订阅",
      "schedule": "每日 UTC 01:00"
    }
  ],
  "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 判断。