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:
14
sql-scripts/add-challenge-type-column.sql
Normal file
14
sql-scripts/add-challenge-type-column.sql
Normal 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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user