From c3b59752eea8b135480a8f1b974ca24b4d7870b9 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Tue, 2 Dec 2025 19:11:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(users):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=AF=8F=E6=97=A5=E5=81=A5=E5=BA=B7=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E7=BB=B4=E5=BA=A6=E5=81=A5=E5=BA=B7=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../create_user_daily_health_table.sql | 24 ++++ src/users/dto/daily-health.dto.ts | 114 +++++++++++++++++ src/users/models/user-daily-health.model.ts | 115 ++++++++++++++++++ src/users/users.controller.ts | 20 +++ src/users/users.module.ts | 2 + src/users/users.service.ts | 74 +++++++++++ 6 files changed, 349 insertions(+) create mode 100644 sql-scripts/create_user_daily_health_table.sql create mode 100644 src/users/dto/daily-health.dto.ts create mode 100644 src/users/models/user-daily-health.model.ts diff --git a/sql-scripts/create_user_daily_health_table.sql b/sql-scripts/create_user_daily_health_table.sql new file mode 100644 index 0000000..0db6961 --- /dev/null +++ b/sql-scripts/create_user_daily_health_table.sql @@ -0,0 +1,24 @@ +-- ============================================================ +-- 用户每日健康记录表 +-- 每日每个用户只会生成一条数据,通过 user_id + record_date 唯一确定 +-- ============================================================ + +CREATE TABLE IF NOT EXISTS `t_user_daily_health` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` VARCHAR(64) NOT NULL COMMENT '用户ID', + `record_date` DATE NOT NULL COMMENT '记录日期 (YYYY-MM-DD)', + `water_intake` INT NULL COMMENT '饮水量 (毫升 ml)', + `exercise_minutes` INT NULL COMMENT '锻炼分钟数', + `calories_burned` FLOAT NULL COMMENT '消耗卡路里 (千卡 kcal)', + `standing_minutes` INT NULL COMMENT '站立时间 (分钟)', + `basal_metabolism` FLOAT NULL COMMENT '基础代谢 (千卡 kcal)', + `sleep_minutes` INT NULL COMMENT '睡眠分钟数', + `blood_oxygen` FLOAT NULL COMMENT '血氧饱和度 (百分比 %)', + `stress_level` DECIMAL(5,1) NULL COMMENT '压力 (ms,保留一位小数)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_record_date` (`user_id`, `record_date`), + KEY `idx_user_id` (`user_id`), + KEY `idx_record_date` (`record_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户每日健康记录表'; diff --git a/src/users/dto/daily-health.dto.ts b/src/users/dto/daily-health.dto.ts new file mode 100644 index 0000000..a3a01df --- /dev/null +++ b/src/users/dto/daily-health.dto.ts @@ -0,0 +1,114 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsNumber, IsString, Min, Max } from 'class-validator'; +import { ApiResponseDto } from 'src/base.dto'; + +/** + * 更新每日健康数据 DTO + */ +export class UpdateDailyHealthDto { + @ApiPropertyOptional({ description: '记录日期 (YYYY-MM-DD),不传则默认为今天' }) + @IsOptional() + @IsString() + date?: string; + + @ApiPropertyOptional({ description: '饮水量 (毫升 ml)' }) + @IsOptional() + @IsNumber() + @Min(0) + waterIntake?: number; + + @ApiPropertyOptional({ description: '锻炼分钟数' }) + @IsOptional() + @IsNumber() + @Min(0) + exerciseMinutes?: number; + + @ApiPropertyOptional({ description: '消耗卡路里 (千卡 kcal)' }) + @IsOptional() + @IsNumber() + @Min(0) + caloriesBurned?: number; + + @ApiPropertyOptional({ description: '站立时间 (分钟)' }) + @IsOptional() + @IsNumber() + @Min(0) + standingMinutes?: number; + + @ApiPropertyOptional({ description: '基础代谢 (千卡 kcal)' }) + @IsOptional() + @IsNumber() + @Min(0) + basalMetabolism?: number; + + @ApiPropertyOptional({ description: '睡眠分钟数' }) + @IsOptional() + @IsNumber() + @Min(0) + sleepMinutes?: number; + + @ApiPropertyOptional({ description: '血氧饱和度 (百分比 %)' }) + @IsOptional() + @IsNumber() + @Min(0) + @Max(100) + bloodOxygen?: number; + + @ApiPropertyOptional({ description: '压力 (ms,保留一位小数)' }) + @IsOptional() + @IsNumber() + @Min(0) + stressLevel?: number; +} + +/** + * 每日健康数据响应 + */ +export class DailyHealthDataDto { + @ApiProperty({ description: '记录ID' }) + id: number; + + @ApiProperty({ description: '用户ID' }) + userId: string; + + @ApiProperty({ description: '记录日期 (YYYY-MM-DD)' }) + recordDate: string; + + @ApiPropertyOptional({ description: '饮水量 (毫升 ml)' }) + waterIntake: number | null; + + @ApiPropertyOptional({ description: '锻炼分钟数' }) + exerciseMinutes: number | null; + + @ApiPropertyOptional({ description: '消耗卡路里 (千卡 kcal)' }) + caloriesBurned: number | null; + + @ApiPropertyOptional({ description: '站立时间 (分钟)' }) + standingMinutes: number | null; + + @ApiPropertyOptional({ description: '基础代谢 (千卡 kcal)' }) + basalMetabolism: number | null; + + @ApiPropertyOptional({ description: '睡眠分钟数' }) + sleepMinutes: number | null; + + @ApiPropertyOptional({ description: '血氧饱和度 (百分比 %)' }) + bloodOxygen: number | null; + + @ApiPropertyOptional({ description: '压力 (ms,保留一位小数)' }) + stressLevel: number | null; + + @ApiProperty({ description: '创建时间' }) + createdAt: Date; + + @ApiProperty({ description: '更新时间' }) + updatedAt: Date; +} + +/** + * 更新每日健康数据响应 DTO + */ +export class UpdateDailyHealthResponseDto extends ApiResponseDto { + @ApiProperty({ type: DailyHealthDataDto }) + declare data: DailyHealthDataDto; +} diff --git a/src/users/models/user-daily-health.model.ts b/src/users/models/user-daily-health.model.ts new file mode 100644 index 0000000..26f6c5f --- /dev/null +++ b/src/users/models/user-daily-health.model.ts @@ -0,0 +1,115 @@ +import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +/** + * 用户每日健康记录表 + * 每日每个用户只会生成一条数据,通过 userId + recordDate 唯一确定 + */ +@Table({ + tableName: 't_user_daily_health', + underscored: true, + indexes: [ + { + unique: true, + fields: ['user_id', 'record_date'], + name: 'uk_user_record_date', + }, + { + fields: ['user_id'], + name: 'idx_user_id', + }, + { + fields: ['record_date'], + name: 'idx_record_date', + }, + ], +}) +export class UserDailyHealth extends Model { + @PrimaryKey + @Column({ + type: DataType.BIGINT, + autoIncrement: true, + }) + declare id: number; + + @Column({ + type: DataType.STRING(64), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.DATEONLY, + allowNull: false, + comment: '记录日期 (YYYY-MM-DD)', + }) + declare recordDate: string; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '饮水量 (毫升 ml)', + }) + declare waterIntake: number | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '锻炼分钟数', + }) + declare exerciseMinutes: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '消耗卡路里 (千卡 kcal)', + }) + declare caloriesBurned: number | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '站立时间 (分钟)', + }) + declare standingMinutes: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '基础代谢 (千卡 kcal)', + }) + declare basalMetabolism: number | null; + + @Column({ + type: DataType.INTEGER, + allowNull: true, + comment: '睡眠分钟数', + }) + declare sleepMinutes: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '血氧饱和度 (百分比 %)', + }) + declare bloodOxygen: number | null; + + @Column({ + type: DataType.DECIMAL(5, 1), + allowNull: true, + comment: '压力 (ms,保留一位小数)', + }) + declare stressLevel: number | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + }) + declare updatedAt: Date; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 1bcd5bf..b1fd49a 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -38,6 +38,7 @@ import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto'; import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.dto'; +import { UpdateDailyHealthDto, UpdateDailyHealthResponseDto } from './dto/daily-health.dto'; import { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; @@ -611,4 +612,23 @@ export class UsersController { } } + // ==================== 每日健康数据相关接口 ==================== + + /** + * 更新用户每日健康数据 + */ + @UseGuards(JwtAuthGuard) + @Put('daily-health') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新用户每日健康数据(每日每用户一条记录,存在则更新)' }) + @ApiBody({ type: UpdateDailyHealthDto }) + @ApiResponse({ status: 200, description: '成功更新每日健康数据', type: UpdateDailyHealthResponseDto }) + async updateDailyHealth( + @Body() updateDto: UpdateDailyHealthDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`更新每日健康数据 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateDto)}`); + return this.usersService.updateDailyHealth(user.sub, updateDto); + } + } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 6710fa7..f5b26f1 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -10,6 +10,7 @@ import { BadgeConfig } from "./models/badge-config.model"; import { UserBadge } from "./models/user-badge.model"; import { UserDietHistory } from "./models/user-diet-history.model"; +import { UserDailyHealth } from "./models/user-daily-health.model"; import { ApplePurchaseService } from "./services/apple-purchase.service"; import { UserActivity } from "./models/user-activity.model"; import { UserActivityService } from "./services/user-activity.service"; @@ -41,6 +42,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module'; UserBadge, UserDietHistory, + UserDailyHealth, UserActivity, ]), forwardRef(() => ActivityLogsModule), diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 72c7416..59498d4 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -37,6 +37,8 @@ import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purch import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model'; import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model'; import { UserBodyMeasurementHistory, BodyMeasurementType, MeasurementUpdateSource } from './models/user-body-measurement-history.model'; +import { UserDailyHealth } from './models/user-daily-health.model'; +import { UpdateDailyHealthDto, UpdateDailyHealthResponseDto } from './dto/daily-health.dto'; import { ActivityLogsService } from '../activity-logs/activity-logs.service'; import { UserActivityService } from './services/user-activity.service'; @@ -78,6 +80,8 @@ export class UsersService { private userWeightHistoryModel: typeof UserWeightHistory, @InjectModel(UserBodyMeasurementHistory) private userBodyMeasurementHistoryModel: typeof UserBodyMeasurementHistory, + @InjectModel(UserDailyHealth) + private userDailyHealthModel: typeof UserDailyHealth, @InjectConnection() private sequelize: Sequelize, @@ -2999,4 +3003,74 @@ export class UsersService { }; } } + + // ==================== 每日健康数据相关方法 ==================== + + /** + * 更新用户每日健康数据 + * 每日每个用户只会生成一条数据,如果已存在则更新 + */ + async updateDailyHealth(userId: string, updateDto: UpdateDailyHealthDto): Promise { + try { + // 确定记录日期,默认为今天 + const recordDate = updateDto.date || dayjs().format('YYYY-MM-DD'); + + this.logger.log(`更新每日健康数据 - 用户ID: ${userId}, 日期: ${recordDate}`); + + // 准备更新字段 + const updateFields: Partial = {}; + if (updateDto.waterIntake !== undefined) updateFields.waterIntake = updateDto.waterIntake; + if (updateDto.exerciseMinutes !== undefined) updateFields.exerciseMinutes = updateDto.exerciseMinutes; + if (updateDto.caloriesBurned !== undefined) updateFields.caloriesBurned = updateDto.caloriesBurned; + if (updateDto.standingMinutes !== undefined) updateFields.standingMinutes = updateDto.standingMinutes; + if (updateDto.basalMetabolism !== undefined) updateFields.basalMetabolism = updateDto.basalMetabolism; + if (updateDto.sleepMinutes !== undefined) updateFields.sleepMinutes = updateDto.sleepMinutes; + if (updateDto.bloodOxygen !== undefined) updateFields.bloodOxygen = updateDto.bloodOxygen; + if (updateDto.stressLevel !== undefined) updateFields.stressLevel = Math.round(updateDto.stressLevel * 10) / 10; // 保留一位小数 + + // 使用 upsert 实现创建或更新 + const [record, created] = await this.userDailyHealthModel.findOrCreate({ + where: { userId, recordDate }, + defaults: { + userId, + recordDate, + ...updateFields, + }, + }); + + // 如果记录已存在,则更新 + if (!created && Object.keys(updateFields).length > 0) { + await record.update(updateFields); + } + + this.logger.log(`每日健康数据${created ? '创建' : '更新'}成功 - 记录ID: ${record.id}`); + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { + id: record.id, + userId: record.userId, + recordDate: record.recordDate, + waterIntake: record.waterIntake, + exerciseMinutes: record.exerciseMinutes, + caloriesBurned: record.caloriesBurned, + standingMinutes: record.standingMinutes, + basalMetabolism: record.basalMetabolism, + sleepMinutes: record.sleepMinutes, + bloodOxygen: record.bloodOxygen, + stressLevel: record.stressLevel, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }, + }; + } catch (error) { + this.logger.error(`更新每日健康数据失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `更新每日健康数据失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: null as any, + }; + } + } }