feat: 添加Expo Updates服务端模块和就医资料管理功能
This commit is contained in:
133
docs/expo-updates.md
Normal file
133
docs/expo-updates.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
@@ -25,6 +25,7 @@ import { ChallengesModule } from './challenges/challenges.module';
|
|||||||
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
|
import { PushNotificationsModule } from './push-notifications/push-notifications.module';
|
||||||
import { MedicationsModule } from './medications/medications.module';
|
import { MedicationsModule } from './medications/medications.module';
|
||||||
import { HealthProfilesModule } from './health-profiles/health-profiles.module';
|
import { HealthProfilesModule } from './health-profiles/health-profiles.module';
|
||||||
|
import { ExpoUpdatesModule } from './expo-updates/expo-updates.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -64,6 +65,7 @@ import { HealthProfilesModule } from './health-profiles/health-profiles.module';
|
|||||||
PushNotificationsModule,
|
PushNotificationsModule,
|
||||||
MedicationsModule,
|
MedicationsModule,
|
||||||
HealthProfilesModule,
|
HealthProfilesModule,
|
||||||
|
ExpoUpdatesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
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 './health-history.dto';
|
||||||
export * from './family-health.dto';
|
export * from './family-health.dto';
|
||||||
export * from './health-overview.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', // 管理员
|
ADMIN = 'admin', // 管理员
|
||||||
MEMBER = 'member', // 普通成员
|
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 { HealthProfilesService } from './health-profiles.service';
|
||||||
import { HealthHistoryService } from './services/health-history.service';
|
import { HealthHistoryService } from './services/health-history.service';
|
||||||
import { FamilyHealthService } from './services/family-health.service';
|
import { FamilyHealthService } from './services/family-health.service';
|
||||||
|
import { MedicalRecordsService } from './services/medical-records.service';
|
||||||
|
|
||||||
// DTOs
|
// DTOs
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +39,12 @@ import {
|
|||||||
GetFamilyMembersResponseDto,
|
GetFamilyMembersResponseDto,
|
||||||
} from './dto/family-health.dto';
|
} from './dto/family-health.dto';
|
||||||
import { GetHealthOverviewResponseDto } from './dto/health-overview.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';
|
import { HealthHistoryCategory } from './enums/health-profile.enum';
|
||||||
|
|
||||||
@ApiTags('health-profiles')
|
@ApiTags('health-profiles')
|
||||||
@@ -50,6 +57,7 @@ export class HealthProfilesController {
|
|||||||
private readonly healthProfilesService: HealthProfilesService,
|
private readonly healthProfilesService: HealthProfilesService,
|
||||||
private readonly healthHistoryService: HealthHistoryService,
|
private readonly healthHistoryService: HealthHistoryService,
|
||||||
private readonly familyHealthService: FamilyHealthService,
|
private readonly familyHealthService: FamilyHealthService,
|
||||||
|
private readonly medicalRecordsService: MedicalRecordsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==================== 健康档案概览 ====================
|
// ==================== 健康档案概览 ====================
|
||||||
@@ -187,4 +195,47 @@ export class HealthProfilesController {
|
|||||||
await this.familyHealthService.leaveFamilyGroup(user.sub);
|
await this.familyHealthService.leaveFamilyGroup(user.sub);
|
||||||
return { code: ResponseCode.SUCCESS, message: 'success' };
|
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 { HealthHistoryItem } from './models/health-history-item.model';
|
||||||
import { FamilyGroup } from './models/family-group.model';
|
import { FamilyGroup } from './models/family-group.model';
|
||||||
import { FamilyMember } from './models/family-member.model';
|
import { FamilyMember } from './models/family-member.model';
|
||||||
|
import { MedicalRecord } from './models/medical-record.model';
|
||||||
|
|
||||||
// User models (for relations)
|
// User models (for relations)
|
||||||
import { User } from '../users/models/user.model';
|
import { User } from '../users/models/user.model';
|
||||||
@@ -18,6 +19,7 @@ import { HealthProfilesController } from './health-profiles.controller';
|
|||||||
import { HealthProfilesService } from './health-profiles.service';
|
import { HealthProfilesService } from './health-profiles.service';
|
||||||
import { HealthHistoryService } from './services/health-history.service';
|
import { HealthHistoryService } from './services/health-history.service';
|
||||||
import { FamilyHealthService } from './services/family-health.service';
|
import { FamilyHealthService } from './services/family-health.service';
|
||||||
|
import { MedicalRecordsService } from './services/medical-records.service';
|
||||||
|
|
||||||
// Modules
|
// Modules
|
||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
@@ -31,6 +33,8 @@ import { UsersModule } from '../users/users.module';
|
|||||||
// Family Health
|
// Family Health
|
||||||
FamilyGroup,
|
FamilyGroup,
|
||||||
FamilyMember,
|
FamilyMember,
|
||||||
|
// Medical Records
|
||||||
|
MedicalRecord,
|
||||||
// User models for relations
|
// User models for relations
|
||||||
User,
|
User,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
@@ -42,11 +46,13 @@ import { UsersModule } from '../users/users.module';
|
|||||||
HealthProfilesService,
|
HealthProfilesService,
|
||||||
HealthHistoryService,
|
HealthHistoryService,
|
||||||
FamilyHealthService,
|
FamilyHealthService,
|
||||||
|
MedicalRecordsService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
HealthProfilesService,
|
HealthProfilesService,
|
||||||
HealthHistoryService,
|
HealthHistoryService,
|
||||||
FamilyHealthService,
|
FamilyHealthService,
|
||||||
|
MedicalRecordsService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class HealthProfilesModule {}
|
export class HealthProfilesModule {}
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ export * from './health-history-item.model';
|
|||||||
// Family Health
|
// Family Health
|
||||||
export * from './family-group.model';
|
export * from './family-group.model';
|
||||||
export * from './family-member.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],
|
controllers: [UsersController],
|
||||||
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService],
|
providers: [UsersService, ApplePurchaseService, EncryptionService, AppleAuthService, CosService, UserActivityService, BadgeService],
|
||||||
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService],
|
exports: [UsersService, AppleAuthService, UserActivityService, BadgeService, CosService],
|
||||||
})
|
})
|
||||||
export class UsersModule { }
|
export class UsersModule { }
|
||||||
|
|||||||
Reference in New Issue
Block a user