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

View File

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

View File

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

View File

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

View File

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