diff --git a/docs/goals-api-guide.md b/docs/goals-api-guide.md new file mode 100644 index 0000000..6521ed7 --- /dev/null +++ b/docs/goals-api-guide.md @@ -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. 达到目标总次数时,目标状态会自动变为已完成 diff --git a/sql-scripts/goals-tables-create.sql b/sql-scripts/goals-tables-create.sql new file mode 100644 index 0000000..9d3e604 --- /dev/null +++ b/sql-scripts/goals-tables-create.sql @@ -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 '完成时的额外数据'; diff --git a/src/app.module.ts b/src/app.module.ts index ecefe59..4a79ad6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/goals/dto/create-goal.dto.ts b/src/goals/dto/create-goal.dto.ts new file mode 100644 index 0000000..bc72387 --- /dev/null +++ b/src/goals/dto/create-goal.dto.ts @@ -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; +} diff --git a/src/goals/dto/goal-completion.dto.ts b/src/goals/dto/goal-completion.dto.ts new file mode 100644 index 0000000..053dbcf --- /dev/null +++ b/src/goals/dto/goal-completion.dto.ts @@ -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; +} diff --git a/src/goals/dto/goal-query.dto.ts b/src/goals/dto/goal-query.dto.ts new file mode 100644 index 0000000..8c98fd0 --- /dev/null +++ b/src/goals/dto/goal-query.dto.ts @@ -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'; +} diff --git a/src/goals/dto/update-goal.dto.ts b/src/goals/dto/update-goal.dto.ts new file mode 100644 index 0000000..7c41ff2 --- /dev/null +++ b/src/goals/dto/update-goal.dto.ts @@ -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; +} diff --git a/src/goals/goals.controller.ts b/src/goals/goals.controller.ts new file mode 100644 index 0000000..72c73e8 --- /dev/null +++ b/src/goals/goals.controller.ts @@ -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> { + 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> { + 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> { + 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> { + 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> { + 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> { + // 确保完成记录的目标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> { + 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> { + 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> { + 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, + }; + } +} diff --git a/src/goals/goals.module.ts b/src/goals/goals.module.ts new file mode 100644 index 0000000..0a8b9a3 --- /dev/null +++ b/src/goals/goals.module.ts @@ -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 {} diff --git a/src/goals/goals.service.ts b/src/goals/goals.service.ts new file mode 100644 index 0000000..a57ccf0 --- /dev/null +++ b/src/goals/goals.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/goals/models/goal-completion.model.ts b/src/goals/models/goal-completion.model.ts new file mode 100644 index 0000000..fb7e1ee --- /dev/null +++ b/src/goals/models/goal-completion.model.ts @@ -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; +} diff --git a/src/goals/models/goal.model.ts b/src/goals/models/goal.model.ts new file mode 100644 index 0000000..b070f60 --- /dev/null +++ b/src/goals/models/goal.model.ts @@ -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[]; +}