diff --git a/docs/WATER_RECORDS.md b/docs/WATER_RECORDS.md new file mode 100644 index 0000000..664f5b2 --- /dev/null +++ b/docs/WATER_RECORDS.md @@ -0,0 +1,140 @@ +# 喝水记录功能 + +## 功能概述 + +新增了用户喝水记录功能,支持用户记录每日的喝水情况,设置喝水目标,并查看统计信息。 + +## 新增文件 + +### 模型文件 +- `src/users/models/user-water-history.model.ts` - 喝水记录模型 +- 更新了 `src/users/models/user-profile.model.ts` - 添加了 `dailyWaterGoal` 字段 + +### DTO文件 +- `src/users/dto/water-record.dto.ts` - 喝水记录相关的DTO + +### 服务文件 +- `src/users/services/water-record.service.ts` - 喝水记录服务 + +### 数据库脚本 +- `sql-scripts/user-water-records-table.sql` - 数据库迁移脚本 + +### 测试脚本 +- `test-water-records.sh` - API接口测试脚本 + +## API接口 + +### 1. 创建喝水记录 +``` +POST /users/water-records +``` + +请求体: +```json +{ + "amount": 250, + "source": "manual", + "remark": "早晨第一杯水" +} +``` + +### 2. 获取喝水记录列表 +``` +GET /users/water-records?limit=10&offset=0 +``` + +### 3. 更新喝水记录 +``` +PUT /users/water-records/:id +``` + +请求体: +```json +{ + "amount": 300, + "remark": "修改后的备注" +} +``` + +### 4. 删除喝水记录 +``` +DELETE /users/water-records/:id +``` + +### 5. 更新喝水目标 +``` +PUT /users/water-goal +``` + +请求体: +```json +{ + "dailyWaterGoal": 2000 +} +``` + +### 6. 获取今日喝水统计 +``` +GET /users/water-stats/today +``` + +响应: +```json +{ + "code": 200, + "message": "success", + "data": { + "totalAmount": 1500, + "recordCount": 6, + "dailyGoal": 2000, + "completionRate": 0.75 + } +} +``` + +## 数据库表结构 + +### t_user_water_history (喝水记录表) +- `id` - 主键,自增 +- `user_id` - 用户ID +- `amount` - 喝水量(毫升) +- `source` - 记录来源(manual/auto/other) +- `remark` - 备注 +- `created_at` - 创建时间 +- `updated_at` - 更新时间 + +### t_user_profile (用户档案表 - 新增字段) +- `daily_water_goal` - 每日喝水目标(毫升) + +## 功能特点 + +1. **完整的CRUD操作** - 支持喝水记录的增删改查 +2. **目标设置** - 用户可以设置每日喝水目标 +3. **统计功能** - 提供今日喝水统计,包括总量、记录数、完成率等 +4. **数据验证** - 对输入数据进行严格验证 +5. **错误处理** - 完善的错误处理机制 +6. **日志记录** - 详细的操作日志 +7. **权限控制** - 所有接口都需要JWT认证 + +## 部署说明 + +1. 运行数据库迁移脚本: + ```bash + mysql -u username -p database_name < sql-scripts/user-water-records-table.sql + ``` + +2. 重启应用服务 + +3. 使用测试脚本验证功能: + ```bash + ./test-water-records.sh + ``` + +## 注意事项 + +1. 喝水目标字段是可选的,可以为空 +2. 喝水记录的来源默认为 'manual' +3. 喝水量的范围限制在 1-5000 毫升之间 +4. 喝水目标的范围限制在 500-10000 毫升之间 +5. 获取profile接口会返回用户的喝水目标 +6. 喝水目标的更新集成在喝水接口中,避免用户服务文件过大 \ No newline at end of file diff --git a/docs/WATER_RECORDS_MODULE.md b/docs/WATER_RECORDS_MODULE.md new file mode 100644 index 0000000..1b59a52 --- /dev/null +++ b/docs/WATER_RECORDS_MODULE.md @@ -0,0 +1,182 @@ +# 喝水记录模块 (Water Records Module) + +## 概述 + +喝水记录模块是一个独立的NestJS模块,用于管理用户的喝水记录和喝水目标。该模块已从用户模块中分离出来,以提高代码的可维护性和模块化程度。 + +## 模块结构 + +``` +src/water-records/ +├── water-records.controller.ts # 控制器 - 处理HTTP请求 +├── water-records.service.ts # 服务 - 业务逻辑处理 +├── water-records.module.ts # 模块定义 +├── models/ +│ └── user-water-history.model.ts # 喝水记录数据模型 +└── dto/ + └── water-record.dto.ts # 数据传输对象 +``` + +## API 接口 + +### 基础路径: `/water-records` + +| 方法 | 路径 | 描述 | 权限 | +|------|------|------|------| +| POST | `/` | 创建喝水记录 | JWT | +| GET | `/` | 获取喝水记录列表 | JWT | +| PUT | `/:id` | 更新喝水记录 | JWT | +| DELETE | `/:id` | 删除喝水记录 | JWT | +| PUT | `/goal/daily` | 更新每日喝水目标 | JWT | +| GET | `/stats/today` | 获取今日喝水统计 | JWT | + +## 数据模型 + +### UserWaterHistory (喝水记录) + +```typescript +{ + id: number; // 记录ID + userId: string; // 用户ID + amount: number; // 喝水量(毫升) + source: WaterRecordSource; // 记录来源 + note: string | null; // 备注 + recordedAt: Date; // 记录时间 + createdAt: Date; // 创建时间 + updatedAt: Date; // 更新时间 +} +``` + +### WaterRecordSource (记录来源枚举) + +- `manual` - 手动记录 +- `auto` - 自动记录 +- `other` - 其他来源 + +## 功能特性 + +### 1. 喝水记录管理 +- ✅ 创建喝水记录 +- ✅ 查询喝水记录(支持日期范围筛选和分页) +- ✅ 更新喝水记录 +- ✅ 删除喝水记录 + +### 2. 喝水目标管理 +- ✅ 设置每日喝水目标 +- ✅ 在用户档案中返回喝水目标 + +### 3. 统计分析 +- ✅ 今日喝水统计 +- ✅ 完成率计算 +- ✅ 记录数量统计 + +### 4. 数据验证 +- ✅ 喝水量范围验证(1-5000ml) +- ✅ 喝水目标范围验证(500-10000ml) +- ✅ 输入数据格式验证 + +## 使用示例 + +### 创建喝水记录 + +```bash +curl -X POST "http://localhost:3000/water-records" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{ + "amount": 250, + "note": "早晨第一杯水", + "recordedAt": "2023-12-01T08:00:00.000Z" + }' +``` + +### 获取喝水记录列表 + +```bash +curl -X GET "http://localhost:3000/water-records?startDate=2023-12-01&endDate=2023-12-31&page=1&limit=20" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +### 更新喝水目标 + +```bash +curl -X PUT "http://localhost:3000/water-records/goal/daily" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" \ + -d '{ + "dailyWaterGoal": 2500 + }' +``` + +### 获取今日统计 + +```bash +curl -X GET "http://localhost:3000/water-records/stats/today" \ + -H "Authorization: Bearer YOUR_JWT_TOKEN" +``` + +## 数据库表结构 + +### t_user_water_history + +```sql +CREATE TABLE t_user_water_history ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id VARCHAR(255) NOT NULL COMMENT '用户ID', + amount INT NOT NULL COMMENT '喝水量(毫升)', + source ENUM('manual', 'auto', 'other') NOT NULL DEFAULT 'manual' COMMENT '记录来源', + note VARCHAR(255) NULL COMMENT '备注', + recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_recorded_at (recorded_at) +); +``` + +## 模块依赖 + +- `@nestjs/common` - NestJS核心功能 +- `@nestjs/sequelize` - Sequelize ORM集成 +- `sequelize-typescript` - TypeScript装饰器支持 +- `class-validator` - 数据验证 +- `class-transformer` - 数据转换 + +## 与其他模块的关系 + +- **Users Module**: 依赖用户模块的UserProfile模型来管理喝水目标 +- **Activity Logs Module**: 可选的活动日志记录 +- **App Module**: 在主应用模块中注册 + +## 测试 + +运行测试脚本: + +```bash +chmod +x test-water-records-module.sh +./test-water-records-module.sh +``` + +## 注意事项 + +1. **权限控制**: 所有接口都需要JWT认证 +2. **数据验证**: 严格的输入验证确保数据质量 +3. **错误处理**: 完善的错误处理和日志记录 +4. **性能优化**: 支持分页查询,避免大量数据加载 +5. **数据一致性**: 使用事务确保数据操作的一致性 + +## 迁移说明 + +该模块从原来的用户模块中分离出来,主要变化: + +1. **路径变更**: 从 `/users/water-*` 变更为 `/water-records/*` +2. **模块独立**: 独立的控制器、服务和模块 +3. **代码分离**: 减少了用户模块的复杂度 +4. **维护性提升**: 更好的代码组织和维护性 + +## 版本历史 + +- **v1.0.0** - 初始版本,从用户模块分离 +- 支持完整的CRUD操作 +- 支持喝水目标管理 +- 支持统计分析功能 \ No newline at end of file diff --git a/sql-scripts/user-water-records-table.sql b/sql-scripts/user-water-records-table.sql new file mode 100644 index 0000000..644ff43 --- /dev/null +++ b/sql-scripts/user-water-records-table.sql @@ -0,0 +1,17 @@ +-- 创建用户喝水记录表 +CREATE TABLE IF NOT EXISTS `t_user_water_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` VARCHAR(255) NOT NULL COMMENT '用户ID', + `amount` INT NOT NULL COMMENT '喝水量(毫升)', + `source` ENUM('manual', 'auto', 'other') NOT NULL DEFAULT 'manual' COMMENT '记录来源', + `remark` VARCHAR(255) NULL COMMENT '备注', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + INDEX `idx_user_id` (`user_id`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户喝水记录表'; + +-- 为用户档案表添加喝水目标字段 +ALTER TABLE `t_user_profile` +ADD COLUMN `daily_water_goal` INT NULL COMMENT '每日喝水目标(毫升)' AFTER `activity_level`; \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index f53e0a3..0146608 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { MoodCheckinsModule } from './mood-checkins/mood-checkins.module'; import { GoalsModule } from './goals/goals.module'; import { DietRecordsModule } from './diet-records/diet-records.module'; import { FoodLibraryModule } from './food-library/food-library.module'; +import { WaterRecordsModule } from './water-records/water-records.module'; @Module({ imports: [ @@ -39,6 +40,7 @@ import { FoodLibraryModule } from './food-library/food-library.module'; GoalsModule, DietRecordsModule, FoodLibraryModule, + WaterRecordsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/users/models/user-profile.model.ts b/src/users/models/user-profile.model.ts index d621e15..887cf77 100644 --- a/src/users/models/user-profile.model.ts +++ b/src/users/models/user-profile.model.ts @@ -86,6 +86,13 @@ export class UserProfile extends Model { }) declare activityLevel: ActivityLevel | null; + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '每日喝水目标(毫升)', + }) + declare dailyWaterGoal: number; + @Column({ type: DataType.DATE, defaultValue: DataType.NOW, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 8e11ca4..4b52ce4 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -30,6 +30,7 @@ import { AppStoreServerNotificationDto, ProcessNotificationResponseDto } from '. import { RestorePurchaseDto, RestorePurchaseResponseDto } from './dto/restore-purchase.dto'; import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; + import { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; import { AccessTokenPayload } from './services/apple-auth.service'; diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 6866ed4..7d7fe74 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -5,10 +5,12 @@ import { UsersService } from "./users.service"; import { User } from "./models/user.model"; import { UserProfile } from "./models/user-profile.model"; import { UserWeightHistory } from "./models/user-weight-history.model"; + import { UserDietHistory } from "./models/user-diet-history.model"; import { ApplePurchaseService } from "./services/apple-purchase.service"; import { UserActivity } from "./models/user-activity.model"; import { UserActivityService } from "./services/user-activity.service"; + import { EncryptionService } from "../common/encryption.service"; import { AppleAuthService } from "./services/apple-auth.service"; import { JwtModule } from '@nestjs/jwt'; @@ -29,6 +31,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; RevenueCatEvent, UserProfile, UserWeightHistory, + UserDietHistory, UserActivity, ]), diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 2678bbb..0cb276d 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -34,10 +34,12 @@ import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-hist import { ActivityLogsService } from '../activity-logs/activity-logs.service'; import { UserActivityService } from './services/user-activity.service'; + import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { ActivityActionType, ActivityEntityType } from '../activity-logs/models/activity-log.model'; + const DEFAULT_FREE_USAGE_COUNT = 5; @Injectable() @@ -111,6 +113,7 @@ export class UsersService { targetWeight: profile?.targetWeight, height: profile?.height, activityLevel: profile?.activityLevel, + dailyWaterGoal: profile?.dailyWaterGoal, } this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`); diff --git a/src/water-records/dto/water-record.dto.ts b/src/water-records/dto/water-record.dto.ts new file mode 100644 index 0000000..272798d --- /dev/null +++ b/src/water-records/dto/water-record.dto.ts @@ -0,0 +1,272 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsEnum, Min, Max, IsString, MaxLength, IsDateString } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { WaterRecordSource } from '../models/user-water-history.model'; + +/** + * 创建喝水记录请求DTO + */ +export class CreateWaterRecordDto { + @ApiProperty({ + description: '喝水量(毫升)', + example: 250, + minimum: 1, + maximum: 5000, + }) + @IsNumber({}, { message: '喝水量必须是数字' }) + @Min(1, { message: '喝水量不能小于1毫升' }) + @Max(5000, { message: '喝水量不能大于5000毫升' }) + amount: number; + + @ApiProperty({ + description: '记录时间', + example: '2023-12-01T10:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString({}, { message: '记录时间格式不正确' }) + recordedAt?: Date; + + @ApiProperty({ + description: '记录来源', + enum: WaterRecordSource, + example: WaterRecordSource.Manual, + required: false, + }) + @IsOptional() + @IsEnum(WaterRecordSource, { message: '记录来源必须是有效值' }) + source?: WaterRecordSource; + + @ApiProperty({ + description: '备注', + example: '早晨第一杯水', + required: false, + maxLength: 100, + }) + @IsOptional() + @IsString({ message: '备注必须是字符串' }) + @MaxLength(100, { message: '备注长度不能超过100个字符' }) + note?: string; +} + +/** + * 更新喝水记录请求DTO + */ +export class UpdateWaterRecordDto { + @ApiProperty({ + description: '喝水量(毫升)', + example: 250, + minimum: 1, + maximum: 5000, + required: false, + }) + @IsOptional() + @IsNumber({}, { message: '喝水量必须是数字' }) + @Min(1, { message: '喝水量不能小于1毫升' }) + @Max(5000, { message: '喝水量不能大于5000毫升' }) + amount?: number; + + @ApiProperty({ + description: '记录时间', + example: '2023-12-01T10:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString({}, { message: '记录时间格式不正确' }) + recordedAt?: Date; + + @ApiProperty({ + description: '记录来源', + enum: WaterRecordSource, + example: WaterRecordSource.Manual, + required: false, + }) + @IsOptional() + @IsEnum(WaterRecordSource, { message: '记录来源必须是有效值' }) + source?: WaterRecordSource; + + @ApiProperty({ + description: '备注', + example: '早晨第一杯水', + required: false, + maxLength: 100, + }) + @IsOptional() + @IsString({ message: '备注必须是字符串' }) + @MaxLength(100, { message: '备注长度不能超过100个字符' }) + note?: string; +} + +/** + * 获取喝水记录查询DTO + */ +export class GetWaterRecordsQueryDto { + @ApiProperty({ + description: '开始日期 (YYYY-MM-DD)', + example: '2023-12-01', + required: false, + }) + @IsOptional() + @IsString() + startDate?: string; + + @ApiProperty({ + description: '结束日期 (YYYY-MM-DD)', + example: '2023-12-31', + required: false, + }) + @IsOptional() + @IsString() + endDate?: string; + + @ApiProperty({ + description: '页码,默认1', + example: 1, + required: false, + }) + @IsOptional() + @Transform(({ value }) => parseInt(value)) + @IsNumber({}, { message: '页码必须是数字' }) + @Min(1, { message: '页码不能小于1' }) + page?: number; + + @ApiProperty({ + description: '每页数量,默认20', + example: 20, + required: false, + }) + @IsOptional() + @Transform(({ value }) => parseInt(value)) + @IsNumber({}, { message: '每页数量必须是数字' }) + @Min(1, { message: '每页数量不能小于1' }) + @Max(100, { message: '每页数量不能大于100' }) + limit?: number; +} + +/** + * 更新喝水目标请求DTO + */ +export class UpdateWaterGoalDto { + @ApiProperty({ + description: '每日喝水目标(毫升)', + example: 2000, + minimum: 500, + maximum: 10000, + }) + @IsNumber({}, { message: '喝水目标必须是数字' }) + @Min(500, { message: '喝水目标不能小于500毫升' }) + @Max(10000, { message: '喝水目标不能大于10000毫升' }) + dailyWaterGoal: number; +} + +/** + * 基础响应DTO + */ +export class BaseResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '响应消息', example: '操作成功' }) + message: string; +} + +/** + * 喝水记录数据DTO + */ +export class WaterRecordDataDto { + @ApiProperty({ description: '记录ID', example: 1 }) + id: number; + + @ApiProperty({ description: '喝水量(毫升)', example: 250 }) + amount: number; + + @ApiProperty({ description: '记录时间', example: '2023-12-01T10:00:00.000Z' }) + recordedAt: Date; + + @ApiProperty({ description: '备注', example: '早晨第一杯水', nullable: true }) + note: string | null; + + @ApiProperty({ description: '创建时间', example: '2023-12-01T10:00:00.000Z' }) + createdAt: Date; + + @ApiProperty({ description: '更新时间', example: '2023-12-01T10:00:00.000Z' }) + updatedAt: Date; +} + +/** + * 喝水记录响应DTO + */ +export class WaterRecordResponseDto extends BaseResponseDto { + @ApiProperty({ description: '喝水记录数据', type: WaterRecordDataDto }) + data: WaterRecordDataDto; +} + +/** + * 分页信息DTO + */ +export class PaginationDto { + @ApiProperty({ description: '当前页码', example: 1 }) + page: number; + + @ApiProperty({ description: '每页数量', example: 20 }) + limit: number; + + @ApiProperty({ description: '总记录数', example: 100 }) + total: number; + + @ApiProperty({ description: '总页数', example: 5 }) + totalPages: number; +} + +/** + * 喝水记录列表响应DTO + */ +export class WaterRecordsListResponseDto extends BaseResponseDto { + data: { + records: WaterRecordDataDto[]; + pagination: PaginationDto; + }; +} + +/** + * 喝水目标响应DTO + */ +export class WaterGoalResponseDto extends BaseResponseDto { + @ApiProperty({ + description: '喝水目标数据', + example: { dailyWaterGoal: 2000 }, + }) + data: { + dailyWaterGoal: number; + }; +} + +/** + * 今日喝水统计响应DTO + */ +export class TodayWaterStatsResponseDto extends BaseResponseDto { + @ApiProperty({ + description: '今日喝水统计', + example: { + date: '2023-12-01', + totalAmount: 1500, + dailyGoal: 2000, + completionRate: 75.0, + recordCount: 6, + records: [], + }, + }) + data: { + date: string; + totalAmount: number; + dailyGoal: number; + completionRate: number; + recordCount: number; + records: { + id: number; + amount: number; + recordedAt: Date; + note: string | null; + }[]; + }; +} \ No newline at end of file diff --git a/src/water-records/models/user-water-history.model.ts b/src/water-records/models/user-water-history.model.ts new file mode 100644 index 0000000..4e6c1e2 --- /dev/null +++ b/src/water-records/models/user-water-history.model.ts @@ -0,0 +1,69 @@ +import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +export enum WaterRecordSource { + Manual = 'manual', + Auto = 'auto', + Other = 'other', +} + +@Table({ + tableName: 't_user_water_history', + underscored: true, +}) +export class UserWaterHistory extends Model { + @PrimaryKey + @Column({ + type: DataType.BIGINT, + autoIncrement: true, + }) + declare id: number; + + @Column({ + type: DataType.STRING, + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + comment: '喝水量(毫升)', + }) + declare amount: number; + + @Column({ + type: DataType.ENUM('manual', 'auto', 'other'), + allowNull: false, + defaultValue: 'manual', + comment: '记录来源', + }) + declare source: WaterRecordSource; + + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '备注', + }) + declare note: string | null; + + @Column({ + type: DataType.DATE, + allowNull: false, + defaultValue: DataType.NOW, + comment: '记录时间', + }) + declare recordedAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} \ No newline at end of file diff --git a/src/water-records/water-records.controller.ts b/src/water-records/water-records.controller.ts new file mode 100644 index 0000000..14a0444 --- /dev/null +++ b/src/water-records/water-records.controller.ts @@ -0,0 +1,146 @@ +import { + Controller, + Get, + Post, + Body, + Param, + HttpCode, + HttpStatus, + Put, + Delete, + Query, + Logger, + UseGuards, + NotFoundException, +} from '@nestjs/common'; +import { ApiOperation, ApiBody, ApiResponse, ApiTags, ApiQuery } from '@nestjs/swagger'; +import { WaterRecordsService } from './water-records.service'; +import { + CreateWaterRecordDto, + UpdateWaterRecordDto, + WaterRecordResponseDto, + GetWaterRecordsQueryDto, + WaterRecordsListResponseDto, + UpdateWaterGoalDto, + WaterGoalResponseDto, + TodayWaterStatsResponseDto +} from './dto/water-record.dto'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; + +@ApiTags('water-records') +@Controller('water-records') +export class WaterRecordsController { + private readonly logger = new Logger(WaterRecordsController.name); + + constructor( + private readonly waterRecordsService: WaterRecordsService, + ) { } + + /** + * 创建喝水记录 + */ + @UseGuards(JwtAuthGuard) + @Post() + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: '创建喝水记录' }) + @ApiBody({ type: CreateWaterRecordDto }) + @ApiResponse({ status: 201, description: '成功创建喝水记录', type: WaterRecordResponseDto }) + async createWaterRecord( + @Body() createDto: CreateWaterRecordDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`创建喝水记录 - 用户ID: ${user.sub}, 喝水量: ${createDto.amount}ml`); + return this.waterRecordsService.createWaterRecord(user.sub, createDto); + } + + /** + * 获取喝水记录列表 + */ + @UseGuards(JwtAuthGuard) + @Get() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取喝水记录列表' }) + @ApiQuery({ name: 'startDate', required: false, description: '开始日期 (YYYY-MM-DD)' }) + @ApiQuery({ name: 'endDate', required: false, description: '结束日期 (YYYY-MM-DD)' }) + @ApiQuery({ name: 'page', required: false, description: '页码,默认1' }) + @ApiQuery({ name: 'limit', required: false, description: '每页数量,默认20' }) + @ApiResponse({ status: 200, description: '成功获取喝水记录列表', type: WaterRecordsListResponseDto }) + async getWaterRecords( + @Query() query: GetWaterRecordsQueryDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取喝水记录列表 - 用户ID: ${user.sub}`); + return this.waterRecordsService.getWaterRecords(user.sub, query); + } + + /** + * 更新喝水记录 + */ + @UseGuards(JwtAuthGuard) + @Put(':id') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新喝水记录' }) + @ApiBody({ type: UpdateWaterRecordDto }) + @ApiResponse({ status: 200, description: '成功更新喝水记录', type: WaterRecordResponseDto }) + async updateWaterRecord( + @Param('id') recordId: string, + @Body() updateDto: UpdateWaterRecordDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`更新喝水记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); + return this.waterRecordsService.updateWaterRecord(user.sub, parseInt(recordId), updateDto); + } + + /** + * 删除喝水记录 + */ + @UseGuards(JwtAuthGuard) + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: '删除喝水记录' }) + @ApiResponse({ status: 204, description: '成功删除喝水记录' }) + async deleteWaterRecord( + @Param('id') recordId: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`删除喝水记录 - 用户ID: ${user.sub}, 记录ID: ${recordId}`); + const success = await this.waterRecordsService.deleteWaterRecord(user.sub, parseInt(recordId)); + if (!success) { + throw new NotFoundException('喝水记录不存在'); + } + } + + /** + * 更新喝水目标 + */ + @UseGuards(JwtAuthGuard) + @Put('goal/daily') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新每日喝水目标' }) + @ApiBody({ type: UpdateWaterGoalDto }) + @ApiResponse({ status: 200, description: '成功更新喝水目标', type: WaterGoalResponseDto }) + async updateWaterGoal( + @Body() updateDto: UpdateWaterGoalDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`更新喝水目标 - 用户ID: ${user.sub}, 目标: ${updateDto.dailyWaterGoal}ml`); + return this.waterRecordsService.updateWaterGoal(user.sub, updateDto); + } + + /** + * 获取今日喝水统计 + */ + @UseGuards(JwtAuthGuard) + @Get('stats/today') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取今日喝水统计' }) + @ApiResponse({ status: 200, description: '成功获取今日喝水统计', type: TodayWaterStatsResponseDto }) + async getTodayWaterStats( + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`获取今日喝水统计 - 用户ID: ${user.sub}`); + return this.waterRecordsService.getTodayWaterStats(user.sub); + } +} \ No newline at end of file diff --git a/src/water-records/water-records.module.ts b/src/water-records/water-records.module.ts new file mode 100644 index 0000000..a259695 --- /dev/null +++ b/src/water-records/water-records.module.ts @@ -0,0 +1,19 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; +import { WaterRecordsController } from './water-records.controller'; +import { WaterRecordsService } from './water-records.service'; +import { UserWaterHistory } from './models/user-water-history.model'; +import { UserProfile } from '../users/models/user-profile.model'; +import { ActivityLog } from '../activity-logs/models/activity-log.model'; +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + SequelizeModule.forFeature([UserWaterHistory, UserProfile, ActivityLog]), + forwardRef(() => UsersModule), + ], + controllers: [WaterRecordsController], + providers: [WaterRecordsService], + exports: [WaterRecordsService], +}) +export class WaterRecordsModule { } \ No newline at end of file diff --git a/src/water-records/water-records.service.ts b/src/water-records/water-records.service.ts new file mode 100644 index 0000000..25ac468 --- /dev/null +++ b/src/water-records/water-records.service.ts @@ -0,0 +1,270 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { UserWaterHistory } from './models/user-water-history.model'; +import { UserProfile } from '../users/models/user-profile.model'; +import { + CreateWaterRecordDto, + UpdateWaterRecordDto, + WaterRecordResponseDto, + GetWaterRecordsQueryDto, + WaterRecordsListResponseDto, + UpdateWaterGoalDto, + WaterGoalResponseDto, + TodayWaterStatsResponseDto +} from './dto/water-record.dto'; +import { Op } from 'sequelize'; + +@Injectable() +export class WaterRecordsService { + private readonly logger = new Logger(WaterRecordsService.name); + + constructor( + @InjectModel(UserWaterHistory) + private readonly userWaterHistoryModel: typeof UserWaterHistory, + @InjectModel(UserProfile) + private readonly userProfileModel: typeof UserProfile, + ) { } + + /** + * 创建喝水记录 + */ + async createWaterRecord(userId: string, createDto: CreateWaterRecordDto): Promise { + try { + const waterRecord = await this.userWaterHistoryModel.create({ + userId, + amount: createDto.amount, + recordedAt: createDto.recordedAt || new Date(), + note: createDto.note, + }); + + this.logger.log(`用户 ${userId} 创建喝水记录成功,记录ID: ${waterRecord.id}`); + + return { + success: true, + message: '喝水记录创建成功', + data: { + id: waterRecord.id, + amount: waterRecord.amount, + recordedAt: waterRecord.recordedAt, + note: waterRecord.note, + createdAt: waterRecord.createdAt, + updatedAt: waterRecord.updatedAt, + }, + }; + } catch (error) { + this.logger.error(`创建喝水记录失败: ${error.message}`, error.stack); + throw new BadRequestException('创建喝水记录失败'); + } + } + + /** + * 获取喝水记录列表 + */ + async getWaterRecords(userId: string, query: GetWaterRecordsQueryDto): Promise { + try { + const { startDate, endDate, page = 1, limit = 20 } = query; + const offset = (page - 1) * limit; + + // 构建查询条件 + const whereCondition: any = { userId }; + + if (startDate || endDate) { + whereCondition.recordedAt = {}; + if (startDate) { + whereCondition.recordedAt[Op.gte] = new Date(startDate); + } + if (endDate) { + const endDateTime = new Date(endDate); + endDateTime.setHours(23, 59, 59, 999); + whereCondition.recordedAt[Op.lte] = endDateTime; + } + } + + const { rows: records, count: total } = await this.userWaterHistoryModel.findAndCountAll({ + where: whereCondition, + order: [['recordedAt', 'DESC']], + limit, + offset, + }); + + const totalPages = Math.ceil(total / limit); + + return { + success: true, + message: '获取喝水记录成功', + data: { + records: records.map(record => ({ + id: record.id, + amount: record.amount, + recordedAt: record.recordedAt, + note: record.note, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + })), + pagination: { + page, + limit, + total, + totalPages, + }, + }, + }; + } catch (error) { + this.logger.error(`获取喝水记录失败: ${error.message}`, error.stack); + throw new BadRequestException('获取喝水记录失败'); + } + } + + /** + * 更新喝水记录 + */ + async updateWaterRecord(userId: string, recordId: number, updateDto: UpdateWaterRecordDto): Promise { + try { + const waterRecord = await this.userWaterHistoryModel.findOne({ + where: { id: recordId, userId }, + }); + + if (!waterRecord) { + throw new NotFoundException('喝水记录不存在'); + } + + await waterRecord.update({ + amount: updateDto.amount ?? waterRecord.amount, + recordedAt: updateDto.recordedAt ?? waterRecord.recordedAt, + note: updateDto.note ?? waterRecord.note, + }); + + this.logger.log(`用户 ${userId} 更新喝水记录成功,记录ID: ${recordId}`); + + return { + success: true, + message: '喝水记录更新成功', + data: { + id: waterRecord.id, + amount: waterRecord.amount, + recordedAt: waterRecord.recordedAt, + note: waterRecord.note, + createdAt: waterRecord.createdAt, + updatedAt: waterRecord.updatedAt, + }, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`更新喝水记录失败: ${error.message}`, error.stack); + throw new BadRequestException('更新喝水记录失败'); + } + } + + /** + * 删除喝水记录 + */ + async deleteWaterRecord(userId: string, recordId: number): Promise { + try { + const result = await this.userWaterHistoryModel.destroy({ + where: { id: recordId, userId }, + }); + + if (result === 0) { + return false; + } + + this.logger.log(`用户 ${userId} 删除喝水记录成功,记录ID: ${recordId}`); + return true; + } catch (error) { + this.logger.error(`删除喝水记录失败: ${error.message}`, error.stack); + throw new BadRequestException('删除喝水记录失败'); + } + } + + /** + * 更新喝水目标 + */ + async updateWaterGoal(userId: string, updateDto: UpdateWaterGoalDto): Promise { + try { + const userProfile = await this.userProfileModel.findOne({ + where: { userId }, + }); + + if (!userProfile) { + throw new NotFoundException('用户档案不存在'); + } + + await userProfile.update({ + dailyWaterGoal: updateDto.dailyWaterGoal, + }); + + this.logger.log(`用户 ${userId} 更新喝水目标成功: ${updateDto.dailyWaterGoal}ml`); + + return { + success: true, + message: '喝水目标更新成功', + data: { + dailyWaterGoal: userProfile.dailyWaterGoal, + }, + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`更新喝水目标失败: ${error.message}`, error.stack); + throw new BadRequestException('更新喝水目标失败'); + } + } + + /** + * 获取今日喝水统计 + */ + async getTodayWaterStats(userId: string): Promise { + try { + // 获取今天的开始和结束时间 + const today = new Date(); + const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate(), 23, 59, 59, 999); + + // 获取今日喝水记录 + const todayRecords = await this.userWaterHistoryModel.findAll({ + where: { + userId, + recordedAt: { + [Op.between]: [startOfDay, endOfDay], + }, + }, + order: [['recordedAt', 'ASC']], + }); + + // 计算今日总喝水量 + const totalAmount = todayRecords.reduce((sum, record) => sum + record.amount, 0); + + // 获取用户的喝水目标 + const userProfile = await this.userProfileModel.findOne({ + where: { userId }, + }); + + const dailyGoal = userProfile?.dailyWaterGoal || 2000; // 默认目标2000ml + const completionRate = Math.min((totalAmount / dailyGoal) * 100, 100); + + return { + success: true, + message: '获取今日喝水统计成功', + data: { + date: startOfDay.toISOString().split('T')[0], + totalAmount, + dailyGoal, + completionRate: Math.round(completionRate * 100) / 100, + recordCount: todayRecords.length, + records: todayRecords.map(record => ({ + id: record.id, + amount: record.amount, + recordedAt: record.recordedAt, + note: record.note, + })), + }, + }; + } catch (error) { + this.logger.error(`获取今日喝水统计失败: ${error.message}`, error.stack); + throw new BadRequestException('获取今日喝水统计失败'); + } + } +} \ No newline at end of file