feat: 添加Expo Updates服务端模块和就医资料管理功能

This commit is contained in:
richarjiang
2025-12-05 16:08:53 +08:00
parent de67132a36
commit 14d791f552
15 changed files with 821 additions and 1 deletions

133
docs/expo-updates.md Normal file
View 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
```

View File

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

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

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

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

View File

@@ -0,0 +1,3 @@
export * from './expo-updates.module';
export * from './expo-updates.service';
export * from './expo-updates.controller';

View File

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

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

View File

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

View File

@@ -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: '删除成功' };
}
}

View File

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

View File

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

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

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

View File

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