Files
plates-server/docs/custom-challenges-api.md

22 KiB
Raw Permalink Blame History

自定义挑战 API 接口文档

版本: v1.0
更新日期: 2025-01-25
基础URL: https://your-domain.com/api

目录


概述

自定义挑战功能允许用户创建自己的挑战,并通过分享码邀请其他用户参与。该功能完全兼容现有的系统挑战。

核心特性

  • 用户可以自由创建挑战
  • 自动生成6位唯一分享码
  • 支持公开和私密两种模式
  • 可设置参与人数限制
  • 完全兼容现有的打卡、排行榜系统

认证

大部分接口需要用户认证。在请求头中添加 JWT Token

Authorization: Bearer {your_jwt_token}

公开接口(无需认证):

  • 获取分享码对应的挑战信息

数据模型

ChallengeType挑战类型

enum ChallengeType {
  WATER = "water", // 喝水
  EXERCISE = "exercise", // 运动
  DIET = "diet", // 饮食
  MOOD = "mood", // 心情
  SLEEP = "sleep", // 睡眠
  WEIGHT = "weight", // 体重
}

ChallengeSource挑战来源

enum ChallengeSource {
  SYSTEM = "system", // 系统预设挑战
  CUSTOM = "custom", // 用户自定义挑战
}

ChallengeState挑战状态

enum ChallengeState {
  DRAFT = "draft", // 草稿(预留)
  ACTIVE = "active", // 活跃
  ARCHIVED = "archived", // 已归档
}

CustomChallengeResponse自定义挑战响应

interface CustomChallengeResponse {
  id: string; // 挑战ID
  title: string; // 挑战标题
  type: ChallengeType; // 挑战类型
  source: ChallengeSource; // 挑战来源
  creatorId: string | null; // 创建者ID
  shareCode: string | null; // 分享码6位大写字母和数字
  image: string | null; // 封面图URL
  startAt: number; // 开始时间戳(毫秒)
  endAt: number; // 结束时间戳(毫秒)
  periodLabel: string | null; // 周期标签,如"21天挑战"
  durationLabel: string; // 持续时间标签,如"持续21天"
  requirementLabel: string; // 要求标签,如"每日喝水8杯"
  summary: string | null; // 挑战说明
  targetValue: number; // 每日目标值
  progressUnit: string; // 进度单位,默认"天"
  minimumCheckInDays: number; // 最少打卡天数
  rankingDescription: string | null; // 排行榜描述
  highlightTitle: string; // 高亮标题
  highlightSubtitle: string; // 高亮副标题
  ctaLabel: string; // CTA按钮文字
  isPublic: boolean; // 是否公开
  maxParticipants: number | null; // 最大参与人数null=无限制)
  challengeState: ChallengeState; // 挑战状态
  participantsCount: number; // 当前参与人数
  progress?: {
    // 用户进度(仅加入后有值)
    completed: number; // 已完成天数
    target: number; // 目标天数
    remaining: number; // 剩余天数
    checkedInToday: boolean; // 今日是否已打卡
  };
  isJoined: boolean; // 当前用户是否已加入
  isCreator: boolean; // 当前用户是否为创建者
  createdAt: Date; // 创建时间
  updatedAt: Date; // 更新时间
}

API 接口

1. 创建自定义挑战

创建一个新的自定义挑战,系统会自动生成唯一的分享码。

接口地址: POST /challenges/custom

是否需要认证:

请求头:

Content-Type: application/json
Authorization: Bearer {token}

请求体:

{
  "title": "21天喝水挑战",
  "type": "water",
  "image": "https://example.com/image.jpg",
  "startAt": 1704067200000,
  "endAt": 1705881600000,
  "targetValue": 8,
  "minimumCheckInDays": 21,
  "durationLabel": "持续21天",
  "requirementLabel": "每日喝水8杯",
  "summary": "坚持每天喝足8杯水养成健康好习惯",
  "progressUnit": "天",
  "periodLabel": "21天挑战",
  "rankingDescription": "连续打卡榜",
  "highlightTitle": "坚持21天",
  "highlightSubtitle": "养成喝水好习惯",
  "ctaLabel": "立即加入",
  "isPublic": true,
  "maxParticipants": 100
}

参数说明:

