feat(users): 添加用户每日健康数据记录功能,支持多维度健康指标更新

This commit is contained in:
richarjiang
2025-12-02 19:11:17 +08:00
parent 6cdd2bc137
commit c3b59752ee
6 changed files with 349 additions and 0 deletions

View File

@@ -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='用户每日健康记录表';

View File

@@ -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<DailyHealthDataDto> {
@ApiProperty({ type: DailyHealthDataDto })
declare data: DailyHealthDataDto;
}

View File

@@ -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;
}

View File

@@ -38,6 +38,7 @@ import { GetUserActivityHistoryResponseDto } from './dto/user-activity.dto';
import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto'; import { UpdateWeightRecordDto, WeightRecordResponseDto, DeleteWeightRecordResponseDto } from './dto/weight-record.dto';
import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto'; import { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto, GetBodyMeasurementAnalysisResponseDto } from './dto/body-measurement.dto';
import { GetUserBadgesResponseDto, GetAvailableBadgesResponseDto, MarkBadgeShownDto, MarkBadgeShownResponseDto } from './dto/badge.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 { Public } from '../common/decorators/public.decorator';
import { CurrentUser } from '../common/decorators/current-user.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<UpdateDailyHealthResponseDto> {
this.logger.log(`更新每日健康数据 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateDto)}`);
return this.usersService.updateDailyHealth(user.sub, updateDto);
}
} }

View File

@@ -10,6 +10,7 @@ import { BadgeConfig } from "./models/badge-config.model";
import { UserBadge } from "./models/user-badge.model"; import { UserBadge } from "./models/user-badge.model";
import { UserDietHistory } from "./models/user-diet-history.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 { ApplePurchaseService } from "./services/apple-purchase.service";
import { UserActivity } from "./models/user-activity.model"; import { UserActivity } from "./models/user-activity.model";
import { UserActivityService } from "./services/user-activity.service"; import { UserActivityService } from "./services/user-activity.service";
@@ -41,6 +42,7 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
UserBadge, UserBadge,
UserDietHistory, UserDietHistory,
UserDailyHealth,
UserActivity, UserActivity,
]), ]),
forwardRef(() => ActivityLogsModule), forwardRef(() => ActivityLogsModule),

View File

@@ -37,6 +37,8 @@ import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purch
import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model'; import { BlockedTransaction, BlockReason } from './models/blocked-transaction.model';
import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model'; import { UserWeightHistory, WeightUpdateSource } from './models/user-weight-history.model';
import { UserBodyMeasurementHistory, BodyMeasurementType, MeasurementUpdateSource } from './models/user-body-measurement-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 { ActivityLogsService } from '../activity-logs/activity-logs.service';
import { UserActivityService } from './services/user-activity.service'; import { UserActivityService } from './services/user-activity.service';
@@ -78,6 +80,8 @@ export class UsersService {
private userWeightHistoryModel: typeof UserWeightHistory, private userWeightHistoryModel: typeof UserWeightHistory,
@InjectModel(UserBodyMeasurementHistory) @InjectModel(UserBodyMeasurementHistory)
private userBodyMeasurementHistoryModel: typeof UserBodyMeasurementHistory, private userBodyMeasurementHistoryModel: typeof UserBodyMeasurementHistory,
@InjectModel(UserDailyHealth)
private userDailyHealthModel: typeof UserDailyHealth,
@InjectConnection() @InjectConnection()
private sequelize: Sequelize, private sequelize: Sequelize,
@@ -2999,4 +3003,74 @@ export class UsersService {
}; };
} }
} }
// ==================== 每日健康数据相关方法 ====================
/**
* 更新用户每日健康数据
* 每日每个用户只会生成一条数据,如果已存在则更新
*/
async updateDailyHealth(userId: string, updateDto: UpdateDailyHealthDto): Promise<UpdateDailyHealthResponseDto> {
try {
// 确定记录日期,默认为今天
const recordDate = updateDto.date || dayjs().format('YYYY-MM-DD');
this.logger.log(`更新每日健康数据 - 用户ID: ${userId}, 日期: ${recordDate}`);
// 准备更新字段
const updateFields: Partial<UserDailyHealth> = {};
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,
};
}
}
} }