feat: 新增目标子任务管理功能模块
- 实现目标子任务的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。 - 支持用户创建、管理和跟踪目标子任务,提供增删改查操作及任务完成记录功能。 - 引入惰性任务生成机制,优化任务管理体验,提升系统性能和用户交互。
This commit is contained in:
304
docs/goal-tasks-api-guide.md
Normal file
304
docs/goal-tasks-api-guide.md
Normal file
@@ -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。
|
||||||
49
sql-scripts/goal-tasks-table-create.sql
Normal file
49
sql-scripts/goal-tasks-table-create.sql
Normal file
@@ -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`);
|
||||||
122
src/goals/dto/goal-task.dto.ts
Normal file
122
src/goals/dto/goal-task.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { CreateGoalDto } from './dto/create-goal.dto';
|
|||||||
import { UpdateGoalDto } from './dto/update-goal.dto';
|
import { UpdateGoalDto } from './dto/update-goal.dto';
|
||||||
import { GoalQueryDto } from './dto/goal-query.dto';
|
import { GoalQueryDto } from './dto/goal-query.dto';
|
||||||
import { CreateGoalCompletionDto } from './dto/goal-completion.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 { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||||
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
||||||
import { GoalStatus } from './models/goal.model';
|
import { GoalStatus } from './models/goal.model';
|
||||||
@@ -25,7 +27,10 @@ import { AccessTokenPayload } from 'src/users/services/apple-auth.service';
|
|||||||
@Controller('goals')
|
@Controller('goals')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class GoalsController {
|
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,
|
data: results,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 子任务相关API ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
*/
|
||||||
|
@Get('tasks')
|
||||||
|
async getTasks(
|
||||||
|
@Query() query: GoalTaskQueryDto,
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
): Promise<BaseResponseDto<any>> {
|
||||||
|
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<BaseResponseDto<any>> {
|
||||||
|
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<BaseResponseDto<any>> {
|
||||||
|
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<BaseResponseDto<any>> {
|
||||||
|
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<BaseResponseDto<any>> {
|
||||||
|
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<BaseResponseDto<any>> {
|
||||||
|
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<GoalTaskQueryDto, 'goalId'>,
|
||||||
|
@CurrentUser() user: AccessTokenPayload,
|
||||||
|
): Promise<BaseResponseDto<any>> {
|
||||||
|
const taskQuery = { ...query, goalId };
|
||||||
|
const result = await this.goalTaskService.getTasks(user.sub, taskQuery);
|
||||||
|
return {
|
||||||
|
code: ResponseCode.SUCCESS,
|
||||||
|
message: '获取目标任务列表成功',
|
||||||
|
data: result,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { Module } from '@nestjs/common';
|
|||||||
import { SequelizeModule } from '@nestjs/sequelize';
|
import { SequelizeModule } from '@nestjs/sequelize';
|
||||||
import { GoalsController } from './goals.controller';
|
import { GoalsController } from './goals.controller';
|
||||||
import { GoalsService } from './goals.service';
|
import { GoalsService } from './goals.service';
|
||||||
|
import { GoalTaskService } from './services/goal-task.service';
|
||||||
import { Goal } from './models/goal.model';
|
import { Goal } from './models/goal.model';
|
||||||
import { GoalCompletion } from './models/goal-completion.model';
|
import { GoalCompletion } from './models/goal-completion.model';
|
||||||
|
import { GoalTask } from './models/goal-task.model';
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
SequelizeModule.forFeature([Goal, GoalCompletion]),
|
SequelizeModule.forFeature([Goal, GoalCompletion, GoalTask]),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
controllers: [GoalsController],
|
controllers: [GoalsController],
|
||||||
providers: [GoalsService],
|
providers: [GoalsService, GoalTaskService],
|
||||||
exports: [GoalsService],
|
exports: [GoalsService, GoalTaskService],
|
||||||
})
|
})
|
||||||
export class GoalsModule {}
|
export class GoalsModule { }
|
||||||
|
|||||||
@@ -1,17 +1,30 @@
|
|||||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/sequelize';
|
||||||
import { Op, WhereOptions, Order } from 'sequelize';
|
import { Op, WhereOptions, Order } from 'sequelize';
|
||||||
import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model';
|
import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model';
|
||||||
import { GoalCompletion } from './models/goal-completion.model';
|
import { GoalCompletion } from './models/goal-completion.model';
|
||||||
|
import { GoalTask } from './models/goal-task.model';
|
||||||
import { CreateGoalDto } from './dto/create-goal.dto';
|
import { CreateGoalDto } from './dto/create-goal.dto';
|
||||||
import { UpdateGoalDto } from './dto/update-goal.dto';
|
import { UpdateGoalDto } from './dto/update-goal.dto';
|
||||||
import { GoalQueryDto } from './dto/goal-query.dto';
|
import { GoalQueryDto } from './dto/goal-query.dto';
|
||||||
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
|
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
|
||||||
|
import { GoalTaskService } from './services/goal-task.service';
|
||||||
import * as dayjs from 'dayjs';
|
import * as dayjs from 'dayjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoalsService {
|
export class GoalsService {
|
||||||
private readonly logger = new Logger(GoalsService.name);
|
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('结束日期不能早于开始日期');
|
throw new BadRequestException('结束日期不能早于开始日期');
|
||||||
}
|
}
|
||||||
|
|
||||||
const goal = await Goal.create({
|
const goal = await this.goalModel.create({
|
||||||
userId,
|
userId,
|
||||||
...createGoalDto,
|
...createGoalDto,
|
||||||
startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : null,
|
startDate: createGoalDto.startDate ? new Date(createGoalDto.startDate) : undefined,
|
||||||
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : null,
|
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : undefined,
|
||||||
startTime: createGoalDto.startTime ? createGoalDto.startTime : null,
|
startTime: createGoalDto.startTime ? createGoalDto.startTime : undefined,
|
||||||
endTime: createGoalDto.endTime ? createGoalDto.endTime : null,
|
endTime: createGoalDto.endTime ? createGoalDto.endTime : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`);
|
this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`);
|
||||||
@@ -49,6 +62,9 @@ export class GoalsService {
|
|||||||
*/
|
*/
|
||||||
async getGoals(userId: string, query: GoalQueryDto) {
|
async getGoals(userId: string, query: GoalQueryDto) {
|
||||||
try {
|
try {
|
||||||
|
// 惰性生成任务
|
||||||
|
await this.goalTaskService.generateTasksLazily(userId);
|
||||||
|
|
||||||
const { page = 1, pageSize = 20, status, repeatType, category, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query;
|
const { page = 1, pageSize = 20, status, repeatType, category, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query;
|
||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
@@ -85,7 +101,7 @@ export class GoalsService {
|
|||||||
// 构建排序条件
|
// 构建排序条件
|
||||||
const order: Order = [[sortBy, sortOrder.toUpperCase()]];
|
const order: Order = [[sortBy, sortOrder.toUpperCase()]];
|
||||||
|
|
||||||
const { rows: goals, count } = await Goal.findAndCountAll({
|
const { rows: goals, count } = await this.goalModel.findAndCountAll({
|
||||||
where,
|
where,
|
||||||
order,
|
order,
|
||||||
offset,
|
offset,
|
||||||
@@ -97,6 +113,14 @@ export class GoalsService {
|
|||||||
where: { deleted: false },
|
where: { deleted: false },
|
||||||
required: 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<Goal> {
|
async getGoal(userId: string, goalId: string): Promise<Goal> {
|
||||||
try {
|
try {
|
||||||
const goal = await Goal.findOne({
|
// 惰性生成任务
|
||||||
|
await this.goalTaskService.generateTasksLazily(userId, goalId);
|
||||||
|
|
||||||
|
const goal = await this.goalModel.findOne({
|
||||||
where: { id: goalId, userId, deleted: false },
|
where: { id: goalId, userId, deleted: false },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@@ -128,6 +155,14 @@ export class GoalsService {
|
|||||||
order: [['completedAt', 'DESC']],
|
order: [['completedAt', 'DESC']],
|
||||||
limit: 10, // 只显示最近10次完成记录
|
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<Goal> {
|
async updateGoal(userId: string, goalId: string, updateGoalDto: UpdateGoalDto): Promise<Goal> {
|
||||||
try {
|
try {
|
||||||
const goal = await Goal.findOne({
|
const goal = await this.goalModel.findOne({
|
||||||
where: { id: goalId, userId, deleted: false },
|
where: { id: goalId, userId, deleted: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,7 +221,7 @@ export class GoalsService {
|
|||||||
*/
|
*/
|
||||||
async deleteGoal(userId: string, goalId: string): Promise<boolean> {
|
async deleteGoal(userId: string, goalId: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const goal = await Goal.findOne({
|
const goal = await this.goalModel.findOne({
|
||||||
where: { id: goalId, userId, deleted: false },
|
where: { id: goalId, userId, deleted: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,7 +233,7 @@ export class GoalsService {
|
|||||||
await goal.update({ deleted: true });
|
await goal.update({ deleted: true });
|
||||||
|
|
||||||
// 软删除相关的完成记录
|
// 软删除相关的完成记录
|
||||||
await GoalCompletion.update(
|
await this.goalCompletionModel.update(
|
||||||
{ deleted: true },
|
{ deleted: true },
|
||||||
{ where: { goalId, userId } }
|
{ where: { goalId, userId } }
|
||||||
);
|
);
|
||||||
@@ -216,7 +251,7 @@ export class GoalsService {
|
|||||||
*/
|
*/
|
||||||
async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise<GoalCompletion> {
|
async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise<GoalCompletion> {
|
||||||
try {
|
try {
|
||||||
const goal = await Goal.findOne({
|
const goal = await this.goalModel.findOne({
|
||||||
where: { id: createCompletionDto.goalId, userId, deleted: false },
|
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 completedAt = createCompletionDto.completedAt ? new Date(createCompletionDto.completedAt) : new Date();
|
||||||
|
|
||||||
// 创建完成记录
|
// 创建完成记录
|
||||||
const completion = await GoalCompletion.create({
|
const completion = await this.goalCompletionModel.create({
|
||||||
goalId: createCompletionDto.goalId,
|
goalId: createCompletionDto.goalId,
|
||||||
userId,
|
userId,
|
||||||
completedAt,
|
completedAt,
|
||||||
@@ -266,7 +301,7 @@ export class GoalsService {
|
|||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
// 验证目标存在
|
// 验证目标存在
|
||||||
const goal = await Goal.findOne({
|
const goal = await this.goalModel.findOne({
|
||||||
where: { id: goalId, userId, deleted: false },
|
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,
|
where,
|
||||||
order: [['completedAt', 'DESC']],
|
order: [['completedAt', 'DESC']],
|
||||||
offset,
|
offset,
|
||||||
@@ -322,7 +357,7 @@ export class GoalsService {
|
|||||||
*/
|
*/
|
||||||
async getGoalStats(userId: string) {
|
async getGoalStats(userId: string) {
|
||||||
try {
|
try {
|
||||||
const goals = await Goal.findAll({
|
const goals = await this.goalModel.findAll({
|
||||||
where: { userId, deleted: false },
|
where: { userId, deleted: false },
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
|
|||||||
166
src/goals/models/goal-task.model.ts
Normal file
166
src/goals/models/goal-task.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Column, DataType, Model, Table, HasMany } from 'sequelize-typescript';
|
import { Column, DataType, Model, Table, HasMany } from 'sequelize-typescript';
|
||||||
import { GoalCompletion } from './goal-completion.model';
|
import { GoalCompletion } from './goal-completion.model';
|
||||||
|
import { GoalTask } from './goal-task.model';
|
||||||
|
|
||||||
export enum GoalRepeatType {
|
export enum GoalRepeatType {
|
||||||
DAILY = 'daily',
|
DAILY = 'daily',
|
||||||
@@ -73,7 +74,7 @@ export class Goal extends Model {
|
|||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: DataType.DATEONLY,
|
type: DataType.DATEONLY,
|
||||||
allowNull: false,
|
allowNull: true,
|
||||||
comment: '目标开始日期',
|
comment: '目标开始日期',
|
||||||
})
|
})
|
||||||
declare startDate: Date;
|
declare startDate: Date;
|
||||||
@@ -182,4 +183,7 @@ export class Goal extends Model {
|
|||||||
|
|
||||||
@HasMany(() => GoalCompletion, 'goalId')
|
@HasMany(() => GoalCompletion, 'goalId')
|
||||||
declare completions: GoalCompletion[];
|
declare completions: GoalCompletion[];
|
||||||
|
|
||||||
|
@HasMany(() => GoalTask, 'goalId')
|
||||||
|
declare tasks: GoalTask[];
|
||||||
}
|
}
|
||||||
|
|||||||
581
src/goals/services/goal-task.service.ts
Normal file
581
src/goals/services/goal-task.service.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<GoalTask> {
|
||||||
|
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<GoalTask> {
|
||||||
|
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<GoalTask> {
|
||||||
|
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<GoalTask> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
185
test-goal-tasks.http
Normal file
185
test-goal-tasks.http
Normal file
@@ -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需要先创建目标和任务后才能测试
|
||||||
Reference in New Issue
Block a user