feat(challenges): 新增每日进度上报防重复机制

- 创建 t_challenge_progress_reports 表记录用户每日上报
- 通过唯一索引 (challenge_id, user_id, report_date) 确保每日仅一次有效上报
- 更新 progress 时先写入报告表,冲突则直接返回当前进度
- 模块中新增 ChallengeProgressReport 模型及相关依赖
This commit is contained in:
richarjiang
2025-09-28 12:13:31 +08:00
parent 1b7132a325
commit ae8039c9ed
4 changed files with 127 additions and 8 deletions

View File

@@ -4,12 +4,13 @@ import { ChallengesController } from './challenges.controller';
import { ChallengesService } from './challenges.service';
import { Challenge } from './models/challenge.model';
import { ChallengeParticipant } from './models/challenge-participant.model';
import { ChallengeProgressReport } from './models/challenge-progress-report.model';
import { UsersModule } from '../users/users.module';
import { User } from '../users/models/user.model';
@Module({
imports: [
SequelizeModule.forFeature([Challenge, ChallengeParticipant, User]),
SequelizeModule.forFeature([Challenge, ChallengeParticipant, ChallengeProgressReport, User]),
UsersModule,
],
controllers: [ChallengesController],

View File

@@ -2,11 +2,12 @@ import { Injectable, NotFoundException, BadRequestException, ConflictException }
import { InjectModel } from '@nestjs/sequelize';
import { Challenge, ChallengeStatus } 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';
import { ChallengeDetailDto } from './dto/challenge-detail.dto';
import { ChallengeListItemDto } from './dto/challenge-list.dto';
import { ChallengeProgressDto, RankingItemDto } from './dto/challenge-progress.dto';
import { fn, col, Op } from 'sequelize';
import { fn, col, Op, UniqueConstraintError } from 'sequelize';
import * as dayjs from 'dayjs';
import { User } from '../users/models/user.model';
@@ -17,6 +18,8 @@ export class ChallengesService {
private readonly challengeModel: typeof Challenge,
@InjectModel(ChallengeParticipant)
private readonly participantModel: typeof ChallengeParticipant,
@InjectModel(ChallengeProgressReport)
private readonly progressReportModel: typeof ChallengeProgressReport,
) { }
async getChallengesForUser(userId: string): Promise<ChallengeListItemDto[]> {
@@ -266,15 +269,55 @@ export class ChallengesService {
throw new BadRequestException('进度增量必须大于 0');
}
const newProgress = participant.progressValue + increment;
participant.progressValue = Math.min(newProgress, participant.targetValue);
participant.lastProgressAt = new Date();
const reportDate = dayjs().format('YYYY-MM-DD');
const now = new Date();
const remainingCapacity = Math.max(participant.targetValue - participant.progressValue, 0);
const effectiveIncrement = Math.min(increment, remainingCapacity);
if (participant.progressValue >= participant.targetValue) {
participant.status = ChallengeParticipantStatus.COMPLETED;
let created = false;
try {
const [report, wasCreated] = await this.progressReportModel.findOrCreate({
where: {
challengeId,
userId,
reportDate,
},
defaults: {
incrementValue: effectiveIncrement,
reportedAt: now,
},
});
created = wasCreated;
if (wasCreated) {
await report.update({
incrementValue: effectiveIncrement,
reportedAt: now,
});
}
} catch (error) {
if (error instanceof UniqueConstraintError) {
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
}
throw error;
}
await participant.save();
if (!created) {
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
}
if (effectiveIncrement > 0) {
const newProgress = Math.min(participant.progressValue + effectiveIncrement, participant.targetValue);
participant.progressValue = newProgress;
participant.lastProgressAt = now;
if (participant.progressValue >= participant.targetValue) {
participant.status = ChallengeParticipantStatus.COMPLETED;
}
await participant.save();
}
return this.buildChallengeProgress(participant.progressValue, participant.targetValue, challenge.progressUnit);
}

View File

@@ -0,0 +1,61 @@
import { Table, Column, DataType, Model, ForeignKey, BelongsTo, Index } from 'sequelize-typescript';
import { Challenge } from './challenge.model';
import { User } from '../../users/models/user.model';
@Table({
tableName: 't_challenge_progress_reports',
underscored: true,
})
export class ChallengeProgressReport extends Model {
@Column({
type: DataType.CHAR(36),
defaultValue: DataType.UUIDV4,
primaryKey: true,
})
declare id: string;
@ForeignKey(() => Challenge)
@Column({
type: DataType.CHAR(36),
allowNull: false,
comment: '挑战 ID',
})
declare challengeId: string;
@ForeignKey(() => User)
@Column({
type: DataType.STRING(64),
allowNull: false,
comment: '用户 ID',
})
declare userId: string;
@Column({
type: DataType.DATEONLY,
allowNull: false,
comment: '自然日,确保每日仅上报一次',
})
declare reportDate: string;
@Column({
type: DataType.INTEGER,
allowNull: false,
defaultValue: 1,
comment: '本次上报的进度增量',
})
declare incrementValue: number;
@Column({
type: DataType.DATE,
allowNull: false,
defaultValue: DataType.NOW,
comment: '上报时间戳',
})
declare reportedAt: Date;
@BelongsTo(() => Challenge)
declare challenge?: Challenge;
@BelongsTo(() => User)
declare user?: User;
}