From 029b8f46b95b5f2f9346495437de4eb9b05622d3 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Wed, 26 Nov 2025 10:43:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E6=9B=B4=E6=96=B0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=8C=91=E6=88=98=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=97=B6=E9=97=B4=E6=88=B3=E8=BD=AC=E6=8D=A2?= =?UTF-8?q?=E5=8F=8A=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/custom-challenges-api.md | 887 ++++++++++++++++++ src/challenges/challenges.service.ts | 35 +- .../dto/create-custom-challenge.dto.ts | 17 - src/challenges/models/challenge.model.ts | 14 +- 4 files changed, 918 insertions(+), 35 deletions(-) create mode 100644 docs/custom-challenges-api.md diff --git a/docs/custom-challenges-api.md b/docs/custom-challenges-api.md new file mode 100644 index 0000000..e42ee7e --- /dev/null +++ b/docs/custom-challenges-api.md @@ -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.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.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 { + 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 { + 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 { + 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. 编辑限制 + +- diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index c923c14..fa50cda 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -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, diff --git a/src/challenges/dto/create-custom-challenge.dto.ts b/src/challenges/dto/create-custom-challenge.dto.ts index dd135c1..60037fd 100644 --- a/src/challenges/dto/create-custom-challenge.dto.ts +++ b/src/challenges/dto/create-custom-challenge.dto.ts @@ -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() diff --git a/src/challenges/models/challenge.model.ts b/src/challenges/models/challenge.model.ts index 27c487b..2f6f052 100644 --- a/src/challenges/models/challenge.model.ts +++ b/src/challenges/models/challenge.model.ts @@ -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;