feat(challenges): 更新自定义挑战功能,支持时间戳转换及数据模型调整
This commit is contained in:
887
docs/custom-challenges-api.md
Normal file
887
docs/custom-challenges-api.md
Normal file
@@ -0,0 +1,887 @@
|
||||
# 自定义挑战 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-10000,null表示无限制 |
|
||||
|
||||
**成功响应**: `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(高亮副标题)
|
||||
- ctaLabel(CTA文字)
|
||||
|
||||
**成功响应**: `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. 编辑限制
|
||||
|
||||
-
|
||||
@@ -166,8 +166,8 @@ export class ChallengesService {
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
status,
|
||||
unit: challenge.progressUnit,
|
||||
startAt: challenge.startAt,
|
||||
endAt: challenge.endAt,
|
||||
startAt: new Date(challenge.startAt).getTime(),
|
||||
endAt: new Date(challenge.endAt).getTime(),
|
||||
participantsCount: participantsCountMap.get(challenge.id) ?? 0,
|
||||
rankingDescription: challenge.rankingDescription,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
@@ -617,7 +617,7 @@ export class ChallengesService {
|
||||
}
|
||||
|
||||
|
||||
private computeStatus(startAt: number, endAt: number): ChallengeStatus {
|
||||
private computeStatus(startAt: Date | number, endAt: Date | number): ChallengeStatus {
|
||||
const now = dayjs();
|
||||
const start = dayjs(startAt);
|
||||
const end = dayjs(endAt);
|
||||
@@ -822,6 +822,15 @@ export class ChallengesService {
|
||||
throw new BadRequestException('结束时间必须晚于开始时间');
|
||||
}
|
||||
|
||||
// 将毫秒时间戳转换为 Date 对象,以匹配数据库 DATETIME 类型
|
||||
const startAtDate = new Date(dto.startAt);
|
||||
const endAtDate = new Date(dto.endAt);
|
||||
|
||||
// 验证日期是否有效
|
||||
if (isNaN(startAtDate.getTime()) || isNaN(endAtDate.getTime())) {
|
||||
throw new BadRequestException('无效的时间戳');
|
||||
}
|
||||
|
||||
// 检查创建频率限制(每天最多创建 5 个)
|
||||
const recentCount = await this.challengeModel.count({
|
||||
where: {
|
||||
@@ -839,13 +848,14 @@ export class ChallengesService {
|
||||
// 生成分享码
|
||||
const shareCode = await this.generateUniqueShareCode();
|
||||
|
||||
|
||||
// 创建挑战
|
||||
const challenge = await this.challengeModel.create({
|
||||
title: dto.title,
|
||||
type: dto.type,
|
||||
image: dto.image || null,
|
||||
startAt: dto.startAt,
|
||||
endAt: dto.endAt,
|
||||
startAt: startAtDate,
|
||||
endAt: endAtDate,
|
||||
periodLabel: dto.periodLabel || null,
|
||||
durationLabel: dto.durationLabel,
|
||||
requirementLabel: dto.requirementLabel,
|
||||
@@ -854,9 +864,9 @@ export class ChallengesService {
|
||||
progressUnit: dto.progressUnit || '天',
|
||||
minimumCheckInDays: dto.minimumCheckInDays,
|
||||
rankingDescription: dto.rankingDescription || '连续打卡榜',
|
||||
highlightTitle: dto.highlightTitle || '坚持挑战',
|
||||
highlightSubtitle: dto.highlightSubtitle || '养成好习惯',
|
||||
ctaLabel: dto.ctaLabel || '立即加入',
|
||||
highlightTitle: dto.title,
|
||||
highlightSubtitle: dto.summary || '养成好习惯',
|
||||
ctaLabel: '立即加入',
|
||||
source: ChallengeSource.CUSTOM,
|
||||
creatorId: userId,
|
||||
shareCode,
|
||||
@@ -865,7 +875,10 @@ export class ChallengesService {
|
||||
challengeState: ChallengeState.ACTIVE,
|
||||
});
|
||||
|
||||
this.winstonLogger.info('创建自定义挑战成功', {
|
||||
// 创建者自动加入挑战
|
||||
await this.joinChallenge(userId, challenge.id);
|
||||
|
||||
this.winstonLogger.info('创建自定义挑战成功,创建者已自动加入', {
|
||||
context: 'createCustomChallenge',
|
||||
userId,
|
||||
challengeId: challenge.id,
|
||||
@@ -1115,8 +1128,8 @@ export class ChallengesService {
|
||||
creatorId: challenge.creatorId,
|
||||
shareCode: challenge.shareCode,
|
||||
image: challenge.image,
|
||||
startAt: challenge.startAt,
|
||||
endAt: challenge.endAt,
|
||||
startAt: new Date(challenge.startAt).getTime(),
|
||||
endAt: new Date(challenge.endAt).getTime(),
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
|
||||
@@ -77,23 +77,6 @@ export class CreateCustomChallengeDto {
|
||||
@MaxLength(255)
|
||||
rankingDescription?: string;
|
||||
|
||||
@ApiProperty({ description: '高亮标题', example: '坚持21天', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
highlightTitle?: string;
|
||||
|
||||
@ApiProperty({ description: '高亮副标题', example: '养成好习惯', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(255)
|
||||
highlightSubtitle?: string;
|
||||
|
||||
@ApiProperty({ description: 'CTA 按钮文字', example: '立即加入', required: false })
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@MaxLength(128)
|
||||
ctaLabel?: string;
|
||||
|
||||
@ApiProperty({ description: '是否公开(可通过分享码加入)', default: true })
|
||||
@IsBoolean()
|
||||
|
||||
@@ -55,18 +55,18 @@ export class Challenge extends Model {
|
||||
declare image: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '挑战开始时间(时间戳)',
|
||||
comment: '挑战开始时间',
|
||||
})
|
||||
declare startAt: number;
|
||||
declare startAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.BIGINT,
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '挑战结束时间(时间戳)',
|
||||
comment: '挑战结束时间',
|
||||
})
|
||||
declare endAt: number;
|
||||
declare endAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(128),
|
||||
@@ -142,7 +142,7 @@ export class Challenge extends Model {
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(128),
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
comment: 'CTA 按钮文字',
|
||||
})
|
||||
declare ctaLabel: string;
|
||||
|
||||
Reference in New Issue
Block a user