参数 类型 必填 说明
title string 挑战标题最长100字符
type ChallengeType 挑战类型
startAt number 开始时间戳(毫秒),必须是未来时间
endAt number 结束时间戳(毫秒),必须晚于开始时间
targetValue number 每日目标值1-1000
minimumCheckInDays number 最少打卡天数1-365
durationLabel string 持续时间标签最长128字符
requirementLabel string 要求标签最长255字符
image string 封面图URL最长512字符
summary string 挑战说明
progressUnit string 进度单位,默认"天"最长64字符
periodLabel string 周期标签最长128字符
rankingDescription string 排行榜描述最长255字符
highlightTitle string 高亮标题最长255字符
highlightSubtitle string 高亮副标题最长255字符
ctaLabel string CTA按钮文字最长128字符
isPublic boolean 是否公开默认true
maxParticipants number 最大参与人数2-10000null表示无限制

成功响应: 200 OK

{
  "code": 0,
  "message": "创建挑战成功",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "shareCode": "A3K9P2",
    "title": "21天喝水挑战",
    "type": "water",
    "source": "custom",
    "creatorId": "user_123",
    "isPublic": true,
    "maxParticipants": 100,
    "participantsCount": 0,
    "isJoined": false,
    "isCreator": true,
    ...
  }
}

错误响应:

{
  "code": 1,
  "message": "每天最多创建 5 个挑战,请明天再试",
  "data": null
}

2. 通过分享码加入挑战

使用分享码加入他人创建的挑战。

接口地址: POST /challenges/join-by-code

是否需要认证:

请求头:

Content-Type: application/json
Authorization: Bearer {token}

请求体:

{
  "shareCode": "A3K9P2"
}

参数说明:

参数 类型 必填 说明
shareCode string 6-12位分享码只能包含大写字母和数字

成功响应: 200 OK

{
  "code": 0,
  "message": "加入挑战成功",
  "data": {
    "completed": 0,
    "target": 21,
    "remaining": 21,
    "checkedInToday": false
  }
}

错误响应:

{
  "code": 1,
  "message": "分享码无效或挑战不存在",
  "data": null
}
{
  "code": 1,
  "message": "挑战人数已满",
  "data": null
}

3. 获取分享码对应的挑战信息

通过分享码查看挑战详情(公开接口,无需登录)。

接口地址: GET /challenges/share/{shareCode}

是否需要认证: 但提供token可获取更多信息

路径参数:

  • shareCode: 分享码,如 A3K9P2

请求示例:

GET /challenges/share/A3K9P2

成功响应: 200 OK

{
  "code": 0,
  "message": "获取挑战信息成功",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "title": "21天喝水挑战",
    "image": "https://example.com/image.jpg",
    "periodLabel": "21天挑战",
    "durationLabel": "持续21天",
    "requirementLabel": "每日喝水8杯",
    "summary": "坚持每天喝足8杯水",
    "rankingDescription": "连续打卡榜",
    "highlightTitle": "坚持21天",
    "highlightSubtitle": "养成喝水好习惯",
    "ctaLabel": "立即加入",
    "minimumCheckInDays": 21,
    "participantsCount": 15,
    "progress": null,
    "rankings": [...],
    "userRank": null,
    "unit": "天",
    "type": "water"
  }
}

错误响应:

{
  "code": 1,
  "message": "分享码无效或挑战不存在",
  "data": null
}

4. 获取我创建的挑战列表

获取当前用户创建的所有挑战。

接口地址: GET /challenges/my/created

是否需要认证:

请求头:

Authorization: Bearer {token}

查询参数:

参数 类型 必填 说明
page number 页码默认1
pageSize number 每页数量默认20最大100
state ChallengeState 挑战状态筛选

请求示例:

GET /challenges/my/created?page=1&pageSize=20&state=active

成功响应: 200 OK

{
  "code": 0,
  "message": "获取我创建的挑战列表成功",
  "data": {
    "items": [
      {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "shareCode": "A3K9P2",
        "title": "21天喝水挑战",
        "type": "water",
        "source": "custom",
        "participantsCount": 15,
        "challengeState": "active",
        "isCreator": true,
        ...
      }
    ],
    "total": 5,
    "page": 1,
    "pageSize": 20
  }
}

5. 更新自定义挑战

更新自定义挑战信息(仅创建者可操作)。

接口地址: PUT /challenges/custom/{id}

是否需要认证: 是(仅创建者)

路径参数:

  • id: 挑战ID

请求头:

Content-Type: application/json
Authorization: Bearer {token}

请求体:

{
  "title": "新的挑战标题",
  "image": "https://example.com/new-image.jpg",
  "summary": "更新的挑战说明",
  "isPublic": false,
  "maxParticipants": 50,
  "highlightTitle": "新的高亮标题",
  "highlightSubtitle": "新的高亮副标题",
  "ctaLabel": "快来加入"
}

