From 64460a9d68a12da9a9b181e99bc750d57637f1b9 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 29 Sep 2025 15:14:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(challenges):=20=E6=96=B0=E5=A2=9E=E6=8C=91?= =?UTF-8?q?=E6=88=98=E7=B1=BB=E5=9E=8B=E5=AD=97=E6=AE=B5=E5=B9=B6=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=BF=9B=E5=BA=A6=E4=B8=8A=E6=8A=A5=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 数据库新增 type 列区分 water/exercise/diet/mood/sleep/weight 六类挑战 - 进度上报由增量模式改为绝对值模式,字段 increment_value → reportedValue - 服务层按 challenge.targetValue 判断当日是否完成,再按 minimumCheckInDays 统计总进度 - 相关 DTO 与模型同步更新,支持新类型返回 BREAKING CHANGE: 上报接口字段由 increment 改为 value,且为当日绝对值 --- sql-scripts/add-challenge-type-column.sql | 14 +++ src/challenges/challenges.service.ts | 111 ++++++++++-------- src/challenges/dto/challenge-detail.dto.ts | 2 + src/challenges/dto/challenge-list.dto.ts | 3 +- .../dto/update-challenge-progress.dto.ts | 7 +- .../models/challenge-progress-report.model.ts | 7 +- src/challenges/models/challenge.model.ts | 18 +++ 7 files changed, 103 insertions(+), 59 deletions(-) create mode 100644 sql-scripts/add-challenge-type-column.sql diff --git a/sql-scripts/add-challenge-type-column.sql b/sql-scripts/add-challenge-type-column.sql new file mode 100644 index 0000000..e415a0e --- /dev/null +++ b/sql-scripts/add-challenge-type-column.sql @@ -0,0 +1,14 @@ +-- Add challenge type column to t_challenges table +-- This migration adds the type column to support different challenge types + +ALTER TABLE t_challenges +ADD COLUMN type ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight') +NOT NULL DEFAULT 'water' +COMMENT '挑战类型' +AFTER cta_label; + +-- Create index on type column for better query performance +CREATE INDEX idx_challenges_type ON t_challenges (type); + +-- Update existing challenges to have 'water' type if they don't have a type +UPDATE t_challenges SET type = 'water' WHERE type IS NULL; \ No newline at end of file diff --git a/src/challenges/challenges.service.ts b/src/challenges/challenges.service.ts index 3671e70..9d4b2e5 100644 --- a/src/challenges/challenges.service.ts +++ b/src/challenges/challenges.service.ts @@ -96,6 +96,7 @@ export class ChallengesService { minimumCheckInDays: completionTarget, progress, isJoined: Boolean(participation), + type: challenge.type, }; }); } @@ -199,6 +200,7 @@ export class ChallengesService { progress, rankings, userRank, + type: challenge.type, }; } @@ -267,6 +269,7 @@ export class ChallengesService { [Op.ne]: ChallengeParticipantStatus.LEFT, }, }, + }); if (!participant) { @@ -302,40 +305,45 @@ export class ChallengesService { userId, status: ChallengeParticipantStatus.ACTIVE, }, + include: [{ + model: Challenge, + as: 'challenge', + attributes: ['minimumCheckInDays', 'targetValue'], + }], + }); + + this.winstonLogger.info('start report progress', { + context: 'reportProgress', + userId, + challengeId, + participant, }); if (!participant) { throw new NotFoundException('请先加入挑战'); } - const completionTarget = challenge.minimumCheckInDays + // 如果要完成当日挑战,最低的上报数据 + const reportCompletedValue = challenge.targetValue - if (completionTarget <= 0) { + if (reportCompletedValue <= 0) { throw new BadRequestException('挑战配置存在问题,请联系管理员'); } - const increment = dto.increment ?? 1; - if (increment < 1) { - throw new BadRequestException('进度增量必须大于 0'); + if (dto.value === undefined || dto.value === null) { + throw new BadRequestException('缺少上报的进度数据'); } - let participantNeedsSave = false; - if (participant.targetValue !== completionTarget) { - participant.targetValue = completionTarget; - participantNeedsSave = true; + if (dto.value < 0) { + throw new BadRequestException('进度数据必须大于等于 0'); } - if (participant.progressValue >= completionTarget && participant.status !== ChallengeParticipantStatus.COMPLETED) { - participant.status = ChallengeParticipantStatus.COMPLETED; - participantNeedsSave = true; - } + const reportedValue = dto.value; + const reportDate = dayjs().format('YYYY-MM-DD'); const now = new Date(); - const remainingCapacity = Math.max(completionTarget - participant.progressValue, 0); - const effectiveIncrement = Math.min(increment, remainingCapacity); - let created = false; try { const [report, wasCreated] = await this.progressReportModel.findOrCreate({ where: { @@ -344,52 +352,53 @@ export class ChallengesService { reportDate, }, defaults: { - incrementValue: effectiveIncrement, + reportedValue, reportedAt: now, }, }); - created = wasCreated; - if (wasCreated) { - await report.update({ - incrementValue: effectiveIncrement, - reportedAt: now, - }); + if (report.reportedValue !== reportedValue) { + await report.update({ + reportedValue, + reportedAt: now, + }); + } + } else { + if (report.reportedValue !== reportedValue) { + report.reportedAt = now; + report.reportedValue = reportedValue; + await report.save(); + } } + + + if (report.reportedValue >= reportCompletedValue && !dayjs(participant.lastProgressAt).isSame(dayjs(), 'd')) { + participant.progressValue++ + participant.lastProgressAt = now; + } + + if (participant.progressValue >= (participant.challenge?.minimumCheckInDays || 0) && participant.status !== ChallengeParticipantStatus.COMPLETED) { + participant.status = ChallengeParticipantStatus.COMPLETED; + } + + await participant.save(); + + this.winstonLogger.info('end report progress', { + context: 'reportProgress', + userId, + challengeId, + participant, + }); + + + return this.buildChallengeProgress(participant.progressValue, participant.targetValue); } catch (error) { if (error instanceof UniqueConstraintError) { - if (participantNeedsSave) { - await participant.save(); - } - return this.buildChallengeProgress(participant.progressValue, completionTarget); + return this.buildChallengeProgress(participant.progressValue, participant.targetValue); } throw error; } - - if (!created) { - if (participantNeedsSave) { - await participant.save(); - } - return this.buildChallengeProgress(participant.progressValue, completionTarget); - } - - if (effectiveIncrement > 0) { - const newProgress = Math.min(participant.progressValue + effectiveIncrement, completionTarget); - participant.progressValue = newProgress; - participant.lastProgressAt = now; - - if (participant.progressValue >= completionTarget) { - participant.status = ChallengeParticipantStatus.COMPLETED; - } - participantNeedsSave = true; - } - - if (participantNeedsSave) { - await participant.save(); - } - - return this.buildChallengeProgress(participant.progressValue, completionTarget); } private buildChallengeProgress(completed: number, target: number, unit = '天'): ChallengeProgressDto { diff --git a/src/challenges/dto/challenge-detail.dto.ts b/src/challenges/dto/challenge-detail.dto.ts index a660a22..db8fb44 100644 --- a/src/challenges/dto/challenge-detail.dto.ts +++ b/src/challenges/dto/challenge-detail.dto.ts @@ -1,4 +1,5 @@ import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto'; +import { ChallengeType } from '../models/challenge.model'; export interface ChallengeDetailDto { id: string; @@ -17,4 +18,5 @@ export interface ChallengeDetailDto { progress?: ChallengeProgressDto; rankings: RankingItemDto[]; userRank?: number; + type: ChallengeType; } diff --git a/src/challenges/dto/challenge-list.dto.ts b/src/challenges/dto/challenge-list.dto.ts index 2f2bf0e..25821fc 100644 --- a/src/challenges/dto/challenge-list.dto.ts +++ b/src/challenges/dto/challenge-list.dto.ts @@ -1,4 +1,4 @@ -import { ChallengeStatus } from '../models/challenge.model'; +import { ChallengeStatus, ChallengeType } from '../models/challenge.model'; import { ChallengeProgressDto } from './challenge-progress.dto'; export interface ChallengeListItemDto { @@ -19,6 +19,7 @@ export interface ChallengeListItemDto { minimumCheckInDays: number; progress?: ChallengeProgressDto; isJoined: boolean; + type: ChallengeType; } export interface ChallengeListResponseDto { diff --git a/src/challenges/dto/update-challenge-progress.dto.ts b/src/challenges/dto/update-challenge-progress.dto.ts index edfb5cf..8780d94 100644 --- a/src/challenges/dto/update-challenge-progress.dto.ts +++ b/src/challenges/dto/update-challenge-progress.dto.ts @@ -1,8 +1,7 @@ -import { IsInt, IsOptional, Min } from 'class-validator'; +import { IsInt, Min } from 'class-validator'; export class UpdateChallengeProgressDto { - @IsOptional() @IsInt() - @Min(1) - increment?: number = 1; + @Min(0) + value!: number; } diff --git a/src/challenges/models/challenge-progress-report.model.ts b/src/challenges/models/challenge-progress-report.model.ts index 0e5d345..5a6dcc2 100644 --- a/src/challenges/models/challenge-progress-report.model.ts +++ b/src/challenges/models/challenge-progress-report.model.ts @@ -38,12 +38,13 @@ export class ChallengeProgressReport extends Model { declare reportDate: string; @Column({ + field: 'increment_value', type: DataType.INTEGER, allowNull: false, - defaultValue: 1, - comment: '本次上报的进度增量', + defaultValue: 0, + comment: '参加挑战某一天上报的原始数据值', }) - declare incrementValue: number; + declare reportedValue: number; @Column({ type: DataType.DATE, diff --git a/src/challenges/models/challenge.model.ts b/src/challenges/models/challenge.model.ts index 76fe08f..934d918 100644 --- a/src/challenges/models/challenge.model.ts +++ b/src/challenges/models/challenge.model.ts @@ -1,5 +1,6 @@ import { Table, Column, DataType, Model, HasMany } from 'sequelize-typescript'; import { ChallengeParticipant } from './challenge-participant.model'; +import { col } from 'sequelize'; export enum ChallengeStatus { UPCOMING = 'upcoming', @@ -7,6 +8,15 @@ export enum ChallengeStatus { EXPIRED = 'expired', } +export enum ChallengeType { + WATER = 'water', + EXERCISE = 'exercise', + DIET = 'diet', + MOOD = 'mood', + SLEEP = 'sleep', + WEIGHT = 'weight', +} + @Table({ tableName: 't_challenges', underscored: true, @@ -126,6 +136,14 @@ export class Challenge extends Model { }) declare ctaLabel: string; + @Column({ + type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight'), + allowNull: false, + defaultValue: ChallengeType.WATER, + comment: '挑战类型', + }) + declare type: ChallengeType; + @HasMany(() => ChallengeParticipant) declare participants?: ChallengeParticipant[]; }