diff --git a/sql-scripts/add-custom-challenges-support.sql b/sql-scripts/add-custom-challenges-support.sql new file mode 100644 index 0000000..5a219ba --- /dev/null +++ b/sql-scripts/add-custom-challenges-support.sql @@ -0,0 +1,43 @@ +-- ===================================================== +-- 自定义挑战功能数据库迁移脚本 +-- 创建时间: 2025-01-25 +-- 说明: 添加用户自定义挑战功能所需的字段和索引 +-- ===================================================== + +-- 1. 扩展 t_challenges 表,添加自定义挑战相关字段 +ALTER TABLE `t_challenges` +ADD COLUMN `source` ENUM('system', 'custom') NOT NULL DEFAULT 'system' COMMENT '挑战来源:system=系统预设, custom=用户创建' AFTER `type`, +ADD COLUMN `creator_id` VARCHAR(64) NULL COMMENT '创建者用户ID,仅custom类型有值' AFTER `source`, +ADD COLUMN `share_code` VARCHAR(12) NULL COMMENT '分享码,6-12位字符,用于加入挑战' AFTER `creator_id`, +ADD COLUMN `is_public` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否公开:true=任何人可通过分享码加入, false=仅邀请' AFTER `share_code`, +ADD COLUMN `max_participants` INT NULL COMMENT '最大参与人数限制,null表示无限制' AFTER `is_public`, +ADD COLUMN `challenge_state` ENUM('draft', 'active', 'archived') NOT NULL DEFAULT 'active' COMMENT '挑战状态:draft=草稿, active=活跃, archived=已归档' AFTER `max_participants`; + +-- 2. 创建索引以提升查询性能 +ALTER TABLE `t_challenges` +ADD UNIQUE INDEX `idx_share_code` (`share_code`), +ADD INDEX `idx_creator_id` (`creator_id`), +ADD INDEX `idx_source_state` (`source`, `challenge_state`); + +-- 3. 更新现有数据,标记为系统挑战 +UPDATE `t_challenges` +SET `source` = 'system', `challenge_state` = 'active' +WHERE `source` IS NULL OR `source` = ''; + +-- 4. 验证数据迁移 +SELECT + COUNT(*) as total_challenges, + SUM(CASE WHEN source = 'system' THEN 1 ELSE 0 END) as system_challenges, + SUM(CASE WHEN source = 'custom' THEN 1 ELSE 0 END) as custom_challenges, + SUM(CASE WHEN challenge_state = 'active' THEN 1 ELSE 0 END) as active_challenges, + SUM(CASE WHEN challenge_state = 'draft' THEN 1 ELSE 0 END) as draft_challenges, + SUM(CASE WHEN challenge_state = 'archived' THEN 1 ELSE 0 END) as archived_challenges +FROM `t_challenges`; + +-- ===================================================== +-- 迁移完成说明: +-- 1. 所有现有挑战已标记为系统挑战 (source='system') +-- 2. 所有现有挑战已标记为活跃状态 (challenge_state='active') +-- 3. 已创建必要的索引以提升查询性能 +-- 4. share_code 字段有唯一索引,确保分享码唯一性 +-- ===================================================== \ No newline at end of file diff --git a/src/challenges/challenges.controller.ts b/src/challenges/challenges.controller.ts index 8483403..4a8d831 100644 --- a/src/challenges/challenges.controller.ts +++ b/src/challenges/challenges.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Param, Post, Body, UseGuards, Query } from '@nestjs/common'; +import { Controller, Get, Param, Post, Body, UseGuards, Query, Put, Delete } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { ChallengesService } from './challenges.service'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { BaseResponseDto, ResponseCode } from '../base.dto'; @@ -9,8 +10,14 @@ import { ChallengeDetailDto } from './dto/challenge-detail.dto'; import { ChallengeListItemDto } from './dto/challenge-list.dto'; import { ChallengeProgressDto } from './dto/challenge-progress.dto'; import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.dto'; +import { CreateCustomChallengeDto } from './dto/create-custom-challenge.dto'; +import { UpdateCustomChallengeDto } from './dto/update-custom-challenge.dto'; +import { JoinByShareCodeDto } from './dto/join-by-share-code.dto'; +import { CustomChallengeResponseDto } from './dto/custom-challenge-response.dto'; import { Public } from 'src/common/decorators/public.decorator'; +import { ChallengeState } from './models/challenge.model'; +@ApiTags('挑战管理') @Controller('challenges') export class ChallengesController { constructor(private readonly challengesService: ChallengesService) { } @@ -104,4 +111,130 @@ export class ChallengesController { data, }; } + + // ==================== 自定义挑战 API ==================== + + @Post('custom') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '创建自定义挑战' }) + @ApiResponse({ status: 201, description: '创建成功' }) + async createCustomChallenge( + @Body() dto: CreateCustomChallengeDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const data = await this.challengesService.createCustomChallenge(user.sub, dto); + return { + code: ResponseCode.SUCCESS, + message: '创建挑战成功', + data, + }; + } + + @Post('join-by-code') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '通过分享码加入挑战' }) + @ApiResponse({ status: 200, description: '加入成功' }) + async joinByShareCode( + @Body() dto: JoinByShareCodeDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const data = await this.challengesService.joinByShareCode(user.sub, dto.shareCode); + return { + code: ResponseCode.SUCCESS, + message: '加入挑战成功', + data, + }; + } + + @Get('share/:shareCode') + @Public() + @ApiOperation({ summary: '获取分享码对应的挑战信息(公开接口)' }) + @ApiResponse({ status: 200, description: '获取成功' }) + async getChallengeByShareCode( + @Param('shareCode') shareCode: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const data = await this.challengesService.getChallengeByShareCode(shareCode, user?.sub); + return { + code: ResponseCode.SUCCESS, + message: '获取挑战信息成功', + data, + }; + } + + @Get('my/created') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '获取我创建的挑战列表' }) + @ApiResponse({ status: 200, description: '获取成功' }) + async getMyCreatedChallenges( + @CurrentUser() user: AccessTokenPayload, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('state') state?: ChallengeState, + ): Promise> { + const data = await this.challengesService.getMyCreatedChallenges(user.sub, { + page: page ? parseInt(page, 10) : undefined, + pageSize: pageSize ? parseInt(pageSize, 10) : undefined, + state, + }); + return { + code: ResponseCode.SUCCESS, + message: '获取我创建的挑战列表成功', + data, + }; + } + + @Put('custom/:id') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '更新自定义挑战' }) + @ApiResponse({ status: 200, description: '更新成功' }) + async updateCustomChallenge( + @Param('id') id: string, + @Body() dto: UpdateCustomChallengeDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const data = await this.challengesService.updateCustomChallenge(user.sub, id, dto); + return { + code: ResponseCode.SUCCESS, + message: '更新挑战成功', + data, + }; + } + + @Delete('custom/:id') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '归档自定义挑战' }) + @ApiResponse({ status: 200, description: '归档成功' }) + async archiveCustomChallenge( + @Param('id') id: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const data = await this.challengesService.archiveCustomChallenge(user.sub, id); + return { + code: ResponseCode.SUCCESS, + message: '归档挑战成功', + data, + }; + } + + @Post('custom/:id/regenerate-code') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: '重新生成分享码' }) + @ApiResponse({ status: 200, description: '生成成功' }) + async regenerateShareCode( + @Param('id') id: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const shareCode = await this.challengesService.regenerateShareCode(user.sub, id); + return { + code: ResponseCode.SUCCESS, + message: '重新生成分享码成功', + data: { shareCode }, + }; + } } diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index 9dd5dfa..c923c14 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -1,6 +1,6 @@ -import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException, ConflictException, Inject, ForbiddenException } from '@nestjs/common'; import { InjectModel } from '@nestjs/sequelize'; -import { Challenge, ChallengeStatus, ChallengeType } from './models/challenge.model'; +import { Challenge, ChallengeStatus, ChallengeType, ChallengeSource, ChallengeState } from './models/challenge.model'; import { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model'; import { ChallengeProgressReport } from './models/challenge-progress-report.model'; import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto'; @@ -8,6 +8,9 @@ import { ChallengeDetailDto } from './dto/challenge-detail.dto'; import { ChallengeListItemDto } from './dto/challenge-list.dto'; import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto'; import { ChallengeRankingListDto } from './dto/challenge-ranking.dto'; +import { CreateCustomChallengeDto } from './dto/create-custom-challenge.dto'; +import { UpdateCustomChallengeDto } from './dto/update-custom-challenge.dto'; +import { CustomChallengeResponseDto } from './dto/custom-challenge-response.dto'; import { fn, col, Op, UniqueConstraintError } from 'sequelize'; import * as dayjs from 'dayjs'; import { User } from '../users/models/user.model'; @@ -35,7 +38,29 @@ export class ChallengesService { ) { } async getChallengesForUser(userId?: string): Promise { + // 获取系统挑战 + 用户已加入的自定义挑战 + 用户创建的自定义挑战 + const whereConditions: any[] = [ + { source: ChallengeSource.SYSTEM, challengeState: ChallengeState.ACTIVE }, + ]; + + if (userId) { + // 获取用户加入的自定义挑战 ID + const joinedChallengeIds = await this.getJoinedCustomChallengeIds(userId); + + whereConditions.push( + { creatorId: userId, source: ChallengeSource.CUSTOM }, // 我创建的 + { + id: { [Op.in]: joinedChallengeIds }, + source: ChallengeSource.CUSTOM, + challengeState: ChallengeState.ACTIVE + } // 我加入的 + ); + } + const challenges = await this.challengeModel.findAll({ + where: { + [Op.or]: whereConditions, + }, order: [['startAt', 'ASC']], }); @@ -695,4 +720,423 @@ export class ChallengesService { total: count, }; } + + // ==================== 自定义挑战功能 ==================== + + /** + * 生成唯一的分享码 + */ + private async generateUniqueShareCode(): Promise { + const chars = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // 避免混淆字符 + let attempts = 0; + const maxAttempts = 10; + + while (attempts < maxAttempts) { + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + const existing = await this.challengeModel.findOne({ + where: { shareCode: code }, + }); + + if (!existing) { + return code; + } + attempts++; + } + + // 如果 10 次都冲突,使用更长的码 + let code = ''; + for (let i = 0; i < 8; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; + } + + /** + * 获取用户加入的自定义挑战 ID 列表 + */ + private async getJoinedCustomChallengeIds(userId: string): Promise { + const participants = await this.participantModel.findAll({ + where: { + userId, + status: { + [Op.ne]: ChallengeParticipantStatus.LEFT, + }, + }, + attributes: ['challengeId'], + raw: true, + }); + + return participants.map(p => p.challengeId); + } + + /** + * 检查用户是否为挑战创建者 + */ + private async isCreator(userId: string, challengeId: string): Promise { + const challenge = await this.challengeModel.findByPk(challengeId); + return challenge?.source === ChallengeSource.CUSTOM && challenge.creatorId === userId; + } + + /** + * 检查挑战是否可以加入 + */ + private async canJoinChallenge(challenge: Challenge): Promise<{ canJoin: boolean; reason?: string }> { + // 检查挑战状态 + if (challenge.challengeState !== ChallengeState.ACTIVE) { + return { canJoin: false, reason: '挑战未激活' }; + } + + // 检查时间 + const status = this.computeStatus(challenge.startAt, challenge.endAt); + if (status === ChallengeStatus.EXPIRED) { + return { canJoin: false, reason: '挑战已过期' }; + } + + // 检查人数限制 + if (challenge.maxParticipants) { + const count = await this.participantModel.count({ + where: { + challengeId: challenge.id, + status: ChallengeParticipantStatus.ACTIVE, + }, + }); + + if (count >= challenge.maxParticipants) { + return { canJoin: false, reason: '挑战人数已满' }; + } + } + + return { canJoin: true }; + } + + /** + * 创建自定义挑战 + */ + async createCustomChallenge(userId: string, dto: CreateCustomChallengeDto): Promise { + // 验证时间 + if (dto.startAt >= dto.endAt) { + throw new BadRequestException('结束时间必须晚于开始时间'); + } + + // 检查创建频率限制(每天最多创建 5 个) + const recentCount = await this.challengeModel.count({ + where: { + creatorId: userId, + createdAt: { + [Op.gte]: dayjs().subtract(24, 'hour').toDate(), + }, + }, + }); + + if (recentCount >= 5) { + throw new BadRequestException('每天最多创建 5 个挑战,请明天再试'); + } + + // 生成分享码 + 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, + periodLabel: dto.periodLabel || null, + durationLabel: dto.durationLabel, + requirementLabel: dto.requirementLabel, + summary: dto.summary || null, + targetValue: dto.targetValue, + progressUnit: dto.progressUnit || '天', + minimumCheckInDays: dto.minimumCheckInDays, + rankingDescription: dto.rankingDescription || '连续打卡榜', + highlightTitle: dto.highlightTitle || '坚持挑战', + highlightSubtitle: dto.highlightSubtitle || '养成好习惯', + ctaLabel: dto.ctaLabel || '立即加入', + source: ChallengeSource.CUSTOM, + creatorId: userId, + shareCode, + isPublic: dto.isPublic !== undefined ? dto.isPublic : true, + maxParticipants: dto.maxParticipants || null, + challengeState: ChallengeState.ACTIVE, + }); + + this.winstonLogger.info('创建自定义挑战成功', { + context: 'createCustomChallenge', + userId, + challengeId: challenge.id, + shareCode, + }); + + return this.buildCustomChallengeResponse(challenge, userId); + } + + /** + * 通过分享码加入挑战 + */ + async joinByShareCode(userId: string, shareCode: string): Promise { + const challenge = await this.challengeModel.findOne({ + where: { + shareCode: shareCode.toUpperCase(), + challengeState: ChallengeState.ACTIVE, + }, + }); + + if (!challenge) { + throw new NotFoundException('分享码无效或挑战不存在'); + } + + // 检查是否可以加入 + const { canJoin, reason } = await this.canJoinChallenge(challenge); + if (!canJoin) { + throw new BadRequestException(reason || '无法加入挑战'); + } + + // 使用现有的加入逻辑 + return this.joinChallenge(userId, challenge.id); + } + + /** + * 获取分享码对应的挑战信息(公开接口) + */ + async getChallengeByShareCode(shareCode: string, userId?: string): Promise { + const challenge = await this.challengeModel.findOne({ + where: { + shareCode: shareCode.toUpperCase(), + challengeState: ChallengeState.ACTIVE, + }, + }); + + if (!challenge) { + throw new NotFoundException('分享码无效或挑战不存在'); + } + + return this.getChallengeDetail(challenge.id, userId); + } + + /** + * 更新自定义挑战 + */ + async updateCustomChallenge( + userId: string, + challengeId: string, + dto: UpdateCustomChallengeDto, + ): Promise { + const challenge = await this.challengeModel.findByPk(challengeId); + + if (!challenge) { + throw new NotFoundException('挑战不存在'); + } + + if (challenge.source !== ChallengeSource.CUSTOM) { + throw new BadRequestException('只能编辑自定义挑战'); + } + + if (challenge.creatorId !== userId) { + throw new ForbiddenException('只有创建者才能编辑挑战'); + } + + // 如果挑战已开始,限制可编辑字段 + const status = this.computeStatus(challenge.startAt, challenge.endAt); + if (status !== ChallengeStatus.UPCOMING) { + // 挑战已开始,只允许编辑部分字段 + const allowedFields: (keyof UpdateCustomChallengeDto)[] = [ + 'summary', + 'isPublic', + 'highlightTitle', + 'highlightSubtitle', + 'ctaLabel', + ]; + + const restrictedFields = Object.keys(dto).filter( + key => !allowedFields.includes(key as keyof UpdateCustomChallengeDto) + ); + + if (restrictedFields.length > 0) { + throw new BadRequestException('挑战已开始,只能编辑概要、公开性和展示文案'); + } + } + + // 更新挑战 + await challenge.update(dto); + + this.winstonLogger.info('更新自定义挑战成功', { + context: 'updateCustomChallenge', + userId, + challengeId, + updates: Object.keys(dto), + }); + + return this.buildCustomChallengeResponse(challenge, userId); + } + + /** + * 归档自定义挑战 + */ + async archiveCustomChallenge(userId: string, challengeId: string): Promise { + const challenge = await this.challengeModel.findByPk(challengeId); + + if (!challenge) { + throw new NotFoundException('挑战不存在'); + } + + if (challenge.source !== ChallengeSource.CUSTOM) { + throw new BadRequestException('只能归档自定义挑战'); + } + + if (challenge.creatorId !== userId) { + throw new ForbiddenException('只有创建者才能归档挑战'); + } + + await challenge.update({ challengeState: ChallengeState.ARCHIVED }); + + this.winstonLogger.info('归档自定义挑战成功', { + context: 'archiveCustomChallenge', + userId, + challengeId, + }); + + return true; + } + + /** + * 重新生成分享码 + */ + async regenerateShareCode(userId: string, challengeId: string): Promise { + const challenge = await this.challengeModel.findByPk(challengeId); + + if (!challenge) { + throw new NotFoundException('挑战不存在'); + } + + if (challenge.source !== ChallengeSource.CUSTOM) { + throw new BadRequestException('只能为自定义挑战重新生成分享码'); + } + + if (challenge.creatorId !== userId) { + throw new ForbiddenException('只有创建者才能重新生成分享码'); + } + + const newShareCode = await this.generateUniqueShareCode(); + await challenge.update({ shareCode: newShareCode }); + + this.winstonLogger.info('重新生成分享码成功', { + context: 'regenerateShareCode', + userId, + challengeId, + oldShareCode: challenge.shareCode, + newShareCode, + }); + + return newShareCode; + } + + /** + * 获取我创建的挑战列表 + */ + async getMyCreatedChallenges( + userId: string, + params: { page?: number; pageSize?: number; state?: ChallengeState } = {}, + ): Promise<{ items: CustomChallengeResponseDto[]; total: number; page: number; pageSize: number }> { + const page = params.page && params.page > 0 ? params.page : 1; + const pageSize = params.pageSize && params.pageSize > 0 ? Math.min(params.pageSize, 100) : 20; + const offset = (page - 1) * pageSize; + + const where: any = { + creatorId: userId, + source: ChallengeSource.CUSTOM, + }; + + if (params.state) { + where.challengeState = params.state; + } + + const { rows, count } = await this.challengeModel.findAndCountAll({ + where, + order: [['createdAt', 'DESC']], + limit: pageSize, + offset, + }); + + const items = await Promise.all( + rows.map(challenge => this.buildCustomChallengeResponse(challenge, userId)) + ); + + return { + items, + total: count, + page, + pageSize, + }; + } + + /** + * 构建自定义挑战响应 + */ + private async buildCustomChallengeResponse( + challenge: Challenge, + userId: string, + ): Promise { + const [participantsCount, participation] = await Promise.all([ + this.participantModel.count({ + where: { + challengeId: challenge.id, + status: ChallengeParticipantStatus.ACTIVE, + }, + }), + this.participantModel.findOne({ + where: { + challengeId: challenge.id, + userId, + status: { + [Op.ne]: ChallengeParticipantStatus.LEFT, + }, + }, + }), + ]); + + const progress = participation + ? this.buildChallengeProgress( + participation.progressValue, + challenge.minimumCheckInDays, + participation.lastProgressAt, + ) + : undefined; + + return { + id: challenge.id, + title: challenge.title, + type: challenge.type, + source: challenge.source as ChallengeSource, + creatorId: challenge.creatorId, + shareCode: challenge.shareCode, + image: challenge.image, + startAt: challenge.startAt, + endAt: challenge.endAt, + periodLabel: challenge.periodLabel, + durationLabel: challenge.durationLabel, + requirementLabel: challenge.requirementLabel, + summary: challenge.summary, + targetValue: challenge.targetValue, + progressUnit: challenge.progressUnit, + minimumCheckInDays: challenge.minimumCheckInDays, + rankingDescription: challenge.rankingDescription, + highlightTitle: challenge.highlightTitle, + highlightSubtitle: challenge.highlightSubtitle, + ctaLabel: challenge.ctaLabel, + isPublic: challenge.isPublic, + maxParticipants: challenge.maxParticipants, + challengeState: challenge.challengeState as ChallengeState, + participantsCount, + progress, + isJoined: Boolean(participation), + isCreator: challenge.creatorId === userId, + createdAt: challenge.createdAt, + updatedAt: challenge.updatedAt, + }; + } } diff --git a/src/challenges/dto/create-custom-challenge.dto.ts b/src/challenges/dto/create-custom-challenge.dto.ts new file mode 100644 index 0000000..dd135c1 --- /dev/null +++ b/src/challenges/dto/create-custom-challenge.dto.ts @@ -0,0 +1,109 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsNumber, Min, Max, IsEnum, IsOptional, IsBoolean, MaxLength, MinLength } from 'class-validator'; +import { ChallengeType } from '../models/challenge.model'; + +export class CreateCustomChallengeDto { + @ApiProperty({ description: '挑战标题', example: '21天喝水挑战' }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + title: string; + + @ApiProperty({ description: '挑战类型', enum: ChallengeType, example: ChallengeType.WATER }) + @IsEnum(ChallengeType) + @IsNotEmpty() + type: ChallengeType; + + @ApiProperty({ description: '挑战封面图 URL', required: false }) + @IsString() + @IsOptional() + @MaxLength(512) + image?: string; + + @ApiProperty({ description: '开始时间戳(毫秒)', example: 1704067200000 }) + @IsNumber() + @Min(Date.now()) + startAt: number; + + @ApiProperty({ description: '结束时间戳(毫秒)', example: 1705881600000 }) + @IsNumber() + @Min(Date.now() + 86400000) // 至少未来 1 天 + endAt: number; + + @ApiProperty({ description: '每日目标值(如喝水8杯)', example: 8, minimum: 1, maximum: 1000 }) + @IsNumber() + @Min(1) + @Max(1000) + targetValue: number; + + @ApiProperty({ description: '最少打卡天数', example: 21, minimum: 1, maximum: 365 }) + @IsNumber() + @Min(1) + @Max(365) + minimumCheckInDays: number; + + @ApiProperty({ description: '持续时间标签', example: '持续21天' }) + @IsString() + @IsNotEmpty() + @MaxLength(128) + durationLabel: string; + + @ApiProperty({ description: '挑战要求标签', example: '每日喝水8杯' }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + requirementLabel: string; + + @ApiProperty({ description: '挑战概要说明', required: false }) + @IsString() + @IsOptional() + summary?: string; + + @ApiProperty({ description: '进度单位', example: '天', required: false }) + @IsString() + @IsOptional() + @MaxLength(64) + progressUnit?: string; + + @ApiProperty({ description: '周期标签', example: '21天挑战', required: false }) + @IsString() + @IsOptional() + @MaxLength(128) + periodLabel?: string; + + @ApiProperty({ description: '排行榜描述', example: '连续打卡榜', required: false }) + @IsString() + @IsOptional() + @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() + @IsOptional() + isPublic?: boolean; + + @ApiProperty({ description: '最大参与人数限制(null表示无限制)', required: false, minimum: 2, maximum: 10000 }) + @IsNumber() + @IsOptional() + @Min(2) + @Max(10000) + maxParticipants?: number; +} \ No newline at end of file diff --git a/src/challenges/dto/custom-challenge-response.dto.ts b/src/challenges/dto/custom-challenge-response.dto.ts new file mode 100644 index 0000000..b39e26e --- /dev/null +++ b/src/challenges/dto/custom-challenge-response.dto.ts @@ -0,0 +1,40 @@ +import { ChallengeType, ChallengeSource, ChallengeState } from '../models/challenge.model'; +import { ChallengeProgressDto } from './challenge-progress.dto'; + +export interface CustomChallengeResponseDto { + id: string; + title: string; + type: ChallengeType; + source: ChallengeSource; + creatorId: string | null; + shareCode: string | null; + image: string | null; + startAt: number; + endAt: number; + periodLabel: string | null; + durationLabel: string; + requirementLabel: string; + summary: string | null; + targetValue: number; + progressUnit: string; + minimumCheckInDays: number; + rankingDescription: string | null; + highlightTitle: string; + highlightSubtitle: string; + ctaLabel: string; + isPublic: boolean; + maxParticipants: number | null; + challengeState: ChallengeState; + participantsCount: number; + progress?: ChallengeProgressDto; + isJoined: boolean; + isCreator: boolean; + createdAt: Date; + updatedAt: Date; +} + +export interface MyCreatedChallengesQueryDto { + page?: number; + pageSize?: number; + state?: ChallengeState; +} \ No newline at end of file diff --git a/src/challenges/dto/join-by-share-code.dto.ts b/src/challenges/dto/join-by-share-code.dto.ts new file mode 100644 index 0000000..c01ca3b --- /dev/null +++ b/src/challenges/dto/join-by-share-code.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, Length, Matches } from 'class-validator'; + +export class JoinByShareCodeDto { + @ApiProperty({ + description: '分享码(6-12位字符)', + example: 'A3K9P2', + minLength: 6, + maxLength: 12 + }) + @IsString() + @IsNotEmpty() + @Length(6, 12) + @Matches(/^[A-Z0-9]+$/, { message: '分享码只能包含大写字母和数字' }) + shareCode: string; +} \ No newline at end of file diff --git a/src/challenges/dto/update-custom-challenge.dto.ts b/src/challenges/dto/update-custom-challenge.dto.ts new file mode 100644 index 0000000..764f119 --- /dev/null +++ b/src/challenges/dto/update-custom-challenge.dto.ts @@ -0,0 +1,51 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, MaxLength, IsNumber, Min, Max } from 'class-validator'; + +export class UpdateCustomChallengeDto { + @ApiProperty({ description: '挑战标题', required: false }) + @IsString() + @IsOptional() + @MaxLength(100) + title?: string; + + @ApiProperty({ description: '挑战封面图 URL', required: false }) + @IsString() + @IsOptional() + @MaxLength(512) + image?: string; + + @ApiProperty({ description: '挑战概要说明', required: false }) + @IsString() + @IsOptional() + summary?: string; + + @ApiProperty({ description: '是否公开', required: false }) + @IsBoolean() + @IsOptional() + isPublic?: boolean; + + @ApiProperty({ description: '最大参与人数限制', required: false }) + @IsNumber() + @IsOptional() + @Min(2) + @Max(10000) + maxParticipants?: number; + + @ApiProperty({ description: '高亮标题', required: false }) + @IsString() + @IsOptional() + @MaxLength(255) + highlightTitle?: string; + + @ApiProperty({ description: '高亮副标题', required: false }) + @IsString() + @IsOptional() + @MaxLength(255) + highlightSubtitle?: string; + + @ApiProperty({ description: 'CTA 按钮文字', required: false }) + @IsString() + @IsOptional() + @MaxLength(128) + ctaLabel?: string; +} \ No newline at end of file diff --git a/src/challenges/models/challenge.model.ts b/src/challenges/models/challenge.model.ts index 934d918..27c487b 100644 --- a/src/challenges/models/challenge.model.ts +++ b/src/challenges/models/challenge.model.ts @@ -17,6 +17,17 @@ export enum ChallengeType { WEIGHT = 'weight', } +export enum ChallengeSource { + SYSTEM = 'system', + CUSTOM = 'custom', +} + +export enum ChallengeState { + DRAFT = 'draft', + ACTIVE = 'active', + ARCHIVED = 'archived', +} + @Table({ tableName: 't_challenges', underscored: true, @@ -144,6 +155,51 @@ export class Challenge extends Model { }) declare type: ChallengeType; + @Column({ + type: DataType.ENUM('system', 'custom'), + allowNull: false, + defaultValue: ChallengeSource.SYSTEM, + comment: '挑战来源:system=系统预设, custom=用户创建', + }) + declare source: ChallengeSource; + + @Column({ + type: DataType.STRING(64), + allowNull: true, + comment: '创建者用户 ID,仅 custom 类型有值', + }) + declare creatorId: string | null; + + @Column({ + type: DataType.STRING(12), + allowNull: true, + comment: '分享码,6-12位字符,用于加入挑战', + }) + declare shareCode: string | null; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: '是否公开:true=任何人可通过分享码加入, false=仅邀请', + }) + declare isPublic: boolean; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '最大参与人数限制,null 表示无限制', + }) + declare maxParticipants: number | null; + + @Column({ + type: DataType.ENUM('draft', 'active', 'archived'), + allowNull: false, + defaultValue: ChallengeState.ACTIVE, + comment: '挑战状态:draft=草稿, active=活跃, archived=已归档', + }) + declare challengeState: ChallengeState; + @HasMany(() => ChallengeParticipant) declare participants?: ChallengeParticipant[]; }