feat(challenges): 新增挑战功能模块及完整接口实现
- 新增挑战列表、详情、加入/退出、进度上报等 REST 接口 - 定义 Challenge / ChallengeParticipant 数据模型与状态枚举 - 提供排行榜查询与用户排名计算 - 包含接口文档与数据库初始化脚本
This commit is contained in:
236
docs/challenges-api.md
Normal file
236
docs/challenges-api.md
Normal file
@@ -0,0 +1,236 @@
|
||||
# 挑战功能接口文档
|
||||
|
||||
> 所有接口均需携带 `Authorization: Bearer <token>`,鉴权方式与现有用户体系一致。
|
||||
> 基础路径:`/challenges`
|
||||
|
||||
## 数据模型概述
|
||||
|
||||
### Challenge
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | 挑战唯一标识 |
|
||||
| `title` | `string` | 挑战名称 |
|
||||
| `image` | `string` | 挑战展示图 URL |
|
||||
| `periodLabel` | `string` | 可选,展示周期文案(如「21 天计划」) |
|
||||
| `durationLabel` | `string` | 必填,持续时间描述 |
|
||||
| `requirementLabel` | `string` | 必填,参与要求文案 |
|
||||
| `status` | `"upcoming" \| "ongoing" \| "expired"` | 由服务端根据时间自动计算 |
|
||||
| `participantsCount` | `number` | 当前参与人数(仅统计 active 状态) |
|
||||
| `rankingDescription` | `string` | 可选,排行榜说明 |
|
||||
| `highlightTitle` | `string` | 高亮标题 |
|
||||
| `highlightSubtitle` | `string` | 高亮副标题 |
|
||||
| `ctaLabel` | `string` | CTA 按钮文案 |
|
||||
| `progress` | `ChallengeProgress` | 可选,仅当当前用户已加入时返回 |
|
||||
| `isJoined` | `boolean` | 当前用户是否已加入 |
|
||||
|
||||
### ChallengeProgress
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `completed` | `number` | 已完成进度值 |
|
||||
| `target` | `number` | 总目标值 |
|
||||
| `remaining` | `number` | 剩余进度 |
|
||||
| `badge` | `string` | 当前进度徽章文案 |
|
||||
| `subtitle` | `string` | 可选,补充提示文案 |
|
||||
|
||||
### RankingItem
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | `string` | 用户 ID |
|
||||
| `name` | `string` | 昵称 |
|
||||
| `avatar` | `string` | 头像 URL |
|
||||
| `metric` | `string` | 排行榜展示文案(如 `5/21天`) |
|
||||
| `badge` | `string` | 可选,名次勋章(`gold`/`silver`/`bronze`) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 获取挑战列表
|
||||
- **Method / Path**:`GET /challenges`
|
||||
- **描述**:获取当前所有挑战(全局共享),按开始时间升序排序。
|
||||
|
||||
### 请求参数
|
||||
无额外 query 参数。
|
||||
|
||||
### 响应示例
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取挑战列表成功",
|
||||
"data": [
|
||||
{
|
||||
"id": "f27c9a5d-8e53-4ba8-b8df-3c843f0241d2",
|
||||
"title": "21 天核心燃脂计划",
|
||||
"image": "https://cdn.example.com/challenges/core-21.png",
|
||||
"periodLabel": "21 天",
|
||||
"durationLabel": "持续 21 天",
|
||||
"requirementLabel": "每日完成 1 次训练",
|
||||
"status": "ongoing",
|
||||
"startAt": "2024-03-01T00:00:00.000Z",
|
||||
"endAt": "2024-03-21T23:59:59.000Z",
|
||||
"participantsCount": 1287,
|
||||
"rankingDescription": "坚持天数排行榜",
|
||||
"highlightTitle": "一起塑造强壮核心",
|
||||
"highlightSubtitle": "与全球用户共同挑战",
|
||||
"ctaLabel": "立即加入挑战",
|
||||
"progress": {
|
||||
"completed": 5,
|
||||
"target": 21,
|
||||
"remaining": 16,
|
||||
"badge": "已坚持 5天",
|
||||
"subtitle": "还差 16天"
|
||||
},
|
||||
"isJoined": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取挑战详情
|
||||
- **Method / Path**:`GET /challenges/{id}`
|
||||
- **描述**:获取单个挑战的详细信息及排行榜。
|
||||
|
||||
### 路径参数
|
||||
| 名称 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | 是 | 挑战 ID |
|
||||
|
||||
### 响应示例
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取挑战详情成功",
|
||||
"data": {
|
||||
"id": "f27c9a5d-8e53-4ba8-b8df-3c843f0241d2",
|
||||
"title": "21 天核心燃脂计划",
|
||||
"image": "https://cdn.example.com/challenges/core-21.png",
|
||||
"periodLabel": "21 天",
|
||||
"durationLabel": "持续 21 天",
|
||||
"requirementLabel": "每日完成 1 次训练",
|
||||
"summary": "21 天集中强化腹部及核心肌群,帮助塑形与燃脂。",
|
||||
"rankingDescription": "坚持天数排行榜",
|
||||
"highlightTitle": "连赢 7 天即可获得限量徽章",
|
||||
"highlightSubtitle": "邀请好友并肩作战",
|
||||
"ctaLabel": "立即加入挑战",
|
||||
"participantsCount": 1287,
|
||||
"progress": {
|
||||
"completed": 5,
|
||||
"target": 21,
|
||||
"remaining": 16,
|
||||
"badge": "已坚持 5天",
|
||||
"subtitle": "还差 16天"
|
||||
},
|
||||
"rankings": [
|
||||
{
|
||||
"id": "user-001",
|
||||
"name": "Alexa",
|
||||
"avatar": "https://cdn.example.com/users/user-001.png",
|
||||
"metric": "15/21天",
|
||||
"badge": "gold"
|
||||
},
|
||||
{
|
||||
"id": "user-002",
|
||||
"name": "Ella",
|
||||
"avatar": null,
|
||||
"metric": "13/21天",
|
||||
"badge": "silver"
|
||||
}
|
||||
],
|
||||
"userRank": 57
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 加入挑战
|
||||
- **Method / Path**:`POST /challenges/{id}/join`
|
||||
- **描述**:当前用户加入挑战。若已加入会返回冲突错误。
|
||||
|
||||
### 响应示例
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "加入挑战成功",
|
||||
"data": {
|
||||
"completed": 0,
|
||||
"target": 21,
|
||||
"remaining": 21,
|
||||
"badge": "已坚持 0天"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 可能错误
|
||||
- `404`:挑战不存在
|
||||
- `400`:挑战已过期
|
||||
- `409`:用户已加入或已完成(需先退出再加入)
|
||||
|
||||
---
|
||||
|
||||
## 4. 退出挑战
|
||||
- **Method / Path**:`POST /challenges/{id}/leave`
|
||||
- **描述**:用户退出挑战,之后不再计入排行榜。可重新加入恢复进度(将重置为 0)。
|
||||
|
||||
### 响应示例
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "退出挑战成功",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
### 可能错误
|
||||
- `404`:用户尚未加入或挑战不存在
|
||||
|
||||
---
|
||||
|
||||
## 5. 上报挑战进度
|
||||
- **Method / Path**:`POST /challenges/{id}/progress`
|
||||
- **描述**:用户完成一次进度上报。默认增量 `1`,也可传入自定义增量,服务端会控制不超过目标值。
|
||||
|
||||
### 请求体
|
||||
| 字段 | 类型 | 必填 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `increment` | `number` | 否 | `1` | 本次增加的进度值,必须大于等于 1 |
|
||||
|
||||
```json
|
||||
{
|
||||
"increment": 2
|
||||
}
|
||||
```
|
||||
|
||||
### 响应示例
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "进度更新成功",
|
||||
"data": {
|
||||
"completed": 7,
|
||||
"target": 21,
|
||||
"remaining": 14,
|
||||
"badge": "已坚持 7天",
|
||||
"subtitle": "还差 14天"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 可能错误
|
||||
- `404`:挑战不存在或用户未加入
|
||||
- `400`:挑战未开始 / 已过期 / 进度增量非法
|
||||
|
||||
---
|
||||
|
||||
## 错误码说明
|
||||
| `code` | `message` | 场景 |
|
||||
| --- | --- | --- |
|
||||
| `0` | `success`/具体文案 | 请求成功 |
|
||||
| `1` | 错误描述 | 业务异常,例如未加入、挑战已过期等 |
|
||||
|
||||
---
|
||||
|
||||
## 接入建议
|
||||
- 列表接口可做缓存(例如 10 分钟),但需结合挑战状态实时变更。
|
||||
- 排行榜为前 10 名,客户端可在详情页展示,并根据 `userRank` 显示用户当前排名。
|
||||
- 进度上报建议结合业务埋点,确保重复提交时可处理幂等性(服务端会封顶到目标值)。
|
||||
42
sql-scripts/challenges.sql
Normal file
42
sql-scripts/challenges.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Challenges feature DDL
|
||||
-- Creates core tables required by the challenge listing, participation, and progress tracking flows.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_challenges (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL COMMENT '挑战标题',
|
||||
image VARCHAR(512) DEFAULT NULL COMMENT '挑战封面图',
|
||||
start_at DATETIME NOT NULL COMMENT '挑战开始时间',
|
||||
end_at DATETIME NOT NULL COMMENT '挑战结束时间',
|
||||
period_label VARCHAR(128) DEFAULT NULL COMMENT '周期标签,例如「21天挑战」',
|
||||
duration_label VARCHAR(128) NOT NULL COMMENT '持续时间标签,例如「持续21天」',
|
||||
requirement_label VARCHAR(255) NOT NULL COMMENT '挑战要求标签,例如「每日练习 1 次」',
|
||||
summary TEXT DEFAULT NULL COMMENT '挑战概要说明',
|
||||
target_value INT NOT NULL COMMENT '挑战目标值(例如需要完成的天数)',
|
||||
progress_unit VARCHAR(64) NOT NULL DEFAULT '天' COMMENT '进度单位,用于展示排行榜指标',
|
||||
ranking_description VARCHAR(255) DEFAULT NULL COMMENT '排行榜描述,例如「连续打卡榜」',
|
||||
highlight_title VARCHAR(255) NOT NULL COMMENT '高亮标题',
|
||||
highlight_subtitle VARCHAR(255) NOT NULL COMMENT '高亮副标题',
|
||||
cta_label VARCHAR(128) NOT NULL COMMENT 'CTA 按钮文字',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_challenge_participants (
|
||||
id CHAR(36) NOT NULL PRIMARY KEY,
|
||||
challenge_id CHAR(36) NOT NULL COMMENT '挑战 ID',
|
||||
user_id VARCHAR(64) NOT NULL COMMENT '用户 ID',
|
||||
progress_value INT NOT NULL DEFAULT 0 COMMENT '当前进度值',
|
||||
target_value INT NOT NULL COMMENT '目标值,通常与挑战 target_value 相同',
|
||||
status ENUM('active', 'completed', 'left') NOT NULL DEFAULT 'active' COMMENT '参与状态',
|
||||
joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
|
||||
left_at DATETIME DEFAULT NULL COMMENT '退出时间',
|
||||
last_progress_at DATETIME DEFAULT NULL COMMENT '最近一次更新进度的时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_challenge_participant_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT fk_challenge_participant_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT uq_challenge_participant UNIQUE KEY (challenge_id, user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_challenge_participants_status_progress
|
||||
ON t_challenge_participants (challenge_id, status, progress_value DESC, updated_at ASC);
|
||||
@@ -18,6 +18,7 @@ import { GoalsModule } from './goals/goals.module';
|
||||
import { DietRecordsModule } from './diet-records/diet-records.module';
|
||||
import { FoodLibraryModule } from './food-library/food-library.module';
|
||||
import { WaterRecordsModule } from './water-records/water-records.module';
|
||||
import { ChallengesModule } from './challenges/challenges.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,6 +42,7 @@ import { WaterRecordsModule } from './water-records/water-records.module';
|
||||
DietRecordsModule,
|
||||
FoodLibraryModule,
|
||||
WaterRecordsModule,
|
||||
ChallengesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
81
src/challenges/challenges.controller.ts
Normal file
81
src/challenges/challenges.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Controller, Get, Param, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ChallengesService } from './challenges.service';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import { AccessTokenPayload } from '../users/services/apple-auth.service';
|
||||
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
|
||||
import { ChallengeDetailDto } from './dto/challenge-detail.dto';
|
||||
import { ChallengeListItemDto } from './dto/challenge-list.dto';
|
||||
import { ChallengeProgressDto } from './dto/challenge-progress.dto';
|
||||
|
||||
@Controller('challenges')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ChallengesController {
|
||||
constructor(private readonly challengesService: ChallengesService) { }
|
||||
|
||||
@Get()
|
||||
async getChallenges(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<ChallengeListItemDto[]>> {
|
||||
const data = await this.challengesService.getChallengesForUser(user.sub);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取挑战列表成功',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getChallengeDetail(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<ChallengeDetailDto>> {
|
||||
const data = await this.challengesService.getChallengeDetail(user.sub, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取挑战详情成功',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/join')
|
||||
async joinChallenge(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<ChallengeProgressDto>> {
|
||||
const data = await this.challengesService.joinChallenge(user.sub, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '加入挑战成功',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/leave')
|
||||
async leaveChallenge(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<boolean>> {
|
||||
const data = await this.challengesService.leaveChallenge(user.sub, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '退出挑战成功',
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@Post(':id/progress')
|
||||
async reportProgress(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateChallengeProgressDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<ChallengeProgressDto>> {
|
||||
const data = await this.challengesService.reportProgress(user.sub, id, dto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '进度更新成功',
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
||||
19
src/challenges/challenges.module.ts
Normal file
19
src/challenges/challenges.module.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { ChallengesController } from './challenges.controller';
|
||||
import { ChallengesService } from './challenges.service';
|
||||
import { Challenge } from './models/challenge.model';
|
||||
import { ChallengeParticipant } from './models/challenge-participant.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { User } from '../users/models/user.model';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SequelizeModule.forFeature([Challenge, ChallengeParticipant, User]),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [ChallengesController],
|
||||
providers: [ChallengesService],
|
||||
exports: [ChallengesService],
|
||||
})
|
||||
export class ChallengesModule { }
|
||||
334
src/challenges/challenges.service.ts
Normal file
334
src/challenges/challenges.service.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { Challenge, ChallengeStatus } from './models/challenge.model';
|
||||
import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model';
|
||||
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
|
||||
import { ChallengeDetailDto } from './dto/challenge-detail.dto';
|
||||
import { ChallengeListItemDto } from './dto/challenge-list.dto';
|
||||
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
|
||||
import { fn, col, Op } from 'sequelize';
|
||||
import * as dayjs from 'dayjs';
|
||||
import { User } from '../users/models/user.model';
|
||||
|
||||
@Injectable()
|
||||
export class ChallengesService {
|
||||
constructor(
|
||||
@InjectModel(Challenge)
|
||||
private readonly challengeModel: typeof Challenge,
|
||||
@InjectModel(ChallengeParticipant)
|
||||
private readonly participantModel: typeof ChallengeParticipant,
|
||||
) { }
|
||||
|
||||
async getChallengesForUser(userId: string): Promise<ChallengeListItemDto[]> {
|
||||
const challenges = await this.challengeModel.findAll({
|
||||
order: [['startAt', 'ASC']],
|
||||
});
|
||||
|
||||
if (!challenges.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const challengeIds = challenges.map((challenge) => challenge.id);
|
||||
|
||||
const participantCountsRaw = await this.participantModel.findAll({
|
||||
attributes: ['challengeId', [fn('COUNT', col('id')), 'count']],
|
||||
where: {
|
||||
challengeId: challengeIds,
|
||||
status: ChallengeParticipantStatus.ACTIVE,
|
||||
},
|
||||
group: ['challenge_id'],
|
||||
raw: true,
|
||||
});
|
||||
|
||||
const participantsCountMap = new Map<string, number>();
|
||||
for (const item of participantCountsRaw as any[]) {
|
||||
const key = item.challengeId ?? item.challenge_id;
|
||||
if (key) {
|
||||
participantsCountMap.set(key, Number(item.count));
|
||||
}
|
||||
}
|
||||
|
||||
const userParticipations = await this.participantModel.findAll({
|
||||
where: {
|
||||
challengeId: challengeIds,
|
||||
userId,
|
||||
status: {
|
||||
[Op.ne]: ChallengeParticipantStatus.LEFT,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const participationMap = new Map<string, ChallengeParticipant>();
|
||||
for (const participation of userParticipations) {
|
||||
participationMap.set(participation.challengeId, participation);
|
||||
}
|
||||
|
||||
return challenges.map((challenge) => {
|
||||
const participation = participationMap.get(challenge.id);
|
||||
const progress = participation
|
||||
? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: challenge.id,
|
||||
title: challenge.title,
|
||||
image: challenge.image,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
status: this.computeStatus(challenge.startAt, challenge.endAt),
|
||||
startAt: challenge.startAt,
|
||||
endAt: challenge.endAt,
|
||||
participantsCount: participantsCountMap.get(challenge.id) ?? 0,
|
||||
rankingDescription: challenge.rankingDescription,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
highlightSubtitle: challenge.highlightSubtitle,
|
||||
ctaLabel: challenge.ctaLabel,
|
||||
progress,
|
||||
isJoined: Boolean(participation),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getChallengeDetail(userId: string, challengeId: string): Promise<ChallengeDetailDto> {
|
||||
const challenge = await this.challengeModel.findByPk(challengeId);
|
||||
|
||||
if (!challenge) {
|
||||
throw new NotFoundException('挑战不存在');
|
||||
}
|
||||
|
||||
const [participantsCount, participation] = await Promise.all([
|
||||
this.participantModel.count({
|
||||
where: {
|
||||
challengeId,
|
||||
status: ChallengeParticipantStatus.ACTIVE,
|
||||
},
|
||||
}),
|
||||
this.participantModel.findOne({
|
||||
where: {
|
||||
challengeId,
|
||||
userId,
|
||||
status: {
|
||||
[Op.ne]: ChallengeParticipantStatus.LEFT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const rankingsRaw = await this.participantModel.findAll({
|
||||
where: {
|
||||
challengeId,
|
||||
status: ChallengeParticipantStatus.ACTIVE,
|
||||
},
|
||||
include: [{ model: User, attributes: ['id', 'name', 'avatar'] }],
|
||||
order: [
|
||||
['progressValue', 'DESC'],
|
||||
['updatedAt', 'ASC'],
|
||||
],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const progress = participation
|
||||
? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit)
|
||||
: undefined;
|
||||
|
||||
const rankings: RankingItemDto[] = rankingsRaw.map((item, index) => ({
|
||||
id: item.user?.id ?? item.userId,
|
||||
name: item.user?.name ?? '未知用户',
|
||||
avatar: item.user?.avatar ?? null,
|
||||
metric: `${item.progressValue}/${item.targetValue}${challenge.progressUnit}`,
|
||||
badge: this.resolveRankingBadge(index),
|
||||
}));
|
||||
|
||||
const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined;
|
||||
|
||||
return {
|
||||
id: challenge.id,
|
||||
title: challenge.title,
|
||||
image: challenge.image,
|
||||
periodLabel: challenge.periodLabel,
|
||||
durationLabel: challenge.durationLabel,
|
||||
requirementLabel: challenge.requirementLabel,
|
||||
summary: challenge.summary,
|
||||
rankingDescription: challenge.rankingDescription,
|
||||
highlightTitle: challenge.highlightTitle,
|
||||
highlightSubtitle: challenge.highlightSubtitle,
|
||||
ctaLabel: challenge.ctaLabel,
|
||||
participantsCount,
|
||||
progress,
|
||||
rankings,
|
||||
userRank,
|
||||
};
|
||||
}
|
||||
|
||||
async joinChallenge(userId: string, challengeId: string): Promise<ChallengeProgressDto> {
|
||||
const challenge = await this.challengeModel.findByPk(challengeId);
|
||||
|
||||
if (!challenge) {
|
||||
throw new NotFoundException('挑战不存在');
|
||||
}
|
||||
|
||||
const status = this.computeStatus(challenge.startAt, challenge.endAt);
|
||||
if (status === ChallengeStatus.EXPIRED) {
|
||||
throw new BadRequestException('挑战已过期,无法加入');
|
||||
}
|
||||
|
||||
const existing = await this.participantModel.findOne({
|
||||
where: {
|
||||
challengeId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing && existing.status === ChallengeParticipantStatus.ACTIVE) {
|
||||
throw new ConflictException('已加入该挑战');
|
||||
}
|
||||
|
||||
if (existing && existing.status === ChallengeParticipantStatus.COMPLETED) {
|
||||
throw new ConflictException('该挑战已完成,如需重新参加请先退出');
|
||||
}
|
||||
|
||||
if (existing && existing.status === ChallengeParticipantStatus.LEFT) {
|
||||
existing.progressValue = 0;
|
||||
existing.targetValue = challenge.targetValue;
|
||||
existing.status = ChallengeParticipantStatus.ACTIVE;
|
||||
existing.joinedAt = new Date();
|
||||
existing.leftAt = null;
|
||||
existing.lastProgressAt = null;
|
||||
await existing.save();
|
||||
return this.buildChallengeProgress(existing.progressValue, existing.targetValue, challenge.progressUnit);
|
||||
}
|
||||
|
||||
const participant = await this.participantModel.create({
|
||||
challengeId,
|
||||
userId,
|
||||
progressValue: 0,
|
||||
targetValue: challenge.targetValue,
|
||||
status: ChallengeParticipantStatus.ACTIVE,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
|
||||
}
|
||||
|
||||
async leaveChallenge(userId: string, challengeId: string): Promise<boolean> {
|
||||
const participant = await this.participantModel.findOne({
|
||||
where: {
|
||||
challengeId,
|
||||
userId,
|
||||
status: {
|
||||
[Op.ne]: ChallengeParticipantStatus.LEFT,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new NotFoundException('尚未加入该挑战');
|
||||
}
|
||||
|
||||
participant.status = ChallengeParticipantStatus.LEFT;
|
||||
participant.leftAt = new Date();
|
||||
await participant.save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async reportProgress(userId: string, challengeId: string, dto: UpdateChallengeProgressDto): Promise<ChallengeProgressDto> {
|
||||
const challenge = await this.challengeModel.findByPk(challengeId);
|
||||
|
||||
if (!challenge) {
|
||||
throw new NotFoundException('挑战不存在');
|
||||
}
|
||||
|
||||
const status = this.computeStatus(challenge.startAt, challenge.endAt);
|
||||
if (status === ChallengeStatus.UPCOMING) {
|
||||
throw new BadRequestException('挑战尚未开始,无法上报进度');
|
||||
}
|
||||
|
||||
if (status === ChallengeStatus.EXPIRED) {
|
||||
throw new BadRequestException('挑战已过期,无法上报进度');
|
||||
}
|
||||
|
||||
const participant = await this.participantModel.findOne({
|
||||
where: {
|
||||
challengeId,
|
||||
userId,
|
||||
status: ChallengeParticipantStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new NotFoundException('请先加入挑战');
|
||||
}
|
||||
|
||||
const increment = dto.increment ?? 1;
|
||||
if (increment < 1) {
|
||||
throw new BadRequestException('进度增量必须大于 0');
|
||||
}
|
||||
|
||||
const newProgress = participant.progressValue + increment;
|
||||
participant.progressValue = Math.min(newProgress, participant.targetValue);
|
||||
participant.lastProgressAt = new Date();
|
||||
|
||||
if (participant.progressValue >= participant.targetValue) {
|
||||
participant.status = ChallengeParticipantStatus.COMPLETED;
|
||||
}
|
||||
|
||||
await participant.save();
|
||||
|
||||
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
|
||||
}
|
||||
|
||||
private buildChallengeProgress(completed: number, target: number, unit: string): ChallengeProgressDto {
|
||||
const remaining = Math.max(target - completed, 0);
|
||||
return {
|
||||
completed,
|
||||
target,
|
||||
remaining,
|
||||
badge: completed >= target ? '已完成' : `已坚持 ${completed}${unit}`,
|
||||
subtitle: remaining > 0 ? `还差 ${remaining}${unit}` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private computeStatus(startAt: Date, endAt: Date): ChallengeStatus {
|
||||
const now = dayjs();
|
||||
const start = dayjs(startAt);
|
||||
const end = dayjs(endAt);
|
||||
|
||||
if (now.isBefore(start, 'minute')) {
|
||||
return ChallengeStatus.UPCOMING;
|
||||
}
|
||||
|
||||
if (now.isAfter(end, 'minute')) {
|
||||
return ChallengeStatus.EXPIRED;
|
||||
}
|
||||
|
||||
return ChallengeStatus.ONGOING;
|
||||
}
|
||||
|
||||
private resolveRankingBadge(index: number): string | undefined {
|
||||
if (index === 0) return 'gold';
|
||||
if (index === 1) return 'silver';
|
||||
if (index === 2) return 'bronze';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async calculateUserRank(challengeId: string, participation: ChallengeParticipant): Promise<number> {
|
||||
const { progressValue, updatedAt } = participation;
|
||||
const higherProgressCount = await this.participantModel.count({
|
||||
where: {
|
||||
challengeId,
|
||||
status: ChallengeParticipantStatus.ACTIVE,
|
||||
[Op.or]: [
|
||||
{ progressValue: { [Op.gt]: progressValue } },
|
||||
{
|
||||
progressValue,
|
||||
updatedAt: { [Op.lt]: updatedAt },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return higherProgressCount + 1;
|
||||
}
|
||||
}
|
||||
19
src/challenges/dto/challenge-detail.dto.ts
Normal file
19
src/challenges/dto/challenge-detail.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
|
||||
|
||||
export interface ChallengeDetailDto {
|
||||
id: string;
|
||||
title: string;
|
||||
image: string | null;
|
||||
periodLabel: string | null;
|
||||
durationLabel: string;
|
||||
requirementLabel: string;
|
||||
summary: string | null;
|
||||
rankingDescription: string | null;
|
||||
highlightTitle: string;
|
||||
highlightSubtitle: string;
|
||||
ctaLabel: string;
|
||||
participantsCount: number;
|
||||
progress?: ChallengeProgressDto;
|
||||
rankings: RankingItemDto[];
|
||||
userRank?: number;
|
||||
}
|
||||
25
src/challenges/dto/challenge-list.dto.ts
Normal file
25
src/challenges/dto/challenge-list.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ChallengeStatus } from '../models/challenge.model';
|
||||
import { ChallengeProgressDto } from './challenge-progress.dto';
|
||||
|
||||
export interface ChallengeListItemDto {
|
||||
id: string;
|
||||
title: string;
|
||||
image: string | null;
|
||||
periodLabel: string | null;
|
||||
durationLabel: string;
|
||||
requirementLabel: string;
|
||||
status: ChallengeStatus;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
participantsCount: number;
|
||||
rankingDescription: string | null;
|
||||
highlightTitle: string;
|
||||
highlightSubtitle: string;
|
||||
ctaLabel: string;
|
||||
progress?: ChallengeProgressDto;
|
||||
isJoined: boolean;
|
||||
}
|
||||
|
||||
export interface ChallengeListResponseDto {
|
||||
challenges: ChallengeListItemDto[];
|
||||
}
|
||||
15
src/challenges/dto/challenge-progress.dto.ts
Normal file
15
src/challenges/dto/challenge-progress.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface ChallengeProgressDto {
|
||||
completed: number;
|
||||
target: number;
|
||||
remaining: number;
|
||||
badge: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
export interface RankingItemDto {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string | null;
|
||||
metric: string;
|
||||
badge?: string;
|
||||
}
|
||||
8
src/challenges/dto/update-challenge-progress.dto.ts
Normal file
8
src/challenges/dto/update-challenge-progress.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsInt, IsOptional, Min } from 'class-validator';
|
||||
|
||||
export class UpdateChallengeProgressDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
increment?: number = 1;
|
||||
}
|
||||
99
src/challenges/models/challenge-participant.model.ts
Normal file
99
src/challenges/models/challenge-participant.model.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Table,
|
||||
Column,
|
||||
DataType,
|
||||
Model,
|
||||
ForeignKey,
|
||||
BelongsTo,
|
||||
Index,
|
||||
} from 'sequelize-typescript';
|
||||
import { Challenge } from './challenge.model';
|
||||
import { User } from '../../users/models/user.model';
|
||||
|
||||
export enum ChallengeParticipantStatus {
|
||||
ACTIVE = 'active',
|
||||
COMPLETED = 'completed',
|
||||
LEFT = 'left',
|
||||
}
|
||||
|
||||
@Table({
|
||||
tableName: 't_challenge_participants',
|
||||
underscored: true,
|
||||
})
|
||||
export class ChallengeParticipant extends Model {
|
||||
@Column({
|
||||
type: DataType.CHAR(36),
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
|
||||
@ForeignKey(() => Challenge)
|
||||
@Column({
|
||||
type: DataType.CHAR(36),
|
||||
allowNull: false,
|
||||
comment: '挑战 ID',
|
||||
})
|
||||
declare challengeId: string;
|
||||
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
allowNull: false,
|
||||
comment: '用户 ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '当前进度值',
|
||||
})
|
||||
declare progressValue: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '目标值,通常与挑战 targetValue 相同',
|
||||
})
|
||||
declare targetValue: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('active', 'completed', 'left'),
|
||||
allowNull: false,
|
||||
defaultValue: ChallengeParticipantStatus.ACTIVE,
|
||||
comment: '参与状态',
|
||||
})
|
||||
declare status: ChallengeParticipantStatus;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataType.NOW,
|
||||
comment: '加入时间',
|
||||
})
|
||||
declare joinedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: true,
|
||||
comment: '退出时间',
|
||||
})
|
||||
declare leftAt: Date | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: true,
|
||||
comment: '最近一次更新进度的时间',
|
||||
})
|
||||
declare lastProgressAt: Date | null;
|
||||
|
||||
@BelongsTo(() => Challenge)
|
||||
declare challenge?: Challenge;
|
||||
|
||||
@BelongsTo(() => User)
|
||||
declare user?: User;
|
||||
}
|
||||
123
src/challenges/models/challenge.model.ts
Normal file
123
src/challenges/models/challenge.model.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { Table, Column, DataType, Model, HasMany } from 'sequelize-typescript';
|
||||
import { ChallengeParticipant } from './challenge-participant.model';
|
||||
|
||||
export enum ChallengeStatus {
|
||||
UPCOMING = 'upcoming',
|
||||
ONGOING = 'ongoing',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
@Table({
|
||||
tableName: 't_challenges',
|
||||
underscored: true,
|
||||
})
|
||||
export class Challenge extends Model {
|
||||
@Column({
|
||||
type: DataType.CHAR(36),
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '挑战标题',
|
||||
})
|
||||
declare title: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(512),
|
||||
allowNull: true,
|
||||
comment: '挑战封面图',
|
||||
})
|
||||
declare image: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '挑战开始时间',
|
||||
})
|
||||
declare startAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '挑战结束时间',
|
||||
})
|
||||
declare endAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(128),
|
||||
allowNull: true,
|
||||
comment: '周期标签,例如「21天挑战」',
|
||||
})
|
||||
declare periodLabel: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(128),
|
||||
allowNull: false,
|
||||
comment: '持续时间标签,例如「持续21天」',
|
||||
})
|
||||
declare durationLabel: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '挑战要求标签,例如「每日练习 1 次」',
|
||||
})
|
||||
declare requirementLabel: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '挑战概要说明',
|
||||
})
|
||||
declare summary: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '挑战目标值(例如需要完成的天数)',
|
||||
})
|
||||
declare targetValue: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: '天',
|
||||
comment: '进度单位,用于展示排行榜指标',
|
||||
})
|
||||
declare progressUnit: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: true,
|
||||
comment: '排行榜描述,例如「连续打卡榜」',
|
||||
})
|
||||
declare rankingDescription: string | null;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '高亮标题',
|
||||
})
|
||||
declare highlightTitle: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '高亮副标题',
|
||||
})
|
||||
declare highlightSubtitle: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(128),
|
||||
allowNull: false,
|
||||
comment: 'CTA 按钮文字',
|
||||
})
|
||||
declare ctaLabel: string;
|
||||
|
||||
@HasMany(() => ChallengeParticipant)
|
||||
declare participants?: ChallengeParticipant[];
|
||||
}
|
||||
Reference in New Issue
Block a user