diff --git a/src/app.module.ts b/src/app.module.ts index 5f87940..15ee142 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -24,6 +24,7 @@ import { WaterRecordsModule } from './water-records/water-records.module'; import { ChallengesModule } from './challenges/challenges.module'; import { PushNotificationsModule } from './push-notifications/push-notifications.module'; import { MedicationsModule } from './medications/medications.module'; +import { HealthProfilesModule } from './health-profiles/health-profiles.module'; @Module({ imports: [ @@ -55,6 +56,7 @@ import { MedicationsModule } from './medications/medications.module'; ChallengesModule, PushNotificationsModule, MedicationsModule, + HealthProfilesModule, ], controllers: [AppController], providers: [ diff --git a/src/health-profiles/constants/health-recommendations.ts b/src/health-profiles/constants/health-recommendations.ts new file mode 100644 index 0000000..d2610b1 --- /dev/null +++ b/src/health-profiles/constants/health-recommendations.ts @@ -0,0 +1,82 @@ +/** + * 健康史推荐选项常量 + * 用于前端展示和数据验证 + */ + +export const HEALTH_HISTORY_RECOMMENDATIONS = { + allergy: [ + 'penicillin', // 青霉素 + 'sulfonamides', // 磺胺类 + 'peanuts', // 花生 + 'seafood', // 海鲜 + 'pollen', // 花粉 + 'dustMites', // 尘螨 + 'alcohol', // 酒精 + 'mango', // 芒果 + ], + disease: [ + 'hypertension', // 高血压 + 'diabetes', // 糖尿病 + 'asthma', // 哮喘 + 'heartDisease', // 心脏病 + 'gastritis', // 胃炎 + 'migraine', // 偏头痛 + ], + surgery: [ + 'appendectomy', // 阑尾切除术 + 'cesareanSection', // 剖腹产 + 'tonsillectomy', // 扁桃体切除术 + 'fractureRepair', // 骨折复位术 + 'none', // 无 + ], + familyDisease: [ + 'hypertension', // 高血压 + 'diabetes', // 糖尿病 + 'cancer', // 癌症 + 'heartDisease', // 心脏病 + 'stroke', // 中风 + 'alzheimers', // 阿尔茨海默病 + ], +}; + +/** + * 健康异常检测规则 + */ +export interface HealthAbnormalityRule { + indicatorName: string; + condition: 'gt' | 'lt' | 'eq' | 'range'; + threshold: number | [number, number]; + severity: 'info' | 'warning' | 'critical'; + message: string; +} + +export const ABNORMALITY_RULES: HealthAbnormalityRule[] = [ + { + indicatorName: '收缩压', + condition: 'gt', + threshold: 140, + severity: 'warning', + message: '血压偏高,建议关注', + }, + { + indicatorName: '舒张压', + condition: 'gt', + threshold: 90, + severity: 'warning', + message: '舒张压偏高,建议关注', + }, + { + indicatorName: '血糖', + condition: 'gt', + threshold: 7.0, + severity: 'warning', + message: '血糖偏高,建议复查', + }, + { + indicatorName: '总胆固醇', + condition: 'gt', + threshold: 5.2, + severity: 'info', + message: '胆固醇偏高,建议注意饮食', + }, +]; diff --git a/src/health-profiles/dto/family-health.dto.ts b/src/health-profiles/dto/family-health.dto.ts new file mode 100644 index 0000000..3755ba0 --- /dev/null +++ b/src/health-profiles/dto/family-health.dto.ts @@ -0,0 +1,182 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsNumber, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { FamilyRole } from '../enums/health-profile.enum'; + +/** + * 创建家庭组请求 DTO + */ +export class CreateFamilyGroupDto { + @ApiPropertyOptional({ description: '家庭组名称', default: '我的家庭' }) + @IsOptional() + @IsString() + name?: string; +} + +/** + * 生成邀请码请求 DTO + */ +export class GenerateInviteCodeDto { + @ApiPropertyOptional({ description: '邀请码有效期(小时)', default: 24 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(168) // 最多7天 + expiresInHours?: number = 24; +} + +/** + * 加入家庭组请求 DTO + */ +export class JoinFamilyGroupDto { + @ApiProperty({ description: '邀请码' }) + @IsString() + inviteCode: string; +} + +/** + * 更新成员权限请求 DTO + */ +export class UpdateFamilyMemberDto { + @ApiPropertyOptional({ description: '是否可查看健康数据' }) + @IsOptional() + @IsBoolean() + canViewHealthData?: boolean; + + @ApiPropertyOptional({ description: '是否可管理健康数据' }) + @IsOptional() + @IsBoolean() + canManageHealthData?: boolean; + + @ApiPropertyOptional({ description: '是否接收异常提醒' }) + @IsOptional() + @IsBoolean() + receiveAlerts?: boolean; + + @ApiPropertyOptional({ description: '关系(如:配偶、父母、子女)' }) + @IsOptional() + @IsString() + relationship?: string; +} + +/** + * 家庭成员响应 DTO + */ +export class FamilyMemberResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + userId: string; + + @ApiProperty() + userName: string; + + @ApiPropertyOptional() + userAvatar?: string; + + @ApiProperty({ enum: FamilyRole }) + role: FamilyRole; + + @ApiPropertyOptional() + relationship?: string; + + @ApiProperty() + canViewHealthData: boolean; + + @ApiProperty() + canManageHealthData: boolean; + + @ApiProperty() + receiveAlerts: boolean; + + @ApiProperty() + joinedAt: string; +} + +/** + * 家庭组响应 DTO + */ +export class FamilyGroupResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + ownerId: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + inviteCode?: string; + + @ApiPropertyOptional() + inviteCodeExpiresAt?: string; + + @ApiProperty() + maxMembers: number; + + @ApiProperty({ type: [FamilyMemberResponseDto] }) + members: FamilyMemberResponseDto[]; + + @ApiProperty() + createdAt: string; + + @ApiProperty() + updatedAt: string; +} + +/** + * 获取家庭组响应 DTO + */ +export class GetFamilyGroupResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ type: FamilyGroupResponseDto, nullable: true }) + data: FamilyGroupResponseDto | null; +} + +/** + * 邀请码响应 DTO + */ +export class FamilyInviteResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ + example: { + familyGroupId: 'uuid', + inviteCode: 'ABC123', + expiresAt: '2024-01-02T00:00:00Z', + qrCodeUrl: 'https://...', + }, + }) + data: { + familyGroupId: string; + inviteCode: string; + expiresAt: string; + qrCodeUrl: string; + }; +} + +/** + * 获取家庭成员列表响应 DTO + */ +export class GetFamilyMembersResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ type: [FamilyMemberResponseDto] }) + data: FamilyMemberResponseDto[]; +} diff --git a/src/health-profiles/dto/health-history.dto.ts b/src/health-profiles/dto/health-history.dto.ts new file mode 100644 index 0000000..90e6549 --- /dev/null +++ b/src/health-profiles/dto/health-history.dto.ts @@ -0,0 +1,151 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsString, IsOptional, IsArray, ValidateNested, IsEnum, IsDateString } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * 健康史详情项 DTO + */ +export class HealthHistoryItemDto { + @ApiPropertyOptional({ description: '已有项的ID(更新时传入)' }) + @IsOptional() + @IsString() + id?: string; + + @ApiProperty({ description: '名称(如:青霉素、高血压)' }) + @IsString() + name: string; + + @ApiPropertyOptional({ description: '确诊/发生日期(YYYY-MM-DD)' }) + @IsOptional() + @IsDateString() + date?: string; + + @ApiPropertyOptional({ description: '是否为推荐选项' }) + @IsOptional() + @IsBoolean() + isRecommendation?: boolean; + + @ApiPropertyOptional({ description: '备注' }) + @IsOptional() + @IsString() + note?: string; +} + +/** + * 更新健康史分类请求 DTO + */ +export class UpdateHealthHistoryDto { + @ApiProperty({ description: '是否有该类健康史', nullable: true }) + @IsBoolean() + hasHistory: boolean; + + @ApiProperty({ description: '健康史详情列表', type: [HealthHistoryItemDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => HealthHistoryItemDto) + items: HealthHistoryItemDto[]; +} + +/** + * 健康史详情项响应 + */ +export class HealthHistoryItemResponseDto { + @ApiProperty() + id: string; + + @ApiProperty() + name: string; + + @ApiPropertyOptional() + date?: string; + + @ApiPropertyOptional() + isRecommendation?: boolean; + + @ApiPropertyOptional() + note?: string; +} + +/** + * 健康史分类响应 + */ +export class HealthHistoryCategoryResponseDto { + @ApiProperty({ nullable: true }) + hasHistory: boolean | null; + + @ApiProperty({ type: [HealthHistoryItemResponseDto] }) + items: HealthHistoryItemResponseDto[]; +} + +/** + * 获取健康史响应 + */ +export class GetHealthHistoryResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ + description: '按分类组织的健康史数据', + example: { + allergy: { hasHistory: true, items: [] }, + disease: { hasHistory: false, items: [] }, + surgery: { hasHistory: null, items: [] }, + familyDisease: { hasHistory: true, items: [] }, + }, + }) + data: { + allergy: HealthHistoryCategoryResponseDto; + disease: HealthHistoryCategoryResponseDto; + surgery: HealthHistoryCategoryResponseDto; + familyDisease: HealthHistoryCategoryResponseDto; + }; +} + +/** + * 更新健康史分类响应 + */ +export class UpdateHealthHistoryCategoryResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ type: HealthHistoryCategoryResponseDto }) + data: HealthHistoryCategoryResponseDto; +} + +/** + * 健康史完成度响应 + */ +export class HealthHistoryProgressResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ + example: { + progress: 75, + details: { + allergy: true, + disease: true, + surgery: true, + familyDisease: false, + }, + }, + }) + data: { + progress: number; + details: { + allergy: boolean; + disease: boolean; + surgery: boolean; + familyDisease: boolean; + }; + }; +} diff --git a/src/health-profiles/dto/health-overview.dto.ts b/src/health-profiles/dto/health-overview.dto.ts new file mode 100644 index 0000000..681e227 --- /dev/null +++ b/src/health-profiles/dto/health-overview.dto.ts @@ -0,0 +1,81 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * 基础信息概览 + */ +export class BasicInfoOverviewDto { + @ApiProperty({ description: '完成度百分比' }) + progress: number; + + @ApiProperty({ + description: '基础数据', + example: { + height: '175', + weight: '70', + bmi: '22.9', + waistCircumference: 80, + }, + }) + data: { + height?: string; + weight?: string; + bmi?: string; + waistCircumference?: number; + }; +} + +/** + * 健康史概览 + */ +export class HealthHistoryOverviewDto { + @ApiProperty({ description: '完成度百分比' }) + progress: number; + + @ApiProperty({ description: '已回答的分类', type: [String] }) + answeredCategories: string[]; + + @ApiProperty({ description: '待回答的分类', type: [String] }) + pendingCategories: string[]; +} + +/** + * 药物管理概览 + */ +export class MedicationsOverviewDto { + @ApiProperty({ description: '当前用药数量' }) + activeCount: number; + + @ApiProperty({ description: '今日服药完成率' }) + todayCompletionRate: number; +} + +/** + * 健康档案概览响应 DTO + */ +export class GetHealthOverviewResponseDto { + @ApiProperty() + code: number; + + @ApiProperty() + message: string; + + @ApiProperty({ + example: { + basicInfo: { + progress: 100, + data: { height: '175', weight: '70', bmi: '22.9', waistCircumference: 80 }, + }, + healthHistory: { + progress: 75, + answeredCategories: ['allergy', 'disease', 'surgery'], + pendingCategories: ['familyDisease'], + }, + medications: { activeCount: 3, todayCompletionRate: 66.7 }, + }, + }) + data: { + basicInfo: BasicInfoOverviewDto; + healthHistory: HealthHistoryOverviewDto; + medications: MedicationsOverviewDto; + }; +} diff --git a/src/health-profiles/dto/index.ts b/src/health-profiles/dto/index.ts new file mode 100644 index 0000000..7293f2e --- /dev/null +++ b/src/health-profiles/dto/index.ts @@ -0,0 +1,3 @@ +export * from './health-history.dto'; +export * from './family-health.dto'; +export * from './health-overview.dto'; diff --git a/src/health-profiles/enums/health-profile.enum.ts b/src/health-profiles/enums/health-profile.enum.ts new file mode 100644 index 0000000..c83ef7b --- /dev/null +++ b/src/health-profiles/enums/health-profile.enum.ts @@ -0,0 +1,18 @@ +/** + * 健康档案相关枚举定义 + */ + +// 健康史分类 +export enum HealthHistoryCategory { + ALLERGY = 'allergy', // 过敏史 + DISEASE = 'disease', // 疾病史 + SURGERY = 'surgery', // 手术史 + FAMILY_DISEASE = 'familyDisease', // 家族疾病史 +} + +// 家庭成员角色 +export enum FamilyRole { + OWNER = 'owner', // 创建者 + ADMIN = 'admin', // 管理员 + MEMBER = 'member', // 普通成员 +} diff --git a/src/health-profiles/health-profiles.controller.ts b/src/health-profiles/health-profiles.controller.ts new file mode 100644 index 0000000..796a6ed --- /dev/null +++ b/src/health-profiles/health-profiles.controller.ts @@ -0,0 +1,205 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Body, + Param, + HttpCode, + HttpStatus, + UseGuards, + Logger, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiParam } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { CurrentUser } from '../common/decorators/current-user.decorator'; +import { AccessTokenPayload } from '../users/services/apple-auth.service'; +import { ResponseCode } from '../base.dto'; + +// Services +import { HealthProfilesService } from './health-profiles.service'; +import { HealthHistoryService } from './services/health-history.service'; +import { FamilyHealthService } from './services/family-health.service'; + +// DTOs +import { + UpdateHealthHistoryDto, + GetHealthHistoryResponseDto, + UpdateHealthHistoryCategoryResponseDto, + HealthHistoryProgressResponseDto, +} from './dto/health-history.dto'; +import { + CreateFamilyGroupDto, + GenerateInviteCodeDto, + JoinFamilyGroupDto, + UpdateFamilyMemberDto, + GetFamilyGroupResponseDto, + FamilyInviteResponseDto, + GetFamilyMembersResponseDto, +} from './dto/family-health.dto'; +import { GetHealthOverviewResponseDto } from './dto/health-overview.dto'; +import { HealthHistoryCategory } from './enums/health-profile.enum'; + +@ApiTags('health-profiles') +@Controller('health-profiles') +@UseGuards(JwtAuthGuard) +export class HealthProfilesController { + private readonly logger = new Logger(HealthProfilesController.name); + + constructor( + private readonly healthProfilesService: HealthProfilesService, + private readonly healthHistoryService: HealthHistoryService, + private readonly familyHealthService: FamilyHealthService, + ) {} + + // ==================== 健康档案概览 ==================== + + @Get('overview') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取健康档案概览' }) + @ApiResponse({ status: 200, type: GetHealthOverviewResponseDto }) + async getHealthOverview(@CurrentUser() user: AccessTokenPayload): Promise { + this.logger.log(`获取健康档案概览 - 用户ID: ${user.sub}`); + return this.healthProfilesService.getHealthOverview(user.sub); + } + + // ==================== 健康史 API ==================== + + @Get('history') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取用户健康史' }) + @ApiResponse({ status: 200, type: GetHealthHistoryResponseDto }) + async getHealthHistory(@CurrentUser() user: AccessTokenPayload): Promise { + this.logger.log(`获取健康史 - 用户ID: ${user.sub}`); + const data = await this.healthHistoryService.getHealthHistory(user.sub); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Put('history/:category') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新健康史分类' }) + @ApiParam({ name: 'category', enum: HealthHistoryCategory, description: '健康史分类' }) + @ApiBody({ type: UpdateHealthHistoryDto }) + @ApiResponse({ status: 200, type: UpdateHealthHistoryCategoryResponseDto }) + async updateHealthHistoryCategory( + @Param('category') category: HealthHistoryCategory, + @Body() updateDto: UpdateHealthHistoryDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`更新健康史分类 - 用户ID: ${user.sub}, 分类: ${category}`); + const data = await this.healthHistoryService.updateHealthHistoryCategory(user.sub, category, updateDto); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Get('history/progress') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取健康史完成度' }) + @ApiResponse({ status: 200, type: HealthHistoryProgressResponseDto }) + async getHealthHistoryProgress(@CurrentUser() user: AccessTokenPayload): Promise { + this.logger.log(`获取健康史完成度 - 用户ID: ${user.sub}`); + const data = await this.healthHistoryService.getHealthHistoryProgress(user.sub); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + // ==================== 家庭健康管理 API ==================== + + @Get('family/group') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取家庭组' }) + @ApiResponse({ status: 200, type: GetFamilyGroupResponseDto }) + async getFamilyGroup(@CurrentUser() user: AccessTokenPayload): Promise { + this.logger.log(`获取家庭组 - 用户ID: ${user.sub}`); + const data = await this.familyHealthService.getFamilyGroup(user.sub); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Post('family/group') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '创建家庭组' }) + @ApiBody({ type: CreateFamilyGroupDto }) + @ApiResponse({ status: 200, type: GetFamilyGroupResponseDto }) + async createFamilyGroup( + @Body() createDto: CreateFamilyGroupDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`创建家庭组 - 用户ID: ${user.sub}`); + const data = await this.familyHealthService.createFamilyGroup(user.sub, createDto); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Post('family/group/invite') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '生成邀请码' }) + @ApiBody({ type: GenerateInviteCodeDto }) + @ApiResponse({ status: 200, type: FamilyInviteResponseDto }) + async generateInviteCode( + @Body() dto: GenerateInviteCodeDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`生成邀请码 - 用户ID: ${user.sub}`); + const data = await this.familyHealthService.generateInviteCode(user.sub, dto); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Post('family/group/join') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '加入家庭组' }) + @ApiBody({ type: JoinFamilyGroupDto }) + @ApiResponse({ status: 200, type: GetFamilyGroupResponseDto }) + async joinFamilyGroup( + @Body() dto: JoinFamilyGroupDto, + @CurrentUser() user: AccessTokenPayload, + ): Promise { + this.logger.log(`加入家庭组 - 用户ID: ${user.sub}`); + const data = await this.familyHealthService.joinFamilyGroup(user.sub, dto); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Get('family/members') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取家庭成员列表' }) + @ApiResponse({ status: 200, type: GetFamilyMembersResponseDto }) + async getFamilyMembers(@CurrentUser() user: AccessTokenPayload): Promise { + this.logger.log(`获取家庭成员列表 - 用户ID: ${user.sub}`); + const data = await this.familyHealthService.getFamilyMembers(user.sub); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Put('family/members/:memberId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '更新成员权限' }) + @ApiParam({ name: 'memberId', description: '成员ID' }) + @ApiBody({ type: UpdateFamilyMemberDto }) + async updateFamilyMember( + @Param('memberId') memberId: string, + @Body() updateDto: UpdateFamilyMemberDto, + @CurrentUser() user: AccessTokenPayload, + ) { + this.logger.log(`更新成员权限 - 用户ID: ${user.sub}, 成员ID: ${memberId}`); + const data = await this.familyHealthService.updateFamilyMember(user.sub, memberId, updateDto); + return { code: ResponseCode.SUCCESS, message: 'success', data }; + } + + @Delete('family/members/:memberId') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '移除家庭成员' }) + @ApiParam({ name: 'memberId', description: '成员ID' }) + async removeFamilyMember( + @Param('memberId') memberId: string, + @CurrentUser() user: AccessTokenPayload, + ): Promise<{ code: number; message: string }> { + this.logger.log(`移除家庭成员 - 用户ID: ${user.sub}, 成员ID: ${memberId}`); + await this.familyHealthService.removeFamilyMember(user.sub, memberId); + return { code: ResponseCode.SUCCESS, message: 'success' }; + } + + @Post('family/leave') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '退出家庭组' }) + async leaveFamilyGroup(@CurrentUser() user: AccessTokenPayload): Promise<{ code: number; message: string }> { + this.logger.log(`退出家庭组 - 用户ID: ${user.sub}`); + await this.familyHealthService.leaveFamilyGroup(user.sub); + return { code: ResponseCode.SUCCESS, message: 'success' }; + } +} diff --git a/src/health-profiles/health-profiles.module.ts b/src/health-profiles/health-profiles.module.ts new file mode 100644 index 0000000..6055c0a --- /dev/null +++ b/src/health-profiles/health-profiles.module.ts @@ -0,0 +1,52 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { SequelizeModule } from '@nestjs/sequelize'; + +// Models +import { HealthHistory } from './models/health-history.model'; +import { HealthHistoryItem } from './models/health-history-item.model'; +import { FamilyGroup } from './models/family-group.model'; +import { FamilyMember } from './models/family-member.model'; + +// User models (for relations) +import { User } from '../users/models/user.model'; +import { UserProfile } from '../users/models/user-profile.model'; + +// Controller +import { HealthProfilesController } from './health-profiles.controller'; + +// Services +import { HealthProfilesService } from './health-profiles.service'; +import { HealthHistoryService } from './services/health-history.service'; +import { FamilyHealthService } from './services/family-health.service'; + +// Modules +import { UsersModule } from '../users/users.module'; + +@Module({ + imports: [ + SequelizeModule.forFeature([ + // Health History + HealthHistory, + HealthHistoryItem, + // Family Health + FamilyGroup, + FamilyMember, + // User models for relations + User, + UserProfile, + ]), + forwardRef(() => UsersModule), + ], + controllers: [HealthProfilesController], + providers: [ + HealthProfilesService, + HealthHistoryService, + FamilyHealthService, + ], + exports: [ + HealthProfilesService, + HealthHistoryService, + FamilyHealthService, + ], +}) +export class HealthProfilesModule {} diff --git a/src/health-profiles/health-profiles.service.ts b/src/health-profiles/health-profiles.service.ts new file mode 100644 index 0000000..487cbc6 --- /dev/null +++ b/src/health-profiles/health-profiles.service.ts @@ -0,0 +1,115 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectModel } from '@nestjs/sequelize'; +import { UserProfile } from '../users/models/user-profile.model'; +import { HealthHistoryService } from './services/health-history.service'; +import { GetHealthOverviewResponseDto } from './dto/health-overview.dto'; +import { ResponseCode } from '../base.dto'; + +@Injectable() +export class HealthProfilesService { + private readonly logger = new Logger(HealthProfilesService.name); + + constructor( + @InjectModel(UserProfile) + private readonly userProfileModel: typeof UserProfile, + private readonly healthHistoryService: HealthHistoryService, + ) {} + + /** + * 获取健康档案概览 + */ + async getHealthOverview(userId: string): Promise { + try { + // 1. 基础信息概览 + const profile = await this.userProfileModel.findOne({ + where: { userId }, + }); + + const basicInfo = this.calculateBasicInfoOverview(profile); + + // 2. 健康史概览 + const healthHistoryProgress = await this.healthHistoryService.getHealthHistoryProgress(userId); + const healthHistory = { + progress: healthHistoryProgress.progress, + answeredCategories: Object.entries(healthHistoryProgress.details) + .filter(([_, answered]) => answered) + .map(([category]) => category), + pendingCategories: Object.entries(healthHistoryProgress.details) + .filter(([_, answered]) => !answered) + .map(([category]) => category), + }; + + // 3. 药物管理概览(需要从药物模块获取,这里先返回默认值) + // TODO: 注入 MedicationsService 获取实际数据 + const medications = { + activeCount: 0, + todayCompletionRate: 0, + }; + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { + basicInfo, + healthHistory, + medications, + }, + }; + } catch (error) { + this.logger.error(`获取健康档案概览失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `获取健康档案概览失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: null as any, + }; + } + } + + /** + * 计算基础信息概览 + */ + private calculateBasicInfoOverview(profile: UserProfile | null): { + progress: number; + data: { + height?: string; + weight?: string; + bmi?: string; + waistCircumference?: number; + }; + } { + if (!profile) { + return { progress: 0, data: {} }; + } + + let filledCount = 0; + const totalFields = 3; // height, weight, waistCircumference + + const data: any = {}; + + if (profile.height && profile.height > 0) { + filledCount++; + data.height = profile.height.toString(); + } + + if (profile.weight && profile.weight > 0) { + filledCount++; + data.weight = profile.weight.toString(); + + // 计算 BMI + if (profile.height && profile.height > 0) { + const heightInMeters = profile.height / 100; + const bmi = profile.weight / (heightInMeters * heightInMeters); + data.bmi = bmi.toFixed(1); + } + } + + if (profile.waistCircumference && profile.waistCircumference > 0) { + filledCount++; + data.waistCircumference = profile.waistCircumference; + } + + const progress = Math.round((filledCount / totalFields) * 100); + + return { progress, data }; + } +} diff --git a/src/health-profiles/models/family-group.model.ts b/src/health-profiles/models/family-group.model.ts new file mode 100644 index 0000000..6ca39a1 --- /dev/null +++ b/src/health-profiles/models/family-group.model.ts @@ -0,0 +1,79 @@ +import { Column, Model, Table, DataType, HasMany, ForeignKey, BelongsTo } from 'sequelize-typescript'; +import { User } from '../../users/models/user.model'; +import { FamilyMember } from './family-member.model'; + +/** + * 家庭组表 + */ +@Table({ + tableName: 't_family_groups', + underscored: true, +}) +export class FamilyGroup extends Model { + @Column({ + type: DataType.STRING(50), + primaryKey: true, + comment: '家庭组ID', + }) + declare id: string; + + @ForeignKey(() => User) + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '创建者用户ID', + }) + declare ownerId: string; + + @Column({ + type: DataType.STRING(100), + allowNull: false, + defaultValue: '我的家庭', + comment: '家庭组名称', + }) + declare name: string; + + @Column({ + type: DataType.STRING(20), + allowNull: true, + unique: true, + comment: '邀请码', + }) + declare inviteCode: string | null; + + @Column({ + type: DataType.DATE, + allowNull: true, + comment: '邀请码过期时间', + }) + declare inviteCodeExpiresAt: Date | null; + + @Column({ + type: DataType.INTEGER, + allowNull: false, + defaultValue: 6, + comment: '最大成员数', + }) + declare maxMembers: number; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '创建时间', + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '更新时间', + }) + declare updatedAt: Date; + + // 关联关系 + @BelongsTo(() => User, 'ownerId') + declare owner: User; + + @HasMany(() => FamilyMember, 'familyGroupId') + declare members: FamilyMember[]; +} diff --git a/src/health-profiles/models/family-member.model.ts b/src/health-profiles/models/family-member.model.ts new file mode 100644 index 0000000..cace207 --- /dev/null +++ b/src/health-profiles/models/family-member.model.ts @@ -0,0 +1,95 @@ +import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript'; +import { FamilyRole } from '../enums/health-profile.enum'; +import { User } from '../../users/models/user.model'; +import { FamilyGroup } from './family-group.model'; + +/** + * 家庭成员表 + */ +@Table({ + tableName: 't_family_members', + underscored: true, + indexes: [ + { + unique: true, + fields: ['family_group_id', 'user_id'], + }, + ], +}) +export class FamilyMember extends Model { + @Column({ + type: DataType.STRING(50), + primaryKey: true, + comment: '成员记录ID', + }) + declare id: string; + + @ForeignKey(() => FamilyGroup) + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '家庭组ID', + }) + declare familyGroupId: string; + + @ForeignKey(() => User) + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING(20), + allowNull: false, + defaultValue: FamilyRole.MEMBER, + comment: '角色:owner | admin | member', + }) + declare role: FamilyRole; + + @Column({ + type: DataType.STRING(50), + allowNull: true, + comment: '关系(如:配偶、父母、子女)', + }) + declare relationship: string | null; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: '是否可查看健康数据', + }) + declare canViewHealthData: boolean; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: '是否可管理健康数据', + }) + declare canManageHealthData: boolean; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: '是否接收异常提醒', + }) + declare receiveAlerts: boolean; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '加入时间', + }) + declare joinedAt: Date; + + // 关联关系 + @BelongsTo(() => FamilyGroup, 'familyGroupId') + declare familyGroup: FamilyGroup; + + @BelongsTo(() => User, 'userId') + declare user: User; +} diff --git a/src/health-profiles/models/health-history-item.model.ts b/src/health-profiles/models/health-history-item.model.ts new file mode 100644 index 0000000..f2f87dc --- /dev/null +++ b/src/health-profiles/models/health-history-item.model.ts @@ -0,0 +1,86 @@ +import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript'; +import { User } from '../../users/models/user.model'; +import { HealthHistory } from './health-history.model'; + +/** + * 健康史详情表 + * 记录具体的过敏源、疾病、手术等信息 + */ +@Table({ + tableName: 't_health_history_items', + underscored: true, +}) +export class HealthHistoryItem extends Model { + @Column({ + type: DataType.STRING(50), + primaryKey: true, + comment: '详情ID', + }) + declare id: string; + + @ForeignKey(() => HealthHistory) + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '关联的健康史ID', + }) + declare healthHistoryId: string; + + @ForeignKey(() => User) + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING(255), + allowNull: false, + comment: '名称(如:青霉素、高血压)', + }) + declare name: string; + + @Column({ + type: DataType.DATEONLY, + allowNull: true, + comment: '确诊/发生日期', + }) + declare diagnosisDate: string | null; + + @Column({ + type: DataType.BOOLEAN, + allowNull: false, + defaultValue: false, + comment: '是否为推荐选项', + }) + declare isRecommendation: boolean; + + @Column({ + type: DataType.TEXT, + allowNull: true, + comment: '备注', + }) + declare note: string | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '创建时间', + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '更新时间', + }) + declare updatedAt: Date; + + // 关联关系 + @BelongsTo(() => HealthHistory, 'healthHistoryId') + declare healthHistory: HealthHistory; + + @BelongsTo(() => User, 'userId') + declare user: User; +} diff --git a/src/health-profiles/models/health-history.model.ts b/src/health-profiles/models/health-history.model.ts new file mode 100644 index 0000000..e0f76ee --- /dev/null +++ b/src/health-profiles/models/health-history.model.ts @@ -0,0 +1,64 @@ +import { Column, Model, Table, DataType, HasMany, ForeignKey, BelongsTo } from 'sequelize-typescript'; +import { HealthHistoryCategory } from '../enums/health-profile.enum'; +import { User } from '../../users/models/user.model'; +import { HealthHistoryItem } from './health-history-item.model'; + +/** + * 健康史主表 + * 记录用户各分类的健康史状态 + */ +@Table({ + tableName: 't_health_histories', + underscored: true, +}) +export class HealthHistory extends Model { + @Column({ + type: DataType.STRING(50), + primaryKey: true, + comment: '健康史记录ID', + }) + declare id: string; + + @ForeignKey(() => User) + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '用户ID', + }) + declare userId: string; + + @Column({ + type: DataType.STRING(50), + allowNull: false, + comment: '健康史分类:allergy | disease | surgery | familyDisease', + }) + declare category: HealthHistoryCategory; + + @Column({ + type: DataType.BOOLEAN, + allowNull: true, + comment: '是否有该类健康史:null=未回答, true=有, false=无', + }) + declare hasHistory: boolean | null; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '创建时间', + }) + declare createdAt: Date; + + @Column({ + type: DataType.DATE, + defaultValue: DataType.NOW, + comment: '更新时间', + }) + declare updatedAt: Date; + + // 关联关系 + @BelongsTo(() => User, 'userId') + declare user: User; + + @HasMany(() => HealthHistoryItem, 'healthHistoryId') + declare items: HealthHistoryItem[]; +} diff --git a/src/health-profiles/models/index.ts b/src/health-profiles/models/index.ts new file mode 100644 index 0000000..a49ded5 --- /dev/null +++ b/src/health-profiles/models/index.ts @@ -0,0 +1,7 @@ +// Health History +export * from './health-history.model'; +export * from './health-history-item.model'; + +// Family Health +export * from './family-group.model'; +export * from './family-member.model'; diff --git a/src/health-profiles/services/family-health.service.ts b/src/health-profiles/services/family-health.service.ts new file mode 100644 index 0000000..6c9dfc4 --- /dev/null +++ b/src/health-profiles/services/family-health.service.ts @@ -0,0 +1,386 @@ +import { Injectable, Logger, NotFoundException, ForbiddenException, BadRequestException } from '@nestjs/common'; +import { InjectModel, InjectConnection } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize-typescript'; +import { Op } from 'sequelize'; +import { v4 as uuidv4 } from 'uuid'; +import * as dayjs from 'dayjs'; +import { FamilyGroup } from '../models/family-group.model'; +import { FamilyMember } from '../models/family-member.model'; +import { User } from '../../users/models/user.model'; +import { FamilyRole } from '../enums/health-profile.enum'; +import { + CreateFamilyGroupDto, + GenerateInviteCodeDto, + JoinFamilyGroupDto, + UpdateFamilyMemberDto, + FamilyGroupResponseDto, + FamilyMemberResponseDto, +} from '../dto/family-health.dto'; + +@Injectable() +export class FamilyHealthService { + private readonly logger = new Logger(FamilyHealthService.name); + + constructor( + @InjectModel(FamilyGroup) + private readonly familyGroupModel: typeof FamilyGroup, + @InjectModel(FamilyMember) + private readonly familyMemberModel: typeof FamilyMember, + @InjectModel(User) + private readonly userModel: typeof User, + @InjectConnection() + private readonly sequelize: Sequelize, + ) {} + + /** + * 获取用户的家庭组 + */ + async getFamilyGroup(userId: string): Promise { + // 先查找用户所属的家庭成员记录 + const membership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (!membership) { + return null; + } + + const familyGroup = await this.familyGroupModel.findOne({ + where: { id: membership.familyGroupId }, + include: [ + { + model: FamilyMember, + as: 'members', + include: [{ model: User, as: 'user' }], + }, + ], + }); + + if (!familyGroup) { + return null; + } + + return this.mapGroupToResponse(familyGroup); + } + + /** + * 创建家庭组 + */ + async createFamilyGroup(userId: string, createDto: CreateFamilyGroupDto): Promise { + // 检查用户是否已经有家庭组 + const existingMembership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (existingMembership) { + throw new BadRequestException('您已经是一个家庭组的成员,请先退出当前家庭组'); + } + + const transaction = await this.sequelize.transaction(); + + try { + // 创建家庭组 + const familyGroup = await this.familyGroupModel.create( + { + id: uuidv4(), + ownerId: userId, + name: createDto.name || '我的家庭', + }, + { transaction }, + ); + + // 将创建者添加为 owner 成员 + await this.familyMemberModel.create( + { + id: uuidv4(), + familyGroupId: familyGroup.id, + userId, + role: FamilyRole.OWNER, + canViewHealthData: true, + canManageHealthData: true, + receiveAlerts: true, + }, + { transaction }, + ); + + await transaction.commit(); + + this.logger.log(`用户 ${userId} 创建家庭组 ${familyGroup.id} 成功`); + + // 重新查询以获取完整数据 + return this.getFamilyGroup(userId) as Promise; + } catch (error) { + await transaction.rollback(); + this.logger.error(`创建家庭组失败: ${error instanceof Error ? error.message : '未知错误'}`); + throw error; + } + } + + /** + * 生成邀请码 + */ + async generateInviteCode( + userId: string, + dto: GenerateInviteCodeDto, + ): Promise<{ familyGroupId: string; inviteCode: string; expiresAt: string; qrCodeUrl: string }> { + const membership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (!membership) { + throw new NotFoundException('您还没有家庭组'); + } + + // 只有 owner 和 admin 可以生成邀请码 + if (membership.role === FamilyRole.MEMBER) { + throw new ForbiddenException('只有管理员可以生成邀请码'); + } + + const familyGroup = await this.familyGroupModel.findByPk(membership.familyGroupId); + if (!familyGroup) { + throw new NotFoundException('家庭组不存在'); + } + + // 生成邀请码 + const inviteCode = this.generateUniqueInviteCode(); + const expiresAt = dayjs().add(dto.expiresInHours || 24, 'hour').toDate(); + + familyGroup.inviteCode = inviteCode; + familyGroup.inviteCodeExpiresAt = expiresAt; + await familyGroup.save(); + + this.logger.log(`用户 ${userId} 为家庭组 ${familyGroup.id} 生成邀请码 ${inviteCode}`); + + return { + familyGroupId: familyGroup.id, + inviteCode, + expiresAt: expiresAt.toISOString(), + qrCodeUrl: `outlive://family/join?code=${inviteCode}`, // 可以根据实际需求生成二维码 URL + }; + } + + /** + * 加入家庭组 + */ + async joinFamilyGroup(userId: string, dto: JoinFamilyGroupDto): Promise { + // 检查用户是否已经有家庭组 + const existingMembership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (existingMembership) { + throw new BadRequestException('您已经是一个家庭组的成员,请先退出当前家庭组'); + } + + // 查找邀请码对应的家庭组 + const familyGroup = await this.familyGroupModel.findOne({ + where: { + inviteCode: dto.inviteCode, + inviteCodeExpiresAt: { [Op.gt]: new Date() }, + }, + }); + + if (!familyGroup) { + throw new BadRequestException('邀请码无效或已过期'); + } + + // 检查成员数量 + const memberCount = await this.familyMemberModel.count({ + where: { familyGroupId: familyGroup.id }, + }); + + if (memberCount >= familyGroup.maxMembers) { + throw new BadRequestException('家庭组已满员'); + } + + // 添加成员 + await this.familyMemberModel.create({ + id: uuidv4(), + familyGroupId: familyGroup.id, + userId, + role: FamilyRole.MEMBER, + canViewHealthData: true, + canManageHealthData: false, + receiveAlerts: true, + }); + + this.logger.log(`用户 ${userId} 加入家庭组 ${familyGroup.id}`); + + return this.getFamilyGroup(userId) as Promise; + } + + /** + * 获取家庭成员列表 + */ + async getFamilyMembers(userId: string): Promise { + const membership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (!membership) { + throw new NotFoundException('您还没有家庭组'); + } + + const members = await this.familyMemberModel.findAll({ + where: { familyGroupId: membership.familyGroupId }, + include: [{ model: User, as: 'user' }], + }); + + return members.map(this.mapMemberToResponse); + } + + /** + * 更新成员权限 + */ + async updateFamilyMember( + userId: string, + memberId: string, + updateDto: UpdateFamilyMemberDto, + ): Promise { + const currentMembership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (!currentMembership) { + throw new NotFoundException('您还没有家庭组'); + } + + // 只有 owner 和 admin 可以修改成员权限 + if (currentMembership.role === FamilyRole.MEMBER) { + throw new ForbiddenException('只有管理员可以修改成员权限'); + } + + const targetMember = await this.familyMemberModel.findOne({ + where: { id: memberId, familyGroupId: currentMembership.familyGroupId }, + include: [{ model: User, as: 'user' }], + }); + + if (!targetMember) { + throw new NotFoundException('成员不存在'); + } + + // 不能修改 owner 的权限 + if (targetMember.role === FamilyRole.OWNER && currentMembership.role !== FamilyRole.OWNER) { + throw new ForbiddenException('不能修改创建者的权限'); + } + + // 更新权限 + if (updateDto.canViewHealthData !== undefined) targetMember.canViewHealthData = updateDto.canViewHealthData; + if (updateDto.canManageHealthData !== undefined) targetMember.canManageHealthData = updateDto.canManageHealthData; + if (updateDto.receiveAlerts !== undefined) targetMember.receiveAlerts = updateDto.receiveAlerts; + if (updateDto.relationship !== undefined) targetMember.relationship = updateDto.relationship; + + await targetMember.save(); + + this.logger.log(`用户 ${userId} 更新成员 ${memberId} 的权限`); + + return this.mapMemberToResponse(targetMember); + } + + /** + * 移除家庭成员 + */ + async removeFamilyMember(userId: string, memberId: string): Promise { + const currentMembership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (!currentMembership) { + throw new NotFoundException('您还没有家庭组'); + } + + const targetMember = await this.familyMemberModel.findOne({ + where: { id: memberId, familyGroupId: currentMembership.familyGroupId }, + }); + + if (!targetMember) { + throw new NotFoundException('成员不存在'); + } + + // 不能移除 owner + if (targetMember.role === FamilyRole.OWNER) { + throw new ForbiddenException('不能移除创建者'); + } + + // 只有 owner 和 admin 可以移除成员,或者成员自己退出 + if ( + currentMembership.role === FamilyRole.MEMBER && + currentMembership.id !== memberId + ) { + throw new ForbiddenException('只有管理员可以移除成员'); + } + + await targetMember.destroy(); + + this.logger.log(`成员 ${memberId} 已从家庭组移除`); + } + + /** + * 退出家庭组 + */ + async leaveFamilyGroup(userId: string): Promise { + const membership = await this.familyMemberModel.findOne({ + where: { userId }, + }); + + if (!membership) { + throw new NotFoundException('您还没有家庭组'); + } + + // owner 不能直接退出,需要先转让或解散 + if (membership.role === FamilyRole.OWNER) { + throw new BadRequestException('创建者不能直接退出,请先转让管理权或解散家庭组'); + } + + await membership.destroy(); + + this.logger.log(`用户 ${userId} 退出家庭组`); + } + + /** + * 生成唯一邀请码 + */ + private generateUniqueInviteCode(): string { + const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + let code = ''; + for (let i = 0; i < 6; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return code; + } + + /** + * 映射家庭组到响应 DTO + */ + private mapGroupToResponse(group: FamilyGroup): FamilyGroupResponseDto { + return { + id: group.id, + ownerId: group.ownerId, + name: group.name, + inviteCode: group.inviteCode || undefined, + inviteCodeExpiresAt: group.inviteCodeExpiresAt?.toISOString() || undefined, + maxMembers: group.maxMembers, + members: group.members?.map(this.mapMemberToResponse) || [], + createdAt: group.createdAt.toISOString(), + updatedAt: group.updatedAt.toISOString(), + }; + } + + /** + * 映射成员到响应 DTO + */ + private mapMemberToResponse(member: FamilyMember): FamilyMemberResponseDto { + return { + id: member.id, + userId: member.userId, + userName: member.user?.name || '未知用户', + userAvatar: member.user?.avatar || undefined, + role: member.role, + relationship: member.relationship || undefined, + canViewHealthData: member.canViewHealthData, + canManageHealthData: member.canManageHealthData, + receiveAlerts: member.receiveAlerts, + joinedAt: member.joinedAt.toISOString(), + }; + } +} diff --git a/src/health-profiles/services/health-history.service.ts b/src/health-profiles/services/health-history.service.ts new file mode 100644 index 0000000..e2f5fe3 --- /dev/null +++ b/src/health-profiles/services/health-history.service.ts @@ -0,0 +1,222 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectModel, InjectConnection } from '@nestjs/sequelize'; +import { Sequelize } from 'sequelize-typescript'; +import { v4 as uuidv4 } from 'uuid'; +import { HealthHistory } from '../models/health-history.model'; +import { HealthHistoryItem } from '../models/health-history-item.model'; +import { HealthHistoryCategory } from '../enums/health-profile.enum'; +import { + UpdateHealthHistoryDto, + HealthHistoryCategoryResponseDto, + HealthHistoryItemResponseDto, +} from '../dto/health-history.dto'; +import { HEALTH_HISTORY_RECOMMENDATIONS } from '../constants/health-recommendations'; + +@Injectable() +export class HealthHistoryService { + private readonly logger = new Logger(HealthHistoryService.name); + + constructor( + @InjectModel(HealthHistory) + private readonly healthHistoryModel: typeof HealthHistory, + @InjectModel(HealthHistoryItem) + private readonly healthHistoryItemModel: typeof HealthHistoryItem, + @InjectConnection() + private readonly sequelize: Sequelize, + ) {} + + /** + * 获取用户所有健康史数据 + */ + async getHealthHistory(userId: string): Promise<{ + allergy: HealthHistoryCategoryResponseDto; + disease: HealthHistoryCategoryResponseDto; + surgery: HealthHistoryCategoryResponseDto; + familyDisease: HealthHistoryCategoryResponseDto; + }> { + const categories = Object.values(HealthHistoryCategory); + const result: any = {}; + + for (const category of categories) { + const history = await this.healthHistoryModel.findOne({ + where: { userId, category }, + include: [{ model: HealthHistoryItem, as: 'items' }], + }); + + result[category] = { + hasHistory: history?.hasHistory ?? null, + items: history?.items?.map(this.mapItemToResponse) ?? [], + }; + } + + return result; + } + + /** + * 更新指定分类的健康史 + */ + async updateHealthHistoryCategory( + userId: string, + category: HealthHistoryCategory, + updateDto: UpdateHealthHistoryDto, + ): Promise { + const transaction = await this.sequelize.transaction(); + + try { + // 查找或创建健康史主记录 + let history = await this.healthHistoryModel.findOne({ + where: { userId, category }, + transaction, + }); + + if (!history) { + history = await this.healthHistoryModel.create( + { + id: uuidv4(), + userId, + category, + hasHistory: updateDto.hasHistory, + }, + { transaction }, + ); + } else { + history.hasHistory = updateDto.hasHistory; + await history.save({ transaction }); + } + + // 如果 hasHistory 为 false,清空所有 items + if (!updateDto.hasHistory) { + await this.healthHistoryItemModel.destroy({ + where: { healthHistoryId: history.id }, + transaction, + }); + + await transaction.commit(); + return { hasHistory: false, items: [] }; + } + + // 处理 items - 全量更新模式 + // 1. 获取现有的 items + const existingItems = await this.healthHistoryItemModel.findAll({ + where: { healthHistoryId: history.id }, + transaction, + }); + const existingItemIds = new Set(existingItems.map(item => item.id)); + + // 2. 处理传入的 items + const newItemIds = new Set(); + const updatedItems: HealthHistoryItem[] = []; + + for (const itemDto of updateDto.items) { + if (itemDto.id && existingItemIds.has(itemDto.id)) { + // 更新现有项 + const existingItem = existingItems.find(i => i.id === itemDto.id); + if (existingItem) { + existingItem.name = itemDto.name; + existingItem.diagnosisDate = itemDto.date || null; + existingItem.isRecommendation = itemDto.isRecommendation ?? this.isRecommendation(category, itemDto.name); + existingItem.note = itemDto.note || null; + await existingItem.save({ transaction }); + updatedItems.push(existingItem); + newItemIds.add(itemDto.id); + } + } else { + // 新增项 + const newItem = await this.healthHistoryItemModel.create( + { + id: uuidv4(), + healthHistoryId: history.id, + userId, + name: itemDto.name, + diagnosisDate: itemDto.date || null, + isRecommendation: itemDto.isRecommendation ?? this.isRecommendation(category, itemDto.name), + note: itemDto.note || null, + }, + { transaction }, + ); + updatedItems.push(newItem); + newItemIds.add(newItem.id); + } + } + + // 3. 删除不在新列表中的旧项 + const itemsToDelete = existingItems.filter(item => !newItemIds.has(item.id)); + for (const item of itemsToDelete) { + await item.destroy({ transaction }); + } + + await transaction.commit(); + + this.logger.log(`用户 ${userId} 更新健康史分类 ${category} 成功`); + + return { + hasHistory: updateDto.hasHistory, + items: updatedItems.map(this.mapItemToResponse), + }; + } catch (error) { + await transaction.rollback(); + this.logger.error(`更新健康史失败: ${error instanceof Error ? error.message : '未知错误'}`); + throw error; + } + } + + /** + * 获取健康史完成度 + */ + async getHealthHistoryProgress(userId: string): Promise<{ + progress: number; + details: { + allergy: boolean; + disease: boolean; + surgery: boolean; + familyDisease: boolean; + }; + }> { + const categories = Object.values(HealthHistoryCategory); + const details: any = {}; + let answeredCount = 0; + + for (const category of categories) { + const history = await this.healthHistoryModel.findOne({ + where: { userId, category }, + }); + + // 只要回答了是否有历史(hasHistory !== null),就算已完成 + const isAnswered = history?.hasHistory !== null && history?.hasHistory !== undefined; + details[category] = isAnswered; + if (isAnswered) answeredCount++; + } + + const progress = Math.round((answeredCount / categories.length) * 100); + + return { progress, details }; + } + + /** + * 获取推荐选项 + */ + getRecommendations(category: HealthHistoryCategory): string[] { + return HEALTH_HISTORY_RECOMMENDATIONS[category] || []; + } + + /** + * 判断是否为推荐选项 + */ + private isRecommendation(category: HealthHistoryCategory, name: string): boolean { + const recommendations = HEALTH_HISTORY_RECOMMENDATIONS[category] || []; + return recommendations.includes(name); + } + + /** + * 映射 item 到响应 DTO + */ + private mapItemToResponse(item: HealthHistoryItem): HealthHistoryItemResponseDto { + return { + id: item.id, + name: item.name, + date: item.diagnosisDate || undefined, + isRecommendation: item.isRecommendation, + note: item.note || undefined, + }; + } +} diff --git a/src/users/models/user.model.ts b/src/users/models/user.model.ts index 10f8411..1f00ddf 100644 --- a/src/users/models/user.model.ts +++ b/src/users/models/user.model.ts @@ -133,6 +133,13 @@ export class User extends Model { }) declare appVersion: string; + @Column({ + type: DataType.STRING, + allowNull: true, + comment: '健康邀请码', + }) + declare healthInviteCode: string; + get isVip(): boolean { return this.membershipExpiration ? dayjs(this.membershipExpiration).isAfter(dayjs()) : false; } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index b1fd49a..93776aa 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -631,4 +631,37 @@ export class UsersController { return this.usersService.updateDailyHealth(user.sub, updateDto); } + // ==================== 健康邀请码相关接口 ==================== + + /** + * 获取用户健康邀请码 + */ + @UseGuards(JwtAuthGuard) + @Get('health-invite-code') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '获取用户健康邀请码(如果没有则自动生成)' }) + @ApiResponse({ + status: 200, + description: '成功获取健康邀请码', + schema: { + type: 'object', + properties: { + code: { type: 'number', example: 0 }, + message: { type: 'string', example: 'success' }, + data: { + type: 'object', + properties: { + healthInviteCode: { type: 'string', example: 'ABC12345' }, + }, + }, + }, + }, + }) + async getHealthInviteCode( + @CurrentUser() user: AccessTokenPayload, + ): Promise<{ code: ResponseCode; message: string; data: { healthInviteCode: string } }> { + this.logger.log(`获取健康邀请码 - 用户ID: ${user.sub}`); + return this.usersService.getHealthInviteCode(user.sub); + } + } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 0ab4b60..8457e0a 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -3092,4 +3092,83 @@ export class UsersService { }; } } + + /** + * 获取用户健康邀请码 + * 如果用户没有邀请码,则生成一个新的 + */ + async getHealthInviteCode(userId: string): Promise<{ code: ResponseCode; message: string; data: { healthInviteCode: string } }> { + try { + const user = await this.userModel.findByPk(userId); + if (!user) { + return { + code: ResponseCode.ERROR, + message: '用户不存在', + data: { healthInviteCode: '' }, + }; + } + + // 如果用户已有邀请码,直接返回 + if (user.healthInviteCode) { + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { healthInviteCode: user.healthInviteCode }, + }; + } + + // 生成唯一的邀请码(8位随机字母数字组合) + const healthInviteCode = await this.generateUniqueInviteCode(8); + + // 保存到数据库 + user.healthInviteCode = healthInviteCode; + await user.save(); + + this.logger.log(`为用户 ${userId} 生成健康邀请码: ${healthInviteCode}`); + + return { + code: ResponseCode.SUCCESS, + message: 'success', + data: { healthInviteCode }, + }; + } catch (error) { + this.logger.error(`获取健康邀请码失败: ${error instanceof Error ? error.message : '未知错误'}`); + return { + code: ResponseCode.ERROR, + message: `获取健康邀请码失败: ${error instanceof Error ? error.message : '未知错误'}`, + data: { healthInviteCode: '' }, + }; + } + } + + /** + * 生成唯一的邀请码,确保不与数据库中已有的重复 + */ + private async generateUniqueInviteCode(length: number, maxAttempts = 10): Promise { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // 生成随机邀请码 + let code = ''; + for (let i = 0; i < length; i++) { + code += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // 检查是否已存在 + const existing = await this.userModel.findOne({ + where: { healthInviteCode: code }, + }); + + if (!existing) { + return code; + } + + this.logger.warn(`邀请码 ${code} 已存在,重新生成(第 ${attempt + 1} 次尝试)`); + } + + // 如果多次尝试都失败,使用时间戳+随机数确保唯一性 + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `${timestamp}${random}`.substring(0, length); + } }