Files
MemeMind-Server/docs/api/game-api.md
richarjiang fe2c13258e refactor: 拆分核心玩法模块并优化代码质量
将 WechatGame 单体模块拆分为独立的 User、Level、GameConfig 模块,
新增体力值系统、关卡闯关流程,并修复多项代码质量问题:
- 体力不足错误码从 401 修正为 400
- enterLevel 改用 findById 替代全表扫描
- consumeStamina 增加原子更新防止并发竞态
- 并行化独立数据库查询 (Promise.all)
- 移除 WechatGameService/Controller 死代码
2026-04-10 09:07:50 +08:00

26 KiB
Raw Blame History

MemeMind 主玩法 API 协议文档

本文档面向微信小游戏客户端Cocos Creator开发人员涵盖认证、用户体力、关卡闯关等核心玩法接口。

目录


概述

MemeMind 核心玩法接口分为以下模块:

模块 路由前缀 说明
认证 /api/v1/auth 微信登录、JWT 签发
用户 /api/v1/user 用户资料、体力值、游戏数据
关卡 /api/v1/levels 关卡列表、进入关卡、通关上报
游戏配置 /api/v1/game-configs 游戏全局配置

与旧版 API 的变更(⚠️ Breaking Changes

废弃接口 替代方案
GET /api/v1/user/assets GET /api/v1/user/profile
POST /api/v1/user/assets/consume 已删除,体力在「进入关卡」时自动扣减
POST /api/v1/user/assets/earn 已删除,通关不再奖励积分
GET /api/v1/user/game-data GET /api/v1/user/game-data(路径不变,响应结构变更)
GET /api/v1/wechat-game/levels GET /api/v1/levels(需鉴权)
GET /api/v1/wechat-game/levels/:id POST /api/v1/levels/:id/enter(需鉴权 + 消耗体力)
GET /api/v1/wechat-game/configs GET /api/v1/game-configs
GET /api/v1/wechat-game/configs/:key GET /api/v1/game-configs/:key

认证方式

除微信登录和游戏配置接口外,所有接口均需通过 JWT Token 进行身份认证。

请求头格式

Authorization: Bearer <token>

token 为微信登录接口返回的 JWT 令牌,有效期 7 天


通用响应格式

所有接口均返回以下 JSON 结构:

{
  "success": true,
  "data": { "..." : "..." },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}
字段 类型 说明
success boolean 请求是否成功
data T | null 成功时返回业务数据,失败时为 null
message string | null 失败时的错误信息,成功时为 null
timestamp string 服务器响应时间ISO 8601

体力值系统说明

体力值stamina替代了原有的积分系统用于控制用户进入关卡的频率。

属性
默认体力 5新用户注册时
上限 5
恢复速度 10 分钟 恢复 1 点
消耗 进入未通关关卡时消耗 1 点
已通关关卡 再次进入不消耗体力

体力值数据结构

接口中体力信息统一使用以下结构返回:

interface StaminaInfo {
  current: number;           // 当前体力值(已计算恢复)
  max: number;               // 体力上限,固定为 5
  nextRecoverAt: string | null; // 下一点体力恢复的时间ISO 8601满体力时为 null
}

示例

{
  "current": 3,
  "max": 5,
  "nextRecoverAt": "2026-04-10T12:10:00.000Z"
}

注意:体力恢复为服务端实时计算,无需客户端轮询。每次调用包含体力信息的接口时,服务端都会返回最新的体力值。客户端可根据 nextRecoverAt 自行做倒计时 UI 展示。


接口列表

1. 微信登录

获取用户身份令牌。

接口地址POST /api/v1/auth/wx-login

是否需要认证:否

请求体

{
  "code": "微信 wx.login 返回的 code"
}
字段 类型 必填 说明
code string 微信 wx.login 返回的临时登录凭证

响应数据

{
  token: string;
  user: {
    id: string;
    nickname: string | null;
    stamina: number;         // 当前体力值(数据库原始值,不含实时恢复计算)
  }
}

成功响应示例

{
  "success": true,
  "data": {
    "token": "eyJhbGciOiJIUzI1NiIs...",
    "user": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "nickname": null,
      "stamina": 5
    }
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

客户端调用时机

  • 小游戏冷启动时
  • 缓存的 token 过期后(收到 401 响应时)

2. 获取用户资料

获取当前用户的资料,包括实时计算后的体力值。

接口地址GET /api/v1/user/profile

是否需要认证:是

请求参数:无

响应数据

{
  id: string;
  nickname: string | null;
  stamina: StaminaInfo;      // 实时体力信息
}

成功响应示例

{
  "success": true,
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "nickname": null,
    "stamina": {
      "current": 3,
      "max": 5,
      "nextRecoverAt": "2026-04-10T12:10:00.000Z"
    }
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

客户端调用时机

  • 需要刷新用户体力显示时
  • 从后台切回前台时

3. 获取游戏数据

获取用户体力值和通关进度,适用于游戏 Loading 页面一次性加载。

接口地址GET /api/v1/user/game-data

是否需要认证:是

请求参数:无

响应数据

{
  user: {
    id: string;
    stamina: StaminaInfo;
  };
  completedLevelIds: string[];   // 已通关的关卡 ID 列表
}

成功响应示例

{
  "success": true,
  "data": {
    "user": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "stamina": {
        "current": 5,
        "max": 5,
        "nextRecoverAt": null
      }
    },
    "completedLevelIds": ["level_001", "level_002", "level_005"]
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

客户端调用时机

  • 游戏 Loading 页面
  • 进入关卡选择页面前

4. 获取关卡列表

获取所有关卡列表。已通关的关卡返回答案和线索,未通关的关卡不返回敏感数据。

接口地址GET /api/v1/levels

是否需要认证:是

请求参数:无

响应数据

{
  levels: LevelListItem[];
  total: number;
}

interface LevelListItem {
  id: string;                 // 关卡 ID
  level: number;              // 关卡编号(从 1 开始)
  imageUrl: string;           // 关卡图片 URL
  answer: string | null;      // 答案(仅已通关时返回,否则 null
  hint1: string | null;       // 线索1仅已通关时返回否则 null
  hint2: string | null;       // 线索2仅已通关时返回否则 null
  hint3: string | null;       // 线索3仅已通关时返回否则 null
  completed: boolean;         // 是否已通关
  timeSpent: number | null;   // 通关时长(秒),未通关时为 null
}

成功响应示例

{
  "success": true,
  "data": {
    "levels": [
      {
        "id": "level_001",
        "level": 1,
        "imageUrl": "https://cdn.example.com/levels/001.png",
        "answer": "梗答案",
        "hint1": "这是一个经典的...",
        "hint2": "和某个明星有关",
        "hint3": null,
        "completed": true,
        "timeSpent": 45
      },
      {
        "id": "level_002",
        "level": 2,
        "imageUrl": "https://cdn.example.com/levels/002.png",
        "answer": null,
        "hint1": null,
        "hint2": null,
        "hint3": null,
        "completed": false,
        "timeSpent": null
      }
    ],
    "total": 2
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

客户端使用说明

  • 关卡选择页面使用此接口获取关卡列表
  • 根据 completed 字段展示不同的 UI 状态(已通关/未通关)
  • 未通关关卡的 answerhint1hint2hint3 均为 null客户端不应缓存这些字段

5. 进入关卡

消耗 1 点体力进入关卡,获取完整的关卡详情(含答案和线索)。

接口地址POST /api/v1/levels/{id}/enter

是否需要认证:是

路径参数

参数 类型 必填 说明
id string 关卡 ID

请求体:无

响应数据

{
  id: string;
  level: number;
  imageUrl: string;
  answer: string;
  hint1: string | null;
  hint2: string | null;
  hint3: string | null;
  stamina: StaminaInfo;      // 消耗后的体力信息
}

成功响应示例

{
  "success": true,
  "data": {
    "id": "level_002",
    "level": 2,
    "imageUrl": "https://cdn.example.com/levels/002.png",
    "answer": "这是答案",
    "hint1": "第一个线索",
    "hint2": "第二个线索",
    "hint3": null,
    "stamina": {
      "current": 2,
      "max": 5,
      "nextRecoverAt": "2026-04-10T12:10:00.000Z"
    }
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

业务逻辑

场景 是否消耗体力 说明
首次进入未通关关卡 消耗 1 点 正常扣减
再次进入已通关关卡 不消耗 直接返回关卡详情
体力为 0 且关卡未通关 返回错误 返回 401 体力不足

客户端调用时机

  • 用户在关卡选择页面点击某个关卡进入时
  • 必须调用此接口获取关卡详情后才能开始游戏
  • 客户端应在调用前检查体力是否足够,体力不足时提示用户等待恢复

6. 通关上报

用户通关后上报通关时长。同一关卡不会重复记录。

接口地址POST /api/v1/levels/{id}/complete

是否需要认证:是

路径参数

参数 类型 必填 说明
id string 关卡 ID

请求体

{
  "timeSpent": 45
}
字段 类型 必填 说明
timeSpent number 通关时长(秒),≥ 0

响应数据

{
  firstClear: boolean;   // 是否为首次通关
  levelId: string;       // 关卡 ID
  timeSpent: number;     // 记录的通关时长(秒)
}

成功响应示例(首次通关)

{
  "success": true,
  "data": {
    "firstClear": true,
    "levelId": "level_002",
    "timeSpent": 45
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

成功响应示例(重复通关)

{
  "success": true,
  "data": {
    "firstClear": false,
    "levelId": "level_002",
    "timeSpent": 30
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

业务逻辑

  • 首次通关:记录 timeSpent,返回 firstClear: true
  • 重复通关:不覆盖记录,返回首次通关的 timeSpentfirstClear: false

客户端调用时机

  • 用户成功回答正确答案后调用
  • 只在通关成功时调用,答错不需要上报

7. 获取游戏配置

获取所有激活的游戏配置。

接口地址GET /api/v1/game-configs

是否需要认证:否

请求参数:无

响应数据

{
  configs: GameConfig[];
  total: number;
}

interface GameConfig {
  id: string;
  configKey: string;
  configValue: string;
  description: string | null;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
}

成功响应示例

{
  "success": true,
  "data": {
    "configs": [
      {
        "id": "cfg-001",
        "configKey": "hint_unlock_cost",
        "configValue": "1",
        "description": "解锁提示消耗体力值",
        "isActive": true,
        "createdAt": "2026-04-01T00:00:00.000Z",
        "updatedAt": "2026-04-01T00:00:00.000Z"
      }
    ],
    "total": 1
  },
  "message": null,
  "timestamp": "2026-04-10T12:00:00.000Z"
}

8. 获取单个游戏配置

根据配置 key 获取单个配置。

接口地址GET /api/v1/game-configs/{key}

是否需要认证:否

路径参数

参数 类型 必填 说明
key string 配置键名

响应数据

{
  id: string;
  configKey: string;
  configValue: string;
  description: string | null;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
}

错误码说明

HTTP Status message 说明
401 未提供访问令牌 请求头缺少 Authorization
401 访问令牌无效或已过期 JWT Token 无效或过期,需重新登录
401 微信登录失败,请重试 微信 code 无效
401 用户不存在 用户 ID 在数据库中不存在
401 体力不足 进入关卡时体力为 0
404 关卡 {id} 不存在 关卡 ID 不存在
404 Game config with key "xxx" not found 配置 key 不存在

接入流程

核心游戏流程

┌──────────────────────────────────────────────────────────────────────┐
│  Phase 1: 启动 & 登录                                                │
├──────────────────────────────────────────────────────────────────────┤
│  1. 小游戏启动,调用 wx.login 获取 code                               │
│  2. POST /api/v1/auth/wx-login → 获取 JWT token                     │
│  3. 缓存 token 到本地                                                │
└──────────────────────┬───────────────────────────────────────────────┘
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│  Phase 2: Loading 页面                                               │
├──────────────────────────────────────────────────────────────────────┤
│  4. GET /api/v1/user/game-data → 获取体力值 + 已通关关卡列表           │
│  5. GET /api/v1/game-configs → 获取游戏配置(可选)                    │
└──────────────────────┬───────────────────────────────────────────────┘
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│  Phase 3: 关卡选择页面                                                │
├──────────────────────────────────────────────────────────────────────┤
│  6. GET /api/v1/levels → 获取关卡列表(含通关状态)                     │
│  7. 展示关卡网格,已通关的显示已完成状态                                │
│  8. 用户点击某个关卡                                                  │
│     ├─ 体力足够 → Phase 4                                            │
│     └─ 体力不足 → 提示等待恢复(显示 nextRecoverAt 倒计时)             │
└──────────────────────┬───────────────────────────────────────────────┘
                       ▼
┌──────────────────────────────────────────────────────────────────────┐
│  Phase 4: 关卡游玩                                                    │
├──────────────────────────────────────────────────────────────────────┤
│  9. POST /api/v1/levels/{id}/enter → 消耗体力,获取关卡详情             │
│ 10. 展示关卡图片,开始计时                                             │
│ 11. 用户输入答案                                                      │
│     ├─ 答案正确 → 停止计时                                            │
│     │   └─ POST /api/v1/levels/{id}/complete → 上报通关时长            │
│     └─ 答案错误 → 提示错误,可使用线索                                  │
└──────────────────────────────────────────────────────────────────────┘

客户端状态管理建议

interface GameState {
  // 用户信息
  token: string | null;
  userId: string | null;

  // 体力信息
  stamina: {
    current: number;
    max: number;
    nextRecoverAt: string | null;
  };

  // 关卡数据
  completedLevelIds: Set<string>;    // 已通关关卡 ID
  currentLevel: {                     // 当前正在玩的关卡
    id: string;
    answer: string;
    hints: (string | null)[];
    startTime: number;                // 开始时间戳,用于计算 timeSpent
  } | null;
}

体力恢复倒计时实现建议

// 客户端体力恢复倒计时(纯 UI 展示)
function startStaminaTimer(staminaInfo: StaminaInfo) {
  if (!staminaInfo.nextRecoverAt || staminaInfo.current >= staminaInfo.max) {
    // 满体力,无需倒计时
    return;
  }

  const targetTime = new Date(staminaInfo.nextRecoverAt).getTime();

  const timer = setInterval(() => {
    const remaining = targetTime - Date.now();
    if (remaining <= 0) {
      // 恢复一点体力(本地模拟)
      staminaInfo.current = Math.min(staminaInfo.current + 1, staminaInfo.max);
      clearInterval(timer);

      if (staminaInfo.current < staminaInfo.max) {
        // 继续下一轮倒计时
        staminaInfo.nextRecoverAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
        startStaminaTimer(staminaInfo);
      } else {
        staminaInfo.nextRecoverAt = null;
      }
    } else {
      // 更新倒计时 UI
      const minutes = Math.floor(remaining / 60000);
      const seconds = Math.floor((remaining % 60000) / 1000);
      updateStaminaUI(`${minutes}:${seconds.toString().padStart(2, '0')}`);
    }
  }, 1000);
}

⚠️ 客户端体力倒计时仅用于 UI 展示。实际体力值以服务端返回为准,每次调用接口时会获得最新体力。


Cocos Creator 调用示例

1. HTTP 请求工具类

// HttpManager.ts
export interface ApiResponse<T> {
  success: boolean;
  data: T | null;
  message: string | null;
  timestamp: string;
}

export interface StaminaInfo {
  current: number;
  max: number;
  nextRecoverAt: string | null;
}

class HttpManager {
  private baseUrl = 'https://your-api-domain.com/api';
  private token: string | null = null;

  setToken(token: string) {
    this.token = token;
  }

  getToken(): string | null {
    return this.token;
  }

  async request<T>(
    method: 'GET' | 'POST',
    url: string,
    body?: object
  ): Promise<ApiResponse<T>> {
    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open(method, this.baseUrl + url, true);
      xhr.setRequestHeader('Content-Type', 'application/json');

      if (this.token) {
        xhr.setRequestHeader('Authorization', `Bearer ${this.token}`);
      }

      xhr.onload = () => {
        try {
          const resp = JSON.parse(xhr.responseText);
          if (xhr.status >= 200 && xhr.status < 300) {
            resolve(resp);
          } else if (xhr.status === 401) {
            // Token 过期,触发重新登录
            this.token = null;
            reject(new Error(resp.message || '登录已过期,请重新登录'));
          } else {
            reject(new Error(resp.message || `请求失败: ${xhr.status}`));
          }
        } catch {
          reject(new Error(`请求失败: ${xhr.status}`));
        }
      };

      xhr.onerror = () => reject(new Error('网络错误'));
      xhr.send(body ? JSON.stringify(body) : undefined);
    });
  }

  get<T>(url: string) { return this.request<T>('GET', url); }
  post<T>(url: string, body?: object) { return this.request<T>('POST', url, body); }
}

export const http = new HttpManager();

2. 微信登录

async function wxLogin() {
  const { code } = await new Promise<{ code: string }>((resolve, reject) => {
    wx.login({ success: (res) => resolve({ code: res.code }), fail: reject });
  });

  const resp = await http.post<{
    token: string;
    user: { id: string; nickname: string | null; stamina: number };
  }>('/v1/auth/wx-login', { code });

  if (resp.success && resp.data) {
    http.setToken(resp.data.token);
    wx.setStorageSync('jwt_token', resp.data.token);
    return resp.data;
  }
  throw new Error(resp.message || '登录失败');
}

3. Loading 页面加载游戏数据

interface GameData {
  user: { id: string; stamina: StaminaInfo };
  completedLevelIds: string[];
}

async function loadGameData(): Promise<GameData> {
  const resp = await http.get<GameData>('/v1/user/game-data');
  if (resp.success && resp.data) {
    return resp.data;
  }
  throw new Error(resp.message || '加载游戏数据失败');
}

4. 获取关卡列表

interface LevelListItem {
  id: string;
  level: number;
  imageUrl: string;
  answer: string | null;
  hint1: string | null;
  hint2: string | null;
  hint3: string | null;
  completed: boolean;
  timeSpent: number | null;
}

async function getLevels(): Promise<LevelListItem[]> {
  const resp = await http.get<{ levels: LevelListItem[]; total: number }>('/v1/levels');
  if (resp.success && resp.data) {
    return resp.data.levels;
  }
  throw new Error(resp.message || '获取关卡列表失败');
}

5. 进入关卡(核心接口)

interface EnterLevelResponse {
  id: string;
  level: number;
  imageUrl: string;
  answer: string;
  hint1: string | null;
  hint2: string | null;
  hint3: string | null;
  stamina: StaminaInfo;
}

async function enterLevel(levelId: string): Promise<EnterLevelResponse> {
  const resp = await http.post<EnterLevelResponse>(`/v1/levels/${levelId}/enter`);

  if (resp.success && resp.data) {
    // 更新本地体力状态
    updateLocalStamina(resp.data.stamina);
    return resp.data;
  }
  throw new Error(resp.message || '进入关卡失败');
}

// 调用示例
async function onClickLevel(levelId: string, currentStamina: number, isCompleted: boolean) {
  if (!isCompleted && currentStamina <= 0) {
    showToast('体力不足,请等待恢复');
    return;
  }

  try {
    const levelData = await enterLevel(levelId);
    // 跳转到游戏页面,传入关卡数据
    startGame(levelData);
  } catch (err) {
    showToast(err.message);
  }
}

6. 通关上报

interface CompleteLevelResponse {
  firstClear: boolean;
  levelId: string;
  timeSpent: number;
}

async function completeLevel(
  levelId: string,
  timeSpent: number
): Promise<CompleteLevelResponse> {
  const resp = await http.post<CompleteLevelResponse>(
    `/v1/levels/${levelId}/complete`,
    { timeSpent }
  );

  if (resp.success && resp.data) {
    if (resp.data.firstClear) {
      showToast('恭喜通关!');
    }
    return resp.data;
  }
  throw new Error(resp.message || '上报通关失败');
}

// 调用示例:用户答对时
async function onAnswerCorrect(levelId: string, startTime: number) {
  const timeSpent = Math.floor((Date.now() - startTime) / 1000);
  const result = await completeLevel(levelId, timeSpent);
  // 更新关卡选择页面的状态
}

7. 完整启动流程

// GameEntry.ts
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;

@ccclass('GameEntry')
export class GameEntry extends Component {
  async start() {
    try {
      // 1. 尝试使用缓存 token
      const cachedToken = wx.getStorageSync('jwt_token');
      if (cachedToken) {
        http.setToken(cachedToken);
      }

      // 2. 登录(获取最新 token
      const loginData = await wxLogin();
      console.log('登录成功:', loginData.user.id);

      // 3. 加载游戏数据
      const gameData = await loadGameData();
      console.log('体力:', gameData.user.stamina.current);
      console.log('已通关:', gameData.completedLevelIds.length, '关');

      // 4. 启动体力恢复倒计时
      startStaminaTimer(gameData.user.stamina);

      // 5. 获取关卡列表,进入关卡选择页面
      const levels = await getLevels();
      showLevelSelect(levels);

    } catch (error) {
      console.error('启动失败:', error);
      showRetryDialog();
    }
  }
}

注意事项

  1. Token 有效期JWT Token 有效期 7 天,建议本地缓存并在启动时尝试复用
  2. 体力值以服务端为准:客户端倒计时仅为 UI 展示,实际体力以接口返回为准
  3. 进入关卡必须调用接口:不要使用列表接口中缓存的关卡数据直接开始游戏,必须调用 enter 接口
  4. 已通关关卡免费进入:已通关关卡再次进入不消耗体力
  5. 通关上报仅限成功:只在用户答对后调用 complete 接口,答错不需要上报
  6. hint 字段hint1/hint2/hint3 可能为 null,表示该线索未配置
  7. 网络异常处理:建议所有接口调用加 loading 状态,并处理 401重新登录和网络错误