feat: 新增目标管理功能模块
实现目标管理的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。支持用户创建、管理和跟踪个人目标,提供增删改查操作及目标完成记录功能。
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user