feat(challenges): 使用 minimumCheckInDays 统一进度目标计算

将挑战完成目标从 targetValue/progressUnit 改为 minimumCheckInDays 字段驱动,确保列表、详情、加入、打卡各场景使用一致的完成天数标准,并移除前端展示字段 badge/subtitle。
This commit is contained in:
richarjiang
2025-09-29 10:25:20 +08:00
parent 22fcf694a6
commit d87fc84575
5 changed files with 71 additions and 24 deletions

View File

@@ -72,9 +72,10 @@ export class ChallengesService {
}
return challenges.map((challenge) => {
const completionTarget = challenge.minimumCheckInDays
const participation = participationMap.get(challenge.id);
const progress = participation
? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit)
? this.buildChallengeProgress(participation.progressValue, completionTarget)
: undefined;
return {
@@ -92,6 +93,7 @@ export class ChallengesService {
highlightTitle: challenge.highlightTitle,
highlightSubtitle: challenge.highlightSubtitle,
ctaLabel: challenge.ctaLabel,
minimumCheckInDays: completionTarget,
progress,
isJoined: Boolean(participation),
};
@@ -161,17 +163,22 @@ export class ChallengesService {
rankingsRawCount: rankingsRaw.length,
});
const completionTarget = challenge.minimumCheckInDays
const progress = participation
? this.buildChallengeProgress(participation.progressValue, participation.targetValue, challenge.progressUnit)
? this.buildChallengeProgress(participation.progressValue, completionTarget)
: 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 rankings: RankingItemDto[] = rankingsRaw.map((item, index) => {
const itemTarget = item.targetValue && item.targetValue > 0 ? item.targetValue : completionTarget;
return {
id: item.user?.id ?? item.userId,
name: item.user?.name ?? '未知用户',
avatar: item.user?.avatar ?? null,
metric: `${item.progressValue}/${itemTarget}`,
badge: this.resolveRankingBadge(index),
};
});
const userRank = participation ? await this.calculateUserRank(challengeId, participation) : undefined;
@@ -187,6 +194,7 @@ export class ChallengesService {
highlightTitle: challenge.highlightTitle,
highlightSubtitle: challenge.highlightSubtitle,
ctaLabel: challenge.ctaLabel,
minimumCheckInDays: completionTarget,
participantsCount,
progress,
rankings,
@@ -206,6 +214,12 @@ export class ChallengesService {
throw new BadRequestException('挑战已过期,无法加入');
}
const completionTarget = challenge.minimumCheckInDays
if (completionTarget <= 0) {
throw new BadRequestException('挑战配置存在问题,请联系管理员');
}
const existing = await this.participantModel.findOne({
where: {
challengeId,
@@ -223,25 +237,25 @@ export class ChallengesService {
if (existing && existing.status === ChallengeParticipantStatus.LEFT) {
existing.progressValue = 0;
existing.targetValue = challenge.targetValue;
existing.targetValue = completionTarget;
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);
return this.buildChallengeProgress(existing.progressValue, completionTarget);
}
const participant = await this.participantModel.create({
challengeId,
userId,
progressValue: 0,
targetValue: challenge.targetValue,
targetValue: completionTarget,
status: ChallengeParticipantStatus.ACTIVE,
joinedAt: new Date(),
});
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
return this.buildChallengeProgress(participant.progressValue, completionTarget);
}
async leaveChallenge(userId: string, challengeId: string): Promise<boolean> {
@@ -294,14 +308,31 @@ export class ChallengesService {
throw new NotFoundException('请先加入挑战');
}
const completionTarget = challenge.minimumCheckInDays
if (completionTarget <= 0) {
throw new BadRequestException('挑战配置存在问题,请联系管理员');
}
const increment = dto.increment ?? 1;
if (increment < 1) {
throw new BadRequestException('进度增量必须大于 0');
}
let participantNeedsSave = false;
if (participant.targetValue !== completionTarget) {
participant.targetValue = completionTarget;
participantNeedsSave = true;
}
if (participant.progressValue >= completionTarget && participant.status !== ChallengeParticipantStatus.COMPLETED) {
participant.status = ChallengeParticipantStatus.COMPLETED;
participantNeedsSave = true;
}
const reportDate = dayjs().format('YYYY-MM-DD');
const now = new Date();
const remainingCapacity = Math.max(participant.targetValue - participant.progressValue, 0);
const remainingCapacity = Math.max(completionTarget - participant.progressValue, 0);
const effectiveIncrement = Math.min(increment, remainingCapacity);
let created = false;
@@ -328,41 +359,49 @@ export class ChallengesService {
}
} catch (error) {
if (error instanceof UniqueConstraintError) {
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
if (participantNeedsSave) {
await participant.save();
}
return this.buildChallengeProgress(participant.progressValue, completionTarget);
}
throw error;
}
if (!created) {
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
if (participantNeedsSave) {
await participant.save();
}
return this.buildChallengeProgress(participant.progressValue, completionTarget);
}
if (effectiveIncrement > 0) {
const newProgress = Math.min(participant.progressValue + effectiveIncrement, participant.targetValue);
const newProgress = Math.min(participant.progressValue + effectiveIncrement, completionTarget);
participant.progressValue = newProgress;
participant.lastProgressAt = now;
if (participant.progressValue >= participant.targetValue) {
if (participant.progressValue >= completionTarget) {
participant.status = ChallengeParticipantStatus.COMPLETED;
}
participantNeedsSave = true;
}
if (participantNeedsSave) {
await participant.save();
}
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
return this.buildChallengeProgress(participant.progressValue, completionTarget);
}
private buildChallengeProgress(completed: number, target: number, unit: string): ChallengeProgressDto {
private buildChallengeProgress(completed: number, target: number, unit = '天'): 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: number, endAt: number): ChallengeStatus {
const now = dayjs();
const start = dayjs(startAt);

View File

@@ -12,6 +12,7 @@ export interface ChallengeDetailDto {
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
minimumCheckInDays: number;
participantsCount: number;
progress?: ChallengeProgressDto;
rankings: RankingItemDto[];

View File

@@ -16,6 +16,7 @@ export interface ChallengeListItemDto {
highlightTitle: string;
highlightSubtitle: string;
ctaLabel: string;
minimumCheckInDays: number;
progress?: ChallengeProgressDto;
isJoined: boolean;
}

View File

@@ -2,8 +2,6 @@ export interface ChallengeProgressDto {
completed: number;
target: number;
remaining: number;
badge: string;
subtitle?: string;
}
export interface RankingItemDto {

View File

@@ -90,6 +90,14 @@ export class Challenge extends Model {
})
declare progressUnit: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 0,
comment: '最低打卡天数,用于判断挑战成功',
})
declare minimumCheckInDays: number;
@Column({
type: DataType.STRING(255),
allowNull: true,