feat: 新增目标管理功能模块

实现目标管理的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。支持用户创建、管理和跟踪个人目标,提供增删改查操作及目标完成记录功能。
This commit is contained in:
2025-08-21 22:50:30 +08:00
parent f26d8e64c6
commit 270b59c599
12 changed files with 1555 additions and 0 deletions

399
docs/goals-api-guide.md Normal file
View File

@@ -0,0 +1,399 @@
# 目标管理 API 文档
## 概述
目标管理功能允许用户创建、管理和跟踪个人目标。每个目标包含标题、重复周期、频率等属性,支持完整的增删改查操作。
## 数据模型
### 目标 (Goal)
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| id | UUID | 是 | 目标唯一标识 |
| userId | String | 是 | 用户ID |
| title | String | 是 | 目标标题 |
| description | Text | 否 | 目标描述 |
| repeatType | Enum | 是 | 重复周期类型daily/weekly/monthly/custom |
| frequency | Integer | 是 | 频率(每天/每周/每月多少次) |
| customRepeatRule | JSON | 否 | 自定义重复规则 |
| startDate | Date | 是 | 目标开始日期 |
| endDate | Date | 否 | 目标结束日期 |
| status | Enum | 是 | 目标状态active/paused/completed/cancelled |
| completedCount | Integer | 是 | 已完成次数 |
| targetCount | Integer | 否 | 目标总次数 |
| category | String | 否 | 目标分类标签 |
| priority | Integer | 是 | 优先级0-10 |
| hasReminder | Boolean | 是 | 是否提醒 |
| reminderTime | Time | 否 | 提醒时间 |
| reminderSettings | JSON | 否 | 提醒设置 |
### 目标完成记录 (GoalCompletion)
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| id | UUID | 是 | 完成记录唯一标识 |
| goalId | UUID | 是 | 目标ID |
| userId | String | 是 | 用户ID |
| completedAt | DateTime | 是 | 完成日期 |
| completionCount | Integer | 是 | 完成次数 |
| notes | Text | 否 | 完成备注 |
| metadata | JSON | 否 | 额外数据 |
## API 接口
### 1. 创建目标
**POST** `/goals`
**请求体:**
```json
{
"title": "每天跑步30分钟",
"description": "提高心肺功能,增强体质",
"repeatType": "daily",
"frequency": 1,
"startDate": "2024-01-01",
"endDate": "2024-12-31",
"targetCount": 365,
"category": "运动",
"priority": 5,
"hasReminder": true,
"reminderTime": "07:00",
"reminderSettings": {
"weekdays": [1, 2, 3, 4, 5, 6, 0],
"enabled": true
}
}
```
**响应:**
```json
{
"code": 0,
"message": "目标创建成功",
"data": {
"id": "uuid",
"title": "每天跑步30分钟",
"status": "active",
"completedCount": 0,
"progressPercentage": 0,
"daysRemaining": 365
}
}
```
### 2. 获取目标列表
**GET** `/goals?page=1&pageSize=20&status=active&category=运动&search=跑步`
**查询参数:**
- `page`: 页码默认1
- `pageSize`: 每页数量默认20最大100
- `status`: 目标状态筛选
- `repeatType`: 重复类型筛选
- `category`: 分类筛选
- `search`: 搜索标题和描述
- `startDate`: 开始日期范围
- `endDate`: 结束日期范围
- `sortBy`: 排序字段createdAt/updatedAt/priority/title/startDate
- `sortOrder`: 排序方向asc/desc
**响应:**
```json
{
"code": 0,
"message": "获取目标列表成功",
"data": {
"page": 1,
"pageSize": 20,
"total": 5,
"items": [
{
"id": "uuid",
"title": "每天跑步30分钟",
"status": "active",
"completedCount": 15,
"targetCount": 365,
"progressPercentage": 4,
"daysRemaining": 350
}
]
}
}
```
### 3. 获取目标详情
**GET** `/goals/{id}`
**响应:**
```json
{
"code": 0,
"message": "获取目标详情成功",
"data": {
"id": "uuid",
"title": "每天跑步30分钟",
"description": "提高心肺功能,增强体质",
"repeatType": "daily",
"frequency": 1,
"status": "active",
"completedCount": 15,
"targetCount": 365,
"progressPercentage": 4,
"daysRemaining": 350,
"completions": [
{
"id": "completion-uuid",
"completedAt": "2024-01-15T07:00:00Z",
"completionCount": 1,
"notes": "今天感觉很好"
}
]
}
}
```
### 4. 更新目标
**PUT** `/goals/{id}`
**请求体:**
```json
{
"title": "每天跑步45分钟",
"frequency": 1,
"priority": 7
}
```
**响应:**
```json
{
"code": 0,
"message": "目标更新成功",
"data": {
"id": "uuid",
"title": "每天跑步45分钟",
"priority": 7
}
}
```
### 5. 删除目标
**DELETE** `/goals/{id}`
**响应:**
```json
{
"code": 0,
"message": "目标删除成功",
"data": true
}
```
### 6. 记录目标完成
**POST** `/goals/{id}/complete`
**请求体:**
```json
{
"completionCount": 1,
"notes": "今天完成了跑步目标",
"completedAt": "2024-01-15T07:30:00Z"
}
```
**响应:**
```json
{
"code": 0,
"message": "目标完成记录成功",
"data": {
"id": "completion-uuid",
"goalId": "goal-uuid",
"completedAt": "2024-01-15T07:30:00Z",
"completionCount": 1,
"notes": "今天完成了跑步目标"
}
}
```
### 7. 获取目标完成记录
**GET** `/goals/{id}/completions?page=1&pageSize=20&startDate=2024-01-01&endDate=2024-01-31`
**响应:**
```json
{
"code": 0,
"message": "获取目标完成记录成功",
"data": {
"page": 1,
"pageSize": 20,
"total": 15,
"items": [
{
"id": "completion-uuid",
"completedAt": "2024-01-15T07:30:00Z",
"completionCount": 1,
"notes": "今天完成了跑步目标",
"goal": {
"id": "goal-uuid",
"title": "每天跑步30分钟"
}
}
]
}
}
```
### 8. 获取目标统计信息
**GET** `/goals/stats/overview`
**响应:**
```json
{
"code": 0,
"message": "获取目标统计成功",
"data": {
"total": 10,
"active": 7,
"completed": 2,
"paused": 1,
"cancelled": 0,
"byCategory": {
"运动": 5,
"学习": 3,
"健康": 2
},
"byRepeatType": {
"daily": 6,
"weekly": 3,
"monthly": 1
},
"totalCompletions": 150,
"thisWeekCompletions": 25,
"thisMonthCompletions": 100
}
}
```
### 9. 批量操作目标
**POST** `/goals/batch`
**请求体:**
```json
{
"goalIds": ["uuid1", "uuid2", "uuid3"],
"action": "pause"
}
```
**支持的操作:**
- `pause`: 暂停目标
- `resume`: 恢复目标
- `complete`: 完成目标
- `delete`: 删除目标
**响应:**
```json
{
"code": 0,
"message": "批量操作完成",
"data": [
{
"goalId": "uuid1",
"success": true
},
{
"goalId": "uuid2",
"success": true
},
{
"goalId": "uuid3",
"success": false,
"error": "目标不存在"
}
]
}
```
## 使用示例
### 创建每日运动目标
```bash
curl -X POST http://localhost:3000/goals \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "每日普拉提练习",
"description": "每天进行30分钟的普拉提练习提高核心力量",
"repeatType": "daily",
"frequency": 1,
"startDate": "2024-01-01",
"category": "运动",
"priority": 8,
"hasReminder": true,
"reminderTime": "18:00"
}'
```
### 创建每周学习目标
```bash
curl -X POST http://localhost:3000/goals \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "每周阅读一本书",
"description": "每周至少阅读一本专业书籍",
"repeatType": "weekly",
"frequency": 1,
"startDate": "2024-01-01",
"targetCount": 52,
"category": "学习",
"priority": 6
}'
```
### 记录目标完成
```bash
curl -X POST http://localhost:3000/goals/GOAL_ID/complete \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"notes": "今天完成了30分钟的普拉提练习感觉很好"
}'
```
## 错误处理
所有接口都遵循统一的错误响应格式:
```json
{
"code": 1,
"message": "错误描述",
"data": null
}
```
常见错误:
- `目标不存在`: 404
- `参数验证失败`: 400
- `权限不足`: 403
- `服务器内部错误`: 500
## 注意事项
1. 所有接口都需要JWT认证
2. 用户只能操作自己的目标
3. 已完成的目标不能修改状态
4. 删除操作采用软删除,不会真正删除数据
5. 目标完成记录会自动更新目标的完成次数
6. 达到目标总次数时,目标状态会自动变为已完成

