feat(challenges): 添加用户自定义挑战功能及分享机制
实现完整的自定义挑战系统,支持用户创建、分享和管理个人挑战: - 数据库扩展:添加 source、creator_id、share_code、is_public、max_participants、challenge_state 字段 - 分享机制:自动生成6位唯一分享码,支持公开和私密模式 - API接口:创建挑战、通过分享码加入、获取创建列表、更新归档挑战、重新生成分享码 - 权限控制:创建者专属编辑权限,频率限制防滥用(每日5个) - 业务逻辑:人数限制检查、挑战状态流转、参与者统计 - 文档完善:使用文档和部署指南,包含API示例和回滚方案 兼容现有系统挑战,使用相同的打卡、排行榜和勋章系统
This commit is contained in:
43
sql-scripts/add-custom-challenges-support.sql
Normal file
43
sql-scripts/add-custom-challenges-support.sql
Normal file
@@ -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 字段有唯一索引,确保分享码唯一性
|
||||||
|
-- =====================================================
|
||||||
@@ -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 { ChallengesService } from './challenges.service';
|
||||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
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 { ChallengeListItemDto } from './dto/challenge-list.dto';
|
||||||
import { ChallengeProgressDto } from './dto/challenge-progress.dto';
|
import { ChallengeProgressDto } from './dto/challenge-progress.dto';
|
||||||
import { ChallengeRankingListDto, GetChallengeRankingQueryDto } from './dto/challenge-ranking.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 { Public } from 'src/common/decorators/public.decorator';
|
||||||
|
import { ChallengeState } from './models/challenge.model';
|
||||||
|
|
||||||
|
@ApiTags('挑战管理')
|
||||||
@Controller('challenges')
|
@Controller('challenges')
|
||||||
export class ChallengesController {
|
export class ChallengesController {
|
||||||
constructor(private readonly challengesService: ChallengesService) { }
|
constructor(private readonly challengesService: ChallengesService) { }
|
||||||
@@ -104,4 +111,130 @@ export class ChallengesController {
|
|||||||
data,
|
data,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 自定义挑战 API ====================
|
||||||
|
|
||||||
|
@Post('custom')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiOperation({ summary: '创建自定义挑战' })
|
||||||
|
@ApiResponse({ status: 201, description: '创建成功' })
|
||||||
|
async createCustomChallenge(
|
||||||
|
@Body() dto: CreateCustomChallengeDto,
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
): Promise<BaseResponseDto<CustomChallengeResponseDto>> {
|
||||||
|
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<BaseResponseDto<ChallengeProgressDto>> {
|
||||||
|
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<BaseResponseDto<ChallengeDetailDto>> {
|
||||||
|
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<BaseResponseDto<{
|
||||||
|
items: CustomChallengeResponseDto[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}>> {
|
||||||
|
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<BaseResponseDto<CustomChallengeResponseDto>> {
|
||||||
|
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<BaseResponseDto<boolean>> {
|
||||||
|
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<BaseResponseDto<{ shareCode: string }>> {
|
||||||
|
const shareCode = await this.challengesService.regenerateShareCode(user.sub, id);
|
||||||
|
return {
|
||||||
|
code: ResponseCode.SUCCESS,
|
||||||
|
message: '重新生成分享码成功',
|
||||||
|
data: { shareCode },
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { ChallengeParticipant, ChallengeParticipantStatus } from './models/challenge-participant.model';
|
||||||
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
|
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
|
||||||
import { UpdateChallengeProgressDto } from './dto/update-challenge-progress.dto';
|
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 { ChallengeListItemDto } from './dto/challenge-list.dto';
|
||||||
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
|
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
|
||||||
import { ChallengeRankingListDto } from './dto/challenge-ranking.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 { fn, col, Op, UniqueConstraintError } from 'sequelize';
|
||||||
import * as dayjs from 'dayjs';
|
import * as dayjs from 'dayjs';
|
||||||
import { User } from '../users/models/user.model';
|
import { User } from '../users/models/user.model';
|
||||||
@@ -35,7 +38,29 @@ export class ChallengesService {
|
|||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
|
async getChallengesForUser(userId?: string): Promise<ChallengeListItemDto[]> {
|
||||||
|
// 获取系统挑战 + 用户已加入的自定义挑战 + 用户创建的自定义挑战
|
||||||
|
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({
|
const challenges = await this.challengeModel.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: whereConditions,
|
||||||
|
},
|
||||||
order: [['startAt', 'ASC']],
|
order: [['startAt', 'ASC']],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -695,4 +720,423 @@ export class ChallengesService {
|
|||||||
total: count,
|
total: count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 自定义挑战功能 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成唯一的分享码
|
||||||
|
*/
|
||||||
|
private async generateUniqueShareCode(): Promise<string> {
|
||||||
|
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<string[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<CustomChallengeResponseDto> {
|
||||||
|
// 验证时间
|
||||||
|
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<ChallengeProgressDto> {
|
||||||
|
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<ChallengeDetailDto> {
|
||||||
|
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<CustomChallengeResponseDto> {
|
||||||
|
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<boolean> {
|
||||||
|
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<string> {
|
||||||
|
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<CustomChallengeResponseDto> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/challenges/dto/create-custom-challenge.dto.ts
Normal file
109
src/challenges/dto/create-custom-challenge.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
40
src/challenges/dto/custom-challenge-response.dto.ts
Normal file
40
src/challenges/dto/custom-challenge-response.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
16
src/challenges/dto/join-by-share-code.dto.ts
Normal file
16
src/challenges/dto/join-by-share-code.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
51
src/challenges/dto/update-custom-challenge.dto.ts
Normal file
51
src/challenges/dto/update-custom-challenge.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,17 @@ export enum ChallengeType {
|
|||||||
WEIGHT = 'weight',
|
WEIGHT = 'weight',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ChallengeSource {
|
||||||
|
SYSTEM = 'system',
|
||||||
|
CUSTOM = 'custom',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChallengeState {
|
||||||
|
DRAFT = 'draft',
|
||||||
|
ACTIVE = 'active',
|
||||||
|
ARCHIVED = 'archived',
|
||||||
|
}
|
||||||
|
|
||||||
@Table({
|
@Table({
|
||||||
tableName: 't_challenges',
|
tableName: 't_challenges',
|
||||||
underscored: true,
|
underscored: true,
|
||||||
@@ -144,6 +155,51 @@ export class Challenge extends Model {
|
|||||||
})
|
})
|
||||||
declare type: ChallengeType;
|
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)
|
@HasMany(() => ChallengeParticipant)
|
||||||
declare participants?: ChallengeParticipant[];
|
declare participants?: ChallengeParticipant[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user