feat: 新增目标子任务管理功能模块
- 实现目标子任务的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。 - 支持用户创建、管理和跟踪目标子任务,提供增删改查操作及任务完成记录功能。 - 引入惰性任务生成机制,优化任务管理体验,提升系统性能和用户交互。
This commit is contained in:
122
src/goals/dto/goal-task.dto.ts
Normal file
122
src/goals/dto/goal-task.dto.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { IsString, IsOptional, IsInt, IsDateString, Min, IsUUID, IsEnum, Max } from 'class-validator';
|
||||
import { TaskStatus } from '../models/goal-task.model';
|
||||
|
||||
export class CreateGoalTaskDto {
|
||||
@IsUUID()
|
||||
goalId: string;
|
||||
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate: string;
|
||||
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1, { message: '目标次数必须大于0' })
|
||||
targetCount: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export class UpdateGoalTaskDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '目标次数必须大于0' })
|
||||
targetCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskStatus, { message: '任务状态无效' })
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export class GoalTaskQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TaskStatus, { message: '任务状态无效' })
|
||||
status?: TaskStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '页码必须大于0' })
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '每页数量必须大于0' })
|
||||
@Max(100, { message: '每页数量不能超过100' })
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
|
||||
export class CompleteGoalTaskDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '完成次数必须大于0' })
|
||||
count?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '完成时间格式无效' })
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export class GoalTaskStatsDto {
|
||||
total: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
completed: number;
|
||||
overdue: number;
|
||||
skipped: number;
|
||||
totalProgress: number; // 总体完成进度
|
||||
todayTasks: number;
|
||||
weekTasks: number;
|
||||
monthTasks: number;
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { CreateGoalDto } from './dto/create-goal.dto';
|
||||
import { UpdateGoalDto } from './dto/update-goal.dto';
|
||||
import { GoalQueryDto } from './dto/goal-query.dto';
|
||||
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
|
||||
import { GoalTaskService } from './services/goal-task.service';
|
||||
import { GoalTaskQueryDto, CompleteGoalTaskDto, UpdateGoalTaskDto } from './dto/goal-task.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
||||
import { GoalStatus } from './models/goal.model';
|
||||
@@ -25,7 +27,10 @@ import { AccessTokenPayload } from 'src/users/services/apple-auth.service';
|
||||
@Controller('goals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GoalsController {
|
||||
constructor(private readonly goalsService: GoalsService) { }
|
||||
constructor(
|
||||
private readonly goalsService: GoalsService,
|
||||
private readonly goalTaskService: GoalTaskService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 创建目标
|
||||
@@ -203,4 +208,123 @@ export class GoalsController {
|
||||
data: results,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 子任务相关API ====================
|
||||
|
||||
/**
|
||||
* 获取任务列表
|
||||
*/
|
||||
@Get('tasks')
|
||||
async getTasks(
|
||||
@Query() query: GoalTaskQueryDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const result = await this.goalTaskService.getTasks(user.sub, query);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取任务列表成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个任务详情
|
||||
*/
|
||||
@Get('tasks/:taskId')
|
||||
async getTask(
|
||||
@Param('taskId') taskId: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const task = await this.goalTaskService.getTask(user.sub, taskId);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取任务详情成功',
|
||||
data: task,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务
|
||||
*/
|
||||
@Post('tasks/:taskId/complete')
|
||||
async completeTask(
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() completeDto: CompleteGoalTaskDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const task = await this.goalTaskService.completeTask(user.sub, taskId, completeDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '任务完成成功',
|
||||
data: task,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务
|
||||
*/
|
||||
@Put('tasks/:taskId')
|
||||
async updateTask(
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() updateDto: UpdateGoalTaskDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const task = await this.goalTaskService.updateTask(user.sub, taskId, updateDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '任务更新成功',
|
||||
data: task,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过任务
|
||||
*/
|
||||
@Post('tasks/:taskId/skip')
|
||||
async skipTask(
|
||||
@Param('taskId') taskId: string,
|
||||
@Body() body: { reason?: string },
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const task = await this.goalTaskService.skipTask(user.sub, taskId, body.reason);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '任务跳过成功',
|
||||
data: task,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务统计
|
||||
*/
|
||||
@Get('tasks/stats/overview')
|
||||
async getTaskStats(
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
@Query('goalId') goalId?: string,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const stats = await this.goalTaskService.getTaskStats(user.sub, goalId);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取任务统计成功',
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定目标的任务列表
|
||||
*/
|
||||
@Get(':id/tasks')
|
||||
async getGoalTasks(
|
||||
@Param('id') goalId: string,
|
||||
@Query() query: Omit<GoalTaskQueryDto, 'goalId'>,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const taskQuery = { ...query, goalId };
|
||||
const result = await this.goalTaskService.getTasks(user.sub, taskQuery);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标任务列表成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@ import { Module } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { GoalsController } from './goals.controller';
|
||||
import { GoalsService } from './goals.service';
|
||||
import { GoalTaskService } from './services/goal-task.service';
|
||||
import { Goal } from './models/goal.model';
|
||||
import { GoalCompletion } from './models/goal-completion.model';
|
||||
import { GoalTask } from './models/goal-task.model';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SequelizeModule.forFeature([Goal, GoalCompletion]),
|
||||
SequelizeModule.forFeature([Goal, GoalCompletion, GoalTask]),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [GoalsController],
|
||||
providers: [GoalsService],
|
||||
exports: [GoalsService],
|
||||
providers: [GoalsService, GoalTaskService],
|
||||
exports: [GoalsService, GoalTaskService],
|
||||
})
|
||||
export class GoalsModule {}
|
||||
export class GoalsModule { }
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { Op, WhereOptions, Order } from 'sequelize';
|
||||
import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model';
|
||||
import { GoalCompletion } from './models/goal-completion.model';
|
||||
import { GoalTask } from './models/goal-task.model';
|
||||
import { CreateGoalDto } from './dto/create-goal.dto';
|
||||
import { UpdateGoalDto } from './dto/update-goal.dto';
|
||||
import { GoalQueryDto } from './dto/goal-query.dto';
|
||||
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
|
||||
import { GoalTaskService } from './services/goal-task.service';
|
||||
import * as dayjs from 'dayjs';
|
||||
|
||||
@Injectable()
|
||||
export class GoalsService {
|
||||
private readonly logger = new Logger(GoalsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(Goal)
|
||||
private readonly goalModel: typeof Goal,
|
||||
@InjectModel(GoalCompletion)
|
||||
private readonly goalCompletionModel: typeof GoalCompletion,
|
||||
@InjectModel(GoalTask)
|
||||
private readonly goalTaskModel: typeof GoalTask,
|
||||
private readonly goalTaskService: GoalTaskService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 创建目标
|
||||
*/
|
||||
@@ -27,13 +40,13 @@ export class GoalsService {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
|
||||
const goal = await Goal.create({
|
||||
const goal = await this.goalModel.create({
|
||||
userId,
|
||||
...createGoalDto,
|
||||
startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : null,
|
||||
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : null,
|
||||
startTime: createGoalDto.startTime ? createGoalDto.startTime : null,
|
||||
endTime: createGoalDto.endTime ? createGoalDto.endTime : null,
|
||||
startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : undefined,
|
||||
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : undefined,
|
||||
startTime: createGoalDto.startTime ? createGoalDto.startTime : undefined,
|
||||
endTime: createGoalDto.endTime ? createGoalDto.endTime : undefined,
|
||||
});
|
||||
|
||||
this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`);
|
||||
@@ -49,6 +62,9 @@ export class GoalsService {
|
||||
*/
|
||||
async getGoals(userId: string, query: GoalQueryDto) {
|
||||
try {
|
||||
// 惰性生成任务
|
||||
await this.goalTaskService.generateTasksLazily(userId);
|
||||
|
||||
const { page = 1, pageSize = 20, status, repeatType, category, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
@@ -85,7 +101,7 @@ export class GoalsService {
|
||||
// 构建排序条件
|
||||
const order: Order = [[sortBy, sortOrder.toUpperCase()]];
|
||||
|
||||
const { rows: goals, count } = await Goal.findAndCountAll({
|
||||
const { rows: goals, count } = await this.goalModel.findAndCountAll({
|
||||
where,
|
||||
order,
|
||||
offset,
|
||||
@@ -97,6 +113,14 @@ export class GoalsService {
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: GoalTask,
|
||||
as: 'tasks',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
limit: 5, // 只显示最近5个任务
|
||||
order: [['startDate', 'DESC']],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -117,7 +141,10 @@ export class GoalsService {
|
||||
*/
|
||||
async getGoal(userId: string, goalId: string): Promise<Goal> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
// 惰性生成任务
|
||||
await this.goalTaskService.generateTasksLazily(userId, goalId);
|
||||
|
||||
const goal = await this.goalModel.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
@@ -128,6 +155,14 @@ export class GoalsService {
|
||||
order: [['completedAt', 'DESC']],
|
||||
limit: 10, // 只显示最近10次完成记录
|
||||
},
|
||||
{
|
||||
model: GoalTask,
|
||||
as: 'tasks',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
order: [['startDate', 'ASC']],
|
||||
limit: 20, // 显示最近20个任务
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -147,7 +182,7 @@ export class GoalsService {
|
||||
*/
|
||||
async updateGoal(userId: string, goalId: string, updateGoalDto: UpdateGoalDto): Promise<Goal> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
const goal = await this.goalModel.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
@@ -186,7 +221,7 @@ export class GoalsService {
|
||||
*/
|
||||
async deleteGoal(userId: string, goalId: string): Promise<boolean> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
const goal = await this.goalModel.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
@@ -198,7 +233,7 @@ export class GoalsService {
|
||||
await goal.update({ deleted: true });
|
||||
|
||||
// 软删除相关的完成记录
|
||||
await GoalCompletion.update(
|
||||
await this.goalCompletionModel.update(
|
||||
{ deleted: true },
|
||||
{ where: { goalId, userId } }
|
||||
);
|
||||
@@ -216,7 +251,7 @@ export class GoalsService {
|
||||
*/
|
||||
async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise<GoalCompletion> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
const goal = await this.goalModel.findOne({
|
||||
where: { id: createCompletionDto.goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
@@ -232,7 +267,7 @@ export class GoalsService {
|
||||
const completedAt = createCompletionDto.completedAt ? new Date(createCompletionDto.completedAt) : new Date();
|
||||
|
||||
// 创建完成记录
|
||||
const completion = await GoalCompletion.create({
|
||||
const completion = await this.goalCompletionModel.create({
|
||||
goalId: createCompletionDto.goalId,
|
||||
userId,
|
||||
completedAt,
|
||||
@@ -266,7 +301,7 @@ export class GoalsService {
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 验证目标存在
|
||||
const goal = await Goal.findOne({
|
||||
const goal = await this.goalModel.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
@@ -291,7 +326,7 @@ export class GoalsService {
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: completions, count } = await GoalCompletion.findAndCountAll({
|
||||
const { rows: completions, count } = await this.goalCompletionModel.findAndCountAll({
|
||||
where,
|
||||
order: [['completedAt', 'DESC']],
|
||||
offset,
|
||||
@@ -322,7 +357,7 @@ export class GoalsService {
|
||||
*/
|
||||
async getGoalStats(userId: string) {
|
||||
try {
|
||||
const goals = await Goal.findAll({
|
||||
const goals = await this.goalModel.findAll({
|
||||
where: { userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
|
||||
166
src/goals/models/goal-task.model.ts
Normal file
166
src/goals/models/goal-task.model.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { Column, DataType, Model, Table, ForeignKey, BelongsTo } from 'sequelize-typescript';
|
||||
import { Goal } from './goal.model';
|
||||
|
||||
export enum TaskStatus {
|
||||
PENDING = 'pending',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed',
|
||||
OVERDUE = 'overdue',
|
||||
SKIPPED = 'skipped'
|
||||
}
|
||||
|
||||
@Table({
|
||||
tableName: 't_goal_tasks',
|
||||
underscored: true,
|
||||
})
|
||||
export class GoalTask extends Model {
|
||||
@Column({
|
||||
type: DataType.CHAR(36),
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
@ForeignKey(() => Goal)
|
||||
@Column({
|
||||
type: DataType.CHAR(36),
|
||||
allowNull: false,
|
||||
comment: '目标ID',
|
||||
})
|
||||
declare goalId: string;
|
||||
|
||||
@BelongsTo(() => Goal)
|
||||
declare goal: Goal;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(255),
|
||||
allowNull: false,
|
||||
comment: '任务标题',
|
||||
})
|
||||
declare title: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '任务描述',
|
||||
})
|
||||
declare description: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: '任务开始日期',
|
||||
})
|
||||
declare startDate: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: '任务结束日期',
|
||||
})
|
||||
declare endDate: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '任务目标次数(如喝水8次)',
|
||||
})
|
||||
declare targetCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '任务当前完成次数',
|
||||
})
|
||||
declare currentCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('pending', 'in_progress', 'completed', 'overdue', 'skipped'),
|
||||
allowNull: false,
|
||||
defaultValue: TaskStatus.PENDING,
|
||||
comment: '任务状态',
|
||||
})
|
||||
declare status: TaskStatus;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '完成进度百分比 (0-100)',
|
||||
})
|
||||
declare progressPercentage: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: true,
|
||||
comment: '任务完成时间',
|
||||
})
|
||||
declare completedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '任务备注',
|
||||
})
|
||||
declare notes: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: true,
|
||||
comment: '任务额外数据',
|
||||
})
|
||||
declare metadata: any;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare createdAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare updatedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否删除',
|
||||
})
|
||||
declare deleted: boolean;
|
||||
|
||||
// 计算完成进度
|
||||
updateProgress(): void {
|
||||
if (this.targetCount > 0) {
|
||||
this.progressPercentage = Math.min(100, Math.round((this.currentCount / this.targetCount) * 100));
|
||||
|
||||
// 更新状态
|
||||
if (this.currentCount >= this.targetCount) {
|
||||
this.status = TaskStatus.COMPLETED;
|
||||
this.completedAt = new Date();
|
||||
} else if (this.currentCount > 0) {
|
||||
this.status = TaskStatus.IN_PROGRESS;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
checkOverdue(): void {
|
||||
const now = new Date();
|
||||
const endDate = new Date(this.endDate);
|
||||
|
||||
if (now > endDate && this.status !== TaskStatus.COMPLETED && this.status !== TaskStatus.SKIPPED) {
|
||||
this.status = TaskStatus.OVERDUE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Column, DataType, Model, Table, HasMany } from 'sequelize-typescript';
|
||||
import { GoalCompletion } from './goal-completion.model';
|
||||
import { GoalTask } from './goal-task.model';
|
||||
|
||||
export enum GoalRepeatType {
|
||||
DAILY = 'daily',
|
||||
@@ -73,7 +74,7 @@ export class Goal extends Model {
|
||||
|
||||
@Column({
|
||||
type: DataType.DATEONLY,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
comment: '目标开始日期',
|
||||
})
|
||||
declare startDate: Date;
|
||||
@@ -182,4 +183,7 @@ export class Goal extends Model {
|
||||
|
||||
@HasMany(() => GoalCompletion, 'goalId')
|
||||
declare completions: GoalCompletion[];
|
||||
|
||||
@HasMany(() => GoalTask, 'goalId')
|
||||
declare tasks: GoalTask[];
|
||||
}
|
||||
|
||||
581
src/goals/services/goal-task.service.ts
Normal file
581
src/goals/services/goal-task.service.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { Op, WhereOptions } from 'sequelize';
|
||||
import { Goal, GoalRepeatType, GoalStatus } from '../models/goal.model';
|
||||
import { GoalTask, TaskStatus } from '../models/goal-task.model';
|
||||
import { CreateGoalTaskDto, UpdateGoalTaskDto, GoalTaskQueryDto, CompleteGoalTaskDto } from '../dto/goal-task.dto';
|
||||
import * as dayjs from 'dayjs';
|
||||
import * as weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import * as isoWeek from 'dayjs/plugin/isoWeek';
|
||||
import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import * as isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(isoWeek);
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
@Injectable()
|
||||
export class GoalTaskService {
|
||||
private readonly logger = new Logger(GoalTaskService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(Goal)
|
||||
private readonly goalModel: typeof Goal,
|
||||
@InjectModel(GoalTask)
|
||||
private readonly goalTaskModel: typeof GoalTask,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 惰性生成任务 - 每次获取任务列表时调用
|
||||
*/
|
||||
async generateTasksLazily(userId: string, goalId?: string): Promise<void> {
|
||||
try {
|
||||
const where: WhereOptions = {
|
||||
userId,
|
||||
deleted: false,
|
||||
status: GoalStatus.ACTIVE,
|
||||
};
|
||||
|
||||
if (goalId) {
|
||||
where.id = goalId;
|
||||
}
|
||||
|
||||
const goals = await this.goalModel.findAll({ where });
|
||||
|
||||
for (const goal of goals) {
|
||||
await this.generateTasksForGoal(goal);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`惰性生成任务失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个目标生成任务
|
||||
*/
|
||||
private async generateTasksForGoal(goal: Goal): Promise<void> {
|
||||
const now = dayjs();
|
||||
const startDate = goal.startDate ? dayjs(goal.startDate) : now;
|
||||
const endDate = goal.endDate ? dayjs(goal.endDate) : now.add(1, 'year');
|
||||
|
||||
// 获取已存在的任务
|
||||
const existingTasks = await this.goalTaskModel.findAll({
|
||||
where: {
|
||||
goalId: goal.id,
|
||||
userId: goal.userId,
|
||||
deleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 根据重复类型生成任务
|
||||
switch (goal.repeatType) {
|
||||
case GoalRepeatType.DAILY:
|
||||
await this.generateDailyTasks(goal, startDate, endDate, existingTasks);
|
||||
break;
|
||||
case GoalRepeatType.WEEKLY:
|
||||
await this.generateWeeklyTasks(goal, startDate, endDate, existingTasks);
|
||||
break;
|
||||
case GoalRepeatType.MONTHLY:
|
||||
await this.generateMonthlyTasks(goal, startDate, endDate, existingTasks);
|
||||
break;
|
||||
case GoalRepeatType.CUSTOM:
|
||||
await this.generateCustomTasks(goal, startDate, endDate, existingTasks);
|
||||
break;
|
||||
}
|
||||
|
||||
// 更新过期任务状态
|
||||
await this.updateOverdueTasks(goal.id, goal.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成每日任务
|
||||
*/
|
||||
private async generateDailyTasks(
|
||||
goal: Goal,
|
||||
startDate: dayjs.Dayjs,
|
||||
endDate: dayjs.Dayjs,
|
||||
existingTasks: GoalTask[]
|
||||
): Promise<void> {
|
||||
const today = dayjs();
|
||||
const generateUntil = today.add(7, 'day'); // 提前生成7天的任务
|
||||
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
|
||||
|
||||
let current = startDate.isBefore(today) ? today : startDate;
|
||||
|
||||
while (current.isSameOrBefore(actualEndDate)) {
|
||||
const taskDate = current.format('YYYY-MM-DD');
|
||||
|
||||
// 检查是否已存在该日期的任务
|
||||
const existingTask = existingTasks.find(task =>
|
||||
dayjs(task.startDate).format('YYYY-MM-DD') === taskDate
|
||||
);
|
||||
|
||||
if (!existingTask) {
|
||||
const taskTitle = goal.title;
|
||||
|
||||
await this.goalTaskModel.create({
|
||||
goalId: goal.id,
|
||||
userId: goal.userId,
|
||||
title: taskTitle,
|
||||
description: `每日目标:完成${goal.frequency}次`,
|
||||
startDate: current.toDate(),
|
||||
endDate: current.toDate(),
|
||||
targetCount: goal.frequency,
|
||||
currentCount: 0,
|
||||
status: TaskStatus.PENDING,
|
||||
});
|
||||
|
||||
this.logger.log(`为目标 ${goal.title} 生成每日任务: ${taskTitle}`);
|
||||
}
|
||||
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成每周任务
|
||||
*/
|
||||
private async generateWeeklyTasks(
|
||||
goal: Goal,
|
||||
startDate: dayjs.Dayjs,
|
||||
endDate: dayjs.Dayjs,
|
||||
existingTasks: GoalTask[]
|
||||
): Promise<void> {
|
||||
const today = dayjs();
|
||||
const generateUntil = today.add(4, 'week'); // 提前生成4周的任务
|
||||
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
|
||||
|
||||
let current = startDate.startOf('isoWeek');
|
||||
|
||||
while (current.isSameOrBefore(actualEndDate)) {
|
||||
const weekStart = current.startOf('isoWeek');
|
||||
const weekEnd = current.endOf('isoWeek');
|
||||
|
||||
// 检查是否已存在该周的任务
|
||||
const existingTask = existingTasks.find(task => {
|
||||
const taskWeekStart = dayjs(task.startDate).startOf('isoWeek');
|
||||
return taskWeekStart.isSame(weekStart);
|
||||
});
|
||||
|
||||
if (!existingTask && weekStart.isSameOrAfter(startDate)) {
|
||||
const taskTitle = `${goal.title} - 第${current.isoWeek()}周 (${weekStart.format('MM-DD')} 至 ${weekEnd.format('MM-DD')})`;
|
||||
|
||||
await this.goalTaskModel.create({
|
||||
goalId: goal.id,
|
||||
userId: goal.userId,
|
||||
title: taskTitle,
|
||||
description: `每周目标:完成${goal.frequency}次`,
|
||||
startDate: weekStart.toDate(),
|
||||
endDate: weekEnd.toDate(),
|
||||
targetCount: goal.frequency,
|
||||
currentCount: 0,
|
||||
status: TaskStatus.PENDING,
|
||||
});
|
||||
|
||||
this.logger.log(`为目标 ${goal.title} 生成每周任务: ${taskTitle}`);
|
||||
}
|
||||
|
||||
current = current.add(1, 'week');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成每月任务
|
||||
*/
|
||||
private async generateMonthlyTasks(
|
||||
goal: Goal,
|
||||
startDate: dayjs.Dayjs,
|
||||
endDate: dayjs.Dayjs,
|
||||
existingTasks: GoalTask[]
|
||||
): Promise<void> {
|
||||
const today = dayjs();
|
||||
const generateUntil = today.add(3, 'month'); // 提前生成3个月的任务
|
||||
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
|
||||
|
||||
let current = startDate.startOf('month');
|
||||
|
||||
while (current.isSameOrBefore(actualEndDate)) {
|
||||
const monthStart = current.startOf('month');
|
||||
const monthEnd = current.endOf('month');
|
||||
|
||||
// 检查是否已存在该月的任务
|
||||
const existingTask = existingTasks.find(task => {
|
||||
const taskMonthStart = dayjs(task.startDate).startOf('month');
|
||||
return taskMonthStart.isSame(monthStart);
|
||||
});
|
||||
|
||||
if (!existingTask && monthStart.isSameOrAfter(startDate)) {
|
||||
const taskTitle = `${goal.title} - ${current.format('YYYY年MM月')}`;
|
||||
|
||||
await this.goalTaskModel.create({
|
||||
goalId: goal.id,
|
||||
userId: goal.userId,
|
||||
title: taskTitle,
|
||||
description: `每月目标:完成${goal.frequency}次`,
|
||||
startDate: monthStart.toDate(),
|
||||
endDate: monthEnd.toDate(),
|
||||
targetCount: goal.frequency,
|
||||
currentCount: 0,
|
||||
status: TaskStatus.PENDING,
|
||||
});
|
||||
|
||||
this.logger.log(`为目标 ${goal.title} 生成每月任务: ${taskTitle}`);
|
||||
}
|
||||
|
||||
current = current.add(1, 'month');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成自定义周期任务
|
||||
*/
|
||||
private async generateCustomTasks(
|
||||
goal: Goal,
|
||||
startDate: dayjs.Dayjs,
|
||||
endDate: dayjs.Dayjs,
|
||||
existingTasks: GoalTask[]
|
||||
): Promise<void> {
|
||||
if (!goal.customRepeatRule) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { weekdays } = goal.customRepeatRule;
|
||||
|
||||
if (weekdays && weekdays.length > 0) {
|
||||
// 按指定星期几生成任务
|
||||
const today = dayjs();
|
||||
const generateUntil = today.add(7, 'day');
|
||||
const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil;
|
||||
|
||||
let current = startDate;
|
||||
|
||||
while (current.isSameOrBefore(actualEndDate)) {
|
||||
const dayOfWeek = current.day(); // 0=周日, 6=周六
|
||||
|
||||
if (weekdays.includes(dayOfWeek)) {
|
||||
const taskDate = current.format('YYYY-MM-DD');
|
||||
|
||||
const existingTask = existingTasks.find(task =>
|
||||
dayjs(task.startDate).format('YYYY-MM-DD') === taskDate
|
||||
);
|
||||
|
||||
if (!existingTask) {
|
||||
const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||
const taskTitle = `${goal.title} - ${current.format('YYYY年MM月DD日')} ${weekDayNames[dayOfWeek]}`;
|
||||
|
||||
await this.goalTaskModel.create({
|
||||
goalId: goal.id,
|
||||
userId: goal.userId,
|
||||
title: taskTitle,
|
||||
description: `自定义目标:完成${goal.frequency}次`,
|
||||
startDate: current.toDate(),
|
||||
endDate: current.toDate(),
|
||||
targetCount: goal.frequency,
|
||||
currentCount: 0,
|
||||
status: TaskStatus.PENDING,
|
||||
});
|
||||
|
||||
this.logger.log(`为目标 ${goal.title} 生成自定义任务: ${taskTitle}`);
|
||||
}
|
||||
}
|
||||
|
||||
current = current.add(1, 'day');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新过期任务状态
|
||||
*/
|
||||
private async updateOverdueTasks(goalId: string, userId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
|
||||
await this.goalTaskModel.update(
|
||||
{ status: TaskStatus.OVERDUE },
|
||||
{
|
||||
where: {
|
||||
goalId,
|
||||
userId,
|
||||
deleted: false,
|
||||
endDate: { [Op.lt]: now },
|
||||
status: { [Op.notIn]: [TaskStatus.COMPLETED, TaskStatus.SKIPPED, TaskStatus.OVERDUE] },
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务列表
|
||||
*/
|
||||
async getTasks(userId: string, query: GoalTaskQueryDto) {
|
||||
try {
|
||||
// 先进行惰性生成
|
||||
await this.generateTasksLazily(userId, query.goalId);
|
||||
|
||||
const { page = 1, pageSize = 20, goalId, status, startDate, endDate } = query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const where: WhereOptions = {
|
||||
userId,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
if (goalId) {
|
||||
where.goalId = goalId;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.startDate = {};
|
||||
if (startDate) {
|
||||
where.startDate[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.startDate[Op.lte] = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: tasks, count } = await this.goalTaskModel.findAndCountAll({
|
||||
where,
|
||||
order: [['startDate', 'ASC'], ['createdAt', 'DESC']],
|
||||
offset,
|
||||
limit: pageSize,
|
||||
include: [
|
||||
{
|
||||
model: Goal,
|
||||
as: 'goal',
|
||||
attributes: ['id', 'title', 'repeatType', 'frequency', 'category'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: count,
|
||||
list: tasks.map(task => this.formatTaskResponse(task)),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取任务列表失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个任务详情
|
||||
*/
|
||||
async getTask(userId: string, taskId: string): Promise<GoalTask> {
|
||||
try {
|
||||
const task = await this.goalTaskModel.findOne({
|
||||
where: { id: taskId, userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
model: Goal,
|
||||
as: 'goal',
|
||||
attributes: ['id', 'title', 'repeatType', 'frequency', 'category'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('任务不存在');
|
||||
}
|
||||
|
||||
return this.formatTaskResponse(task);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取任务详情失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务
|
||||
*/
|
||||
async completeTask(userId: string, taskId: string, completeDto: CompleteGoalTaskDto): Promise<GoalTask> {
|
||||
try {
|
||||
const task = await this.goalTaskModel.findOne({
|
||||
where: { id: taskId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('任务不存在');
|
||||
}
|
||||
|
||||
if (task.status === TaskStatus.COMPLETED) {
|
||||
throw new BadRequestException('任务已完成');
|
||||
}
|
||||
|
||||
const { count = 1, notes, completedAt } = completeDto;
|
||||
|
||||
// 更新完成次数
|
||||
task.currentCount = Math.min(task.currentCount + count, task.targetCount);
|
||||
task.notes = notes || task.notes;
|
||||
|
||||
if (completedAt) {
|
||||
task.completedAt = new Date(completedAt);
|
||||
}
|
||||
|
||||
// 更新进度和状态
|
||||
task.updateProgress();
|
||||
|
||||
await task.save();
|
||||
|
||||
this.logger.log(`用户 ${userId} 完成任务: ${task.title}, 当前进度: ${task.currentCount}/${task.targetCount}`);
|
||||
|
||||
return this.formatTaskResponse(task);
|
||||
} catch (error) {
|
||||
this.logger.error(`完成任务失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务
|
||||
*/
|
||||
async updateTask(userId: string, taskId: string, updateDto: UpdateGoalTaskDto): Promise<GoalTask> {
|
||||
try {
|
||||
const task = await this.goalTaskModel.findOne({
|
||||
where: { id: taskId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('任务不存在');
|
||||
}
|
||||
|
||||
await task.update({
|
||||
...updateDto,
|
||||
startDate: updateDto.startDate ? new Date(updateDto.startDate) : task.startDate,
|
||||
endDate: updateDto.endDate ? new Date(updateDto.endDate) : task.endDate,
|
||||
});
|
||||
|
||||
// 如果更新了目标次数,重新计算进度
|
||||
if (updateDto.targetCount !== undefined) {
|
||||
task.updateProgress();
|
||||
await task.save();
|
||||
}
|
||||
|
||||
this.logger.log(`用户 ${userId} 更新任务: ${task.title}`);
|
||||
|
||||
return this.formatTaskResponse(task);
|
||||
} catch (error) {
|
||||
this.logger.error(`更新任务失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳过任务
|
||||
*/
|
||||
async skipTask(userId: string, taskId: string, reason?: string): Promise<GoalTask> {
|
||||
try {
|
||||
const task = await this.goalTaskModel.findOne({
|
||||
where: { id: taskId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('任务不存在');
|
||||
}
|
||||
|
||||
await task.update({
|
||||
status: TaskStatus.SKIPPED,
|
||||
notes: reason || '用户主动跳过',
|
||||
});
|
||||
|
||||
this.logger.log(`用户 ${userId} 跳过任务: ${task.title}`);
|
||||
|
||||
return this.formatTaskResponse(task);
|
||||
} catch (error) {
|
||||
this.logger.error(`跳过任务失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务统计
|
||||
*/
|
||||
async getTaskStats(userId: string, goalId?: string) {
|
||||
try {
|
||||
const where: WhereOptions = {
|
||||
userId,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
if (goalId) {
|
||||
where.goalId = goalId;
|
||||
}
|
||||
|
||||
const tasks = await this.goalTaskModel.findAll({ where });
|
||||
|
||||
const now = dayjs();
|
||||
const todayStart = now.startOf('day');
|
||||
const weekStart = now.startOf('isoWeek');
|
||||
const monthStart = now.startOf('month');
|
||||
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
pending: tasks.filter(t => t.status === TaskStatus.PENDING).length,
|
||||
inProgress: tasks.filter(t => t.status === TaskStatus.IN_PROGRESS).length,
|
||||
completed: tasks.filter(t => t.status === TaskStatus.COMPLETED).length,
|
||||
overdue: tasks.filter(t => t.status === TaskStatus.OVERDUE).length,
|
||||
skipped: tasks.filter(t => t.status === TaskStatus.SKIPPED).length,
|
||||
totalProgress: 0,
|
||||
todayTasks: 0,
|
||||
weekTasks: 0,
|
||||
monthTasks: 0,
|
||||
};
|
||||
|
||||
// 计算总体进度
|
||||
if (tasks.length > 0) {
|
||||
const totalProgress = tasks.reduce((sum, task) => sum + task.progressPercentage, 0);
|
||||
stats.totalProgress = Math.round(totalProgress / tasks.length);
|
||||
}
|
||||
|
||||
// 统计时间范围内的任务
|
||||
tasks.forEach(task => {
|
||||
const taskDate = dayjs(task.startDate);
|
||||
|
||||
if (taskDate.isSame(todayStart, 'day')) {
|
||||
stats.todayTasks++;
|
||||
}
|
||||
|
||||
if (taskDate.isSameOrAfter(weekStart)) {
|
||||
stats.weekTasks++;
|
||||
}
|
||||
|
||||
if (taskDate.isSameOrAfter(monthStart)) {
|
||||
stats.monthTasks++;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(`获取任务统计失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化任务响应
|
||||
*/
|
||||
private formatTaskResponse(task: GoalTask) {
|
||||
const taskData = task.toJSON();
|
||||
|
||||
// 检查是否过期
|
||||
task.checkOverdue();
|
||||
|
||||
// 计算剩余天数
|
||||
const endDate = dayjs(taskData.endDate);
|
||||
const now = dayjs();
|
||||
taskData.daysRemaining = Math.max(0, endDate.diff(now, 'day'));
|
||||
|
||||
// 计算是否为今日任务
|
||||
taskData.isToday = dayjs(taskData.startDate).isSame(now, 'day');
|
||||
|
||||
return taskData;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user