diff --git a/sql-scripts/body-measurements-migration.sql b/sql-scripts/body-measurements-migration.sql new file mode 100644 index 0000000..2dbaca9 --- /dev/null +++ b/sql-scripts/body-measurements-migration.sql @@ -0,0 +1,63 @@ +-- 身体围度功能数据库迁移脚本 +-- 执行日期: 2024年 +-- 功能: 为用户档案表新增围度字段,创建围度历史记录表 + +-- 禁用外键检查(执行时) +SET FOREIGN_KEY_CHECKS = 0; + +-- 1. 为用户档案表新增围度字段 +ALTER TABLE `t_user_profile` +ADD COLUMN `chest_circumference` FLOAT NULL COMMENT '胸围(厘米)' AFTER `daily_water_goal`, +ADD COLUMN `waist_circumference` FLOAT NULL COMMENT '腰围(厘米)' AFTER `chest_circumference`, +ADD COLUMN `upper_hip_circumference` FLOAT NULL COMMENT '上臀围(厘米)' AFTER `waist_circumference`, +ADD COLUMN `arm_circumference` FLOAT NULL COMMENT '臂围(厘米)' AFTER `upper_hip_circumference`, +ADD COLUMN `thigh_circumference` FLOAT NULL COMMENT '大腿围(厘米)' AFTER `arm_circumference`, +ADD COLUMN `calf_circumference` FLOAT NULL COMMENT '小腿围(厘米)' AFTER `thigh_circumference`; + +-- 2. 创建用户身体围度历史记录表 +CREATE TABLE `t_user_body_measurement_history` ( + `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` VARCHAR(255) NOT NULL COMMENT '用户ID', + `measurement_type` ENUM( + 'chest_circumference', + 'waist_circumference', + 'upper_hip_circumference', + 'arm_circumference', + 'thigh_circumference', + 'calf_circumference' + ) NOT NULL COMMENT '围度类型', + `value` FLOAT NOT NULL COMMENT '围度值(厘米)', + `source` ENUM('manual', 'other') NOT NULL DEFAULT 'manual' COMMENT '更新来源', + `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`), + KEY `idx_user_id` (`user_id`), + KEY `idx_measurement_type` (`measurement_type`), + KEY `idx_created_at` (`created_at`), + KEY `idx_user_measurement_time` (`user_id`, `measurement_type`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户身体围度历史记录表'; + +-- 重新启用外键检查 +SET FOREIGN_KEY_CHECKS = 1; + +-- 验证表结构 +SHOW CREATE TABLE `t_user_profile`; +SHOW CREATE TABLE `t_user_body_measurement_history`; + +-- 验证新增字段 +SELECT + COLUMN_NAME, + DATA_TYPE, + IS_NULLABLE, + COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 't_user_profile' + AND COLUMN_NAME IN ( + 'chest_circumference', + 'waist_circumference', + 'upper_hip_circumference', + 'arm_circumference', + 'thigh_circumference', + 'calf_circumference' + ); \ No newline at end of file diff --git a/src/users/dto/body-measurement.dto.ts b/src/users/dto/body-measurement.dto.ts new file mode 100644 index 0000000..4ac8963 --- /dev/null +++ b/src/users/dto/body-measurement.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsEnum, Min } from 'class-validator'; +import { ResponseCode } from 'src/base.dto'; +import { BodyMeasurementType } from '../models/user-body-measurement-history.model'; + +export class UpdateBodyMeasurementDto { + @IsNumber({}, { message: '胸围必须是数字' }) + @Min(0, { message: '胸围不能为负数' }) + @IsOptional() + @ApiProperty({ description: '胸围(厘米)', example: 90.5, required: false }) + chestCircumference?: number; + + @IsNumber({}, { message: '腰围必须是数字' }) + @Min(0, { message: '腰围不能为负数' }) + @IsOptional() + @ApiProperty({ description: '腰围(厘米)', example: 70.5, required: false }) + waistCircumference?: number; + + @IsNumber({}, { message: '上臀围必须是数字' }) + @Min(0, { message: '上臀围不能为负数' }) + @IsOptional() + @ApiProperty({ description: '上臀围(厘米)', example: 95.0, required: false }) + upperHipCircumference?: number; + + @IsNumber({}, { message: '臂围必须是数字' }) + @Min(0, { message: '臂围不能为负数' }) + @IsOptional() + @ApiProperty({ description: '臂围(厘米)', example: 28.5, required: false }) + armCircumference?: number; + + @IsNumber({}, { message: '大腿围必须是数字' }) + @Min(0, { message: '大腿围不能为负数' }) + @IsOptional() + @ApiProperty({ description: '大腿围(厘米)', example: 55.0, required: false }) + thighCircumference?: number; + + @IsNumber({}, { message: '小腿围必须是数字' }) + @Min(0, { message: '小腿围不能为负数' }) + @IsOptional() + @ApiProperty({ description: '小腿围(厘米)', example: 35.0, required: false }) + calfCircumference?: number; +} + +export class UpdateBodyMeasurementResponseDto { + @ApiProperty({ description: '状态码', example: ResponseCode.SUCCESS }) + code: ResponseCode; + @ApiProperty({ description: '消息', example: 'success' }) + message: string; +} + +export class GetBodyMeasurementHistoryResponseDto { + @ApiProperty({ description: '状态码', example: ResponseCode.SUCCESS }) + code: ResponseCode; + @ApiProperty({ description: '消息', example: 'success' }) + message: string; + @ApiProperty({ + description: '围度历史记录', + example: [ + { + id: 1, + userId: 'user123', + measurementType: 'chest_circumference', + value: 90.5, + source: 'manual', + createdAt: '2024-01-01T00:00:00.000Z' + } + ] + }) + data: Array<{ + id: number; + userId: string; + measurementType: BodyMeasurementType; + value: number; + source: string; + createdAt: Date; + }>; +} \ No newline at end of file diff --git a/src/users/models/user-body-measurement-history.model.ts b/src/users/models/user-body-measurement-history.model.ts new file mode 100644 index 0000000..7cf01bc --- /dev/null +++ b/src/users/models/user-body-measurement-history.model.ts @@ -0,0 +1,69 @@ +import { Column, DataType, Index, Model, PrimaryKey, Table } from 'sequelize-typescript'; + +export enum BodyMeasurementType { + ChestCircumference = 'chest_circumference', + WaistCircumference = 'waist_circumference', + UpperHipCircumference = 'upper_hip_circumference', + ArmCircumference = 'arm_circumference', + ThighCircumference = 'thigh_circumference', + CalfCircumference = 'calf_circumference', +} + +export enum MeasurementUpdateSource { + Manual = 'manual', + Other = 'other', +} + +@Table({ + tableName: 't_user_body_measurement_history', + underscored: true, +}) +export class UserBodyMeasurementHistory 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.ENUM('chest_circumference', 'waist_circumference', 'upper_hip_circumference', 'arm_circumference', 'thigh_circumference', 'calf_circumference'), + allowNull: false, + comment: '围度类型', + }) + declare measurementType: BodyMeasurementType; + + @Column({ + type: DataType.FLOAT, + allowNull: false, + comment: '围度值(厘米)', + }) + declare value: number; + + @Column({ + type: DataType.ENUM('manual', 'other'), + allowNull: false, + defaultValue: 'manual', + comment: '更新来源', + }) + declare source: MeasurementUpdateSource; + + @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/users/models/user-profile.model.ts b/src/users/models/user-profile.model.ts index 887cf77..9375592 100644 --- a/src/users/models/user-profile.model.ts +++ b/src/users/models/user-profile.model.ts @@ -93,6 +93,48 @@ export class UserProfile extends Model { }) declare dailyWaterGoal: number; + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '胸围(厘米)', + }) + declare chestCircumference: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '腰围(厘米)', + }) + declare waistCircumference: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '上臀围(厘米)', + }) + declare upperHipCircumference: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '臂围(厘米)', + }) + declare armCircumference: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '大腿围(厘米)', + }) + declare thighCircumference: number | null; + + @Column({ + type: DataType.FLOAT, + allowNull: true, + comment: '小腿围(厘米)', + }) + declare calfCircumference: number | null; + @Column({ type: DataType.DATE, defaultValue: DataType.NOW, diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index e1d4161..2b7ed9f 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -34,6 +34,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 { UpdateBodyMeasurementDto, UpdateBodyMeasurementResponseDto, GetBodyMeasurementHistoryResponseDto } from './dto/body-measurement.dto'; import { Public } from '../common/decorators/public.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator'; @@ -341,4 +342,40 @@ export class UsersController { return this.usersService.getUserActivityHistory(user.sub); } + // ==================== 围度相关接口 ==================== + + /** + * 更新用户围度信息 + */ + @UseGuards(JwtAuthGuard) + @Put('body-measurements') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新用户围度信息' }) + @ApiBody({ type: UpdateBodyMeasurementDto }) + @ApiResponse({ status: 200, description: '成功更新围度信息', type: UpdateBodyMeasurementResponseDto }) + async updateBodyMeasurements( + @Body() updateBodyMeasurementDto: UpdateBodyMeasurementDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`更新用户围度信息 - 用户ID: ${user.sub}, 数据: ${JSON.stringify(updateBodyMeasurementDto)}`); + return this.usersService.updateBodyMeasurements(user.sub, updateBodyMeasurementDto); + } + + /** + * 获取用户围度历史记录 + */ + @UseGuards(JwtAuthGuard) + @Get('body-measurements/history') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取用户围度历史记录' }) + @ApiQuery({ name: 'measurementType', required: false, description: '围度类型筛选' }) + @ApiResponse({ status: 200, description: '成功获取围度历史记录', type: GetBodyMeasurementHistoryResponseDto }) + async getBodyMeasurementHistory( + @CurrentUser() user: AccessTokenPayload, + @Query('measurementType') measurementType?: string, + ): Promise { + this.logger.log(`获取用户围度历史记录 - 用户ID: ${user.sub}, 围度类型: ${measurementType || '全部'}`); + return this.usersService.getBodyMeasurementHistory(user.sub, measurementType as any); + } + } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 7d7fe74..522899e 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -5,6 +5,7 @@ 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 { UserBodyMeasurementHistory } from "./models/user-body-measurement-history.model"; import { UserDietHistory } from "./models/user-diet-history.model"; import { ApplePurchaseService } from "./services/apple-purchase.service"; @@ -31,6 +32,7 @@ import { ActivityLogsModule } from '../activity-logs/activity-logs.module'; RevenueCatEvent, UserProfile, UserWeightHistory, + UserBodyMeasurementHistory, UserDietHistory, UserActivity, diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 0cb276d..aeed3af 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -31,6 +31,7 @@ import { RestorePurchaseDto, RestorePurchaseResponseDto, RestoredPurchaseInfo, A import { PurchaseRestoreLog, RestoreStatus, RestoreSource } from './models/purchase-restore-log.model'; 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 { ActivityLogsService } from '../activity-logs/activity-logs.service'; import { UserActivityService } from './services/user-activity.service'; @@ -63,6 +64,8 @@ export class UsersService { private userProfileModel: typeof UserProfile, @InjectModel(UserWeightHistory) private userWeightHistoryModel: typeof UserWeightHistory, + @InjectModel(UserBodyMeasurementHistory) + private userBodyMeasurementHistoryModel: typeof UserBodyMeasurementHistory, @InjectConnection() private sequelize: Sequelize, @@ -114,6 +117,12 @@ export class UsersService { height: profile?.height, activityLevel: profile?.activityLevel, dailyWaterGoal: profile?.dailyWaterGoal, + chestCircumference: profile?.chestCircumference, + waistCircumference: profile?.waistCircumference, + upperHipCircumference: profile?.upperHipCircumference, + armCircumference: profile?.armCircumference, + thighCircumference: profile?.thighCircumference, + calfCircumference: profile?.calfCircumference, } this.logger.log(`getProfile returnData: ${JSON.stringify(returnData, null, 2)}`); @@ -2278,4 +2287,103 @@ export class UsersService { }; } } + + /** + * 更新用户围度信息 + */ + async updateBodyMeasurements(userId: string, measurements: any): Promise<{ code: any; message: string }> { + const transaction = await this.sequelize.transaction(); + + try { + // 获取或创建用户档案 + const [profile] = await this.userProfileModel.findOrCreate({ + where: { userId }, + defaults: { userId }, + transaction, + }); + + const updateFields: any = {}; + const historyRecords: any[] = []; + + // 映射字段名到围度类型 + const fieldMappings = { + chestCircumference: BodyMeasurementType.ChestCircumference, + waistCircumference: BodyMeasurementType.WaistCircumference, + upperHipCircumference: BodyMeasurementType.UpperHipCircumference, + armCircumference: BodyMeasurementType.ArmCircumference, + thighCircumference: BodyMeasurementType.ThighCircumference, + calfCircumference: BodyMeasurementType.CalfCircumference, + }; + + // 处理每个传入的围度字段 + for (const [fieldName, measurementType] of Object.entries(fieldMappings)) { + if (measurements[fieldName] !== undefined) { + const value = measurements[fieldName]; + updateFields[fieldName] = value; + + // 准备历史记录 + historyRecords.push({ + userId, + measurementType, + value, + source: MeasurementUpdateSource.Manual, + }); + } + } + + // 更新用户档案 + if (Object.keys(updateFields).length > 0) { + await profile.update(updateFields, { transaction }); + + // 批量创建历史记录 + if (historyRecords.length > 0) { + await this.userBodyMeasurementHistoryModel.bulkCreate(historyRecords, { transaction }); + } + } + + await transaction.commit(); + + this.logger.log(`用户 ${userId} 围度更新成功: ${JSON.stringify(updateFields)}`); + + return { + code: ResponseCode.SUCCESS, + message: 'success', + }; + } catch (error) { + await transaction.rollback(); + this.logger.error(`更新用户围度失败: ${error instanceof Error ? error.message : '未知错误'}`); + throw new BadRequestException(`更新围度信息失败: ${error instanceof Error ? error.message : '未知错误'}`); + } + } + + /** + * 获取用户围度历史记录 + */ + async getBodyMeasurementHistory(userId: string, measurementType?: BodyMeasurementType): Promise { + try { + const whereCondition: any = { userId }; + if (measurementType) { + whereCondition.measurementType = measurementType; + } + + const history = await this.userBodyMeasurementHistoryModel.findAll({ + where: whereCondition, + order: [['createdAt', 'DESC']], + limit: 100, // 限制返回最近100条记录 + }); + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: history, + }; + } catch (error) { + this.logger.error(`获取围度历史记录失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `获取围度历史记录失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: [], + }; + } + } }