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

888 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 自定义挑战 API 接口文档
**版本**: v1.0
**更新日期**: 2025-01-25
**基础URL**: `https://your-domain.com/api`
## 目录
- [概述](#概述)
- [认证](#认证)
- [数据模型](#数据模型)
- [API 接口](#api-接口)
- [创建自定义挑战](#1-创建自定义挑战)
- [通过分享码加入挑战](#2-通过分享码加入挑战)
- [获取分享码对应的挑战信息](#3-获取分享码对应的挑战信息)
- [获取我创建的挑战列表](#4-获取我创建的挑战列表)
- [更新自定义挑战](#5-更新自定义挑战)
- [归档自定义挑战](#6-归档自定义挑战)
- [重新生成分享码](#7-重新生成分享码)
- [错误码说明](#错误码说明)
- [客户端集成示例](#客户端集成示例)
---
## 概述
自定义挑战功能允许用户创建自己的挑战,并通过分享码邀请其他用户参与。该功能完全兼容现有的系统挑战。
### 核心特性
- ✅ 用户可以自由创建挑战
- ✅ 自动生成6位唯一分享码
- ✅ 支持公开和私密两种模式
- ✅ 可设置参与人数限制
- ✅ 完全兼容现有的打卡、排行榜系统
---
## 认证
大部分接口需要用户认证。在请求头中添加 JWT Token
```http
Authorization: Bearer {your_jwt_token}
```
**公开接口**(无需认证):
- 获取分享码对应的挑战信息
---
## 数据模型
### ChallengeType挑战类型
```typescript
enum ChallengeType {
WATER = "water", // 喝水
EXERCISE = "exercise", // 运动
DIET = "diet", // 饮食
MOOD = "mood", // 心情
SLEEP = "sleep", // 睡眠
WEIGHT = "weight", // 体重
}
```
### ChallengeSource挑战来源
```typescript
enum ChallengeSource {
SYSTEM = "system", // 系统预设挑战
CUSTOM = "custom", // 用户自定义挑战
}
```
### ChallengeState挑战状态
```typescript
enum ChallengeState {
DRAFT = "draft", // 草稿(预留)
ACTIVE = "active", // 活跃
ARCHIVED = "archived", // 已归档
}
```
### CustomChallengeResponse自定义挑战响应
```typescript
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`
**是否需要认证**: ✅ 是
**请求头**:
```http
Content-Type: application/json
Authorization: Bearer {token}
```
**请求体**:
```json
{
"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`
```json
{
"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,
...
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "每天最多创建 5 个挑战,请明天再试",
"data": null
}
```
---
### 2. 通过分享码加入挑战
使用分享码加入他人创建的挑战。
**接口地址**: `POST /challenges/join-by-code`
**是否需要认证**: ✅ 是
**请求头**:
```http
Content-Type: application/json
Authorization: Bearer {token}
```
**请求体**:
```json
{
"shareCode": "A3K9P2"
}
```
**参数说明**:
| 参数 | 类型 | 必填 | 说明 |
| --------- | ------ | ---- | ------------------------------------ |
| shareCode | string | ✅ | 6-12位分享码只能包含大写字母和数字 |
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "加入挑战成功",
"data": {
"completed": 0,
"target": 21,
"remaining": 21,
"checkedInToday": false
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "分享码无效或挑战不存在",
"data": null
}
```
```json
{
"code": 1,
"message": "挑战人数已满",
"data": null
}
```
---
### 3. 获取分享码对应的挑战信息
通过分享码查看挑战详情(公开接口,无需登录)。
**接口地址**: `GET /challenges/share/{shareCode}`
**是否需要认证**: ❌ 否但提供token可获取更多信息
**路径参数**:
- `shareCode`: 分享码,如 `A3K9P2`
**请求示例**:
```http
GET /challenges/share/A3K9P2
```
**成功响应**: `200 OK`
```json
{
"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"
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "分享码无效或挑战不存在",
"data": null
}
```
---
### 4. 获取我创建的挑战列表
获取当前用户创建的所有挑战。
**接口地址**: `GET /challenges/my/created`
**是否需要认证**: ✅ 是
**请求头**:
```http
Authorization: Bearer {token}
```
**查询参数**:
| 参数 | 类型 | 必填 | 说明 |
| -------- | -------------- | ---- | ------------------------- |
| page | number | ❌ | 页码默认1 |
| pageSize | number | ❌ | 每页数量默认20最大100 |
| state | ChallengeState | ❌ | 挑战状态筛选 |
**请求示例**:
```http
GET /challenges/my/created?page=1&pageSize=20&state=active
```
**成功响应**: `200 OK`
```json
{
"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
**请求头**:
```http
Content-Type: application/json
Authorization: Bearer {token}
```
**请求体**:
```json
{
"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`
```json
{
"code": 0,
"message": "更新挑战成功",
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "新的挑战标题",
...
}
}
```
**错误响应**:
```json
{
"code": 1,
"message": "只有创建者才能编辑挑战",
"data": null
}
```
```json
{
"code": 1,
"message": "挑战已开始,只能编辑概要、公开性和展示文案",
"data": null
}
```
---
### 6. 归档自定义挑战
归档(软删除)自定义挑战(仅创建者可操作)。
**接口地址**: `DELETE /challenges/custom/{id}`
**是否需要认证**: ✅ 是(仅创建者)
**路径参数**:
- `id`: 挑战ID
**请求头**:
```http
Authorization: Bearer {token}
```
**请求示例**:
```http
DELETE /challenges/custom/550e8400-e29b-41d4-a716-446655440000
```
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "归档挑战成功",
"data": true
}
```
**错误响应**:
```json
{
"code": 1,
"message": "只有创建者才能归档挑战",
"data": null
}
```
---
### 7. 重新生成分享码
为挑战重新生成一个新的分享码(仅创建者可操作)。
**接口地址**: `POST /challenges/custom/{id}/regenerate-code`
**是否需要认证**: ✅ 是(仅创建者)
**路径参数**:
- `id`: 挑战ID
**请求头**:
```http
Authorization: Bearer {token}
```
**请求示例**:
```http
POST /challenges/custom/550e8400-e29b-41d4-a716-446655440000/regenerate-code
```
**成功响应**: `200 OK`
```json
{
"code": 0,
"message": "重新生成分享码成功",
"data": {
"shareCode": "B7M4N9"
}
}
```
**使用场景**:
- 分享码泄露,需要更换
- 想要限制旧分享码的传播
- 重新组织挑战参与者
---
## 错误码说明
### 通用错误码
| code | message | 说明 |
| ---- | ------- | ------------------------------- |
| 0 | Success | 请求成功 |
| 1 | Error | 业务错误message中有具体说明 |
### 常见业务错误
| 错误信息 | 说明 | 解决方案 |
| -------------------------------------------- | ---------------------- | -------------------- |
| "每天最多创建 5 个挑战,请明天再试" | 创建频率限制 | 提示用户明天再试 |
| "分享码无效或挑战不存在" | 分享码错误或挑战已归档 | 提示用户检查分享码 |
| "挑战人数已满" | 达到最大参与人数 | 提示用户挑战已满 |
| "挑战已过期,无法加入" | 挑战已结束 | 提示挑战已结束 |
| "只有创建者才能编辑挑战" | 权限不足 | 提示只有创建者可操作 |
| "挑战已开始,只能编辑概要、公开性和展示文案" | 限制编辑 | 提示可编辑字段 |
| "已加入该挑战" | 重复加入 | 跳转到挑战详情页 |
### HTTP 状态码
| 状态码 | 说明 |
| ------ | -------------------- |
| 200 | 请求成功 |
| 400 | 请求参数错误 |
| 401 | 未授权(需要登录) |
| 403 | 禁止访问(权限不足) |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
---
## 客户端集成示例
### Swift (iOS)
#### 1. 创建挑战
```swift
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. 通过分享码加入
```swift
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. 分享功能
```swift
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. 创建挑战
```kotlin
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. 通过分享码加入
```kotlin
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. 分享功能
```kotlin
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 客户端封装
```typescript
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. 使用示例
```typescript
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()`
- 示例: `1704067200000`2024-01-01 00:00:00
### 2. 分享码规则
- 长度: 6-12位字符
- 字符集: 大写字母和数字A-Z, 2-9
- 排除易混淆字符: 0/O, 1/I/l
- 示例: `A3K9P2`, `B7M4N9`
### 3. 创建频率限制
- 每个用户每天最多创建 **5 个挑战**
- 超出限制会返回错误,建议提示用户
### 4. 人数限制
- `maxParticipants``null` 表示无限制
- 最小值: 2 人
- 最大值: 10000 人
### 5. 编辑限制
-