feat(challenges): 添加用户自定义挑战功能及分享机制
实现完整的自定义挑战系统,支持用户创建、分享和管理个人挑战: - 数据库扩展:添加 source、creator_id、share_code、is_public、max_participants、challenge_state 字段 - 分享机制:自动生成6位唯一分享码,支持公开和私密模式 - API接口:创建挑战、通过分享码加入、获取创建列表、更新归档挑战、重新生成分享码 - 权限控制:创建者专属编辑权限,频率限制防滥用(每日5个) - 业务逻辑:人数限制检查、挑战状态流转、参与者统计 - 文档完善:使用文档和部署指南,包含API示例和回滚方案 兼容现有系统挑战,使用相同的打卡、排行榜和勋章系统
This commit is contained in:
@@ -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<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({
|
||||
where: {
|
||||
[Op.or]: whereConditions,
|
||||
},
|
||||
order: [['startAt', 'ASC']],
|
||||
});
|
||||
|
||||
@@ -695,4 +720,423 @@ export class ChallengesService {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user