feat: 新增目标管理功能模块
实现目标管理的完整功能,包括数据库表设计、API接口、业务逻辑和文档说明。支持用户创建、管理和跟踪个人目标,提供增删改查操作及目标完成记录功能。
This commit is contained in:
399
docs/goals-api-guide.md
Normal file
399
docs/goals-api-guide.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# 目标管理 API 文档
|
||||
|
||||
## 概述
|
||||
|
||||
目标管理功能允许用户创建、管理和跟踪个人目标。每个目标包含标题、重复周期、频率等属性,支持完整的增删改查操作。
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 目标 (Goal)
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| id | UUID | 是 | 目标唯一标识 |
|
||||
| userId | String | 是 | 用户ID |
|
||||
| title | String | 是 | 目标标题 |
|
||||
| description | Text | 否 | 目标描述 |
|
||||
| repeatType | Enum | 是 | 重复周期类型:daily/weekly/monthly/custom |
|
||||
| frequency | Integer | 是 | 频率(每天/每周/每月多少次) |
|
||||
| customRepeatRule | JSON | 否 | 自定义重复规则 |
|
||||
| startDate | Date | 是 | 目标开始日期 |
|
||||
| endDate | Date | 否 | 目标结束日期 |
|
||||
| status | Enum | 是 | 目标状态:active/paused/completed/cancelled |
|
||||
| completedCount | Integer | 是 | 已完成次数 |
|
||||
| targetCount | Integer | 否 | 目标总次数 |
|
||||
| category | String | 否 | 目标分类标签 |
|
||||
| priority | Integer | 是 | 优先级(0-10) |
|
||||
| hasReminder | Boolean | 是 | 是否提醒 |
|
||||
| reminderTime | Time | 否 | 提醒时间 |
|
||||
| reminderSettings | JSON | 否 | 提醒设置 |
|
||||
|
||||
### 目标完成记录 (GoalCompletion)
|
||||
|
||||
| 字段 | 类型 | 必填 | 描述 |
|
||||
|------|------|------|------|
|
||||
| id | UUID | 是 | 完成记录唯一标识 |
|
||||
| goalId | UUID | 是 | 目标ID |
|
||||
| userId | String | 是 | 用户ID |
|
||||
| completedAt | DateTime | 是 | 完成日期 |
|
||||
| completionCount | Integer | 是 | 完成次数 |
|
||||
| notes | Text | 否 | 完成备注 |
|
||||
| metadata | JSON | 否 | 额外数据 |
|
||||
|
||||
## API 接口
|
||||
|
||||
### 1. 创建目标
|
||||
|
||||
**POST** `/goals`
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"title": "每天跑步30分钟",
|
||||
"description": "提高心肺功能,增强体质",
|
||||
"repeatType": "daily",
|
||||
"frequency": 1,
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": "2024-12-31",
|
||||
"targetCount": 365,
|
||||
"category": "运动",
|
||||
"priority": 5,
|
||||
"hasReminder": true,
|
||||
"reminderTime": "07:00",
|
||||
"reminderSettings": {
|
||||
"weekdays": [1, 2, 3, 4, 5, 6, 0],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "目标创建成功",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "每天跑步30分钟",
|
||||
"status": "active",
|
||||
"completedCount": 0,
|
||||
"progressPercentage": 0,
|
||||
"daysRemaining": 365
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 获取目标列表
|
||||
|
||||
**GET** `/goals?page=1&pageSize=20&status=active&category=运动&search=跑步`
|
||||
|
||||
**查询参数:**
|
||||
- `page`: 页码(默认1)
|
||||
- `pageSize`: 每页数量(默认20,最大100)
|
||||
- `status`: 目标状态筛选
|
||||
- `repeatType`: 重复类型筛选
|
||||
- `category`: 分类筛选
|
||||
- `search`: 搜索标题和描述
|
||||
- `startDate`: 开始日期范围
|
||||
- `endDate`: 结束日期范围
|
||||
- `sortBy`: 排序字段(createdAt/updatedAt/priority/title/startDate)
|
||||
- `sortOrder`: 排序方向(asc/desc)
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取目标列表成功",
|
||||
"data": {
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 5,
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "每天跑步30分钟",
|
||||
"status": "active",
|
||||
"completedCount": 15,
|
||||
"targetCount": 365,
|
||||
"progressPercentage": 4,
|
||||
"daysRemaining": 350
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 获取目标详情
|
||||
|
||||
**GET** `/goals/{id}`
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取目标详情成功",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "每天跑步30分钟",
|
||||
"description": "提高心肺功能,增强体质",
|
||||
"repeatType": "daily",
|
||||
"frequency": 1,
|
||||
"status": "active",
|
||||
"completedCount": 15,
|
||||
"targetCount": 365,
|
||||
"progressPercentage": 4,
|
||||
"daysRemaining": 350,
|
||||
"completions": [
|
||||
{
|
||||
"id": "completion-uuid",
|
||||
"completedAt": "2024-01-15T07:00:00Z",
|
||||
"completionCount": 1,
|
||||
"notes": "今天感觉很好"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 更新目标
|
||||
|
||||
**PUT** `/goals/{id}`
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"title": "每天跑步45分钟",
|
||||
"frequency": 1,
|
||||
"priority": 7
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "目标更新成功",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"title": "每天跑步45分钟",
|
||||
"priority": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 删除目标
|
||||
|
||||
**DELETE** `/goals/{id}`
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "目标删除成功",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
### 6. 记录目标完成
|
||||
|
||||
**POST** `/goals/{id}/complete`
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"completionCount": 1,
|
||||
"notes": "今天完成了跑步目标",
|
||||
"completedAt": "2024-01-15T07:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "目标完成记录成功",
|
||||
"data": {
|
||||
"id": "completion-uuid",
|
||||
"goalId": "goal-uuid",
|
||||
"completedAt": "2024-01-15T07:30:00Z",
|
||||
"completionCount": 1,
|
||||
"notes": "今天完成了跑步目标"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7. 获取目标完成记录
|
||||
|
||||
**GET** `/goals/{id}/completions?page=1&pageSize=20&startDate=2024-01-01&endDate=2024-01-31`
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取目标完成记录成功",
|
||||
"data": {
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 15,
|
||||
"items": [
|
||||
{
|
||||
"id": "completion-uuid",
|
||||
"completedAt": "2024-01-15T07:30:00Z",
|
||||
"completionCount": 1,
|
||||
"notes": "今天完成了跑步目标",
|
||||
"goal": {
|
||||
"id": "goal-uuid",
|
||||
"title": "每天跑步30分钟"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. 获取目标统计信息
|
||||
|
||||
**GET** `/goals/stats/overview`
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "获取目标统计成功",
|
||||
"data": {
|
||||
"total": 10,
|
||||
"active": 7,
|
||||
"completed": 2,
|
||||
"paused": 1,
|
||||
"cancelled": 0,
|
||||
"byCategory": {
|
||||
"运动": 5,
|
||||
"学习": 3,
|
||||
"健康": 2
|
||||
},
|
||||
"byRepeatType": {
|
||||
"daily": 6,
|
||||
"weekly": 3,
|
||||
"monthly": 1
|
||||
},
|
||||
"totalCompletions": 150,
|
||||
"thisWeekCompletions": 25,
|
||||
"thisMonthCompletions": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 9. 批量操作目标
|
||||
|
||||
**POST** `/goals/batch`
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
{
|
||||
"goalIds": ["uuid1", "uuid2", "uuid3"],
|
||||
"action": "pause"
|
||||
}
|
||||
```
|
||||
|
||||
**支持的操作:**
|
||||
- `pause`: 暂停目标
|
||||
- `resume`: 恢复目标
|
||||
- `complete`: 完成目标
|
||||
- `delete`: 删除目标
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "批量操作完成",
|
||||
"data": [
|
||||
{
|
||||
"goalId": "uuid1",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"goalId": "uuid2",
|
||||
"success": true
|
||||
},
|
||||
{
|
||||
"goalId": "uuid3",
|
||||
"success": false,
|
||||
"error": "目标不存在"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 创建每日运动目标
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/goals \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "每日普拉提练习",
|
||||
"description": "每天进行30分钟的普拉提练习,提高核心力量",
|
||||
"repeatType": "daily",
|
||||
"frequency": 1,
|
||||
"startDate": "2024-01-01",
|
||||
"category": "运动",
|
||||
"priority": 8,
|
||||
"hasReminder": true,
|
||||
"reminderTime": "18:00"
|
||||
}'
|
||||
```
|
||||
|
||||
### 创建每周学习目标
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/goals \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "每周阅读一本书",
|
||||
"description": "每周至少阅读一本专业书籍",
|
||||
"repeatType": "weekly",
|
||||
"frequency": 1,
|
||||
"startDate": "2024-01-01",
|
||||
"targetCount": 52,
|
||||
"category": "学习",
|
||||
"priority": 6
|
||||
}'
|
||||
```
|
||||
|
||||
### 记录目标完成
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/goals/GOAL_ID/complete \
|
||||
-H "Authorization: Bearer YOUR_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"notes": "今天完成了30分钟的普拉提练习,感觉很好"
|
||||
}'
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有接口都遵循统一的错误响应格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1,
|
||||
"message": "错误描述",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
常见错误:
|
||||
- `目标不存在`: 404
|
||||
- `参数验证失败`: 400
|
||||
- `权限不足`: 403
|
||||
- `服务器内部错误`: 500
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 所有接口都需要JWT认证
|
||||
2. 用户只能操作自己的目标
|
||||
3. 已完成的目标不能修改状态
|
||||
4. 删除操作采用软删除,不会真正删除数据
|
||||
5. 目标完成记录会自动更新目标的完成次数
|
||||
6. 达到目标总次数时,目标状态会自动变为已完成
|
||||
83
sql-scripts/goals-tables-create.sql
Normal file
83
sql-scripts/goals-tables-create.sql
Normal file
@@ -0,0 +1,83 @@
|
||||
-- 创建目标表
|
||||
CREATE TABLE IF NOT EXISTS t_goals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
repeat_type VARCHAR(20) NOT NULL DEFAULT 'daily' CHECK (repeat_type IN ('daily', 'weekly', 'monthly', 'custom')),
|
||||
frequency INTEGER NOT NULL DEFAULT 1 CHECK (frequency > 0 AND frequency <= 100),
|
||||
custom_repeat_rule JSONB,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'completed', 'cancelled')),
|
||||
completed_count INTEGER NOT NULL DEFAULT 0,
|
||||
target_count INTEGER CHECK (target_count > 0),
|
||||
category VARCHAR(100),
|
||||
priority INTEGER NOT NULL DEFAULT 0 CHECK (priority >= 0 AND priority <= 10),
|
||||
has_reminder BOOLEAN NOT NULL DEFAULT false,
|
||||
reminder_time TIME,
|
||||
reminder_settings JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- 创建目标完成记录表
|
||||
CREATE TABLE IF NOT EXISTS t_goal_completions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
goal_id UUID NOT NULL REFERENCES t_goals(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR(255) NOT NULL,
|
||||
completed_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
completion_count INTEGER NOT NULL DEFAULT 1 CHECK (completion_count > 0),
|
||||
notes TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_user_id ON t_goals(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_status ON t_goals(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_repeat_type ON t_goals(repeat_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_category ON t_goals(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_start_date ON t_goals(start_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_deleted ON t_goals(deleted);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_completions_goal_id ON t_goal_completions(goal_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_completions_user_id ON t_goal_completions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_completions_completed_at ON t_goal_completions(completed_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_completions_deleted ON t_goal_completions(deleted);
|
||||
|
||||
-- 创建复合索引
|
||||
CREATE INDEX IF NOT EXISTS idx_goals_user_status ON t_goals(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_goal_completions_goal_completed ON t_goal_completions(goal_id, completed_at);
|
||||
|
||||
-- 添加注释
|
||||
COMMENT ON TABLE t_goals IS '用户目标表';
|
||||
COMMENT ON COLUMN t_goals.id IS '目标ID';
|
||||
COMMENT ON COLUMN t_goals.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN t_goals.title IS '目标标题';
|
||||
COMMENT ON COLUMN t_goals.description IS '目标描述';
|
||||
COMMENT ON COLUMN t_goals.repeat_type IS '重复周期类型:daily-每日,weekly-每周,monthly-每月,custom-自定义';
|
||||
COMMENT ON COLUMN t_goals.frequency IS '频率(每天/每周/每月多少次)';
|
||||
COMMENT ON COLUMN t_goals.custom_repeat_rule IS '自定义重复规则(如每周几)';
|
||||
COMMENT ON COLUMN t_goals.start_date IS '目标开始日期';
|
||||
COMMENT ON COLUMN t_goals.end_date IS '目标结束日期';
|
||||
COMMENT ON COLUMN t_goals.status IS '目标状态:active-激活,paused-暂停,completed-已完成,cancelled-已取消';
|
||||
COMMENT ON COLUMN t_goals.completed_count IS '已完成次数';
|
||||
COMMENT ON COLUMN t_goals.target_count IS '目标总次数(null表示无限制)';
|
||||
COMMENT ON COLUMN t_goals.category IS '目标分类标签';
|
||||
COMMENT ON COLUMN t_goals.priority IS '优先级(数字越大优先级越高)';
|
||||
COMMENT ON COLUMN t_goals.has_reminder IS '是否提醒';
|
||||
COMMENT ON COLUMN t_goals.reminder_time IS '提醒时间';
|
||||
COMMENT ON COLUMN t_goals.reminder_settings IS '提醒设置(如每周几提醒)';
|
||||
|
||||
COMMENT ON TABLE t_goal_completions IS '目标完成记录表';
|
||||
COMMENT ON COLUMN t_goal_completions.id IS '完成记录ID';
|
||||
COMMENT ON COLUMN t_goal_completions.goal_id IS '目标ID';
|
||||
COMMENT ON COLUMN t_goal_completions.user_id IS '用户ID';
|
||||
COMMENT ON COLUMN t_goal_completions.completed_at IS '完成日期';
|
||||
COMMENT ON COLUMN t_goal_completions.completion_count IS '完成次数';
|
||||
COMMENT ON COLUMN t_goal_completions.notes IS '完成备注';
|
||||
COMMENT ON COLUMN t_goal_completions.metadata IS '完成时的额外数据';
|
||||
@@ -14,6 +14,7 @@ import { ActivityLogsModule } from './activity-logs/activity-logs.module';
|
||||
import { ExercisesModule } from './exercises/exercises.module';
|
||||
import { WorkoutsModule } from './workouts/workouts.module';
|
||||
import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
|
||||
import { GoalsModule } from './goals/goals.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -33,6 +34,7 @@ import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module';
|
||||
ExercisesModule,
|
||||
WorkoutsModule,
|
||||
MoodCheckinsModule,
|
||||
GoalsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
95
src/goals/dto/create-goal.dto.ts
Normal file
95
src/goals/dto/create-goal.dto.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { IsString, IsNotEmpty, IsOptional, IsEnum, IsInt, IsDateString, IsBoolean, Min, Max, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { GoalRepeatType, GoalStatus } from '../models/goal.model';
|
||||
|
||||
export class CustomRepeatRuleDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
weekdays?: number[]; // 0-6 表示周日到周六
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(31)
|
||||
dayOfMonth?: number; // 每月第几天
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(12)
|
||||
monthOfYear?: number; // 每年第几月
|
||||
}
|
||||
|
||||
export class ReminderSettingsDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
@Max(6, { each: true })
|
||||
weekdays?: number[]; // 提醒的星期几
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class CreateGoalDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '目标标题不能为空' })
|
||||
title: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(GoalRepeatType, { message: '重复周期类型无效' })
|
||||
repeatType: GoalRepeatType;
|
||||
|
||||
@IsInt()
|
||||
@Min(1, { message: '频率必须大于0' })
|
||||
@Max(100, { message: '频率不能超过100' })
|
||||
frequency: number;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => CustomRepeatRuleDto)
|
||||
customRepeatRule?: CustomRepeatRuleDto;
|
||||
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '目标总次数必须大于0' })
|
||||
targetCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0, { message: '优先级不能小于0' })
|
||||
@Max(10, { message: '优先级不能超过10' })
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
hasReminder?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminderTime?: string; // HH:mm 格式
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => ReminderSettingsDto)
|
||||
reminderSettings?: ReminderSettingsDto;
|
||||
}
|
||||
33
src/goals/dto/goal-completion.dto.ts
Normal file
33
src/goals/dto/goal-completion.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { IsString, IsOptional, IsInt, IsDateString, Min, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateGoalCompletionDto {
|
||||
@IsUUID()
|
||||
goalId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '完成日期格式无效' })
|
||||
completedAt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1, { message: '完成次数必须大于0' })
|
||||
completionCount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class GoalCompletionQueryDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '开始日期格式无效' })
|
||||
startDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: '结束日期格式无效' })
|
||||
endDate?: string;
|
||||
}
|
||||
50
src/goals/dto/goal-query.dto.ts
Normal file
50
src/goals/dto/goal-query.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { GoalStatus, GoalRepeatType } from '../models/goal.model';
|
||||
|
||||
export class GoalQueryDto {
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
pageSize?: number = 20;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GoalStatus)
|
||||
status?: GoalStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GoalRepeatType)
|
||||
repeatType?: GoalRepeatType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string; // 搜索标题和描述
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
startDate?: string; // 开始日期范围
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
endDate?: string; // 结束日期范围
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'priority' | 'title' | 'startDate' = 'createdAt';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sortOrder?: 'asc' | 'desc' = 'desc';
|
||||
}
|
||||
10
src/goals/dto/update-goal.dto.ts
Normal file
10
src/goals/dto/update-goal.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateGoalDto } from './create-goal.dto';
|
||||
import { IsOptional, IsEnum } from 'class-validator';
|
||||
import { GoalStatus } from '../models/goal.model';
|
||||
|
||||
export class UpdateGoalDto extends PartialType(CreateGoalDto) {
|
||||
@IsOptional()
|
||||
@IsEnum(GoalStatus, { message: '目标状态无效' })
|
||||
status?: GoalStatus;
|
||||
}
|
||||
202
src/goals/goals.controller.ts
Normal file
202
src/goals/goals.controller.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { GoalsService } from './goals.service';
|
||||
import { CreateGoalDto } from './dto/create-goal.dto';
|
||||
import { UpdateGoalDto } from './dto/update-goal.dto';
|
||||
import { GoalQueryDto } from './dto/goal-query.dto';
|
||||
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
|
||||
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
|
||||
import { BaseResponseDto, ResponseCode } from '../base.dto';
|
||||
|
||||
@Controller('goals')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class GoalsController {
|
||||
constructor(private readonly goalsService: GoalsService) {}
|
||||
|
||||
/**
|
||||
* 创建目标
|
||||
*/
|
||||
@Post()
|
||||
async createGoal(
|
||||
@Request() req,
|
||||
@Body() createGoalDto: CreateGoalDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const goal = await this.goalsService.createGoal(req.user.id, createGoalDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标创建成功',
|
||||
data: goal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标列表
|
||||
*/
|
||||
@Get()
|
||||
async getGoals(
|
||||
@Request() req,
|
||||
@Query() query: GoalQueryDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const result = await this.goalsService.getGoals(req.user.id, query);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标列表成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个目标详情
|
||||
*/
|
||||
@Get(':id')
|
||||
async getGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const goal = await this.goalsService.getGoal(req.user.id, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标详情成功',
|
||||
data: goal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新目标
|
||||
*/
|
||||
@Put(':id')
|
||||
async updateGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
@Body() updateGoalDto: UpdateGoalDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const goal = await this.goalsService.updateGoal(req.user.id, id, updateGoalDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标更新成功',
|
||||
data: goal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目标
|
||||
*/
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async deleteGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
): Promise<BaseResponseDto<boolean>> {
|
||||
const result = await this.goalsService.deleteGoal(req.user.id, id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标删除成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录目标完成
|
||||
*/
|
||||
@Post(':id/complete')
|
||||
async completeGoal(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
@Body() createCompletionDto: CreateGoalCompletionDto,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
// 确保完成记录的目标ID与路径参数一致
|
||||
createCompletionDto.goalId = id;
|
||||
const completion = await this.goalsService.completeGoal(req.user.id, createCompletionDto);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '目标完成记录成功',
|
||||
data: completion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标完成记录
|
||||
*/
|
||||
@Get(':id/completions')
|
||||
async getGoalCompletions(
|
||||
@Request() req,
|
||||
@Param('id') id: string,
|
||||
@Query() query: any,
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const result = await this.goalsService.getGoalCompletions(req.user.id, id, query);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标完成记录成功',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标统计信息
|
||||
*/
|
||||
@Get('stats/overview')
|
||||
async getGoalStats(@Request() req): Promise<BaseResponseDto<any>> {
|
||||
const stats = await this.goalsService.getGoalStats(req.user.id);
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '获取目标统计成功',
|
||||
data: stats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量操作目标
|
||||
*/
|
||||
@Post('batch')
|
||||
async batchUpdateGoals(
|
||||
@Request() req,
|
||||
@Body() body: {
|
||||
goalIds: string[];
|
||||
action: 'pause' | 'resume' | 'complete' | 'delete';
|
||||
data?: any;
|
||||
},
|
||||
): Promise<BaseResponseDto<any>> {
|
||||
const { goalIds, action, data } = body;
|
||||
const results = [];
|
||||
|
||||
for (const goalId of goalIds) {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'pause':
|
||||
await this.goalsService.updateGoal(req.user.id, goalId, { status: 'paused' });
|
||||
break;
|
||||
case 'resume':
|
||||
await this.goalsService.updateGoal(req.user.id, goalId, { status: 'active' });
|
||||
break;
|
||||
case 'complete':
|
||||
await this.goalsService.updateGoal(req.user.id, goalId, { status: 'completed' });
|
||||
break;
|
||||
case 'delete':
|
||||
await this.goalsService.deleteGoal(req.user.id, goalId);
|
||||
break;
|
||||
}
|
||||
results.push({ goalId, success: true });
|
||||
} catch (error) {
|
||||
results.push({ goalId, success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
code: ResponseCode.SUCCESS,
|
||||
message: '批量操作完成',
|
||||
data: results,
|
||||
};
|
||||
}
|
||||
}
|
||||
16
src/goals/goals.module.ts
Normal file
16
src/goals/goals.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SequelizeModule } from '@nestjs/sequelize';
|
||||
import { GoalsController } from './goals.controller';
|
||||
import { GoalsService } from './goals.service';
|
||||
import { Goal } from './models/goal.model';
|
||||
import { GoalCompletion } from './models/goal-completion.model';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SequelizeModule.forFeature([Goal, GoalCompletion]),
|
||||
],
|
||||
controllers: [GoalsController],
|
||||
providers: [GoalsService],
|
||||
exports: [GoalsService],
|
||||
})
|
||||
export class GoalsModule {}
|
||||
412
src/goals/goals.service.ts
Normal file
412
src/goals/goals.service.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Op, WhereOptions, Order } from 'sequelize';
|
||||
import { Goal, GoalStatus, GoalRepeatType } from './models/goal.model';
|
||||
import { GoalCompletion } from './models/goal-completion.model';
|
||||
import { CreateGoalDto } from './dto/create-goal.dto';
|
||||
import { UpdateGoalDto } from './dto/update-goal.dto';
|
||||
import { GoalQueryDto } from './dto/goal-query.dto';
|
||||
import { CreateGoalCompletionDto } from './dto/goal-completion.dto';
|
||||
import * as dayjs from 'dayjs';
|
||||
|
||||
@Injectable()
|
||||
export class GoalsService {
|
||||
private readonly logger = new Logger(GoalsService.name);
|
||||
|
||||
/**
|
||||
* 创建目标
|
||||
*/
|
||||
async createGoal(userId: string, createGoalDto: CreateGoalDto): Promise<Goal> {
|
||||
try {
|
||||
// 验证自定义重复规则
|
||||
if (createGoalDto.repeatType === GoalRepeatType.CUSTOM && !createGoalDto.customRepeatRule) {
|
||||
throw new BadRequestException('自定义重复类型必须提供自定义重复规则');
|
||||
}
|
||||
|
||||
// 验证日期逻辑
|
||||
if (createGoalDto.endDate && dayjs(createGoalDto.endDate).isBefore(createGoalDto.startDate)) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
|
||||
const goal = await Goal.create({
|
||||
userId,
|
||||
...createGoalDto,
|
||||
startDate: new Date(createGoalDto.startDate),
|
||||
endDate: createGoalDto.endDate ? new Date(createGoalDto.endDate) : null,
|
||||
});
|
||||
|
||||
this.logger.log(`用户 ${userId} 创建了目标: ${goal.title}`);
|
||||
return goal;
|
||||
} catch (error) {
|
||||
this.logger.error(`创建目标失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的目标列表
|
||||
*/
|
||||
async getGoals(userId: string, query: GoalQueryDto) {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, status, repeatType, category, search, startDate, endDate, sortBy = 'createdAt', sortOrder = 'desc' } = query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 构建查询条件
|
||||
const where: WhereOptions = {
|
||||
userId,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
if (status) {
|
||||
where.status = status;
|
||||
}
|
||||
|
||||
if (repeatType) {
|
||||
where.repeatType = repeatType;
|
||||
}
|
||||
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
if (search) {
|
||||
where[Op.or] = [
|
||||
{ title: { [Op.like]: `%${search}%` } },
|
||||
{ description: { [Op.like]: `%${search}%` } },
|
||||
];
|
||||
}
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.startDate = {};
|
||||
if (startDate) {
|
||||
where.startDate[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.startDate[Op.lte] = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建排序条件
|
||||
const order: Order = [[sortBy, sortOrder.toUpperCase()]];
|
||||
|
||||
const { rows: goals, count } = await Goal.findAndCountAll({
|
||||
where,
|
||||
order,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
include: [
|
||||
{
|
||||
model: GoalCompletion,
|
||||
as: 'completions',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: count,
|
||||
items: goals.map(goal => this.formatGoalResponse(goal)),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标列表失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个目标详情
|
||||
*/
|
||||
async getGoal(userId: string, goalId: string): Promise<Goal> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
model: GoalCompletion,
|
||||
as: 'completions',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
order: [['completedAt', 'DESC']],
|
||||
limit: 10, // 只显示最近10次完成记录
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
return this.formatGoalResponse(goal);
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标详情失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新目标
|
||||
*/
|
||||
async updateGoal(userId: string, goalId: string, updateGoalDto: UpdateGoalDto): Promise<Goal> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
// 验证日期逻辑
|
||||
if (updateGoalDto.endDate && updateGoalDto.startDate) {
|
||||
if (dayjs(updateGoalDto.endDate).isBefore(updateGoalDto.startDate)) {
|
||||
throw new BadRequestException('结束日期不能早于开始日期');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果目标已完成,不允许修改
|
||||
if (goal.status === GoalStatus.COMPLETED && updateGoalDto.status !== GoalStatus.COMPLETED) {
|
||||
throw new BadRequestException('已完成的目标不能修改状态');
|
||||
}
|
||||
|
||||
await goal.update({
|
||||
...updateGoalDto,
|
||||
startDate: updateGoalDto.startDate ? new Date(updateGoalDto.startDate) : goal.startDate,
|
||||
endDate: updateGoalDto.endDate ? new Date(updateGoalDto.endDate) : goal.endDate,
|
||||
});
|
||||
|
||||
this.logger.log(`用户 ${userId} 更新了目标: ${goal.title}`);
|
||||
return this.formatGoalResponse(goal);
|
||||
} catch (error) {
|
||||
this.logger.error(`更新目标失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目标
|
||||
*/
|
||||
async deleteGoal(userId: string, goalId: string): Promise<boolean> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
// 软删除目标
|
||||
await goal.update({ deleted: true });
|
||||
|
||||
// 软删除相关的完成记录
|
||||
await GoalCompletion.update(
|
||||
{ deleted: true },
|
||||
{ where: { goalId, userId } }
|
||||
);
|
||||
|
||||
this.logger.log(`用户 ${userId} 删除了目标: ${goal.title}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(`删除目标失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录目标完成
|
||||
*/
|
||||
async completeGoal(userId: string, createCompletionDto: CreateGoalCompletionDto): Promise<GoalCompletion> {
|
||||
try {
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: createCompletionDto.goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
if (goal.status !== GoalStatus.ACTIVE) {
|
||||
throw new BadRequestException('只有激活状态的目标才能记录完成');
|
||||
}
|
||||
|
||||
const completionCount = createCompletionDto.completionCount || 1;
|
||||
const completedAt = createCompletionDto.completedAt ? new Date(createCompletionDto.completedAt) : new Date();
|
||||
|
||||
// 创建完成记录
|
||||
const completion = await GoalCompletion.create({
|
||||
goalId: createCompletionDto.goalId,
|
||||
userId,
|
||||
completedAt,
|
||||
completionCount,
|
||||
notes: createCompletionDto.notes,
|
||||
});
|
||||
|
||||
// 更新目标的完成次数
|
||||
const newCompletedCount = goal.completedCount + completionCount;
|
||||
await goal.update({ completedCount: newCompletedCount });
|
||||
|
||||
// 检查是否达到目标总次数
|
||||
if (goal.targetCount && newCompletedCount >= goal.targetCount) {
|
||||
await goal.update({ status: GoalStatus.COMPLETED });
|
||||
}
|
||||
|
||||
this.logger.log(`用户 ${userId} 完成了目标: ${goal.title}`);
|
||||
return completion;
|
||||
} catch (error) {
|
||||
this.logger.error(`记录目标完成失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标完成记录
|
||||
*/
|
||||
async getGoalCompletions(userId: string, goalId: string, query: any = {}) {
|
||||
try {
|
||||
const { page = 1, pageSize = 20, startDate, endDate } = query;
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 验证目标存在
|
||||
const goal = await Goal.findOne({
|
||||
where: { id: goalId, userId, deleted: false },
|
||||
});
|
||||
|
||||
if (!goal) {
|
||||
throw new NotFoundException('目标不存在');
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
const where: WhereOptions = {
|
||||
goalId,
|
||||
userId,
|
||||
deleted: false,
|
||||
};
|
||||
|
||||
if (startDate || endDate) {
|
||||
where.completedAt = {};
|
||||
if (startDate) {
|
||||
where.completedAt[Op.gte] = new Date(startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
where.completedAt[Op.lte] = new Date(endDate);
|
||||
}
|
||||
}
|
||||
|
||||
const { rows: completions, count } = await GoalCompletion.findAndCountAll({
|
||||
where,
|
||||
order: [['completedAt', 'DESC']],
|
||||
offset,
|
||||
limit: pageSize,
|
||||
include: [
|
||||
{
|
||||
model: Goal,
|
||||
as: 'goal',
|
||||
attributes: ['id', 'title', 'repeatType', 'frequency'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
total: count,
|
||||
items: completions,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标完成记录失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标统计信息
|
||||
*/
|
||||
async getGoalStats(userId: string) {
|
||||
try {
|
||||
const goals = await Goal.findAll({
|
||||
where: { userId, deleted: false },
|
||||
include: [
|
||||
{
|
||||
model: GoalCompletion,
|
||||
as: 'completions',
|
||||
where: { deleted: false },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const stats = {
|
||||
total: goals.length,
|
||||
active: goals.filter(g => g.status === GoalStatus.ACTIVE).length,
|
||||
completed: goals.filter(g => g.status === GoalStatus.COMPLETED).length,
|
||||
paused: goals.filter(g => g.status === GoalStatus.PAUSED).length,
|
||||
cancelled: goals.filter(g => g.status === GoalStatus.CANCELLED).length,
|
||||
byCategory: {},
|
||||
byRepeatType: {},
|
||||
totalCompletions: 0,
|
||||
thisWeekCompletions: 0,
|
||||
thisMonthCompletions: 0,
|
||||
};
|
||||
|
||||
const now = dayjs();
|
||||
const weekStart = now.startOf('week');
|
||||
const monthStart = now.startOf('month');
|
||||
|
||||
goals.forEach(goal => {
|
||||
// 按分类统计
|
||||
if (goal.category) {
|
||||
stats.byCategory[goal.category] = (stats.byCategory[goal.category] || 0) + 1;
|
||||
}
|
||||
|
||||
// 按重复类型统计
|
||||
stats.byRepeatType[goal.repeatType] = (stats.byRepeatType[goal.repeatType] || 0) + 1;
|
||||
|
||||
// 统计完成次数
|
||||
stats.totalCompletions += goal.completedCount;
|
||||
|
||||
// 统计本周和本月的完成次数
|
||||
goal.completions?.forEach(completion => {
|
||||
const completionDate = dayjs(completion.completedAt);
|
||||
if (completionDate.isAfter(weekStart)) {
|
||||
stats.thisWeekCompletions += completion.completionCount;
|
||||
}
|
||||
if (completionDate.isAfter(monthStart)) {
|
||||
stats.thisMonthCompletions += completion.completionCount;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
this.logger.error(`获取目标统计失败: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化目标响应
|
||||
*/
|
||||
private formatGoalResponse(goal: Goal) {
|
||||
const goalData = goal.toJSON();
|
||||
|
||||
// 计算进度百分比
|
||||
if (goalData.targetCount) {
|
||||
goalData.progressPercentage = Math.min(100, Math.round((goalData.completedCount / goalData.targetCount) * 100));
|
||||
} else {
|
||||
goalData.progressPercentage = 0;
|
||||
}
|
||||
|
||||
// 计算剩余天数
|
||||
if (goalData.endDate) {
|
||||
const endDate = dayjs(goalData.endDate);
|
||||
const now = dayjs();
|
||||
goalData.daysRemaining = Math.max(0, endDate.diff(now, 'day'));
|
||||
} else {
|
||||
goalData.daysRemaining = null;
|
||||
}
|
||||
|
||||
return goalData;
|
||||
}
|
||||
}
|
||||
83
src/goals/models/goal-completion.model.ts
Normal file
83
src/goals/models/goal-completion.model.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Column, DataType, Model, Table, Index, ForeignKey, BelongsTo } from 'sequelize-typescript';
|
||||
import { Goal } from './goal.model';
|
||||
|
||||
@Table({
|
||||
tableName: 't_goal_completions',
|
||||
underscored: true,
|
||||
})
|
||||
export class GoalCompletion extends Model {
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
@Index
|
||||
@ForeignKey(() => Goal)
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
allowNull: false,
|
||||
comment: '目标ID',
|
||||
})
|
||||
declare goalId: string;
|
||||
|
||||
@BelongsTo(() => Goal)
|
||||
declare goal: Goal;
|
||||
|
||||
@Index
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '完成日期',
|
||||
})
|
||||
declare completedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '完成次数',
|
||||
})
|
||||
declare completionCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '完成备注',
|
||||
})
|
||||
declare notes: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: true,
|
||||
comment: '完成时的额外数据',
|
||||
})
|
||||
declare metadata: any;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare createdAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare updatedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否删除',
|
||||
})
|
||||
declare deleted: boolean;
|
||||
}
|
||||
170
src/goals/models/goal.model.ts
Normal file
170
src/goals/models/goal.model.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Column, DataType, Model, Table, Index, HasMany } from 'sequelize-typescript';
|
||||
import { GoalCompletion } from './goal-completion.model';
|
||||
|
||||
export enum GoalRepeatType {
|
||||
DAILY = 'daily',
|
||||
WEEKLY = 'weekly',
|
||||
MONTHLY = 'monthly',
|
||||
CUSTOM = 'custom'
|
||||
}
|
||||
|
||||
export enum GoalStatus {
|
||||
ACTIVE = 'active',
|
||||
PAUSED = 'paused',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
@Table({
|
||||
tableName: 't_goals',
|
||||
underscored: true,
|
||||
})
|
||||
export class Goal extends Model {
|
||||
@Column({
|
||||
type: DataType.UUID,
|
||||
defaultValue: DataType.UUIDV4,
|
||||
primaryKey: true,
|
||||
})
|
||||
declare id: string;
|
||||
|
||||
@Index
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '用户ID',
|
||||
})
|
||||
declare userId: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: false,
|
||||
comment: '目标标题',
|
||||
})
|
||||
declare title: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.TEXT,
|
||||
allowNull: true,
|
||||
comment: '目标描述',
|
||||
})
|
||||
declare description: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('daily', 'weekly', 'monthly', 'custom'),
|
||||
allowNull: false,
|
||||
defaultValue: GoalRepeatType.DAILY,
|
||||
comment: '重复周期类型',
|
||||
})
|
||||
declare repeatType: GoalRepeatType;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 1,
|
||||
comment: '频率(每天/每周/每月多少次)',
|
||||
})
|
||||
declare frequency: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: true,
|
||||
comment: '自定义重复规则(如每周几)',
|
||||
})
|
||||
declare customRepeatRule: any;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: false,
|
||||
comment: '目标开始日期',
|
||||
})
|
||||
declare startDate: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: true,
|
||||
comment: '目标结束日期',
|
||||
})
|
||||
declare endDate: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.ENUM('active', 'paused', 'completed', 'cancelled'),
|
||||
allowNull: false,
|
||||
defaultValue: GoalStatus.ACTIVE,
|
||||
comment: '目标状态',
|
||||
})
|
||||
declare status: GoalStatus;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '已完成次数',
|
||||
})
|
||||
declare completedCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: true,
|
||||
comment: '目标总次数(null表示无限制)',
|
||||
})
|
||||
declare targetCount: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING,
|
||||
allowNull: true,
|
||||
comment: '目标分类标签',
|
||||
})
|
||||
declare category: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: '优先级(数字越大优先级越高)',
|
||||
})
|
||||
declare priority: number;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
comment: '是否提醒',
|
||||
})
|
||||
declare hasReminder: boolean;
|
||||
|
||||
@Column({
|
||||
type: DataType.TIME,
|
||||
allowNull: true,
|
||||
comment: '提醒时间',
|
||||
})
|
||||
declare reminderTime: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: true,
|
||||
comment: '提醒设置(如每周几提醒)',
|
||||
})
|
||||
declare reminderSettings: any;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare createdAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
defaultValue: DataType.NOW,
|
||||
})
|
||||
declare updatedAt: Date;
|
||||
|
||||
@Column({
|
||||
type: DataType.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '是否删除',
|
||||
})
|
||||
declare deleted: boolean;
|
||||
|
||||
@HasMany(() => GoalCompletion, 'goalId')
|
||||
declare completions: GoalCompletion[];
|
||||
}
|
||||
Reference in New Issue
Block a user