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,
|
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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user