feat(health-profiles): 添加健康档案模块,支持健康史记录、家庭健康管理和档案概览功能

This commit is contained in:
richarjiang
2025-12-04 17:15:11 +08:00
parent 03bd0b041e
commit 2d7e067888
20 changed files with 1949 additions and 0 deletions

View File

@@ -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: [

View 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: '胆固醇偏高,建议注意饮食',
},
];

View 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[];
}

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

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

View File

@@ -0,0 +1,3 @@
export * from './health-history.dto';
export * from './family-health.dto';
export * from './health-overview.dto';

View 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', // 普通成员
}

View 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' };
}
}

View 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 {}

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

View 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[];
}

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

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

View 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[];
}

View 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';

View 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(),
};
}
}

View 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,
};
}
}

View File

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

View File

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

View File

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