From 989b113a4cc826873dee230d13b656ffeecc2d23 Mon Sep 17 00:00:00 2001 From: liuyun Date: Wed, 21 Oct 2026 15:11:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(abnormal):=20=E5=BC=82=E5=B8=B8=E7=85=A7?= =?UTF-8?q?=E7=89=87=E4=B8=8A=E4=BC=A0=E7=AB=AF=E7=82=B9=EF=BC=88POST=20/a?= =?UTF-8?q?bnormal/photo=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 4 ++ prisma/schema.prisma | 1 + src/abnormal/abnormal.controller.ts | 11 ++++- src/abnormal/abnormal.module.ts | 2 + src/abnormal/abnormal.service.spec.ts | 43 ++++++++++++++++++- src/abnormal/abnormal.service.ts | 15 ++++++- src/abnormal/dto.ts | 7 ++- src/storage/storage.service.spec.ts | 6 +++ 8 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20261021150000_abnormal_photo_file/migration.sql diff --git a/prisma/migrations/20261021150000_abnormal_photo_file/migration.sql b/prisma/migrations/20261021150000_abnormal_photo_file/migration.sql new file mode 100644 index 0000000..0a7577c --- /dev/null +++ b/prisma/migrations/20261021150000_abnormal_photo_file/migration.sql @@ -0,0 +1,4 @@ +-- 异常上报照片:ho_record_abnormal 增加 photo_file(MinIO key),替代 has_photo 布尔占位。 +-- 列 collation 继承表级 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci。 +-- AlterTable +ALTER TABLE `ho_record_abnormal` ADD COLUMN `photo_file` VARCHAR(255) NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d7f559..235e601 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -679,6 +679,7 @@ model RecordAbnormal { abnormalType String @map("abnormal_type") @db.VarChar(20) abnormalDesc String? @map("abnormal_desc") @db.Text voiceFile String? @map("voice_file") @db.VarChar(200) + photoFile String? @map("photo_file") @db.VarChar(255) hasPhoto Boolean @default(false) @map("has_photo") visitTime DateTime? @map("visit_time") visitLat Decimal? @map("visit_lat") @db.Decimal(10, 6) diff --git a/src/abnormal/abnormal.controller.ts b/src/abnormal/abnormal.controller.ts index 8389997..8c305ae 100644 --- a/src/abnormal/abnormal.controller.ts +++ b/src/abnormal/abnormal.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Get, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Post, Query, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { Permissions } from '../common/permissions.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { ListAbnormalQuery, ReportAbnormalDto } from './dto.js'; @@ -15,6 +16,14 @@ export class AbnormalController { return this.service.report(dto); } + @Post('photo') + @RequirePermissions(Permissions.abnormal.report) + @AuditLog('abnormal', 'photo-upload') + @UseInterceptors(FileInterceptor('file')) + uploadPhoto(@UploadedFile() file: { buffer: Buffer; originalname: string }) { + return this.service.uploadPhoto(file); + } + @Get() @RequirePermissions(Permissions.abnormal.list) list(@Query() q: ListAbnormalQuery) { diff --git a/src/abnormal/abnormal.module.ts b/src/abnormal/abnormal.module.ts index 5a9d2d3..01cbbb1 100644 --- a/src/abnormal/abnormal.module.ts +++ b/src/abnormal/abnormal.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { AbnormalController } from './abnormal.controller.js'; import { AbnormalService } from './abnormal.service.js'; +import { StorageModule } from '../storage/storage.module.js'; @Module({ + imports: [StorageModule], controllers: [AbnormalController], providers: [AbnormalService], }) diff --git a/src/abnormal/abnormal.service.spec.ts b/src/abnormal/abnormal.service.spec.ts index 42ad5a7..38c87c2 100644 --- a/src/abnormal/abnormal.service.spec.ts +++ b/src/abnormal/abnormal.service.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AbnormalService } from './abnormal.service.js'; import type { PrismaService } from '../prisma/prisma.service.js'; +import type { StorageService } from '../storage/storage.service.js'; import { ErrorCodes } from '../common/error-codes.js'; import { runWithRequestContext } from '../common/context/request-context.js'; import type { RequestContext } from '../common/context/request-context.js'; @@ -44,9 +45,17 @@ function makeService( // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。 client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); const prisma = { client }; - const service = new AbnormalService(prisma as unknown as PrismaService); + const storage = { + buildKey: vi.fn((_tenant: string, filename: string, prefix = 'elder') => `${prefix}/${_tenant}/123-${filename}`), + upload: vi.fn().mockResolvedValue('abnormal/T001/123-a.jpg'), + }; + const service = new AbnormalService( + prisma as unknown as PrismaService, + storage as unknown as StorageService, + ); return { service, + storage, recordAbnormalCreate, recordAbnormalFindMany, recordAbnormalCount, @@ -83,6 +92,38 @@ describe('AbnormalService', () => { expect(res).toEqual({ id: 'a1', visitLat: null, visitLng: null }); }); + it('report 带 photoFile → 落 photoFile(照片已上传后提交)', async () => { + const { service, recordAbnormalCreate } = makeService(); + + await runWithRequestContext(ctx, () => + service.report({ + abnormalType: 'fall', + elderId: 'e1', + photoFile: 'abnormal/T001/123-a.jpg', + hasPhoto: true, + }), + ); + + expect(recordAbnormalCreate).toHaveBeenCalledWith({ + data: expect.objectContaining({ + photoFile: 'abnormal/T001/123-a.jpg', + hasPhoto: true, + }), + }); + }); + + it('uploadPhoto 走 buildKey + upload(abnormal 前缀)→ 返回 { key }', async () => { + const { service, storage } = makeService(); + + const res = await runWithRequestContext(ctx, () => + service.uploadPhoto({ buffer: Buffer.from('x'), originalname: 'a.jpg' }), + ); + + expect(storage.buildKey).toHaveBeenCalledWith('T001', 'a.jpg', 'abnormal'); + expect(storage.upload).toHaveBeenCalledWith(expect.any(Buffer), 'abnormal/T001/123-a.jpg'); + expect(res).toEqual({ key: 'abnormal/T001/123-a.jpg' }); + }); + it('report 带 clientMd5 命中 → 幂等返回已存在,不重复落库、不转工单', async () => { const recordAbnormalFindFirst = vi.fn().mockResolvedValue({ id: 'a-exist', diff --git a/src/abnormal/abnormal.service.ts b/src/abnormal/abnormal.service.ts index c66be75..4889340 100644 --- a/src/abnormal/abnormal.service.ts +++ b/src/abnormal/abnormal.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service.js'; import { getRequestContext } from '../common/context/request-context.js'; import { BusinessException } from '../common/business-exception.js'; import { ErrorCodes } from '../common/error-codes.js'; +import { StorageService } from '../storage/storage.service.js'; import { ListAbnormalQuery, NOT_MET, ReportAbnormalDto } from './dto.js'; /** ISO datetime 字符串 → Date。业务时区为北京时间(UTC+8),与 order.service 口径一致。 */ @@ -26,7 +27,10 @@ function toDateTime(v?: string): Date | undefined { */ @Injectable() export class AbnormalService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly storage: StorageService, + ) {} /** 上报异常(普通 9 宫格或扑空专门形态)。 */ async report(dto: ReportAbnormalDto) { @@ -73,6 +77,7 @@ export class AbnormalService { abnormalType: dto.abnormalType, abnormalDesc: dto.abnormalDesc ?? null, voiceFile: dto.voiceFile ?? null, + photoFile: dto.photoFile ?? null, hasPhoto: dto.hasPhoto ?? false, orderId: dto.orderId ?? null, recordId: dto.recordId ?? null, @@ -88,6 +93,14 @@ export class AbnormalService { return this.serializeAbnormal(created); } + /** 上传异常照片:读 buffer → StorageService.buildKey + upload(abnormal 前缀)→ 返回 { key }。 */ + async uploadPhoto(file: { buffer: Buffer; originalname: string }) { + const ctx = getRequestContext(); + const key = this.storage.buildKey(ctx?.tenantCode ?? '', file.originalname, 'abnormal'); + await this.storage.upload(file.buffer, key); + return { key }; + } + /** 异常列表(后台 B4)。 */ async list(q: ListAbnormalQuery) { const where: Record = {}; diff --git a/src/abnormal/dto.ts b/src/abnormal/dto.ts index 6f878df..fbec24b 100644 --- a/src/abnormal/dto.ts +++ b/src/abnormal/dto.ts @@ -55,7 +55,12 @@ export class ReportAbnormalDto { @IsString() voiceFile?: string; - @ApiPropertyOptional({ description: '是否附带照片' }) + @ApiPropertyOptional({ description: '照片文件 MinIO key(先调 POST /abnormal/photo 上传拿到 key)' }) + @IsOptional() + @IsString() + photoFile?: string; + + @ApiPropertyOptional({ description: '是否附带照片(有照片则传 photoFile + hasPhoto=true)' }) @IsOptional() @IsBoolean() hasPhoto?: boolean; diff --git a/src/storage/storage.service.spec.ts b/src/storage/storage.service.spec.ts index 5da8994..62ce75f 100644 --- a/src/storage/storage.service.spec.ts +++ b/src/storage/storage.service.spec.ts @@ -18,6 +18,12 @@ describe('StorageService', () => { expect(key).toMatch(/^elder\/T001\/\d+-a\.jpg$/); }); + it('buildKey 支持指定前缀(abnormal)', () => { + const svc = new StorageService(); + const key = svc.buildKey('T001', 'a.jpg', 'abnormal'); + expect(key).toMatch(/^abnormal\/T001\/\d+-a\.jpg$/); + }); + it('presignGet 生成 GET 预签名 URL(默认 1 小时)', async () => { const presignedGetObject = vi.fn().mockResolvedValue('http://minio/carelink/record/T001/a.jpg?x=1'); const svc = new StorageService();