参数说明:

参数 类型 必填 说明
title string 挑战标题
image string 封面图URL
summary string 挑战说明
isPublic boolean 是否公开
maxParticipants number 最大参与人数
highlightTitle string 高亮标题
highlightSubtitle string 高亮副标题
ctaLabel string CTA按钮文字

⚠️ 重要: 挑战开始后,只能编辑以下字段:

  • summary挑战说明
  • isPublic公开性
  • highlightTitle高亮标题
  • highlightSubtitle高亮副标题
  • ctaLabelCTA文字

成功响应: 200 OK

{
  "code": 0,
  "message": "更新挑战成功",
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "title": "新的挑战标题",
    ...
  }
}

错误响应:

{
  "code": 1,
  "message": "只有创建者才能编辑挑战",
  "data": null
}
{
  "code": 1,
  "message": "挑战已开始,只能编辑概要、公开性和展示文案",
  "data": null
}

6. 归档自定义挑战

归档(软删除)自定义挑战(仅创建者可操作)。

接口地址: DELETE /challenges/custom/{id}

是否需要认证: 是(仅创建者)

路径参数:

  • id: 挑战ID

请求头:

Authorization: Bearer {token}

请求示例:

DELETE /challenges/custom/550e8400-e29b-41d4-a716-446655440000

成功响应: 200 OK

{
  "code": 0,
  "message": "归档挑战成功",
  "data": true
}

错误响应:

{
  "code": 1,
  "message": "只有创建者才能归档挑战",
  "data": null
}

7. 重新生成分享码

为挑战重新生成一个新的分享码(仅创建者可操作)。

接口地址: POST /challenges/custom/{id}/regenerate-code

是否需要认证: 是(仅创建者)

路径参数:

  • id: 挑战ID

请求头:

Authorization: Bearer {token}

请求示例:

POST /challenges/custom/550e8400-e29b-41d4-a716-446655440000/regenerate-code

成功响应: 200 OK

{
  "code": 0,
  "message": "重新生成分享码成功",
  "data": {
    "shareCode": "B7M4N9"
  }
}

使用场景:

  • 分享码泄露,需要更换
  • 想要限制旧分享码的传播
  • 重新组织挑战参与者

错误码说明

通用错误码

code message 说明
0 Success 请求成功
1 Error 业务错误message中有具体说明

常见业务错误

错误信息 说明 解决方案
"每天最多创建 5 个挑战,请明天再试" 创建频率限制 提示用户明天再试
"分享码无效或挑战不存在" 分享码错误或挑战已归档 提示用户检查分享码
"挑战人数已满" 达到最大参与人数 提示用户挑战已满
"挑战已过期,无法加入" 挑战已结束 提示挑战已结束
"只有创建者才能编辑挑战" 权限不足 提示只有创建者可操作
"挑战已开始,只能编辑概要、公开性和展示文案" 限制编辑 提示可编辑字段
"已加入该挑战" 重复加入 跳转到挑战详情页

HTTP 状态码

状态码 说明
200 请求成功
400 请求参数错误
401 未授权(需要登录)
403 禁止访问(权限不足)
404 资源不存在
500 服务器内部错误

客户端集成示例

Swift (iOS)

1. 创建挑战

struct CreateChallengeRequest: Codable {
    let title: String
    let type: String
    let startAt: Int64
    let endAt: Int64
    let targetValue: Int
    let minimumCheckInDays: Int
    let durationLabel: String
    let requirementLabel: String
    let isPublic: Bool
}

func createChallenge(request: CreateChallengeRequest) async throws -> CustomChallengeResponse {
    guard let url = URL(string: "\(baseURL)/challenges/custom") else {
        throw NetworkError.invalidURL
    }

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
    urlRequest.httpBody = try JSONEncoder().encode(request)

    let (data, response) = try await URLSession.shared.data(for: urlRequest)

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.requestFailed
    }

    let result = try JSONDecoder().decode(APIResponse<CustomChallengeResponse>.self, from: data)
    return result.data
}

2. 通过分享码加入

func joinByShareCode(_ shareCode: String) async throws {
    guard let url = URL(string: "\(baseURL)/challenges/join-by-code") else {
        throw NetworkError.invalidURL
    }

    var urlRequest = URLRequest(url: url)
    urlRequest.httpMethod = "POST"
    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
    urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

    let body = ["shareCode": shareCode]
    urlRequest.httpBody = try JSONEncoder().encode(body)

    let (data, response) = try await URLSession.shared.data(for: urlRequest)

    guard let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200 else {
        throw NetworkError.requestFailed
    }

    let result = try JSONDecoder().decode(APIResponse<ChallengeProgress>.self, from: data)
    // Handle success
}

