feat: 添加Expo Updates服务端模块和就医资料管理功能
This commit is contained in:
@@ -25,6 +25,7 @@ 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';
|
||||
import { ExpoUpdatesModule } from './expo-updates/expo-updates.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -64,6 +65,7 @@ import { HealthProfilesModule } from './health-profiles/health-profiles.module';
|
||||
PushNotificationsModule,
|
||||
MedicationsModule,
|
||||
HealthProfilesModule,
|
||||
ExpoUpdatesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
99
src/expo-updates/expo-updates.controller.ts
Normal file
99
src/expo-updates/expo-updates.controller.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
Headers,
|
||||
Res,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiHeader, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ExpoUpdatesService } from './expo-updates.service';
|
||||
|
||||
@ApiTags('Expo Updates')
|
||||
@Controller('expo-updates')
|
||||
export class ExpoUpdatesController {
|
||||
constructor(private readonly expoUpdatesService: ExpoUpdatesService) {}
|
||||
|
||||
@Get('manifest')
|
||||
@ApiOperation({ summary: '获取 Expo 更新 manifest' })
|
||||
@ApiHeader({ name: 'expo-platform', description: '平台类型 (ios/android)', required: false })
|
||||
@ApiHeader({ name: 'expo-runtime-version', description: '运行时版本', required: false })
|
||||
@ApiHeader({ name: 'expo-protocol-version', description: '协议版本 (0/1)', required: false })
|
||||
@ApiHeader({ name: 'expo-current-update-id', description: '当前更新ID', required: false })
|
||||
@ApiQuery({ name: 'platform', description: '平台类型', required: false })
|
||||
@ApiQuery({ name: 'runtime-version', description: '运行时版本', required: false })
|
||||
@ApiResponse({ status: 200, description: '返回更新 manifest' })
|
||||
async getManifest(
|
||||
@Headers('expo-platform') headerPlatform: string,
|
||||
@Headers('expo-runtime-version') headerRuntimeVersion: string,
|
||||
@Headers('expo-protocol-version') protocolVersionHeader: string,
|
||||
@Headers('expo-current-update-id') currentUpdateId: string,
|
||||
@Query('platform') queryPlatform: string,
|
||||
@Query('runtime-version') queryRuntimeVersion: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const protocolVersion = parseInt(protocolVersionHeader || '0', 10);
|
||||
if (![0, 1].includes(protocolVersion)) {
|
||||
throw new BadRequestException('Unsupported protocol version. Expected either 0 or 1.');
|
||||
}
|
||||
|
||||
const platform = headerPlatform || queryPlatform;
|
||||
if (platform !== 'ios' && platform !== 'android') {
|
||||
throw new BadRequestException('Unsupported platform. Expected either ios or android.');
|
||||
}
|
||||
|
||||
const runtimeVersion = headerRuntimeVersion || queryRuntimeVersion;
|
||||
if (!runtimeVersion) {
|
||||
throw new BadRequestException('No runtimeVersion provided.');
|
||||
}
|
||||
|
||||
const manifest = this.expoUpdatesService.buildManifest(platform as 'ios' | 'android', runtimeVersion);
|
||||
|
||||
// 已是最新版本
|
||||
if (currentUpdateId === manifest.id && protocolVersion === 1) {
|
||||
return this.sendNoUpdateAvailable(res);
|
||||
}
|
||||
|
||||
// 构建 multipart 响应
|
||||
const boundary = `----ExpoUpdates${Date.now()}`;
|
||||
const parts = [
|
||||
`--${boundary}`,
|
||||
'content-disposition: form-data; name="manifest"',
|
||||
'content-type: application/json; charset=utf-8',
|
||||
'',
|
||||
JSON.stringify(manifest),
|
||||
`--${boundary}`,
|
||||
'content-disposition: form-data; name="extensions"',
|
||||
'content-type: application/json',
|
||||
'',
|
||||
JSON.stringify({ assetRequestHeaders: {} }),
|
||||
`--${boundary}--`,
|
||||
];
|
||||
|
||||
res.setHeader('expo-protocol-version', protocolVersion);
|
||||
res.setHeader('expo-sfv-version', 0);
|
||||
res.setHeader('cache-control', 'private, max-age=0');
|
||||
res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`);
|
||||
res.send(parts.join('\r\n'));
|
||||
}
|
||||
|
||||
private sendNoUpdateAvailable(res: Response) {
|
||||
const boundary = `----ExpoUpdates${Date.now()}`;
|
||||
const directive = this.expoUpdatesService.createNoUpdateAvailableDirective();
|
||||
const parts = [
|
||||
`--${boundary}`,
|
||||
'content-disposition: form-data; name="directive"',
|
||||
'content-type: application/json; charset=utf-8',
|
||||
'',
|
||||
JSON.stringify(directive),
|
||||
`--${boundary}--`,
|
||||
];
|
||||
|
||||
res.setHeader('expo-protocol-version', 1);
|
||||
res.setHeader('expo-sfv-version', 0);
|
||||
res.setHeader('cache-control', 'private, max-age=0');
|
||||
res.setHeader('content-type', `multipart/mixed; boundary=${boundary}`);
|
||||
res.send(parts.join('\r\n'));
|
||||
}
|
||||
}
|
||||
10
src/expo-updates/expo-updates.module.ts
Normal file
10
src/expo-updates/expo-updates.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ExpoUpdatesController } from './expo-updates.controller';
|
||||
import { ExpoUpdatesService } from './expo-updates.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ExpoUpdatesController],
|
||||
providers: [ExpoUpdatesService],
|
||||
exports: [ExpoUpdatesService],
|
||||
})
|
||||
export class ExpoUpdatesModule {}
|
||||
101
src/expo-updates/expo-updates.service.ts
Normal file
101
src/expo-updates/expo-updates.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface AssetMetadata {
|
||||
hash: string;
|
||||
key: string;
|
||||
contentType: string;
|
||||
fileExtension?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface UpdateManifest {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
runtimeVersion: string;
|
||||
assets: AssetMetadata[];
|
||||
launchAsset: AssetMetadata;
|
||||
metadata: Record<string, any>;
|
||||
extra: {
|
||||
expoClient?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NoUpdateAvailableDirective {
|
||||
type: 'noUpdateAvailable';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ExpoUpdatesService {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
/**
|
||||
* 从环境变量构建 manifest
|
||||
*
|
||||
* 环境变量配置:
|
||||
* - EXPO_UPDATE_ID: 更新ID
|
||||
* - EXPO_RUNTIME_VERSION: 运行时版本
|
||||
* - EXPO_CREATED_AT: 创建时间
|
||||
* - EXPO_IOS_BUNDLE_URL: iOS bundle URL
|
||||
* - EXPO_IOS_BUNDLE_HASH: iOS bundle hash
|
||||
* - EXPO_ANDROID_BUNDLE_URL: Android bundle URL
|
||||
* - EXPO_ANDROID_BUNDLE_HASH: Android bundle hash
|
||||
* - EXPO_ASSETS: JSON格式的资源数组(可选)
|
||||
*/
|
||||
buildManifest(platform: 'ios' | 'android', runtimeVersion: string): UpdateManifest {
|
||||
const configRuntimeVersion = this.configService.get<string>('EXPO_RUNTIME_VERSION');
|
||||
|
||||
// 检查运行时版本是否匹配
|
||||
if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) {
|
||||
throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`);
|
||||
}
|
||||
|
||||
const bundleUrl = platform === 'ios'
|
||||
? this.configService.get<string>('EXPO_IOS_BUNDLE_URL')
|
||||
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_URL');
|
||||
|
||||
const bundleHash = platform === 'ios'
|
||||
? this.configService.get<string>('EXPO_IOS_BUNDLE_HASH')
|
||||
: this.configService.get<string>('EXPO_ANDROID_BUNDLE_HASH');
|
||||
|
||||
if (!bundleUrl || !bundleHash) {
|
||||
throw new BadRequestException(`No update configured for platform: ${platform}`);
|
||||
}
|
||||
|
||||
// 解析资源配置
|
||||
const assetsJson = this.configService.get<string>('EXPO_ASSETS');
|
||||
let assets: AssetMetadata[] = [];
|
||||
if (assetsJson) {
|
||||
try {
|
||||
assets = JSON.parse(assetsJson);
|
||||
} catch {
|
||||
assets = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.configService.get<string>('EXPO_UPDATE_ID') || this.generateId(),
|
||||
createdAt: this.configService.get<string>('EXPO_CREATED_AT') || new Date().toISOString(),
|
||||
runtimeVersion: configRuntimeVersion || runtimeVersion,
|
||||
launchAsset: {
|
||||
hash: bundleHash,
|
||||
key: 'bundle',
|
||||
contentType: 'application/javascript',
|
||||
url: bundleUrl,
|
||||
},
|
||||
assets,
|
||||
metadata: {},
|
||||
extra: {},
|
||||
};
|
||||
}
|
||||
|
||||
createNoUpdateAvailableDirective(): NoUpdateAvailableDirective {
|
||||
return { type: 'noUpdateAvailable' };
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const timestamp = Date.now().toString(36);
|
||||
const random = Math.random().toString(36).substring(2, 10);
|
||||
return `${timestamp}-${random}`;
|
||||
}
|
||||
}
|
||||
3
src/expo-updates/index.ts
Normal file
3
src/expo-updates/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './expo-updates.module';
|
||||
export * from './expo-updates.service';
|
||||
export * from './expo-updates.controller';
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './health-history.dto';
|
||||
export * from './family-health.dto';
|
||||
export * from './health-overview.dto';
|
||||
export * from './medical-records.dto';
|
||||
|
||||
181
src/health-profiles/dto/medical-records.dto.ts
Normal file
181
src/health-profiles/dto/medical-records.dto.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsString,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsDateString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ArrayMinSize,
|
||||
ArrayMaxSize,
|
||||
IsUrl,
|
||||
} from 'class-validator';
|
||||
import { MedicalRecordType, UploadFileType } from '../enums/health-profile.enum';
|
||||
import { ResponseCode } from '../../base.dto';
|
||||
|
||||
/**
|
||||
* 就医资料条目
|
||||
*/
|
||||
export class MedicalRecordItemDto {
|
||||
@ApiProperty({ description: '唯一标识符', example: 'rec_1234567890' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '资料类型',
|
||||
enum: MedicalRecordType,
|
||||
example: MedicalRecordType.MEDICAL_RECORD,
|
||||
})
|
||||
type: MedicalRecordType;
|
||||
|
||||
@ApiProperty({ description: '标题', example: '血常规检查' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '日期 (YYYY-MM-DD)', example: '2024-12-01' })
|
||||
date: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '图片/文件 URL 数组',
|
||||
type: [String],
|
||||
example: ['https://cdn.example.com/images/blood_test_1.jpg'],
|
||||
})
|
||||
images: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: '备注', example: '空腹检查结果' })
|
||||
note?: string;
|
||||
|
||||
@ApiProperty({ description: '创建时间', example: '2024-12-01T08:30:00Z' })
|
||||
createdAt: string;
|
||||
|
||||
@ApiProperty({ description: '更新时间', example: '2024-12-01T08:30:00Z' })
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 就医资料数据集
|
||||
*/
|
||||
export class MedicalRecordsDataDto {
|
||||
@ApiProperty({ description: '病历资料列表', type: [MedicalRecordItemDto] })
|
||||
records: MedicalRecordItemDto[];
|
||||
|
||||
@ApiProperty({ description: '处方单据列表', type: [MedicalRecordItemDto] })
|
||||
prescriptions: MedicalRecordItemDto[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取就医资料列表响应
|
||||
*/
|
||||
export class GetMedicalRecordsResponseDto {
|
||||
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
|
||||
code: number;
|
||||
|
||||
@ApiProperty({ description: '响应消息', example: 'success' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '就医资料数据', type: MedicalRecordsDataDto })
|
||||
data: MedicalRecordsDataDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加就医资料请求
|
||||
*/
|
||||
export class CreateMedicalRecordDto {
|
||||
@ApiProperty({
|
||||
description: '资料类型',
|
||||
enum: MedicalRecordType,
|
||||
example: MedicalRecordType.MEDICAL_RECORD,
|
||||
})
|
||||
@IsEnum(MedicalRecordType, { message: '资料类型必须是 medical_record 或 prescription' })
|
||||
type: MedicalRecordType;
|
||||
|
||||
@ApiProperty({ description: '标题,最多100字符', example: '胸部X光检查' })
|
||||
@IsString({ message: '标题必须是字符串' })
|
||||
@MinLength(1, { message: '标题不能为空' })
|
||||
@MaxLength(100, { message: '标题最多100字符' })
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '日期,格式:YYYY-MM-DD,不能是未来日期', example: '2024-12-05' })
|
||||
@IsDateString({}, { message: '日期格式必须是 YYYY-MM-DD' })
|
||||
date: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '图片URL数组,至少1张,最多9张',
|
||||
type: [String],
|
||||
example: ['https://cdn.example.com/uploads/temp/xray_001.jpg'],
|
||||
})
|
||||
@IsArray({ message: '图片必须是数组' })
|
||||
@ArrayMinSize(1, { message: '至少需要上传一张图片' })
|
||||
@ArrayMaxSize(9, { message: '最多支持9张图片' })
|
||||
@IsUrl({}, { each: true, message: '图片URL格式不正确' })
|
||||
images: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: '备注,最多500字符', example: '体检常规项目' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '备注必须是字符串' })
|
||||
@MaxLength(500, { message: '备注最多500字符' })
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加就医资料响应
|
||||
*/
|
||||
export class CreateMedicalRecordResponseDto {
|
||||
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
|
||||
code: number;
|
||||
|
||||
@ApiProperty({ description: '响应消息', example: '添加成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '新创建的就医资料', type: MedicalRecordItemDto })
|
||||
data: MedicalRecordItemDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除就医资料响应
|
||||
*/
|
||||
export class DeleteMedicalRecordResponseDto {
|
||||
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
|
||||
code: number;
|
||||
|
||||
@ApiProperty({ description: '响应消息', example: '删除成功' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片请求
|
||||
*/
|
||||
export class UploadMedicalFilesDto {
|
||||
@ApiProperty({
|
||||
description: '上传类型',
|
||||
enum: UploadFileType,
|
||||
example: UploadFileType.IMAGE,
|
||||
})
|
||||
@IsEnum(UploadFileType, { message: '上传类型必须是 image 或 document' })
|
||||
type: UploadFileType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片响应数据
|
||||
*/
|
||||
export class UploadMedicalFilesDataDto {
|
||||
@ApiProperty({
|
||||
description: '上传成功的文件URL列表',
|
||||
type: [String],
|
||||
example: ['https://cdn.example.com/uploads/temp/file_001.jpg'],
|
||||
})
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传图片响应
|
||||
*/
|
||||
export class UploadMedicalFilesResponseDto {
|
||||
@ApiProperty({ description: '响应码', example: ResponseCode.SUCCESS })
|
||||
code: number;
|
||||
|
||||
@ApiProperty({ description: '响应消息', example: '上传成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '上传结果', type: UploadMedicalFilesDataDto })
|
||||
data: UploadMedicalFilesDataDto;
|
||||
}
|
||||
@@ -16,3 +16,15 @@ export enum FamilyRole {
|
||||
ADMIN = 'admin', // 管理员
|
||||
MEMBER = 'member', // 普通成员
|
||||
}
|
||||
|
||||
// 就医资料类型
|
||||
export enum MedicalRecordType {
|
||||
MEDICAL_RECORD = 'medical_record', // 病历资料(检查报告、诊断证明等)
|
||||
PRESCRIPTION = 'prescription', // 处方单据(处方单、用药清单等)
|
||||
}
|
||||
|
||||
// 上传文件类型
|
||||
export enum UploadFileType {
|
||||
IMAGE = 'image', // 图片
|
||||
DOCUMENT = 'document', // 文档(PDF)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ResponseCode } from '../base.dto';
|
||||
import { HealthProfilesService } from './health-profiles.service';
|
||||
import { HealthHistoryService } from './services/health-history.service';
|
||||
import { FamilyHealthService } from './services/family-health.service';
|
||||
import { MedicalRecordsService } from './services/medical-records.service';
|
||||
|
||||
// DTOs
|
||||
import {
|
||||
@@ -38,6 +39,12 @@ import {
|
||||
GetFamilyMembersResponseDto,
|
||||
} from './dto/family-health.dto';
|
||||
import { GetHealthOverviewResponseDto } from './dto/health-overview.dto';
|
||||
import {
|
||||
CreateMedicalRecordDto,
|
||||
GetMedicalRecordsResponseDto,
|
||||
CreateMedicalRecordResponseDto,
|
||||
DeleteMedicalRecordResponseDto,
|
||||
} from './dto/medical-records.dto';
|
||||
import { HealthHistoryCategory } from './enums/health-profile.enum';
|
||||
|
||||
@ApiTags('health-profiles')
|
||||
@@ -50,6 +57,7 @@ export class HealthProfilesController {
|
||||
private readonly healthProfilesService: HealthProfilesService,
|
||||
private readonly healthHistoryService: HealthHistoryService,
|
||||
private readonly familyHealthService: FamilyHealthService,
|
||||
private readonly medicalRecordsService: MedicalRecordsService,
|
||||
) {}
|
||||
|
||||
// ==================== 健康档案概览 ====================
|
||||
@@ -187,4 +195,47 @@ export class HealthProfilesController {
|
||||
await this.familyHealthService.leaveFamilyGroup(user.sub);
|
||||
return { code: ResponseCode.SUCCESS, message: 'success' };
|
||||
}
|
||||
|
||||
// ==================== 就医资料管理 API ====================
|
||||
|
||||
@Get('medical-records')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '获取就医资料列表' })
|
||||
@ApiResponse({ status: 200, type: GetMedicalRecordsResponseDto })
|
||||
async getMedicalRecords(@CurrentUser() user: AccessTokenPayload): Promise<GetMedicalRecordsResponseDto> {
|
||||
this.logger.log(`获取就医资料列表 - 用户ID: ${user.sub}`);
|
||||
const data = await this.medicalRecordsService.getMedicalRecords(user.sub);
|
||||
return { code: ResponseCode.SUCCESS, message: 'success', data };
|
||||
}
|
||||
|
||||
@Post('medical-records')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '添加就医资料' })
|
||||
@ApiBody({ type: CreateMedicalRecordDto })
|
||||
@ApiResponse({ status: 201, type: CreateMedicalRecordResponseDto })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
async createMedicalRecord(
|
||||
@Body() dto: CreateMedicalRecordDto,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<CreateMedicalRecordResponseDto> {
|
||||
this.logger.log(`添加就医资料 - 用户ID: ${user.sub}, 类型: ${dto.type}`);
|
||||
const data = await this.medicalRecordsService.createMedicalRecord(user.sub, dto);
|
||||
return { code: ResponseCode.SUCCESS, message: '添加成功', data };
|
||||
}
|
||||
|
||||
@Delete('medical-records/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '删除就医资料' })
|
||||
@ApiParam({ name: 'id', description: '资料记录ID' })
|
||||
@ApiResponse({ status: 200, type: DeleteMedicalRecordResponseDto })
|
||||
@ApiResponse({ status: 404, description: '资料不存在或已被删除' })
|
||||
@ApiResponse({ status: 403, description: '无权限删除该资料' })
|
||||
async deleteMedicalRecord(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AccessTokenPayload,
|
||||
): Promise<DeleteMedicalRecordResponseDto> {
|
||||
this.logger.log(`删除就医资料 - 用户ID: ${user.sub}, 资料ID: ${id}`);
|
||||
await this.medicalRecordsService.deleteMedicalRecord(user.sub, id);
|
||||
return { code: ResponseCode.SUCCESS, message: '删除成功' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
import { MedicalRecord } from './models/medical-record.model';
|
||||
|
||||
// User models (for relations)
|
||||
import { User } from '../users/models/user.model';
|
||||
@@ -18,6 +19,7 @@ import { HealthProfilesController } from './health-profiles.controller';
|
||||
import { HealthProfilesService } from './health-profiles.service';
|
||||
import { HealthHistoryService } from './services/health-history.service';
|
||||
import { FamilyHealthService } from './services/family-health.service';
|
||||
import { MedicalRecordsService } from './services/medical-records.service';
|
||||
|
||||
// Modules
|
||||
import { UsersModule } from '../users/users.module';
|
||||
@@ -31,6 +33,8 @@ import { UsersModule } from '../users/users.module';
|
||||
// Family Health
|
||||
FamilyGroup,
|
||||
FamilyMember,
|
||||
// Medical Records
|
||||
MedicalRecord,
|
||||
// User models for relations
|
||||
User,
|
||||
UserProfile,
|
||||
@@ -42,11 +46,13 @@ import { UsersModule } from '../users/users.module';
|
||||
HealthProfilesService,
|
||||
HealthHistoryService,
|
||||
FamilyHealthService,
|
||||
MedicalRecordsService,
|
||||
],
|
||||
exports: [
|
||||
HealthProfilesService,
|
||||
HealthHistoryService,
|
||||
FamilyHealthService,
|
||||
MedicalRecordsService,
|
||||
],
|
||||
})
|
||||
export class HealthProfilesModule {}
|
||||
|
||||
@@ -5,3 +5,6 @@ export * from './health-history-item.model';
|
||||
// Family Health
|
||||
export * from './family-group.model';
|
||||
export * from './family-member.model';
|
||||
|
||||
// Medical Records
|
||||
export * from './medical-record.model';
|
||||
|
||||
90
src/health-profiles/models/medical-record.model.ts
Normal file
90
src/health-profiles/models/medical-record.model.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Column, Model, Table, DataType, ForeignKey, BelongsTo } from 'sequelize-typescript';
|
||||
import { User } from '../../users/models/user.model';
|
||||
import { MedicalRecordType } from '../enums/health-profile.enum';
|
||||
|
||||
/**
|
||||
* 就医资料表
|
||||
* 存储用户的病历资料和处方单据
|
||||
*/
|
||||
@Table({
|
||||
tableName: 't_medical_records',
|
||||
underscored: true,
|
||||
paranoid: true, // 软删除
|
||||
})
|
||||
export class MedicalRecord 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(20),
|
||||
allowNull: false,
|
||||
comment: '资料类型:medical_record | prescription',
|
||||
})
|
||||
declare type: MedicalRecordType;
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(100),
|
||||
allowNull: false,
|
||||
comment: '标题',
|
||||
})
|
||||
declare title: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: '日期 (YYYY-MM-DD)',
|
||||
})
|
||||
declare date: string;
|
||||
|
||||
@Column({
|
||||
type: DataType.JSON,
|
||||
allowNull: false,
|
||||
defaultValue: [],
|
||||
comment: '图片/文件 URL 数组',
|
||||
})
|
||||
declare images: string[];
|
||||
|
||||
@Column({
|
||||
type: DataType.STRING(500),
|
||||
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;
|
||||
|
||||
@Column({
|
||||
type: DataType.DATE,
|
||||
allowNull: true,
|
||||
comment: '删除时间(软删除)',
|
||||
})
|
||||
declare deletedAt: Date | null;
|
||||
|
||||
// 关联关系
|
||||
@BelongsTo(() => User, 'userId')
|
||||
declare user: User;
|
||||
}
|
||||
128
src/health-profiles/services/medical-records.service.ts
Normal file
128
src/health-profiles/services/medical-records.service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/sequelize';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { MedicalRecord } from '../models/medical-record.model';
|
||||
import { MedicalRecordType } from '../enums/health-profile.enum';
|
||||
import {
|
||||
CreateMedicalRecordDto,
|
||||
MedicalRecordItemDto,
|
||||
MedicalRecordsDataDto,
|
||||
} from '../dto/medical-records.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MedicalRecordsService {
|
||||
private readonly logger = new Logger(MedicalRecordsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectModel(MedicalRecord)
|
||||
private readonly medicalRecordModel: typeof MedicalRecord,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取用户的就医资料列表
|
||||
*/
|
||||
async getMedicalRecords(userId: string): Promise<MedicalRecordsDataDto> {
|
||||
this.logger.log(`获取就医资料列表 - 用户ID: ${userId}`);
|
||||
|
||||
const records = await this.medicalRecordModel.findAll({
|
||||
where: { userId },
|
||||
order: [['date', 'DESC'], ['createdAt', 'DESC']],
|
||||
});
|
||||
|
||||
// 分类整理
|
||||
const medicalRecords: MedicalRecordItemDto[] = [];
|
||||
const prescriptions: MedicalRecordItemDto[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const item = this.toMedicalRecordItemDto(record);
|
||||
if (record.type === MedicalRecordType.MEDICAL_RECORD) {
|
||||
medicalRecords.push(item);
|
||||
} else {
|
||||
prescriptions.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
records: medicalRecords,
|
||||
prescriptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加就医资料
|
||||
*/
|
||||
async createMedicalRecord(
|
||||
userId: string,
|
||||
dto: CreateMedicalRecordDto,
|
||||
): Promise<MedicalRecordItemDto> {
|
||||
this.logger.log(`添加就医资料 - 用户ID: ${userId}, 类型: ${dto.type}`);
|
||||
|
||||
// 验证日期不能是未来日期
|
||||
const recordDate = new Date(dto.date);
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
if (recordDate > today) {
|
||||
throw new BadRequestException('日期不能是未来日期');
|
||||
}
|
||||
|
||||
// 生成ID前缀
|
||||
const idPrefix = dto.type === MedicalRecordType.MEDICAL_RECORD ? 'rec_' : 'presc_';
|
||||
const id = `${idPrefix}${uuidv4().replace(/-/g, '').substring(0, 16)}`;
|
||||
|
||||
const record = await this.medicalRecordModel.create({
|
||||
id,
|
||||
userId,
|
||||
type: dto.type,
|
||||
title: dto.title,
|
||||
date: dto.date,
|
||||
images: dto.images,
|
||||
note: dto.note || null,
|
||||
});
|
||||
|
||||
this.logger.log(`就医资料添加成功 - ID: ${id}`);
|
||||
return this.toMedicalRecordItemDto(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除就医资料
|
||||
*/
|
||||
async deleteMedicalRecord(userId: string, recordId: string): Promise<void> {
|
||||
this.logger.log(`删除就医资料 - 用户ID: ${userId}, 资料ID: ${recordId}`);
|
||||
|
||||
const record = await this.medicalRecordModel.findByPk(recordId);
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('资料不存在或已被删除');
|
||||
}
|
||||
|
||||
if (record.userId !== userId) {
|
||||
throw new ForbiddenException('无权限删除该资料');
|
||||
}
|
||||
|
||||
// 软删除
|
||||
await record.destroy();
|
||||
this.logger.log(`就医资料删除成功 - ID: ${recordId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换为 DTO
|
||||
*/
|
||||
private toMedicalRecordItemDto(record: MedicalRecord): MedicalRecordItemDto {
|
||||
return {
|
||||
id: record.id,
|
||||
type: record.type,
|
||||
title: record.title,
|
||||
date: record.date,
|
||||
images: record.images || [],
|
||||
note: record.note || undefined,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,6 @@ import { AiCoachModule } from '../ai-coach/ai-coach.module';
|
||||
],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService],
|
||||
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService],
|
||||
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService, CosService],
|
||||
})
|
||||
export class UsersModule { }
|
||||
|
||||
Reference in New Issue
Block a user