feat(challenges): 新增挑战功能模块及完整接口实现

- 新增挑战列表、详情、加入/退出、进度上报等 REST 接口
- 定义 Challenge / ChallengeParticipant 数据模型与状态枚举
- 提供排行榜查询与用户排名计算
- 包含接口文档与数据库初始化脚本
This commit is contained in:
richarjiang
2025-09-28 12:02:39 +08:00
parent 8e51994e71
commit 1b7132a325
12 changed files with 1003 additions and 0 deletions

View File

@@ -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],

View 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,
};
}
}

View 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 { }

View 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;
}
}

View 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;
}

View 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[];
}

View 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;
}

View File

@@ -0,0 +1,8 @@
import { IsInt, IsOptional, Min } from 'class-validator';
export class UpdateChallengeProgressDto {
@IsOptional()
@IsInt()
@Min(1)
increment?: number = 1;
}

View 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;
}

View 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[];
}