From ae8039c9edc8ea74fe158e00b28678859a3bbc64 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 28 Sep 2025 12:13:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E6=96=B0=E5=A2=9E=E6=AF=8F?= =?UTF-8?q?=E6=97=A5=E8=BF=9B=E5=BA=A6=E4=B8=8A=E6=8A=A5=E9=98=B2=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 t_challenge_progress_reports 表记录用户每日上报 - 通过唯一索引 (challenge_id, user_id, report_date) 确保每日仅一次有效上报 - 更新 progress 时先写入报告表,冲突则直接返回当前进度 - 模块中新增 ChallengeProgressReport 模型及相关依赖 --- sql-scripts/challenges.sql | 14 +++++ src/challenges/challenges.module.ts | 3 +- src/challenges/challenges.service.ts | 57 ++++++++++++++--- .../models/challenge-progress-report.model.ts | 61 +++++++++++++++++++ 4 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 src/challenges/models/challenge-progress-report.model.ts diff --git a/sql-scripts/challenges.sql b/sql-scripts/challenges.sql index c13c85e..ee0cadf 100644 --- a/sql-scripts/challenges.sql +++ b/sql-scripts/challenges.sql @@ -40,3 +40,17 @@ CREATE TABLE IF NOT EXISTS t_challenge_participants ( CREATE INDEX idx_challenge_participants_status_progress ON t_challenge_participants (challenge_id, status, progress_value DESC, updated_at ASC); + +CREATE TABLE IF NOT EXISTS t_challenge_progress_reports ( + id CHAR(36) NOT NULL PRIMARY KEY, + challenge_id CHAR(36) NOT NULL COMMENT '挑战 ID', + user_id VARCHAR(64) NOT NULL COMMENT '用户 ID', + report_date DATE NOT NULL COMMENT '自然日,确保每日仅上报一次', + increment_value INT NOT NULL DEFAULT 1 COMMENT '本次上报的进度增量', + reported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上报时间戳', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_challenge_progress_reports_challenge FOREIGN KEY (challenge_id) REFERENCES t_challenges (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT fk_challenge_progress_reports_user FOREIGN KEY (user_id) REFERENCES t_users (id) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT uq_challenge_progress_reports_day UNIQUE KEY (challenge_id, user_id, report_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/challenges/challenges.module.ts b/src/challenges/challenges.module.ts index 7071d3f..cc375c4 100644 --- a/src/challenges/challenges.module.ts +++ b/src/challenges/challenges.module.ts @@ -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], diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index b508cf8..0ed55b3 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -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 { @@ -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); } diff --git a/src/challenges/models/challenge-progress-report.model.ts b/src/challenges/models/challenge-progress-report.model.ts new file mode 100644 index 0000000..0e5d345 --- /dev/null +++ b/src/challenges/models/challenge-progress-report.model.ts @@ -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; +}