3. 分享功能

func shareChallengeCode(_ shareCode: String, title: String) {
    let message = "邀请你加入挑战:\(title)\n分享码:\(shareCode)"

    let activityVC = UIActivityViewController(
        activityItems: [message],
        applicationActivities: nil
    )

    present(activityVC, animated: true)
}

Kotlin (Android)

1. 创建挑战

data class CreateChallengeRequest(
    val title: String,
    val type: String,
    val startAt: Long,
    val endAt: Long,
    val targetValue: Int,
    val minimumCheckInDays: Int,
    val durationLabel: String,
    val requirementLabel: String,
    val isPublic: Boolean = true
)

suspend fun createChallenge(request: CreateChallengeRequest): CustomChallengeResponse {
    return withContext(Dispatchers.IO) {
        val response = apiService.createChallenge(request)
        if (response.code == 0) {
            response.data
        } else {
            throw Exception(response.message)
        }
    }
}

2. 通过分享码加入

suspend fun joinByShareCode(shareCode: String): ChallengeProgress {
    return withContext(Dispatchers.IO) {
        val request = JoinByCodeRequest(shareCode)
        val response = apiService.joinByShareCode(request)
        if (response.code == 0) {
            response.data
        } else {
            throw Exception(response.message)
        }
    }
}

3. 分享功能

fun shareChallenge(context: Context, shareCode: String, title: String) {
    val message = "邀请你加入挑战:$title\n分享码:$shareCode"

    val shareIntent = Intent().apply {
        action = Intent.ACTION_SEND
        putExtra(Intent.EXTRA_TEXT, message)
        type = "text/plain"
    }

    context.startActivity(Intent.createChooser(shareIntent, "分享挑战"))
}

TypeScript/JavaScript

1. API 客户端封装

class ChallengesAPI {
  private baseURL: string;
  private token: string;

  constructor(baseURL: string, token: string) {
    this.baseURL = baseURL;
    this.token = token;
  }

  async createChallenge(
    data: CreateChallengeRequest
  ): Promise<CustomChallengeResponse> {
    const response = await fetch(`${this.baseURL}/challenges/custom`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${this.token}`,
      },
      body: JSON.stringify(data),
    });

    const result = await response.json();
    if (result.code !== 0) {
      throw new Error(result.message);
    }
    return result.data;
  }

  async joinByShareCode(shareCode: string): Promise<ChallengeProgress> {
    const response = await fetch(`${this.baseURL}/challenges/join-by-code`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${this.token}`,
      },
      body: JSON.stringify({ shareCode }),
    });

    const result = await response.json();
    if (result.code !== 0) {
      throw new Error(result.message);
    }
    return result.data;
  }

  async getChallengeByShareCode(shareCode: string): Promise<ChallengeDetail> {
    const response = await fetch(
      `${this.baseURL}/challenges/share/${shareCode}`
    );
    const result = await response.json();
    if (result.code !== 0) {
      throw new Error(result.message);
    }
    return result.data;
  }
}

2. 使用示例

const api = new ChallengesAPI("https://api.example.com/api", userToken);

// 创建挑战
try {
  const challenge = await api.createChallenge({
    title: "21天喝水挑战",
    type: "water",
    startAt: Date.now(),
    endAt: Date.now() + 21 * 24 * 60 * 60 * 1000,
    targetValue: 8,
    minimumCheckInDays: 21,
    durationLabel: "持续21天",
    requirementLabel: "每日喝水8杯",
    isPublic: true,
  });

  console.log("分享码:", challenge.shareCode);
} catch (error) {
  console.error("创建失败:", error.message);
}

注意事项

1. 时间戳格式

  • 所有时间戳均为毫秒级JavaScript: Date.now()
  • 示例: 17040672000002024-01-01 00:00:00

2. 分享码规则

  • 长度: 6-12位字符
  • 字符集: 大写字母和数字A-Z, 2-9
  • 排除易混淆字符: 0/O, 1/I/l
  • 示例: A3K9P2, B7M4N9

3. 创建频率限制

  • 每个用户每天最多创建 5 个挑战
  • 超出限制会返回错误,建议提示用户

4. 人数限制

  • maxParticipantsnull 表示无限制
  • 最小值: 2 人
  • 最大值: 10000 人

5. 编辑限制