From 3530d123fc7844fa171e1cc194669d3a2e773d22 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Fri, 22 Aug 2025 16:01:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E5=AD=90=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现目标子任务的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。 - 支持用户创建、管理和跟踪目标子任务,提供增删改查操作及任务完成记录功能。 - 引入惰性任务生成机制,优化任务管理体验,提升系统性能和用户交互。 --- docs/goal-tasks-api-guide.md | 304 +++++++++++++ sql-scripts/goal-tasks-table-create.sql | 49 ++ src/goals/dto/goal-task.dto.ts | 122 +++++ src/goals/goals.controller.ts | 126 ++++- src/goals/goals.module.ts | 10 +- src/goals/goals.service.ts | 65 ++- src/goals/models/goal-task.model.ts | 166 +++++++ src/goals/models/goal.model.ts | 6 +- src/goals/services/goal-task.service.ts | 581 ++++++++++++++++++++++++ test-goal-tasks.http | 185 ++++++++ 10 files changed, 1593 insertions(+), 21 deletions(-) create mode 100644 docs/goal-tasks-api-guide.md create mode 100644 sql-scripts/goal-tasks-table-create.sql create mode 100644 src/goals/dto/goal-task.dto.ts create mode 100644 src/goals/models/goal-task.model.ts create mode 100644 src/goals/services/goal-task.service.ts create mode 100644 test-goal-tasks.http diff --git a/docs/goal-tasks-api-guide.md b/docs/goal-tasks-api-guide.md new file mode 100644 index 0000000..cbab48b --- /dev/null +++ b/docs/goal-tasks-api-guide.md @@ -0,0 +1,304 @@ +# 目标子任务API使用指南 + +## 概述 + +目标子任务系统是对目标管理功能的扩展,它支持用户为每个目标自动生成对应的子任务,用户可以根据目标的频率设置(每天、每周、每月)来完成这些子任务。系统采用惰性加载的方式,每次获取任务列表时才生成新的任务。 + +## 核心功能 + +### 1. 惰性任务生成 +- **触发时机**: 当用户调用获取任务列表API时 +- **生成策略**: + - 每日任务:提前生成7天的任务 + - 每周任务:提前生成4周的任务 + - 每月任务:提前生成3个月的任务 + - 自定义任务:根据自定义规则生成 + +### 2. 任务状态管理 +- `pending`: 待开始 +- `in_progress`: 进行中(部分完成) +- `completed`: 已完成 +- `overdue`: 已过期 +- `skipped`: 已跳过 + +### 3. 进度追踪 +- 支持分步完成(如一天要喝8杯水,可以分8次上报) +- 自动计算完成进度百分比 +- 当完成次数达到目标次数时自动标记为完成 + +## API接口详解 + +### 1. 获取任务列表 + +**请求方式**: `GET /goals/tasks` + +**查询参数**: +```typescript +{ + goalId?: string; // 目标ID(可选) + status?: TaskStatus; // 任务状态(可选) + startDate?: string; // 开始日期(可选) + endDate?: string; // 结束日期(可选) + page?: number; // 页码,默认1 + pageSize?: number; // 每页数量,默认20 +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "获取任务列表成功", + "data": { + "page": 1, + "pageSize": 20, + "total": 5, + "list": [ + { + "id": "task-uuid", + "goalId": "goal-uuid", + "userId": "user-123", + "title": "每日喝水 - 2024年01月15日", + "description": "每日目标:完成8次", + "startDate": "2024-01-15", + "endDate": "2024-01-15", + "targetCount": 8, + "currentCount": 3, + "status": "in_progress", + "progressPercentage": 37, + "completedAt": null, + "notes": null, + "metadata": null, + "daysRemaining": 0, + "isToday": true, + "goal": { + "id": "goal-uuid", + "title": "每日喝水", + "repeatType": "daily", + "frequency": 8, + "category": "健康" + } + } + ] + } +} +``` + +### 2. 完成任务 + +**请求方式**: `POST /goals/tasks/:taskId/complete` + +**请求体**: +```typescript +{ + count?: number; // 完成次数,默认1 + notes?: string; // 备注(可选) + completedAt?: string; // 完成时间(可选) +} +``` + +**使用示例**: +```bash +# 喝水1次 +curl -X POST "http://localhost:3000/goals/tasks/task-uuid/complete" \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"count": 1, "notes": "午饭后喝水"}' + +# 一次性完成多次 +curl -X POST "http://localhost:3000/goals/tasks/task-uuid/complete" \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"count": 3, "notes": "连续喝了3杯水"}' +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "任务完成成功", + "data": { + "id": "task-uuid", + "currentCount": 4, + "progressPercentage": 50, + "status": "in_progress", + "notes": "午饭后喝水" + } +} +``` + +### 3. 获取特定目标的任务列表 + +**请求方式**: `GET /goals/:goalId/tasks` + +**使用示例**: +```bash +curl -X GET "http://localhost:3000/goals/goal-uuid/tasks?page=1&pageSize=10" \ + -H "Authorization: Bearer your-token" +``` + +### 4. 跳过任务 + +**请求方式**: `POST /goals/tasks/:taskId/skip` + +**请求体**: +```typescript +{ + reason?: string; // 跳过原因(可选) +} +``` + +**使用示例**: +```bash +curl -X POST "http://localhost:3000/goals/tasks/task-uuid/skip" \ + -H "Authorization: Bearer your-token" \ + -H "Content-Type: application/json" \ + -d '{"reason": "今天身体不舒服"}' +``` + +### 5. 获取任务统计 + +**请求方式**: `GET /goals/tasks/stats/overview` + +**查询参数**: +```typescript +{ + goalId?: string; // 目标ID(可选,不传则统计所有目标的任务) +} +``` + +**响应示例**: +```json +{ + "code": 200, + "message": "获取任务统计成功", + "data": { + "total": 15, + "pending": 5, + "inProgress": 3, + "completed": 6, + "overdue": 1, + "skipped": 0, + "totalProgress": 68, + "todayTasks": 3, + "weekTasks": 8, + "monthTasks": 15 + } +} +``` + +## 典型使用场景 + +### 场景1:每日喝水目标 + +1. **创建目标**: +```json +{ + "title": "每日喝水", + "description": "每天喝8杯水保持健康", + "repeatType": "daily", + "frequency": 8, + "category": "健康", + "startDate": "2024-01-01" +} +``` + +2. **系统自动生成任务**: 当用户第一次获取任务列表时,系统会自动生成当天及未来7天的每日任务 + +3. **用户完成任务**: 每次喝水后调用完成API,直到当天任务完成 + +### 场景2:每周运动目标 + +1. **创建目标**: +```json +{ + "title": "每周运动", + "description": "每周运动3次", + "repeatType": "weekly", + "frequency": 3, + "category": "运动", + "startDate": "2024-01-01" +} +``` + +2. **系统生成周任务**: 按周生成任务,每个任务的开始时间是周一,结束时间是周日 + +3. **灵活完成**: 用户可以在一周内的任何时间完成3次运动,每次完成后调用API更新进度 + +### 场景3:自定义周期目标 + +1. **创建目标**: +```json +{ + "title": "周末阅读", + "description": "每个周末阅读1小时", + "repeatType": "custom", + "frequency": 1, + "customRepeatRule": { + "weekdays": [0, 6] // 周日和周六 + }, + "category": "学习" +} +``` + +2. **系统按规则生成**: 只在周六和周日生成任务 + +## 最佳实践 + +### 1. 任务列表获取 +- 建议按日期范围获取任务,避免一次性加载过多数据 +- 可以按状态筛选任务,如只显示今天的待完成任务 + +### 2. 进度上报 +- 鼓励用户及时上报完成情况,保持任务状态的实时性 +- 对于可分步完成的目标,支持分次上报更有利于习惯养成 + +### 3. 错误处理 +- 当任务不存在或已完成时,API会返回相应错误信息 +- 客户端应该处理网络异常情况,支持离线记录后同步 + +### 4. 性能优化 +- 惰性生成机制确保只在需要时生成任务 +- 建议客户端缓存任务列表,减少不必要的API调用 + +## 数据库表结构 + +### t_goal_tasks 表字段说明 + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| id | CHAR(36) | 任务ID,主键 | +| goal_id | CHAR(36) | 关联的目标ID | +| user_id | VARCHAR(255) | 用户ID | +| title | VARCHAR(255) | 任务标题 | +| description | TEXT | 任务描述 | +| start_date | DATE | 任务开始日期 | +| end_date | DATE | 任务结束日期 | +| target_count | INT | 目标完成次数 | +| current_count | INT | 当前完成次数 | +| status | ENUM | 任务状态 | +| progress_percentage | INT | 完成进度(0-100) | +| completed_at | DATETIME | 完成时间 | +| notes | TEXT | 备注 | +| metadata | JSON | 扩展数据 | + +## 注意事项 + +1. **时区处理**: 所有日期时间都使用服务器时区,客户端需要进行相应转换 +2. **并发安全**: 多次快速调用完成API可能导致计数不准确,建议客户端控制调用频率 +3. **数据一致性**: 目标删除时会级联删除相关任务 +4. **性能考虑**: 大量历史任务可能影响查询性能,建议定期清理过期数据 + +## 常见问题 + +**Q: 如何修改已生成的任务?** +A: 可以使用更新任务API (PUT /goals/tasks/:taskId) 修改任务的基本信息,但不建议频繁修改以保持数据一致性。 + +**Q: 任务过期后还能完成吗?** +A: 过期任务状态会自动更新为'overdue',但仍然可以完成,完成后状态会变为'completed'。 + +**Q: 如何处理用户时区问题?** +A: 客户端应该将用户本地时间转换为服务器时区后发送请求,显示时再转换回用户时区。 + +**Q: 能否批量完成多个任务?** +A: 目前API设计为单个任务操作,如需批量操作可以在客户端并发调用多个API。 diff --git a/sql-scripts/goal-tasks-table-create.sql b/sql-scripts/goal-tasks-table-create.sql new file mode 100644 index 0000000..a7347b2 --- /dev/null +++ b/sql-scripts/goal-tasks-table-create.sql @@ -0,0 +1,49 @@ +-- 创建目标子任务表 +CREATE TABLE IF NOT EXISTS `t_goal_tasks` ( + `id` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '任务ID', + `goal_id` CHAR(36) NOT NULL COMMENT '目标ID', + `user_id` VARCHAR(255) NOT NULL COMMENT '用户ID', + `title` VARCHAR(255) NOT NULL COMMENT '任务标题', + `description` TEXT COMMENT '任务描述', + `start_date` DATE NOT NULL COMMENT '任务开始日期', + `end_date` DATE NOT NULL COMMENT '任务结束日期', + `target_count` INT NOT NULL DEFAULT 1 COMMENT '任务目标次数(如喝水8次)', + `current_count` INT NOT NULL DEFAULT 0 COMMENT '任务当前完成次数', + `status` ENUM('pending', 'in_progress', 'completed', 'overdue', 'skipped') NOT NULL DEFAULT 'pending' COMMENT '任务状态', + `progress_percentage` INT NOT NULL DEFAULT 0 COMMENT '完成进度百分比 (0-100)', + `completed_at` DATETIME COMMENT '任务完成时间', + `notes` TEXT COMMENT '任务备注', + `metadata` JSON COMMENT '任务额外数据', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否删除', + PRIMARY KEY (`id`), + INDEX `idx_goal_id` (`goal_id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_status` (`status`), + INDEX `idx_start_date` (`start_date`), + INDEX `idx_end_date` (`end_date`), + INDEX `idx_deleted` (`deleted`), + INDEX `idx_user_goal` (`user_id`, `goal_id`), + INDEX `idx_user_status` (`user_id`, `status`), + INDEX `idx_user_date_range` (`user_id`, `start_date`, `end_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='目标子任务表'; + +-- 添加外键约束 +ALTER TABLE `t_goal_tasks` +ADD CONSTRAINT `fk_goal_tasks_goal_id` +FOREIGN KEY (`goal_id`) REFERENCES `t_goals` (`id`) +ON DELETE CASCADE ON UPDATE CASCADE; + +-- 添加检查约束(MySQL 8.0+) +-- ALTER TABLE `t_goal_tasks` +-- ADD CONSTRAINT `chk_target_count_positive` CHECK (`target_count` > 0); +-- +-- ALTER TABLE `t_goal_tasks` +-- ADD CONSTRAINT `chk_current_count_non_negative` CHECK (`current_count` >= 0); +-- +-- ALTER TABLE `t_goal_tasks` +-- ADD CONSTRAINT `chk_progress_percentage_range` CHECK (`progress_percentage` >= 0 AND `progress_percentage` <= 100); +-- +-- ALTER TABLE `t_goal_tasks` +-- ADD CONSTRAINT `chk_date_range` CHECK (`end_date` >= `start_date`); diff --git a/src/goals/dto/goal-task.dto.ts b/src/goals/dto/goal-task.dto.ts new file mode 100644 index 0000000..3a6bc47 --- /dev/null +++ b/src/goals/dto/goal-task.dto.ts @@ -0,0 +1,122 @@ +import { IsString, IsOptional, IsInt, IsDateString, Min, IsUUID, IsEnum, Max } from 'class-validator'; +import { TaskStatus } from '../models/goal-task.model'; + +export class CreateGoalTaskDto { + @IsUUID() + goalId: string; + + @IsString() + title: string; + + @IsOptional() + @IsString() + description?: string; + + @IsDateString({}, { message: '开始日期格式无效' }) + startDate: string; + + @IsDateString({}, { message: '结束日期格式无效' }) + endDate: string; + + @IsInt() + @Min(1, { message: '目标次数必须大于0' }) + targetCount: number; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + metadata?: any; +} + +export class UpdateGoalTaskDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsDateString({}, { message: '开始日期格式无效' }) + startDate?: string; + + @IsOptional() + @IsDateString({}, { message: '结束日期格式无效' }) + endDate?: string; + + @IsOptional() + @IsInt() + @Min(1, { message: '目标次数必须大于0' }) + targetCount?: number; + + @IsOptional() + @IsEnum(TaskStatus, { message: '任务状态无效' }) + status?: TaskStatus; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + metadata?: any; +} + +export class GoalTaskQueryDto { + @IsOptional() + @IsUUID() + goalId?: string; + + @IsOptional() + @IsEnum(TaskStatus, { message: '任务状态无效' }) + status?: TaskStatus; + + @IsOptional() + @IsDateString({}, { message: '开始日期格式无效' }) + startDate?: string; + + @IsOptional() + @IsDateString({}, { message: '结束日期格式无效' }) + endDate?: string; + + @IsOptional() + @IsInt() + @Min(1, { message: '页码必须大于0' }) + page?: number = 1; + + @IsOptional() + @IsInt() + @Min(1, { message: '每页数量必须大于0' }) + @Max(100, { message: '每页数量不能超过100' }) + pageSize?: number = 20; +} + +export class CompleteGoalTaskDto { + @IsOptional() + @IsInt() + @Min(1, { message: '完成次数必须大于0' }) + count?: number = 1; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsDateString({}, { message: '完成时间格式无效' }) + completedAt?: string; +} + +export class GoalTaskStatsDto { + total: number; + pending: number; + inProgress: number; + completed: number; + overdue: number; + skipped: number; + totalProgress: number; // 总体完成进度 + todayTasks: number; + weekTasks: number; + monthTasks: number; +} diff --git a/src/goals/goals.controller.ts b/src/goals/goals.controller.ts index 963c738..6d85b1e 100644 --- a/src/goals/goals.controller.ts +++ b/src/goals/goals.controller.ts @@ -16,6 +16,8 @@ import { CreateGoalDto } from './dto/create-goal.dto'; import { UpdateGoalDto } from './dto/update-goal.dto'; import { GoalQueryDto } from './dto/goal-query.dto'; import { CreateGoalCompletionDto } from './dto/goal-completion.dto'; +import { GoalTaskService } from './services/goal-task.service'; +import { GoalTaskQueryDto, CompleteGoalTaskDto, UpdateGoalTaskDto } from './dto/goal-task.dto'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { BaseResponseDto, ResponseCode } from '../base.dto'; import { GoalStatus } from './models/goal.model'; @@ -25,7 +27,10 @@ import { AccessTokenPayload } from 'src/users/services/apple-auth.service'; @Controller('goals') @UseGuards(JwtAuthGuard) export class GoalsController { - constructor(private readonly goalsService: GoalsService) { } + constructor( + private readonly goalsService: GoalsService, + private readonly goalTaskService: GoalTaskService, + ) { } /** * 创建目标 @@ -203,4 +208,123 @@ export class GoalsController { data: results, }; } + + // ==================== 子任务相关API ==================== + + /** + * 获取任务列表 + */ + @Get('tasks') + async getTasks( + @Query() query: GoalTaskQueryDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const result = await this.goalTaskService.getTasks(user.sub, query); + return { + code: ResponseCode.SUCCESS, + message: '获取任务列表成功', + data: result, + }; + } + + /** + * 获取单个任务详情 + */ + @Get('tasks/:taskId') + async getTask( + @Param('taskId') taskId: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const task = await this.goalTaskService.getTask(user.sub, taskId); + return { + code: ResponseCode.SUCCESS, + message: '获取任务详情成功', + data: task, + }; + } + + /** + * 完成任务 + */ + @Post('tasks/:taskId/complete') + async completeTask( + @Param('taskId') taskId: string, + @Body() completeDto: CompleteGoalTaskDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const task = await this.goalTaskService.completeTask(user.sub, taskId, completeDto); + return { + code: ResponseCode.SUCCESS, + message: '任务完成成功', + data: task, + }; + } + + /** + * 更新任务 + */ + @Put('tasks/:taskId') + async updateTask( + @Param('taskId') taskId: string, + @Body() updateDto: UpdateGoalTaskDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const task = await this.goalTaskService.updateTask(user.sub, taskId, updateDto); + return { + code: ResponseCode.SUCCESS, + message: '任务更新成功', + data: task, + }; + } + + /** + * 跳过任务 + */ + @Post('tasks/:taskId/skip') + async skipTask( + @Param('taskId') taskId: string, + @Body() body: { reason?: string }, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const task = await this.goalTaskService.skipTask(user.sub, taskId, body.reason); + return { + code: ResponseCode.SUCCESS, + message: '任务跳过成功', + data: task, + }; + } + + /** + * 获取任务统计 + */ + @Get('tasks/stats/overview') + async getTaskStats( + @CurrentUser() user: AccessTokenPayload, + @Query('goalId') goalId?: string, + ): Promise> { + const stats = await this.goalTaskService.getTaskStats(user.sub, goalId); + return { + code: ResponseCode.SUCCESS, + message: '获取任务统计成功', + data: stats, + }; + } + + /** + * 获取特定目标的任务列表 + */ + @Get(':id/tasks') + async getGoalTasks( + @Param('id') goalId: string, + @Query() query: Omit, + @CurrentUser() user: AccessTokenPayload, + ): Promise> { + const taskQuery = { ...query, goalId }; + const result = await this.goalTaskService.getTasks(user.sub, taskQuery); + return { + code: ResponseCode.SUCCESS, + message: '获取目标任务列表成功', + data: result, + }; + } } diff --git a/src/goals/goals.module.ts b/src/goals/goals.module.ts index 0c34f1f..68c1114 100644 --- a/src/goals/goals.module.ts +++ b/src/goals/goals.module.ts @@ -2,17 +2,19 @@ import { Module } from '@nestjs/common'; import { SequelizeModule } from '@nestjs/sequelize'; import { GoalsController } from './goals.controller'; import { GoalsService } from './goals.service'; +import { GoalTaskService } from './services/goal-task.service'; import { Goal } from './models/goal.model'; import { GoalCompletion } from './models/goal-completion.model'; +import { GoalTask } from './models/goal-task.model'; import { UsersModule } from '../users/users.module'; @Module({ imports: [ - SequelizeModule.forFeature([Goal, GoalCompletion]), + SequelizeModule.forFeature([Goal, GoalCompletion, GoalTask]), UsersModule, ], controllers: [GoalsController], - providers: [GoalsService], - exports: [GoalsService], + providers: [GoalsService, GoalTaskService], + exports: [GoalsService, GoalTaskService], }) -export class GoalsModule {} +export class GoalsModule { } diff --git a/src/goals/goals.service.ts b/src/goals/goals.service.ts index 87aabaf..e848fea 100644 --- a/src/goals/goals.service.ts +++ b/src/goals/goals.service.ts @@ -1,17 +1,30 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; import { Op, WhereOptions, Order } from 'sequelize'; import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model'; import { GoalCompletion } from './models/goal-completion.model'; +import { GoalTask } from './models/goal-task.model'; import { CreateGoalDto } from './dto/create-goal.dto'; import { UpdateGoalDto } from './dto/update-goal.dto'; import { GoalQueryDto } from './dto/goal-query.dto'; import { CreateGoalCompletionDto } from './dto/goal-completion.dto'; +import { GoalTaskService } from './services/goal-task.service'; import * as dayjs from 'dayjs'; @Injectable() export class GoalsService { private readonly logger = new Logger(GoalsService.name); + constructor( + @InjectModel(Goal) + private readonly goalModel: typeof Goal, + @InjectModel(GoalCompletion) + private readonly goalCompletionModel: typeof GoalCompletion, + @InjectModel(GoalTask) + private readonly goalTaskModel: typeof GoalTask, + private readonly goalTaskService: GoalTaskService, + ) { } + /** * 创建目标 */ @@ -27,13 +40,13 @@ export class GoalsService { throw new BadRequestException('结束日期不能早于开始日期'); } - const goal = await Goal.create({ + const goal = await this.goalModel.create({ userId, ...createGoalDto, - startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : null, - endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : null, - startTime: createGoalDto.startTime ? createGoalDto.startTime : null, - endTime: createGoalDto.endTime ? createGoalDto.endTime : null, + startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : undefined, + endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : undefined, + startTime: createGoalDto.startTime ? createGoalDto.startTime : undefined, + endTime: createGoalDto.endTime ? createGoalDto.endTime : undefined, }); this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`); @@ -49,6 +62,9 @@ export class GoalsService { */ async getGoals(userId: string, query: GoalQueryDto) { try { + // 惰性生成任务 + await this.goalTaskService.generateTasksLazily(userId); + const { page = 1, pageSize = 20, status, repeatType, category, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query; const offset = (page - 1) * pageSize; @@ -85,7 +101,7 @@ export class GoalsService { // 构建排序条件 const order: Order = [[sortBy, sortOrder.toUpperCase()]]; - const { rows: goals, count } = await Goal.findAndCountAll({ + const { rows: goals, count } = await this.goalModel.findAndCountAll({ where, order, offset, @@ -97,6 +113,14 @@ export class GoalsService { where: { deleted: false }, required: false, }, + { + model: GoalTask, + as: 'tasks', + where: { deleted: false }, + required: false, + limit: 5, // 只显示最近5个任务 + order: [['startDate', 'DESC']], + }, ], }); @@ -117,7 +141,10 @@ export class GoalsService { */ async getGoal(userId: string, goalId: string): Promise { try { - const goal = await Goal.findOne({ + // 惰性生成任务 + await this.goalTaskService.generateTasksLazily(userId, goalId); + + const goal = await this.goalModel.findOne({ where: { id: goalId, userId, deleted: false }, include: [ { @@ -128,6 +155,14 @@ export class GoalsService { order: [['completedAt', 'DESC']], limit: 10, // 只显示最近10次完成记录 }, + { + model: GoalTask, + as: 'tasks', + where: { deleted: false }, + required: false, + order: [['startDate', 'ASC']], + limit: 20, // 显示最近20个任务 + }, ], }); @@ -147,7 +182,7 @@ export class GoalsService { */ async updateGoal(userId: string, goalId: string, updateGoalDto: UpdateGoalDto): Promise { try { - const goal = await Goal.findOne({ + const goal = await this.goalModel.findOne({ where: { id: goalId, userId, deleted: false }, }); @@ -186,7 +221,7 @@ export class GoalsService { */ async deleteGoal(userId: string, goalId: string): Promise { try { - const goal = await Goal.findOne({ + const goal = await this.goalModel.findOne({ where: { id: goalId, userId, deleted: false }, }); @@ -198,7 +233,7 @@ export class GoalsService { await goal.update({ deleted: true }); // 软删除相关的完成记录 - await GoalCompletion.update( + await this.goalCompletionModel.update( { deleted: true }, { where: { goalId, userId } } ); @@ -216,7 +251,7 @@ export class GoalsService { */ async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise { try { - const goal = await Goal.findOne({ + const goal = await this.goalModel.findOne({ where: { id: createCompletionDto.goalId, userId, deleted: false }, }); @@ -232,7 +267,7 @@ export class GoalsService { const completedAt = createCompletionDto.completedAt ? new Date(createCompletionDto.completedAt) : new Date(); // 创建完成记录 - const completion = await GoalCompletion.create({ + const completion = await this.goalCompletionModel.create({ goalId: createCompletionDto.goalId, userId, completedAt, @@ -266,7 +301,7 @@ export class GoalsService { const offset = (page - 1) * pageSize; // 验证目标存在 - const goal = await Goal.findOne({ + const goal = await this.goalModel.findOne({ where: { id: goalId, userId, deleted: false }, }); @@ -291,7 +326,7 @@ export class GoalsService { } } - const { rows: completions, count } = await GoalCompletion.findAndCountAll({ + const { rows: completions, count } = await this.goalCompletionModel.findAndCountAll({ where, order: [['completedAt', 'DESC']], offset, @@ -322,7 +357,7 @@ export class GoalsService { */ async getGoalStats(userId: string) { try { - const goals = await Goal.findAll({ + const goals = await this.goalModel.findAll({ where: { userId, deleted: false }, include: [ { diff --git a/src/goals/models/goal-task.model.ts b/src/goals/models/goal-task.model.ts new file mode 100644 index 0000000..ab2e6a4 --- /dev/null +++ b/src/goals/models/goal-task.model.ts @@ -0,0 +1,166 @@ +import { Column, DataType, Model, Table, ForeignKey, BelongsTo } from 'sequelize-typescript'; +import { Goal } from './goal.model'; + +export enum TaskStatus { + PENDING = 'pending', + IN_PROGRESS = 'in_progress', + COMPLETED = 'completed', + OVERDUE = 'overdue', + SKIPPED = 'skipped' +} + +@Table({ + tableName: 't_goal_tasks', + underscored: true, +}) +export class GoalTask extends Model { + @Column({ + type: DataType.CHAR(36), + defaultValue: DataType.UUIDV4, + primaryKey: true, + }) + declare id: string; + + @ForeignKey(() => Goal) + @Column({ + type: DataType.CHAR(36), + allowNull: false, + comment: '目标ID', + }) + declare goalId: string; + + @BelongsTo(() => Goal) + declare goal: Goal; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + comment: '任务标题', + }) + declare title: string; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '任务描述', + }) + declare description: string; + + @Column({ + type: DataType.DATEONLY, + allowNull: false, + comment: '任务开始日期', + }) + declare startDate: Date; + + @Column({ + type: DataType.DATEONLY, + allowNull: false, + comment: '任务结束日期', + }) + declare endDate: Date; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + defaultValue: 1, + comment: '任务目标次数(如喝水8次)', + }) + declare targetCount: number; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + defaultValue: 0, + comment: '任务当前完成次数', + }) + declare currentCount: number; + + @Column({ + type: DataType.ENUM('pending', 'in_progress', 'completed', 'overdue', 'skipped'), + allowNull: false, + defaultValue: TaskStatus.PENDING, + comment: '任务状态', + }) + declare status: TaskStatus; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + defaultValue: 0, + comment: '完成进度百分比 (0-100)', + }) + declare progressPercentage: number; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '任务完成时间', + }) + declare completedAt: Date; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '任务备注', + }) + declare notes: string; + + @Column({ + type: DataType.JSON, + allowNull: true, + comment: '任务额外数据', + }) + declare metadata: any; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; + + @Column({ + type: DataType.BOOLEAN, + defaultValue: false, + comment: '是否删除', + }) + declare deleted: boolean; + + // 计算完成进度 + updateProgress(): void { + if (this.targetCount > 0) { + this.progressPercentage = Math.min(100, Math.round((this.currentCount / this.targetCount) * 100)); + + // 更新状态 + if (this.currentCount >= this.targetCount) { + this.status = TaskStatus.COMPLETED; + this.completedAt = new Date(); + } else if (this.currentCount > 0) { + this.status = TaskStatus.IN_PROGRESS; + } + } + } + + // 检查是否过期 + checkOverdue(): void { + const now = new Date(); + const endDate = new Date(this.endDate); + + if (now > endDate && this.status !== TaskStatus.COMPLETED && this.status !== TaskStatus.SKIPPED) { + this.status = TaskStatus.OVERDUE; + } + } +} diff --git a/src/goals/models/goal.model.ts b/src/goals/models/goal.model.ts index cfc6dd0..7bca0ed 100644 --- a/src/goals/models/goal.model.ts +++ b/src/goals/models/goal.model.ts @@ -1,5 +1,6 @@ import { Column, DataType, Model, Table, HasMany } from 'sequelize-typescript'; import { GoalCompletion } from './goal-completion.model'; +import { GoalTask } from './goal-task.model'; export enum GoalRepeatType { DAILY = 'daily', @@ -73,7 +74,7 @@ export class Goal extends Model { @Column({ type: DataType.DATEONLY, - allowNull: false, + allowNull: true, comment: '目标开始日期', }) declare startDate: Date; @@ -182,4 +183,7 @@ export class Goal extends Model { @HasMany(() => GoalCompletion, 'goalId') declare completions: GoalCompletion[]; + + @HasMany(() => GoalTask, 'goalId') + declare tasks: GoalTask[]; } diff --git a/src/goals/services/goal-task.service.ts b/src/goals/services/goal-task.service.ts new file mode 100644 index 0000000..a1c20fc --- /dev/null +++ b/src/goals/services/goal-task.service.ts @@ -0,0 +1,581 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { Op, WhereOptions } from 'sequelize'; +import { Goal, GoalRepeatType, GoalStatus } from '../models/goal.model'; +import { GoalTask, TaskStatus } from '../models/goal-task.model'; +import { CreateGoalTaskDto, UpdateGoalTaskDto, GoalTaskQueryDto, CompleteGoalTaskDto } from '../dto/goal-task.dto'; +import * as dayjs from 'dayjs'; +import * as weekOfYear from 'dayjs/plugin/weekOfYear'; +import * as isoWeek from 'dayjs/plugin/isoWeek'; +import * as isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; +import * as isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; + +dayjs.extend(weekOfYear); +dayjs.extend(isoWeek); +dayjs.extend(isSameOrBefore); +dayjs.extend(isSameOrAfter); + +@Injectable() +export class GoalTaskService { + private readonly logger = new Logger(GoalTaskService.name); + + constructor( + @InjectModel(Goal) + private readonly goalModel: typeof Goal, + @InjectModel(GoalTask) + private readonly goalTaskModel: typeof GoalTask, + ) { } + + /** + * 惰性生成任务 - 每次获取任务列表时调用 + */ + async generateTasksLazily(userId: string, goalId?: string): Promise { + try { + const where: WhereOptions = { + userId, + deleted: false, + status: GoalStatus.ACTIVE, + }; + + if (goalId) { + where.id = goalId; + } + + const goals = await this.goalModel.findAll({ where }); + + for (const goal of goals) { + await this.generateTasksForGoal(goal); + } + } catch (error) { + this.logger.error(`惰性生成任务失败: ${error.message}`); + throw error; + } + } + + /** + * 为单个目标生成任务 + */ + private async generateTasksForGoal(goal: Goal): Promise { + const now = dayjs(); + const startDate = goal.startDate ? dayjs(goal.startDate) : now; + const endDate = goal.endDate ? dayjs(goal.endDate) : now.add(1, 'year'); + + // 获取已存在的任务 + const existingTasks = await this.goalTaskModel.findAll({ + where: { + goalId: goal.id, + userId: goal.userId, + deleted: false, + }, + }); + + // 根据重复类型生成任务 + switch (goal.repeatType) { + case GoalRepeatType.DAILY: + await this.generateDailyTasks(goal, startDate, endDate, existingTasks); + break; + case GoalRepeatType.WEEKLY: + await this.generateWeeklyTasks(goal, startDate, endDate, existingTasks); + break; + case GoalRepeatType.MONTHLY: + await this.generateMonthlyTasks(goal, startDate, endDate, existingTasks); + break; + case GoalRepeatType.CUSTOM: + await this.generateCustomTasks(goal, startDate, endDate, existingTasks); + break; + } + + // 更新过期任务状态 + await this.updateOverdueTasks(goal.id, goal.userId); + } + + /** + * 生成每日任务 + */ + private async generateDailyTasks( + goal: Goal, + startDate: dayjs.Dayjs, + endDate: dayjs.Dayjs, + existingTasks: GoalTask[] + ): Promise { + const today = dayjs(); + const generateUntil = today.add(7, 'day'); // 提前生成7天的任务 + const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil; + + let current = startDate.isBefore(today) ? today : startDate; + + while (current.isSameOrBefore(actualEndDate)) { + const taskDate = current.format('YYYY-MM-DD'); + + // 检查是否已存在该日期的任务 + const existingTask = existingTasks.find(task => + dayjs(task.startDate).format('YYYY-MM-DD') === taskDate + ); + + if (!existingTask) { + const taskTitle = goal.title; + + await this.goalTaskModel.create({ + goalId: goal.id, + userId: goal.userId, + title: taskTitle, + description: `每日目标:完成${goal.frequency}次`, + startDate: current.toDate(), + endDate: current.toDate(), + targetCount: goal.frequency, + currentCount: 0, + status: TaskStatus.PENDING, + }); + + this.logger.log(`为目标 ${goal.title} 生成每日任务: ${taskTitle}`); + } + + current = current.add(1, 'day'); + } + } + + /** + * 生成每周任务 + */ + private async generateWeeklyTasks( + goal: Goal, + startDate: dayjs.Dayjs, + endDate: dayjs.Dayjs, + existingTasks: GoalTask[] + ): Promise { + const today = dayjs(); + const generateUntil = today.add(4, 'week'); // 提前生成4周的任务 + const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil; + + let current = startDate.startOf('isoWeek'); + + while (current.isSameOrBefore(actualEndDate)) { + const weekStart = current.startOf('isoWeek'); + const weekEnd = current.endOf('isoWeek'); + + // 检查是否已存在该周的任务 + const existingTask = existingTasks.find(task => { + const taskWeekStart = dayjs(task.startDate).startOf('isoWeek'); + return taskWeekStart.isSame(weekStart); + }); + + if (!existingTask && weekStart.isSameOrAfter(startDate)) { + const taskTitle = `${goal.title} - 第${current.isoWeek()}周 (${weekStart.format('MM-DD')} 至 ${weekEnd.format('MM-DD')})`; + + await this.goalTaskModel.create({ + goalId: goal.id, + userId: goal.userId, + title: taskTitle, + description: `每周目标:完成${goal.frequency}次`, + startDate: weekStart.toDate(), + endDate: weekEnd.toDate(), + targetCount: goal.frequency, + currentCount: 0, + status: TaskStatus.PENDING, + }); + + this.logger.log(`为目标 ${goal.title} 生成每周任务: ${taskTitle}`); + } + + current = current.add(1, 'week'); + } + } + + /** + * 生成每月任务 + */ + private async generateMonthlyTasks( + goal: Goal, + startDate: dayjs.Dayjs, + endDate: dayjs.Dayjs, + existingTasks: GoalTask[] + ): Promise { + const today = dayjs(); + const generateUntil = today.add(3, 'month'); // 提前生成3个月的任务 + const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil; + + let current = startDate.startOf('month'); + + while (current.isSameOrBefore(actualEndDate)) { + const monthStart = current.startOf('month'); + const monthEnd = current.endOf('month'); + + // 检查是否已存在该月的任务 + const existingTask = existingTasks.find(task => { + const taskMonthStart = dayjs(task.startDate).startOf('month'); + return taskMonthStart.isSame(monthStart); + }); + + if (!existingTask && monthStart.isSameOrAfter(startDate)) { + const taskTitle = `${goal.title} - ${current.format('YYYY年MM月')}`; + + await this.goalTaskModel.create({ + goalId: goal.id, + userId: goal.userId, + title: taskTitle, + description: `每月目标:完成${goal.frequency}次`, + startDate: monthStart.toDate(), + endDate: monthEnd.toDate(), + targetCount: goal.frequency, + currentCount: 0, + status: TaskStatus.PENDING, + }); + + this.logger.log(`为目标 ${goal.title} 生成每月任务: ${taskTitle}`); + } + + current = current.add(1, 'month'); + } + } + + /** + * 生成自定义周期任务 + */ + private async generateCustomTasks( + goal: Goal, + startDate: dayjs.Dayjs, + endDate: dayjs.Dayjs, + existingTasks: GoalTask[] + ): Promise { + if (!goal.customRepeatRule) { + return; + } + + const { weekdays } = goal.customRepeatRule; + + if (weekdays && weekdays.length > 0) { + // 按指定星期几生成任务 + const today = dayjs(); + const generateUntil = today.add(7, 'day'); + const actualEndDate = endDate.isBefore(generateUntil) ? endDate : generateUntil; + + let current = startDate; + + while (current.isSameOrBefore(actualEndDate)) { + const dayOfWeek = current.day(); // 0=周日, 6=周六 + + if (weekdays.includes(dayOfWeek)) { + const taskDate = current.format('YYYY-MM-DD'); + + const existingTask = existingTasks.find(task => + dayjs(task.startDate).format('YYYY-MM-DD') === taskDate + ); + + if (!existingTask) { + const weekDayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + const taskTitle = `${goal.title} - ${current.format('YYYY年MM月DD日')} ${weekDayNames[dayOfWeek]}`; + + await this.goalTaskModel.create({ + goalId: goal.id, + userId: goal.userId, + title: taskTitle, + description: `自定义目标:完成${goal.frequency}次`, + startDate: current.toDate(), + endDate: current.toDate(), + targetCount: goal.frequency, + currentCount: 0, + status: TaskStatus.PENDING, + }); + + this.logger.log(`为目标 ${goal.title} 生成自定义任务: ${taskTitle}`); + } + } + + current = current.add(1, 'day'); + } + } + } + + /** + * 更新过期任务状态 + */ + private async updateOverdueTasks(goalId: string, userId: string): Promise { + const now = new Date(); + + await this.goalTaskModel.update( + { status: TaskStatus.OVERDUE }, + { + where: { + goalId, + userId, + deleted: false, + endDate: { [Op.lt]: now }, + status: { [Op.notIn]: [TaskStatus.COMPLETED, TaskStatus.SKIPPED, TaskStatus.OVERDUE] }, + }, + } + ); + } + + /** + * 获取任务列表 + */ + async getTasks(userId: string, query: GoalTaskQueryDto) { + try { + // 先进行惰性生成 + await this.generateTasksLazily(userId, query.goalId); + + const { page = 1, pageSize = 20, goalId, status, startDate, endDate } = query; + const offset = (page - 1) * pageSize; + + const where: WhereOptions = { + userId, + deleted: false, + }; + + if (goalId) { + where.goalId = goalId; + } + + if (status) { + where.status = status; + } + + if (startDate || endDate) { + where.startDate = {}; + if (startDate) { + where.startDate[Op.gte] = new Date(startDate); + } + if (endDate) { + where.startDate[Op.lte] = new Date(endDate); + } + } + + const { rows: tasks, count } = await this.goalTaskModel.findAndCountAll({ + where, + order: [['startDate', 'ASC'], ['createdAt', 'DESC']], + offset, + limit: pageSize, + include: [ + { + model: Goal, + as: 'goal', + attributes: ['id', 'title', 'repeatType', 'frequency', 'category'], + }, + ], + }); + + return { + page, + pageSize, + total: count, + list: tasks.map(task => this.formatTaskResponse(task)), + }; + } catch (error) { + this.logger.error(`获取任务列表失败: ${error.message}`); + throw error; + } + } + + /** + * 获取单个任务详情 + */ + async getTask(userId: string, taskId: string): Promise { + try { + const task = await this.goalTaskModel.findOne({ + where: { id: taskId, userId, deleted: false }, + include: [ + { + model: Goal, + as: 'goal', + attributes: ['id', 'title', 'repeatType', 'frequency', 'category'], + }, + ], + }); + + if (!task) { + throw new NotFoundException('任务不存在'); + } + + return this.formatTaskResponse(task); + } catch (error) { + this.logger.error(`获取任务详情失败: ${error.message}`); + throw error; + } + } + + /** + * 完成任务 + */ + async completeTask(userId: string, taskId: string, completeDto: CompleteGoalTaskDto): Promise { + try { + const task = await this.goalTaskModel.findOne({ + where: { id: taskId, userId, deleted: false }, + }); + + if (!task) { + throw new NotFoundException('任务不存在'); + } + + if (task.status === TaskStatus.COMPLETED) { + throw new BadRequestException('任务已完成'); + } + + const { count = 1, notes, completedAt } = completeDto; + + // 更新完成次数 + task.currentCount = Math.min(task.currentCount + count, task.targetCount); + task.notes = notes || task.notes; + + if (completedAt) { + task.completedAt = new Date(completedAt); + } + + // 更新进度和状态 + task.updateProgress(); + + await task.save(); + + this.logger.log(`用户 ${userId} 完成任务: ${task.title}, 当前进度: ${task.currentCount}/${task.targetCount}`); + + return this.formatTaskResponse(task); + } catch (error) { + this.logger.error(`完成任务失败: ${error.message}`); + throw error; + } + } + + /** + * 更新任务 + */ + async updateTask(userId: string, taskId: string, updateDto: UpdateGoalTaskDto): Promise { + try { + const task = await this.goalTaskModel.findOne({ + where: { id: taskId, userId, deleted: false }, + }); + + if (!task) { + throw new NotFoundException('任务不存在'); + } + + await task.update({ + ...updateDto, + startDate: updateDto.startDate ? new Date(updateDto.startDate) : task.startDate, + endDate: updateDto.endDate ? new Date(updateDto.endDate) : task.endDate, + }); + + // 如果更新了目标次数,重新计算进度 + if (updateDto.targetCount !== undefined) { + task.updateProgress(); + await task.save(); + } + + this.logger.log(`用户 ${userId} 更新任务: ${task.title}`); + + return this.formatTaskResponse(task); + } catch (error) { + this.logger.error(`更新任务失败: ${error.message}`); + throw error; + } + } + + /** + * 跳过任务 + */ + async skipTask(userId: string, taskId: string, reason?: string): Promise { + try { + const task = await this.goalTaskModel.findOne({ + where: { id: taskId, userId, deleted: false }, + }); + + if (!task) { + throw new NotFoundException('任务不存在'); + } + + await task.update({ + status: TaskStatus.SKIPPED, + notes: reason || '用户主动跳过', + }); + + this.logger.log(`用户 ${userId} 跳过任务: ${task.title}`); + + return this.formatTaskResponse(task); + } catch (error) { + this.logger.error(`跳过任务失败: ${error.message}`); + throw error; + } + } + + /** + * 获取任务统计 + */ + async getTaskStats(userId: string, goalId?: string) { + try { + const where: WhereOptions = { + userId, + deleted: false, + }; + + if (goalId) { + where.goalId = goalId; + } + + const tasks = await this.goalTaskModel.findAll({ where }); + + const now = dayjs(); + const todayStart = now.startOf('day'); + const weekStart = now.startOf('isoWeek'); + const monthStart = now.startOf('month'); + + const stats = { + total: tasks.length, + pending: tasks.filter(t => t.status === TaskStatus.PENDING).length, + inProgress: tasks.filter(t => t.status === TaskStatus.IN_PROGRESS).length, + completed: tasks.filter(t => t.status === TaskStatus.COMPLETED).length, + overdue: tasks.filter(t => t.status === TaskStatus.OVERDUE).length, + skipped: tasks.filter(t => t.status === TaskStatus.SKIPPED).length, + totalProgress: 0, + todayTasks: 0, + weekTasks: 0, + monthTasks: 0, + }; + + // 计算总体进度 + if (tasks.length > 0) { + const totalProgress = tasks.reduce((sum, task) => sum + task.progressPercentage, 0); + stats.totalProgress = Math.round(totalProgress / tasks.length); + } + + // 统计时间范围内的任务 + tasks.forEach(task => { + const taskDate = dayjs(task.startDate); + + if (taskDate.isSame(todayStart, 'day')) { + stats.todayTasks++; + } + + if (taskDate.isSameOrAfter(weekStart)) { + stats.weekTasks++; + } + + if (taskDate.isSameOrAfter(monthStart)) { + stats.monthTasks++; + } + }); + + return stats; + } catch (error) { + this.logger.error(`获取任务统计失败: ${error.message}`); + throw error; + } + } + + /** + * 格式化任务响应 + */ + private formatTaskResponse(task: GoalTask) { + const taskData = task.toJSON(); + + // 检查是否过期 + task.checkOverdue(); + + // 计算剩余天数 + const endDate = dayjs(taskData.endDate); + const now = dayjs(); + taskData.daysRemaining = Math.max(0, endDate.diff(now, 'day')); + + // 计算是否为今日任务 + taskData.isToday = dayjs(taskData.startDate).isSame(now, 'day'); + + return taskData; + } +} diff --git a/test-goal-tasks.http b/test-goal-tasks.http new file mode 100644 index 0000000..c977ae4 --- /dev/null +++ b/test-goal-tasks.http @@ -0,0 +1,185 @@ +### 目标子任务API测试文件 + +@baseUrl = http://localhost:3000 +@token = your-auth-token-here + +### 1. 创建每日喝水目标 +POST {{baseUrl}}/goals +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "title": "每日喝水", + "description": "每天喝8杯水保持健康", + "repeatType": "daily", + "frequency": 8, + "category": "健康", + "startDate": "2024-01-01", + "hasReminder": true, + "reminderTime": "09:00" +} + +### 2. 创建每周运动目标 +POST {{baseUrl}}/goals +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "title": "每周运动", + "description": "每周运动3次,每次至少30分钟", + "repeatType": "weekly", + "frequency": 3, + "category": "运动", + "startDate": "2024-01-01", + "targetCount": 52 +} + +### 3. 创建自定义周期目标(周末阅读) +POST {{baseUrl}}/goals +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "title": "周末阅读", + "description": "每个周末阅读1小时", + "repeatType": "custom", + "frequency": 1, + "customRepeatRule": { + "weekdays": [0, 6] + }, + "category": "学习", + "startDate": "2024-01-01" +} + +### 4. 获取目标列表(触发任务生成) +GET {{baseUrl}}/goals?page=1&pageSize=10 +Authorization: Bearer {{token}} + +### 5. 获取所有任务列表 +GET {{baseUrl}}/goals/tasks?page=1&pageSize=20 +Authorization: Bearer {{token}} + +### 6. 获取今天的任务 +GET {{baseUrl}}/goals/tasks?startDate=2024-01-15&endDate=2024-01-15 +Authorization: Bearer {{token}} + +### 7. 获取进行中的任务 +GET {{baseUrl}}/goals/tasks?status=pending +Authorization: Bearer {{token}} + +### 8. 获取特定目标的任务列表 +# 需要替换为实际的goalId +GET {{baseUrl}}/goals/{goalId}/tasks +Authorization: Bearer {{token}} + +### 9. 完成任务 - 单次完成 +# 需要替换为实际的taskId +POST {{baseUrl}}/goals/tasks/{taskId}/complete +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "count": 1, + "notes": "早上喝了第一杯水" +} + +### 10. 完成任务 - 多次完成 +# 需要替换为实际的taskId +POST {{baseUrl}}/goals/tasks/{taskId}/complete +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "count": 3, + "notes": "连续喝了3杯水", + "completedAt": "2024-01-15T14:30:00Z" +} + +### 11. 获取单个任务详情 +# 需要替换为实际的taskId +GET {{baseUrl}}/goals/tasks/{taskId} +Authorization: Bearer {{token}} + +### 12. 更新任务 +# 需要替换为实际的taskId +PUT {{baseUrl}}/goals/tasks/{taskId} +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "notes": "修改了任务备注", + "targetCount": 10 +} + +### 13. 跳过任务 +# 需要替换为实际的taskId +POST {{baseUrl}}/goals/tasks/{taskId}/skip +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "reason": "今天身体不舒服,暂时跳过" +} + +### 14. 获取任务统计(所有目标) +GET {{baseUrl}}/goals/tasks/stats/overview +Authorization: Bearer {{token}} + +### 15. 获取特定目标的任务统计 +# 需要替换为实际的goalId +GET {{baseUrl}}/goals/tasks/stats/overview?goalId={goalId} +Authorization: Bearer {{token}} + +### 16. 获取目标详情(包含任务) +# 需要替换为实际的goalId +GET {{baseUrl}}/goals/{goalId} +Authorization: Bearer {{token}} + +### 17. 批量操作目标 +POST {{baseUrl}}/goals/batch +Authorization: Bearer {{token}} +Content-Type: application/json + +{ + "goalIds": ["goal-id-1", "goal-id-2"], + "action": "pause" +} + +### 18. 获取过期任务 +GET {{baseUrl}}/goals/tasks?status=overdue +Authorization: Bearer {{token}} + +### 19. 获取已完成任务 +GET {{baseUrl}}/goals/tasks?status=completed&page=1&pageSize=10 +Authorization: Bearer {{token}} + +### 20. 获取本周任务 +GET {{baseUrl}}/goals/tasks?startDate=2024-01-08&endDate=2024-01-14 +Authorization: Bearer {{token}} + +### 测试场景说明 + +# 场景1:每日喝水目标测试流程 +# 1. 创建每日喝水目标(接口1) +# 2. 获取目标列表触发任务生成(接口4) +# 3. 查看今天的任务(接口6) +# 4. 分多次完成喝水任务(接口9、10) +# 5. 查看任务统计(接口14) + +# 场景2:每周运动目标测试流程 +# 1. 创建每周运动目标(接口2) +# 2. 获取本周任务(接口20) +# 3. 完成运动任务(接口9) +# 4. 查看特定目标的任务列表(接口8) + +# 场景3:任务管理测试流程 +# 1. 查看所有待完成任务(接口7) +# 2. 跳过某个任务(接口13) +# 3. 更新任务信息(接口12) +# 4. 查看过期任务(接口18) + +### 注意事项 +# 1. 替换所有的 {goalId} 和 {taskId} 为实际的ID值 +# 2. 替换 {{token}} 为有效的认证令牌 +# 3. 根据实际情况调整日期参数 +# 4. 某些API需要先创建目标和任务后才能测试