feat: 新增目标管理功能模块
实现目标管理的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。支持用户创建、管理和跟踪个人目标,提供增删改查操作及目标完成记录功能。
This commit is contained in:
@@ -14,6 +14,7 @@ import { ActivityLogsModule } from './activity-logs/activity-logs.module';
|
||||
import { ExercisesModule } from './exercises/exercises.module';
|
||||
import { WorkoutsModule } from './workouts/workouts.module';
|
||||
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
|
||||
import { GoalsModule } from './goals/goals.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,6 +34,7 @@ import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
|
||||
ExercisesModule,
|
||||
WorkoutsModule,
|
||||
MoodCheckinsModule,
|
||||
GoalsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
95
src/goals/dto/create-goal.dto.ts
Normal file
95
src/goals/dto/create-goal.dto.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsInt, IsDateString, IsBoolean, Min, Max, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { GoalRepeatType, GoalStatus } from '../models/goal.model';
|
||||
|
||||
export class CustomRepeatRuleDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
weekdays?: number[]; // 0-6 表示周日到周六
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(31)
|
||||
dayOfMonth?: number; // 每月第几天
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
monthOfYear?: number; // 每年第几月
|
||||
}
|
||||
|
||||
export class ReminderSettingsDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
weekdays?: number[]; // 提醒的星期几
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class CreateGoalDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '目标标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(GoalRepeatType, { message: '重复周期类型无效' })
|
||||
repeatType: GoalRepeatType;
|
||||
|
||||
@IsInt()
|
||||
@Min(1, { message: '频率必须大于0' })
|
||||
@Max(100, { message: '频率不能超过100' })
|
||||
frequency: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CustomRepeatRuleDto)
|
||||
customRepeatRule?: CustomRepeatRuleDto;
|
||||
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '目标总次数必须大于0' })
|
||||
targetCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0, { message: '优先级不能小于0' })
|
||||
@Max(10, { message: '优先级不能超过10' })
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasReminder?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminderTime?: string; // HH:mm 格式
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ReminderSettingsDto)
|
||||
reminderSettings?: ReminderSettingsDto;
|
||||
}
|
||||
33
src/goals/dto/goal-completion.dto.ts
Normal file
33
src/goals/dto/goal-completion.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsOptional, IsInt, IsDateString, Min, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateGoalCompletionDto {
|
||||
@IsUUID()
|
||||
goalId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '完成日期格式无效' })
|
||||
completedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '完成次数必须大于0' })
|
||||
completionCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class GoalCompletionQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate?: string;
|
||||
}
|
||||
50
src/goals/dto/goal-query.dto.ts
Normal file
50
src/goals/dto/goal-query.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { GoalStatus, GoalRepeatType } from '../models/goal.model';
|
||||
|
||||
export class GoalQueryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
pageSize?: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GoalStatus)
|
||||
status?: GoalStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GoalRepeatType)
|
||||
repeatType?: GoalRepeatType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string; // 搜索标题和描述
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startDate?: string; // 开始日期范围
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endDate?: string; // 结束日期范围
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'priority' | 'title' | 'startDate' = 'createdAt';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||
}
|
||||
10
src/goals/dto/update-goal.dto.ts
Normal file
10
src/goals/dto/update-goal.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateGoalDto } from './create-goal.dto';
|
||||
import { IsOptional, IsEnum } from 'class-validator';
|
||||
import { GoalStatus } from '../models/goal.model';
|
||||
|
||||
export class UpdateGoalDto extends PartialType(CreateGoalDto) {
|
||||
@IsOptional()
|
||||
@IsEnum(GoalStatus, { message: '目标状态无效' })
|
||||
status?: GoalStatus;
|
||||
}
|
||||
202
src/goals/goals.controller.ts
Normal file
202
src/goals/goals.controller.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { GoalsService } from './goals.service';
|
||||
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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
||||
|
||||
@Controller('goals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GoalsController {
|
||||
constructor(private readonly goalsService: GoalsService) {}
|
||||
|
||||
/**
|
||||
* 创建目标
|
||||
*/
|
||||
@Post()
|
||||
async createGoal(
|
||||
@Request() req,
|
||||
@Body() createGoalDto: CreateGoalDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const goal = await this.goalsService.createGoal(req.user.id, createGoalDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标创建成功',
|
||||
data: goal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标列表
|
||||
*/
|
||||
@Get()
|
||||
async getGoals(
|
||||
@Request() req,
|
||||
@Query() query: GoalQueryDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const result = await this.goalsService.getGoals(req.user.id, query);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标列表成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个目标详情
|
||||
*/
|
||||
@Get(':id')
|
||||
async getGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const goal = await this.goalsService.getGoal(req.user.id, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标详情成功',
|
||||
data: goal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新目标
|
||||
*/
|
||||
@Put(':id')
|
||||
async updateGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
@Body() updateGoalDto: UpdateGoalDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const goal = await this.goalsService.updateGoal(req.user.id, id, updateGoalDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标更新成功',
|
||||
data: goal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目标
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
): Promise<BaseResponseDto<boolean>> {
|
||||
const result = await this.goalsService.deleteGoal(req.user.id, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标删除成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录目标完成
|
||||
*/
|
||||
@Post(':id/complete')
|
||||
async completeGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
@Body() createCompletionDto: CreateGoalCompletionDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
// 确保完成记录的目标ID与路径参数一致
|
||||
createCompletionDto.goalId = id;
|
||||
const completion = await this.goalsService.completeGoal(req.user.id, createCompletionDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标完成记录成功',
|
||||
data: completion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标完成记录
|
||||
*/
|
||||
@Get(':id/completions')
|
||||
async getGoalCompletions(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
@Query() query: any,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const result = await this.goalsService.getGoalCompletions(req.user.id, id, query);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标完成记录成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标统计信息
|
||||
*/
|
||||
@Get('stats/overview')
|
||||
async getGoalStats(@Request() req): Promise<BaseResponseDto<any>> {
|
||||
const stats = await this.goalsService.getGoalStats(req.user.id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标统计成功',
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作目标
|
||||
*/
|
||||
@Post('batch')
|
||||
async batchUpdateGoals(
|
||||
@Request() req,
|
||||
@Body() body: {
|
||||
goalIds: string[];
|
||||
action: 'pause' | 'resume' | 'complete' | 'delete';
|
||||
data?: any;
|
||||
},
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const { goalIds, action, data } = body;
|
||||
const results = [];
|
||||
|
||||
for (const goalId of goalIds) {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
await this.goalsService.updateGoal(req.user.id, goalId, { status: 'paused' });
|
||||
break;
|
||||
case 'resume':
|
||||
await this.goalsService.updateGoal(req.user.id, goalId, { status: 'active' });
|
||||
break;
|
||||
case 'complete':
|
||||
await this.goalsService.updateGoal(req.user.id, goalId, { status: 'completed' });
|
||||
break;
|
||||
case 'delete':
|
||||
await this.goalsService.deleteGoal(req.user.id, goalId);
|
||||
break;
|
||||
}
|
||||
results.push({ goalId, success: true });
|
||||
} catch (error) {
|
||||
results.push({ goalId, success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '批量操作完成',
|
||||
data: results,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/goals/goals.module.ts
Normal file
16
src/goals/goals.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { GoalsController } from './goals.controller';
|
||||
import { GoalsService } from './goals.service';
|
||||
import { Goal } from './models/goal.model';
|
||||
import { GoalCompletion } from './models/goal-completion.model';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SequelizeModule.forFeature([Goal, GoalCompletion]),
|
||||
],
|
||||
controllers: [GoalsController],
|
||||
providers: [GoalsService],
|
||||
exports: [GoalsService],
|
||||
})
|
||||
export class GoalsModule {}
|
||||
412
src/goals/goals.service.ts
Normal file
412
src/goals/goals.service.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Op, WhereOptions, Order } from 'sequelize';
|
||||
import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model';
|
||||
import { GoalCompletion } from './models/goal-completion.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 * as dayjs from 'dayjs';
|
||||
|
||||
@Injectable()
|
||||
export class GoalsService {
|
||||
private readonly logger = new Logger(GoalsService.name);
|
||||
|
||||
/**
|
||||
* 创建目标
|
||||
*/
|
||||
async createGoal(userId: string, createGoalDto: CreateGoalDto): Promise<Goal> {
|
||||
try {
|
||||
// 验证自定义重复规则
|
||||
if (createGoalDto.repeatType === GoalRepeatType.CUSTOM && !createGoalDto.customRepeatRule) {
|
||||
throw new BadRequestException('自定义重复类型必须提供自定义重复规则');
|
||||
}
|
||||
|
||||
// 验证日期逻辑
|
||||
if (createGoalDto.endDate && dayjs(createGoalDto.endDate).isBefore(createGoalDto.startDate)) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
|
||||
const goal = await Goal.create({
|
||||
userId,
|
||||
...createGoalDto,
|
||||
startDate: new Date(createGoalDto.startDate),
|
||||
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : null,
|
||||
});
|
||||
|
||||
this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`);
|
||||
return goal;
|
||||
} catch (error) {
|
||||
this.logger.error(`创建目标失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的目标列表
|
||||
*/
|
||||
async getGoals(userId: string, query: GoalQueryDto) {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, status, repeatType, category, search, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 构建查询条件
|
||||
const where: WhereOptions = {
|
||||
userId,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (repeatType) {
|
||||
where.repeatType = repeatType;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where[Op.or] = [
|
||||
{ title: { [Op.like]: `%${search}%` } },
|
||||
{ description: { [Op.like]: `%${search}%` } },
|
||||
];
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.startDate = {};
|
||||
if (startDate) {
|
||||
where.startDate[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.startDate[Op.lte] = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建排序条件
|
||||
const order: Order = [[sortBy, sortOrder.toUpperCase()]];
|
||||
|
||||
const { rows: goals, count } = await Goal.findAndCountAll({
|
||||
where,
|
||||
order,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
include: [
|
||||
{
|
||||
model: GoalCompletion,
|
||||
as: 'completions',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: count,
|
||||
items: goals.map(goal => this.formatGoalResponse(goal)),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标列表失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个目标详情
|
||||
*/
|
||||
async getGoal(userId: string, goalId: string): Promise<Goal> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
model: GoalCompletion,
|
||||
as: 'completions',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
order: [['completedAt', 'DESC']],
|
||||
limit: 10, // 只显示最近10次完成记录
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
return this.formatGoalResponse(goal);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标详情失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新目标
|
||||
*/
|
||||
async updateGoal(userId: string, goalId: string, updateGoalDto: UpdateGoalDto): Promise<Goal> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
// 验证日期逻辑
|
||||
if (updateGoalDto.endDate && updateGoalDto.startDate) {
|
||||
if (dayjs(updateGoalDto.endDate).isBefore(updateGoalDto.startDate)) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果目标已完成,不允许修改
|
||||
if (goal.status === GoalStatus.COMPLETED && updateGoalDto.status !== GoalStatus.COMPLETED) {
|
||||
throw new BadRequestException('已完成的目标不能修改状态');
|
||||
}
|
||||
|
||||
await goal.update({
|
||||
...updateGoalDto,
|
||||
startDate: updateGoalDto.startDate ? new Date(updateGoalDto.startDate) : goal.startDate,
|
||||
endDate: updateGoalDto.endDate ? new Date(updateGoalDto.endDate) : goal.endDate,
|
||||
});
|
||||
|
||||
this.logger.log(`用户 ${userId} 更新了目标: ${goal.title}`);
|
||||
return this.formatGoalResponse(goal);
|
||||
} catch (error) {
|
||||
this.logger.error(`更新目标失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目标
|
||||
*/
|
||||
async deleteGoal(userId: string, goalId: string): Promise<boolean> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
// 软删除目标
|
||||
await goal.update({ deleted: true });
|
||||
|
||||
// 软删除相关的完成记录
|
||||
await GoalCompletion.update(
|
||||
{ deleted: true },
|
||||
{ where: { goalId, userId } }
|
||||
);
|
||||
|
||||
this.logger.log(`用户 ${userId} 删除了目标: ${goal.title}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`删除目标失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录目标完成
|
||||
*/
|
||||
async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise<GoalCompletion> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: createCompletionDto.goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
if (goal.status !== GoalStatus.ACTIVE) {
|
||||
throw new BadRequestException('只有激活状态的目标才能记录完成');
|
||||
}
|
||||
|
||||
const completionCount = createCompletionDto.completionCount || 1;
|
||||
const completedAt = createCompletionDto.completedAt ? new Date(createCompletionDto.completedAt) : new Date();
|
||||
|
||||
// 创建完成记录
|
||||
const completion = await GoalCompletion.create({
|
||||
goalId: createCompletionDto.goalId,
|
||||
userId,
|
||||
completedAt,
|
||||
completionCount,
|
||||
notes: createCompletionDto.notes,
|
||||
});
|
||||
|
||||
// 更新目标的完成次数
|
||||
const newCompletedCount = goal.completedCount + completionCount;
|
||||
await goal.update({ completedCount: newCompletedCount });
|
||||
|
||||
// 检查是否达到目标总次数
|
||||
if (goal.targetCount && newCompletedCount >= goal.targetCount) {
|
||||
await goal.update({ status: GoalStatus.COMPLETED });
|
||||
}
|
||||
|
||||
this.logger.log(`用户 ${userId} 完成了目标: ${goal.title}`);
|
||||
return completion;
|
||||
} catch (error) {
|
||||
this.logger.error(`记录目标完成失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标完成记录
|
||||
*/
|
||||
async getGoalCompletions(userId: string, goalId: string, query: any = {}) {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, startDate, endDate } = query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 验证目标存在
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const where: WhereOptions = {
|
||||
goalId,
|
||||
userId,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.completedAt = {};
|
||||
if (startDate) {
|
||||
where.completedAt[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.completedAt[Op.lte] = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: completions, count } = await GoalCompletion.findAndCountAll({
|
||||
where,
|
||||
order: [['completedAt', 'DESC']],
|
||||
offset,
|
||||
limit: pageSize,
|
||||
include: [
|
||||
{
|
||||
model: Goal,
|
||||
as: 'goal',
|
||||
attributes: ['id', 'title', 'repeatType', 'frequency'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: count,
|
||||
items: completions,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标完成记录失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标统计信息
|
||||
*/
|
||||
async getGoalStats(userId: string) {
|
||||
try {
|
||||
const goals = await Goal.findAll({
|
||||
where: { userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
model: GoalCompletion,
|
||||
as: 'completions',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: goals.length,
|
||||
active: goals.filter(g => g.status === GoalStatus.ACTIVE).length,
|
||||
completed: goals.filter(g => g.status === GoalStatus.COMPLETED).length,
|
||||
paused: goals.filter(g => g.status === GoalStatus.PAUSED).length,
|
||||
cancelled: goals.filter(g => g.status === GoalStatus.CANCELLED).length,
|
||||
byCategory: {},
|
||||
byRepeatType: {},
|
||||
totalCompletions: 0,
|
||||
thisWeekCompletions: 0,
|
||||
thisMonthCompletions: 0,
|
||||
};
|
||||
|
||||
const now = dayjs();
|
||||
const weekStart = now.startOf('week');
|
||||
const monthStart = now.startOf('month');
|
||||
|
||||
goals.forEach(goal => {
|
||||
// 按分类统计
|
||||
if (goal.category) {
|
||||
stats.byCategory[goal.category] = (stats.byCategory[goal.category] || 0) + 1;
|
||||
}
|
||||
|
||||
// 按重复类型统计
|
||||
stats.byRepeatType[goal.repeatType] = (stats.byRepeatType[goal.repeatType] || 0) + 1;
|
||||
|
||||
// 统计完成次数
|
||||
stats.totalCompletions += goal.completedCount;
|
||||
|
||||
// 统计本周和本月的完成次数
|
||||
goal.completions?.forEach(completion => {
|
||||
const completionDate = dayjs(completion.completedAt);
|
||||
if (completionDate.isAfter(weekStart)) {
|
||||
stats.thisWeekCompletions += completion.completionCount;
|
||||
}
|
||||
if (completionDate.isAfter(monthStart)) {
|
||||
stats.thisMonthCompletions += completion.completionCount;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标统计失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化目标响应
|
||||
*/
|
||||
private formatGoalResponse(goal: Goal) {
|
||||
const goalData = goal.toJSON();
|
||||
|
||||
// 计算进度百分比
|
||||
if (goalData.targetCount) {
|
||||
goalData.progressPercentage = Math.min(100, Math.round((goalData.completedCount / goalData.targetCount) * 100));
|
||||
} else {
|
||||
goalData.progressPercentage = 0;
|
||||
}
|
||||
|
||||
// 计算剩余天数
|
||||
if (goalData.endDate) {
|
||||
const endDate = dayjs(goalData.endDate);
|
||||
const now = dayjs();
|
||||
goalData.daysRemaining = Math.max(0, endDate.diff(now, 'day'));
|
||||
} else {
|
||||
goalData.daysRemaining = null;
|
||||
}
|
||||
|
||||
return goalData;
|
||||
}
|
||||
}
|
||||
83
src/goals/models/goal-completion.model.ts
Normal file
83
src/goals/models/goal-completion.model.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Column, DataType, Model, Table, Index, ForeignKey, BelongsTo } from 'sequelize-typescript';
|
||||
import { Goal } from './goal.model';
|
||||
|
||||
@Table({
|
||||
tableName: 't_goal_completions',
|
||||
underscored: true,
|
||||
})
|
||||
export class GoalCompletion extends Model {
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
@Index
|
||||
@ForeignKey(() => Goal)
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
allowNull: false,
|
||||
comment: '目标ID',
|
||||
})
|
||||
declare goalId: string;
|
||||
|
||||
@BelongsTo(() => Goal)
|
||||
declare goal: Goal;
|
||||
|
||||
@Index
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '完成日期',
|
||||
})
|
||||
declare completedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '完成次数',
|
||||
})
|
||||
declare completionCount: number;
|
||||
|
||||
@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;
|
||||
}
|
||||
170
src/goals/models/goal.model.ts
Normal file
170
src/goals/models/goal.model.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Column, DataType, Model, Table, Index, HasMany } from 'sequelize-typescript';
|
||||
import { GoalCompletion } from './goal-completion.model';
|
||||
|
||||
export enum GoalRepeatType {
|
||||
DAILY = 'daily',
|
||||
WEEKLY = 'weekly',
|
||||
MONTHLY = 'monthly',
|
||||
CUSTOM = 'custom'
|
||||
}
|
||||
|
||||
export enum GoalStatus {
|
||||
ACTIVE = 'active',
|
||||
PAUSED = 'paused',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
@Table({
|
||||
tableName: 't_goals',
|
||||
underscored: true,
|
||||
})
|
||||
export class Goal extends Model {
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
@Index
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '目标标题',
|
||||
})
|
||||
declare title: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '目标描述',
|
||||
})
|
||||
declare description: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('daily', 'weekly', 'monthly', 'custom'),
|
||||
allowNull: false,
|
||||
defaultValue: GoalRepeatType.DAILY,
|
||||
comment: '重复周期类型',
|
||||
})
|
||||
declare repeatType: GoalRepeatType;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '频率(每天/每周/每月多少次)',
|
||||
})
|
||||
declare frequency: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: true,
|
||||
comment: '自定义重复规则(如每周几)',
|
||||
})
|
||||
declare customRepeatRule: any;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '目标开始日期',
|
||||
})
|
||||
declare startDate: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: true,
|
||||
comment: '目标结束日期',
|
||||
})
|
||||
declare endDate: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('active', 'paused', 'completed', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: GoalStatus.ACTIVE,
|
||||
comment: '目标状态',
|
||||
})
|
||||
declare status: GoalStatus;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '已完成次数',
|
||||
})
|
||||
declare completedCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '目标总次数(null表示无限制)',
|
||||
})
|
||||
declare targetCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true,
|
||||
comment: '目标分类标签',
|
||||
})
|
||||
declare category: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '优先级(数字越大优先级越高)',
|
||||
})
|
||||
declare priority: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: '是否提醒',
|
||||
})
|
||||
declare hasReminder: boolean;
|
||||
|
||||
@Column({
|
||||
type: DataType.TIME,
|
||||
allowNull: true,
|
||||
comment: '提醒时间',
|
||||
})
|
||||
declare reminderTime: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: true,
|
||||
comment: '提醒设置(如每周几提醒)',
|
||||
})
|
||||
declare reminderSettings: 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;
|
||||
|
||||
@HasMany(() => GoalCompletion, 'goalId')
|
||||
declare completions: GoalCompletion[];
|
||||
}
|
||||
Reference in New Issue
Block a user