feat(users): 添加用户每日健康数据记录功能,支持多维度健康指标更新
This commit is contained in:
24
sql-scripts/create_user_daily_health_table.sql
Normal file
24
sql-scripts/create_user_daily_health_table.sql
Normal 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='用户每日健康记录表';
|
||||
114
src/users/dto/daily-health.dto.ts
Normal file
114
src/users/dto/daily-health.dto.ts
Normal 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;
|
||||
}
|
||||
115
src/users/models/user-daily-health.model.ts
Normal file
115
src/users/models/user-daily-health.model.ts
Normal 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;
|
||||
}
|
||||
@@ -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<UpdateDailyHealthResponseDto> {
|
||||
this.logger.log(`更新每日健康数据 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateDto)}`);
|
||||
return this.usersService.updateDailyHealth(user.sub, updateDto);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user