feat(health-profiles): 添加健康档案模块,支持健康史记录、家庭健康管理和档案概览功能
This commit is contained in:
@@ -24,6 +24,7 @@ import { WaterRecordsModule } from './water-records/water-records.module';
|
|||||||
import { ChallengesModule } from './challenges/challenges.module';
|
import { ChallengesModule } from './challenges/challenges.module';
|
||||||
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
|
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
|
||||||
import { MedicationsModule } from './medications/medications.module';
|
import { MedicationsModule } from './medications/medications.module';
|
||||||
|
import { HealthProfilesModule } from './health-profiles/health-profiles.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -55,6 +56,7 @@ import { MedicationsModule } from './medications/medications.module';
|
|||||||
ChallengesModule,
|
ChallengesModule,
|
||||||
PushNotificationsModule,
|
PushNotificationsModule,
|
||||||
MedicationsModule,
|
MedicationsModule,
|
||||||
|
HealthProfilesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
82
src/health-profiles/constants/health-recommendations.ts
Normal file
82
src/health-profiles/constants/health-recommendations.ts
Normal file
@@ -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: '胆固醇偏高,建议注意饮食',
|
||||||
|
},
|
||||||
|
];
|
||||||
182
src/health-profiles/dto/family-health.dto.ts
Normal file
182
src/health-profiles/dto/family-health.dto.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
151
src/health-profiles/dto/health-history.dto.ts
Normal file
151
src/health-profiles/dto/health-history.dto.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
81
src/health-profiles/dto/health-overview.dto.ts
Normal file
81
src/health-profiles/dto/health-overview.dto.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
3
src/health-profiles/dto/index.ts
Normal file
3
src/health-profiles/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './health-history.dto';
|
||||||
|
export * from './family-health.dto';
|
||||||
|
export * from './health-overview.dto';
|
||||||
18
src/health-profiles/enums/health-profile.enum.ts
Normal file
18
src/health-profiles/enums/health-profile.enum.ts
Normal file
@@ -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', // 普通成员
|
||||||
|
}
|
||||||
205
src/health-profiles/health-profiles.controller.ts
Normal file
205
src/health-profiles/health-profiles.controller.ts
Normal file
@@ -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<GetHealthOverviewResponseDto> {
|
||||||
|
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<GetHealthHistoryResponseDto> {
|
||||||
|
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<UpdateHealthHistoryCategoryResponseDto> {
|
||||||
|
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<HealthHistoryProgressResponseDto> {
|
||||||
|
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<GetFamilyGroupResponseDto> {
|
||||||
|
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<GetFamilyGroupResponseDto> {
|
||||||
|
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<FamilyInviteResponseDto> {
|
||||||
|
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<GetFamilyGroupResponseDto> {
|
||||||
|
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<GetFamilyMembersResponseDto> {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/health-profiles/health-profiles.module.ts
Normal file
52
src/health-profiles/health-profiles.module.ts
Normal file
@@ -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 {}
|
||||||
115
src/health-profiles/health-profiles.service.ts
Normal file
115
src/health-profiles/health-profiles.service.ts
Normal file
@@ -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<GetHealthOverviewResponseDto> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/health-profiles/models/family-group.model.ts
Normal file
79
src/health-profiles/models/family-group.model.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
95
src/health-profiles/models/family-member.model.ts
Normal file
95
src/health-profiles/models/family-member.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
86
src/health-profiles/models/health-history-item.model.ts
Normal file
86
src/health-profiles/models/health-history-item.model.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
64
src/health-profiles/models/health-history.model.ts
Normal file
64
src/health-profiles/models/health-history.model.ts
Normal file
@@ -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[];
|
||||||
|
}
|
||||||
7
src/health-profiles/models/index.ts
Normal file
7
src/health-profiles/models/index.ts
Normal file
@@ -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';
|
||||||
386
src/health-profiles/services/family-health.service.ts
Normal file
386
src/health-profiles/services/family-health.service.ts
Normal file
@@ -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<FamilyGroupResponseDto | null> {
|
||||||
|
// 先查找用户所属的家庭成员记录
|
||||||
|
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<FamilyGroupResponseDto> {
|
||||||
|
// 检查用户是否已经有家庭组
|
||||||
|
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<FamilyGroupResponseDto>;
|
||||||
|
} 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<FamilyGroupResponseDto> {
|
||||||
|
// 检查用户是否已经有家庭组
|
||||||
|
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<FamilyGroupResponseDto>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取家庭成员列表
|
||||||
|
*/
|
||||||
|
async getFamilyMembers(userId: string): Promise<FamilyMemberResponseDto[]> {
|
||||||
|
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<FamilyMemberResponseDto> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
222
src/health-profiles/services/health-history.service.ts
Normal file
222
src/health-profiles/services/health-history.service.ts
Normal file
@@ -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<HealthHistoryCategoryResponseDto> {
|
||||||
|
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<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,13 @@ export class User extends Model {
|
|||||||
})
|
})
|
||||||
declare appVersion: string;
|
declare appVersion: string;
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
type: DataType.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '健康邀请码',
|
||||||
|
})
|
||||||
|
declare healthInviteCode: string;
|
||||||
|
|
||||||
get isVip(): boolean {
|
get isVip(): boolean {
|
||||||
return this.membershipExpiration ? dayjs(this.membershipExpiration).isAfter(dayjs()) : false;
|
return this.membershipExpiration ? dayjs(this.membershipExpiration).isAfter(dayjs()) : false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -631,4 +631,37 @@ export class UsersController {
|
|||||||
return this.usersService.updateDailyHealth(user.sub, updateDto);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user