diff --git a/docs/expo-updates.md b/docs/expo-updates.md new file mode 100644 index 0000000..8ee69cd --- /dev/null +++ b/docs/expo-updates.md @@ -0,0 +1,133 @@ +# Expo Updates 服务端实现(COS 资源版) + +本服务实现了 Expo Updates 协议 v0 和 v1,支持 React Native 应用的 OTA 热更新。 +资源文件存储在腾讯云 COS 上,服务端只负责返回 manifest。 + +## API 端点 + +### 1. 获取 Manifest + +``` +GET /expo-updates/manifest +``` + +**请求头:** +| 头部 | 必需 | 说明 | +|------|------|------| +| `expo-platform` | 是 | 平台类型:`ios` 或 `android` | +| `expo-runtime-version` | 是 | 运行时版本号 | +| `expo-protocol-version` | 否 | 协议版本:`0` 或 `1`(默认 `0`) | +| `expo-current-update-id` | 否 | 当前更新 ID | + +### 2. 注册更新版本 + +``` +POST /expo-updates/updates +``` + +**请求体:** +```json +{ + "runtimeVersion": "1.0.0", + "createdAt": "2024-01-01T00:00:00.000Z", + "ios": { + "launchAsset": { + "url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/ios/bundle.js", + "hash": "Base64URL编码的SHA256哈希" + }, + "assets": [ + { + "url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/assets/icon.png", + "hash": "Base64URL编码的SHA256哈希", + "key": "icon", + "contentType": "image/png", + "fileExtension": ".png" + } + ] + }, + "android": { + "launchAsset": { + "url": "https://your-bucket.cos.ap-guangzhou.myqcloud.com/updates/1.0.0/android/bundle.js", + "hash": "Base64URL编码的SHA256哈希" + }, + "assets": [] + }, + "expoClient": { + "name": "YourApp", + "version": "1.0.0" + } +} +``` + +### 3. 获取所有更新版本 + +``` +GET /expo-updates/updates +``` + +### 4. 获取指定版本 + +``` +GET /expo-updates/updates/:runtimeVersion +``` + +### 5. 删除更新版本 + +``` +DELETE /expo-updates/updates/:runtimeVersion +``` + +## 客户端配置 + +在 React Native 应用的 `app.json` 中: + +```json +{ + "expo": { + "updates": { + "url": "https://your-server.com/expo-updates/manifest", + "enabled": true + }, + "runtimeVersion": "1.0.0" + } +} +``` + +## 生成资源哈希 + +资源的 hash 需要是 Base64URL 编码的 SHA256 哈希: + +```javascript +const crypto = require('crypto'); +const fs = require('fs'); + +function getAssetHash(filePath) { + const content = fs.readFileSync(filePath); + const hash = crypto.createHash('sha256').update(content).digest('base64'); + // 转换为 Base64URL + return hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +``` + +## 测试 + +```bash +# 注册更新 +curl -X POST http://localhost:3000/expo-updates/updates \ + -H "Content-Type: application/json" \ + -d '{ + "runtimeVersion": "1.0.0", + "ios": { + "launchAsset": { + "url": "https://cos.example.com/bundle.js", + "hash": "abc123" + }, + "assets": [] + } + }' + +# 获取 manifest +curl -H "expo-platform: ios" \ + -H "expo-runtime-version: 1.0.0" \ + http://localhost:3000/expo-updates/manifest +``` diff --git a/src/app.module.ts b/src/app.module.ts index 9a9ac6d..0ce2851 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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: [ diff --git a/src/expo-updates/expo-updates.controller.ts b/src/expo-updates/expo-updates.controller.ts new file mode 100644 index 0000000..bca790b --- /dev/null +++ b/src/expo-updates/expo-updates.controller.ts @@ -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')); + } +} diff --git a/src/expo-updates/expo-updates.module.ts b/src/expo-updates/expo-updates.module.ts new file mode 100644 index 0000000..bb280c0 --- /dev/null +++ b/src/expo-updates/expo-updates.module.ts @@ -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 {} diff --git a/src/expo-updates/expo-updates.service.ts b/src/expo-updates/expo-updates.service.ts new file mode 100644 index 0000000..4d04314 --- /dev/null +++ b/src/expo-updates/expo-updates.service.ts @@ -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; + extra: { + expoClient?: Record; + }; +} + +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('EXPO_RUNTIME_VERSION'); + + // 检查运行时版本是否匹配 + if (configRuntimeVersion && configRuntimeVersion !== runtimeVersion) { + throw new BadRequestException(`No update available for runtime version: ${runtimeVersion}`); + } + + const bundleUrl = platform === 'ios' + ? this.configService.get('EXPO_IOS_BUNDLE_URL') + : this.configService.get('EXPO_ANDROID_BUNDLE_URL'); + + const bundleHash = platform === 'ios' + ? this.configService.get('EXPO_IOS_BUNDLE_HASH') + : this.configService.get('EXPO_ANDROID_BUNDLE_HASH'); + + if (!bundleUrl || !bundleHash) { + throw new BadRequestException(`No update configured for platform: ${platform}`); + } + + // 解析资源配置 + const assetsJson = this.configService.get('EXPO_ASSETS'); + let assets: AssetMetadata[] = []; + if (assetsJson) { + try { + assets = JSON.parse(assetsJson); + } catch { + assets = []; + } + } + + return { + id: this.configService.get('EXPO_UPDATE_ID') || this.generateId(), + createdAt: this.configService.get('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}`; + } +} diff --git a/src/expo-updates/index.ts b/src/expo-updates/index.ts new file mode 100644 index 0000000..a254f6e --- /dev/null +++ b/src/expo-updates/index.ts @@ -0,0 +1,3 @@ +export * from './expo-updates.module'; +export * from './expo-updates.service'; +export * from './expo-updates.controller'; diff --git a/src/health-profiles/dto/index.ts b/src/health-profiles/dto/index.ts index 7293f2e..8960a7a 100644 --- a/src/health-profiles/dto/index.ts +++ b/src/health-profiles/dto/index.ts @@ -1,3 +1,4 @@ export * from './health-history.dto'; export * from './family-health.dto'; export * from './health-overview.dto'; +export * from './medical-records.dto'; diff --git a/src/health-profiles/dto/medical-records.dto.ts b/src/health-profiles/dto/medical-records.dto.ts new file mode 100644 index 0000000..7999ddf --- /dev/null +++ b/src/health-profiles/dto/medical-records.dto.ts @@ -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; +} diff --git a/src/health-profiles/enums/health-profile.enum.ts b/src/health-profiles/enums/health-profile.enum.ts index c83ef7b..60812bf 100644 --- a/src/health-profiles/enums/health-profile.enum.ts +++ b/src/health-profiles/enums/health-profile.enum.ts @@ -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) +} diff --git a/src/health-profiles/health-profiles.controller.ts b/src/health-profiles/health-profiles.controller.ts index 8a41e1f..dfaa578 100644 --- a/src/health-profiles/health-profiles.controller.ts +++ b/src/health-profiles/health-profiles.controller.ts @@ -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 { + 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 { + 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 { + this.logger.log(`删除就医资料 - 用户ID: ${user.sub}, 资料ID: ${id}`); + await this.medicalRecordsService.deleteMedicalRecord(user.sub, id); + return { code: ResponseCode.SUCCESS, message: '删除成功' }; + } } diff --git a/src/health-profiles/health-profiles.module.ts b/src/health-profiles/health-profiles.module.ts index 6055c0a..74f7d8d 100644 --- a/src/health-profiles/health-profiles.module.ts +++ b/src/health-profiles/health-profiles.module.ts @@ -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 {} diff --git a/src/health-profiles/models/index.ts b/src/health-profiles/models/index.ts index a49ded5..6f027d2 100644 --- a/src/health-profiles/models/index.ts +++ b/src/health-profiles/models/index.ts @@ -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'; diff --git a/src/health-profiles/models/medical-record.model.ts b/src/health-profiles/models/medical-record.model.ts new file mode 100644 index 0000000..04a7aee --- /dev/null +++ b/src/health-profiles/models/medical-record.model.ts @@ -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; +} diff --git a/src/health-profiles/services/medical-records.service.ts b/src/health-profiles/services/medical-records.service.ts new file mode 100644 index 0000000..07a101b --- /dev/null +++ b/src/health-profiles/services/medical-records.service.ts @@ -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 { + 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 { + 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 { + 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(), + }; + } +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index f5b26f1..574d9ef 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -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 { }