View File

@@ -0,0 +1,83 @@
-- 创建目标表
CREATE TABLE IF NOT EXISTS t_goals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id VARCHAR(255) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
repeat_type VARCHAR(20) NOT NULL DEFAULT 'daily' CHECK (repeat_type IN ('daily', 'weekly', 'monthly', 'custom')),
frequency INTEGER NOT NULL DEFAULT 1 CHECK (frequency > 0 AND frequency <= 100),
custom_repeat_rule JSONB,
start_date DATE NOT NULL,
end_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'completed', 'cancelled')),
completed_count INTEGER NOT NULL DEFAULT 0,
target_count INTEGER CHECK (target_count > 0),
category VARCHAR(100),
priority INTEGER NOT NULL DEFAULT 0 CHECK (priority >= 0 AND priority <= 10),
has_reminder BOOLEAN NOT NULL DEFAULT false,
reminder_time TIME,
reminder_settings JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted BOOLEAN NOT NULL DEFAULT false
);
-- 创建目标完成记录表
CREATE TABLE IF NOT EXISTS t_goal_completions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
goal_id UUID NOT NULL REFERENCES t_goals(id) ON DELETE CASCADE,
user_id VARCHAR(255) NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE NOT NULL,
completion_count INTEGER NOT NULL DEFAULT 1 CHECK (completion_count > 0),
notes TEXT,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
deleted BOOLEAN NOT NULL DEFAULT false
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_goals_user_id ON t_goals(user_id);
CREATE INDEX IF NOT EXISTS idx_goals_status ON t_goals(status);
CREATE INDEX IF NOT EXISTS idx_goals_repeat_type ON t_goals(repeat_type);
CREATE INDEX IF NOT EXISTS idx_goals_category ON t_goals(category);
CREATE INDEX IF NOT EXISTS idx_goals_start_date ON t_goals(start_date);
CREATE INDEX IF NOT EXISTS idx_goals_deleted ON t_goals(deleted);
CREATE INDEX IF NOT EXISTS idx_goal_completions_goal_id ON t_goal_completions(goal_id);
CREATE INDEX IF NOT EXISTS idx_goal_completions_user_id ON t_goal_completions(user_id);
CREATE INDEX IF NOT EXISTS idx_goal_completions_completed_at ON t_goal_completions(completed_at);
CREATE INDEX IF NOT EXISTS idx_goal_completions_deleted ON t_goal_completions(deleted);
-- 创建复合索引
CREATE INDEX IF NOT EXISTS idx_goals_user_status ON t_goals(user_id, status);
CREATE INDEX IF NOT EXISTS idx_goal_completions_goal_completed ON t_goal_completions(goal_id, completed_at);
-- 添加注释
COMMENT ON TABLE t_goals IS '用户目标表';
COMMENT ON COLUMN t_goals.id IS '目标ID';
COMMENT ON COLUMN t_goals.user_id IS '用户ID';
COMMENT ON COLUMN t_goals.title IS '目标标题';
COMMENT ON COLUMN t_goals.description IS '目标描述';
COMMENT ON COLUMN t_goals.repeat_type IS '重复周期类型daily-每日weekly-每周monthly-每月custom-自定义';
COMMENT ON COLUMN t_goals.frequency IS '频率(每天/每周/每月多少次)';
COMMENT ON COLUMN t_goals.custom_repeat_rule IS '自定义重复规则(如每周几)';
COMMENT ON COLUMN t_goals.start_date IS '目标开始日期';
COMMENT ON COLUMN t_goals.end_date IS '目标结束日期';
COMMENT ON COLUMN t_goals.status IS '目标状态active-激活paused-暂停completed-已完成cancelled-已取消';
COMMENT ON COLUMN t_goals.completed_count IS '已完成次数';
COMMENT ON COLUMN t_goals.target_count IS '目标总次数null表示无限制';
COMMENT ON COLUMN t_goals.category IS '目标分类标签';
COMMENT ON COLUMN t_goals.priority IS '优先级(数字越大优先级越高)';
COMMENT ON COLUMN t_goals.has_reminder IS '是否提醒';
COMMENT ON COLUMN t_goals.reminder_time IS '提醒时间';
COMMENT ON COLUMN t_goals.reminder_settings IS '提醒设置(如每周几提醒)';
COMMENT ON TABLE t_goal_completions IS '目标完成记录表';
COMMENT ON COLUMN t_goal_completions.id IS '完成记录ID';
COMMENT ON COLUMN t_goal_completions.goal_id IS '目标ID';
COMMENT ON COLUMN t_goal_completions.user_id IS '用户ID';
COMMENT ON COLUMN t_goal_completions.completed_at IS '完成日期';
COMMENT ON COLUMN t_goal_completions.completion_count IS '完成次数';
COMMENT ON COLUMN t_goal_completions.notes IS '完成备注';
COMMENT ON COLUMN t_goal_completions.metadata IS '完成时的额外数据';

View File

@@ -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],

View 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;
}

View 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;
}

View 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';
}

View 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;
}

View 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
View 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
View 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;
}
}

View 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;
}

View 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[];
}