feat(challenges): 新增挑战类型字段并重构进度上报逻辑

- 数据库新增 type 列区分 water/exercise/diet/mood/sleep/weight 六类挑战
- 进度上报由增量模式改为绝对值模式,字段 increment_value → reportedValue
- 服务层按 challenge.targetValue 判断当日是否完成,再按 minimumCheckInDays 统计总进度
- 相关 DTO 与模型同步更新,支持新类型返回

BREAKING CHANGE: 上报接口字段由 increment 改为 value,且为当日绝对值
This commit is contained in:
richarjiang
2025-09-29 15:14:48 +08:00
parent d87fc84575
commit 64460a9d68
7 changed files with 103 additions and 59 deletions

View File

@@ -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;

View File

@@ -96,6 +96,7 @@ export class ChallengesService {
minimumCheckInDays: completionTarget, minimumCheckInDays: completionTarget,
progress, progress,
isJoined: Boolean(participation), isJoined: Boolean(participation),
type: challenge.type,
}; };
}); });
} }
@@ -199,6 +200,7 @@ export class ChallengesService {
progress, progress,
rankings, rankings,
userRank, userRank,
type: challenge.type,
}; };
} }
@@ -267,6 +269,7 @@ export class ChallengesService {
[Op.ne]: ChallengeParticipantStatus.LEFT, [Op.ne]: ChallengeParticipantStatus.LEFT,
}, },
}, },
}); });
if (!participant) { if (!participant) {
@@ -302,40 +305,45 @@ export class ChallengesService {
userId, userId,
status: ChallengeParticipantStatus.ACTIVE, status: ChallengeParticipantStatus.ACTIVE,
}, },
include: [{
model: Challenge,
as: 'challenge',
attributes: ['minimumCheckInDays', 'targetValue'],
}],
});
this.winstonLogger.info('start report progress', {
context: 'reportProgress',
userId,
challengeId,
participant,
}); });
if (!participant) { if (!participant) {
throw new NotFoundException('请先加入挑战'); throw new NotFoundException('请先加入挑战');
} }
const completionTarget = challenge.minimumCheckInDays // 如果要完成当日挑战,最低的上报数据
const reportCompletedValue = challenge.targetValue
if (completionTarget <= 0) { if (reportCompletedValue <= 0) {
throw new BadRequestException('挑战配置存在问题,请联系管理员'); throw new BadRequestException('挑战配置存在问题,请联系管理员');
} }
const increment = dto.increment ?? 1; if (dto.value === undefined || dto.value === null) {
if (increment < 1) { throw new BadRequestException('缺少上报的进度数据');
throw new BadRequestException('进度增量必须大于 0');
} }
let participantNeedsSave = false; if (dto.value < 0) {
if (participant.targetValue !== completionTarget) { throw new BadRequestException('进度数据必须大于等于 0');
participant.targetValue = completionTarget;
participantNeedsSave = true;
} }
if (participant.progressValue >= completionTarget && participant.status !== ChallengeParticipantStatus.COMPLETED) { const reportedValue = dto.value;
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(completionTarget - participant.progressValue, 0);
const effectiveIncrement = Math.min(increment, remainingCapacity);
let created = false;
try { try {
const [report, wasCreated] = await this.progressReportModel.findOrCreate({ const [report, wasCreated] = await this.progressReportModel.findOrCreate({
where: { where: {
@@ -344,52 +352,53 @@ export class ChallengesService {
reportDate, reportDate,
}, },
defaults: { defaults: {
incrementValue: effectiveIncrement, reportedValue,
reportedAt: now, reportedAt: now,
}, },
}); });
created = wasCreated;
if (wasCreated) { if (wasCreated) {
await report.update({ if (report.reportedValue !== reportedValue) {
incrementValue: effectiveIncrement, await report.update({
reportedAt: now, 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) { } catch (error) {
if (error instanceof UniqueConstraintError) { if (error instanceof UniqueConstraintError) {
if (participantNeedsSave) { return this.buildChallengeProgress(participant.progressValue, participant.targetValue);
await participant.save();
}
return this.buildChallengeProgress(participant.progressValue, completionTarget);
} }
throw error; 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 { private buildChallengeProgress(completed: number, target: number, unit = '天'): ChallengeProgressDto {

View File

@@ -1,4 +1,5 @@
import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto'; import { ChallengeProgressDto, RankingItemDto } from './challenge-progress.dto';
import { ChallengeType } from '../models/challenge.model';
export interface ChallengeDetailDto { export interface ChallengeDetailDto {
id: string; id: string;
@@ -17,4 +18,5 @@ export interface ChallengeDetailDto {
progress?: ChallengeProgressDto; progress?: ChallengeProgressDto;
rankings: RankingItemDto[]; rankings: RankingItemDto[];
userRank?: number; userRank?: number;
type: ChallengeType;
} }

View File

@@ -1,4 +1,4 @@
import { ChallengeStatus } from '../models/challenge.model'; import { ChallengeStatus, ChallengeType } from '../models/challenge.model';
import { ChallengeProgressDto } from './challenge-progress.dto'; import { ChallengeProgressDto } from './challenge-progress.dto';
export interface ChallengeListItemDto { export interface ChallengeListItemDto {
@@ -19,6 +19,7 @@ export interface ChallengeListItemDto {
minimumCheckInDays: number; minimumCheckInDays: number;
progress?: ChallengeProgressDto; progress?: ChallengeProgressDto;
isJoined: boolean; isJoined: boolean;
type: ChallengeType;
} }
export interface ChallengeListResponseDto { export interface ChallengeListResponseDto {

View File

@@ -1,8 +1,7 @@
import { IsInt, IsOptional, Min } from 'class-validator'; import { IsInt, Min } from 'class-validator';
export class UpdateChallengeProgressDto { export class UpdateChallengeProgressDto {
@IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(0)
increment?: number = 1; value!: number;
} }

View File

@@ -38,12 +38,13 @@ export class ChallengeProgressReport extends Model {
declare reportDate: string; declare reportDate: string;
@Column({ @Column({
field: 'increment_value',
type: DataType.INTEGER, type: DataType.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 1, defaultValue: 0,
comment: '本次上报的进度增量', comment: '参加挑战某一天上报的原始数据值',
}) })
declare incrementValue: number; declare reportedValue: number;
@Column({ @Column({
type: DataType.DATE, type: DataType.DATE,

View File

@@ -1,5 +1,6 @@
import { Table, Column, DataType, Model, HasMany } from 'sequelize-typescript'; import { Table, Column, DataType, Model, HasMany } from 'sequelize-typescript';
import { ChallengeParticipant } from './challenge-participant.model'; import { ChallengeParticipant } from './challenge-participant.model';
import { col } from 'sequelize';
export enum ChallengeStatus { export enum ChallengeStatus {
UPCOMING = 'upcoming', UPCOMING = 'upcoming',
@@ -7,6 +8,15 @@ export enum ChallengeStatus {
EXPIRED = 'expired', EXPIRED = 'expired',
} }
export enum ChallengeType {
WATER = 'water',
EXERCISE = 'exercise',
DIET = 'diet',
MOOD = 'mood',
SLEEP = 'sleep',
WEIGHT = 'weight',
}
@Table({ @Table({
tableName: 't_challenges', tableName: 't_challenges',
underscored: true, underscored: true,
@@ -126,6 +136,14 @@ export class Challenge extends Model {
}) })
declare ctaLabel: string; declare ctaLabel: string;
@Column({
type: DataType.ENUM('water', 'exercise', 'diet', 'mood', 'sleep', 'weight'),
allowNull: false,
defaultValue: ChallengeType.WATER,
comment: '挑战类型',
})
declare type: ChallengeType;
@HasMany(() => ChallengeParticipant) @HasMany(() => ChallengeParticipant)
declare participants?: ChallengeParticipant[]; declare participants?: ChallengeParticipant[];
} }