diff --git a/docs/challenges-api.md b/docs/challenges-api.md new file mode 100644 index 0000000..7735808 --- /dev/null +++ b/docs/challenges-api.md @@ -0,0 +1,236 @@ +# 挑战功能接口文档 + +> 所有接口均需携带 `Authorization: Bearer `,鉴权方式与现有用户体系一致。 +> 基础路径:`/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` 显示用户当前排名。 +- 进度上报建议结合业务埋点,确保重复提交时可处理幂等性(服务端会封顶到目标值)。 diff --git a/sql-scripts/challenges.sql b/sql-scripts/challenges.sql new file mode 100644 index 0000000..c13c85e --- /dev/null +++ b/sql-scripts/challenges.sql @@ -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); diff --git a/src/app.module.ts b/src/app.module.ts index 0146608..8e68365 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/challenges/challenges.controller.ts b/src/challenges/challenges.controller.ts new file mode 100644 index 0000000..01ede9d --- /dev/null +++ b/src/challenges/challenges.controller.ts @@ -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> { + 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> { + 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> { + 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> { + 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> { + const data = await this.challengesService.reportProgress(user.sub, id, dto); + return { + code: ResponseCode.SUCCESS, + message: '进度更新成功', + data, + }; + } +} diff --git a/src/challenges/challenges.module.ts b/src/challenges/challenges.module.ts new file mode 100644 index 0000000..7071d3f --- /dev/null +++ b/src/challenges/challenges.module.ts @@ -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 { } diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts new file mode 100644 index 0000000..b508cf8 --- /dev/null +++ b/src/challenges/challenges.service.ts @@ -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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/challenges/dto/challenge-detail.dto.ts b/src/challenges/dto/challenge-detail.dto.ts new file mode 100644 index 0000000..3777bae --- /dev/null +++ b/src/challenges/dto/challenge-detail.dto.ts @@ -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; +} diff --git a/src/challenges/dto/challenge-list.dto.ts b/src/challenges/dto/challenge-list.dto.ts new file mode 100644 index 0000000..4cc8aa4 --- /dev/null +++ b/src/challenges/dto/challenge-list.dto.ts @@ -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[]; +} diff --git a/src/challenges/dto/challenge-progress.dto.ts b/src/challenges/dto/challenge-progress.dto.ts new file mode 100644 index 0000000..33eefc0 --- /dev/null +++ b/src/challenges/dto/challenge-progress.dto.ts @@ -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; +} diff --git a/src/challenges/dto/update-challenge-progress.dto.ts b/src/challenges/dto/update-challenge-progress.dto.ts new file mode 100644 index 0000000..edfb5cf --- /dev/null +++ b/src/challenges/dto/update-challenge-progress.dto.ts @@ -0,0 +1,8 @@ +import { IsInt, IsOptional, Min } from 'class-validator'; + +export class UpdateChallengeProgressDto { + @IsOptional() + @IsInt() + @Min(1) + increment?: number = 1; +} diff --git a/src/challenges/models/challenge-participant.model.ts b/src/challenges/models/challenge-participant.model.ts new file mode 100644 index 0000000..cf8ce1b --- /dev/null +++ b/src/challenges/models/challenge-participant.model.ts @@ -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; +} diff --git a/src/challenges/models/challenge.model.ts b/src/challenges/models/challenge.model.ts new file mode 100644 index 0000000..eb376fd --- /dev/null +++ b/src/challenges/models/challenge.model.ts @@ -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[]; +}