feat(abnormal): 异常照片上传端点(POST /abnormal/photo)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-21 15:11:14 +08:00
parent bc6910bff6
commit 989b113a4c
8 changed files with 85 additions and 4 deletions

View File

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

View File

@ -679,6 +679,7 @@ model RecordAbnormal {
abnormalType String @map("abnormal_type") @db.VarChar(20) abnormalType String @map("abnormal_type") @db.VarChar(20)
abnormalDesc String? @map("abnormal_desc") @db.Text abnormalDesc String? @map("abnormal_desc") @db.Text
voiceFile String? @map("voice_file") @db.VarChar(200) voiceFile String? @map("voice_file") @db.VarChar(200)
photoFile String? @map("photo_file") @db.VarChar(255)
hasPhoto Boolean @default(false) @map("has_photo") hasPhoto Boolean @default(false) @map("has_photo")
visitTime DateTime? @map("visit_time") visitTime DateTime? @map("visit_time")
visitLat Decimal? @map("visit_lat") @db.Decimal(10, 6) visitLat Decimal? @map("visit_lat") @db.Decimal(10, 6)

View File

@ -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 { Permissions } from '../common/permissions.js';
import { AuditLog, RequirePermissions } from '../auth/decorators.js'; import { AuditLog, RequirePermissions } from '../auth/decorators.js';
import { ListAbnormalQuery, ReportAbnormalDto } from './dto.js'; import { ListAbnormalQuery, ReportAbnormalDto } from './dto.js';
@ -15,6 +16,14 @@ export class AbnormalController {
return this.service.report(dto); 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() @Get()
@RequirePermissions(Permissions.abnormal.list) @RequirePermissions(Permissions.abnormal.list)
list(@Query() q: ListAbnormalQuery) { list(@Query() q: ListAbnormalQuery) {

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AbnormalController } from './abnormal.controller.js'; import { AbnormalController } from './abnormal.controller.js';
import { AbnormalService } from './abnormal.service.js'; import { AbnormalService } from './abnormal.service.js';
import { StorageModule } from '../storage/storage.module.js';
@Module({ @Module({
imports: [StorageModule],
controllers: [AbnormalController], controllers: [AbnormalController],
providers: [AbnormalService], providers: [AbnormalService],
}) })

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { AbnormalService } from './abnormal.service.js'; import { AbnormalService } from './abnormal.service.js';
import type { PrismaService } from '../prisma/prisma.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 { ErrorCodes } from '../common/error-codes.js';
import { runWithRequestContext } from '../common/context/request-context.js'; import { runWithRequestContext } from '../common/context/request-context.js';
import type { RequestContext } 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)。 // $transaction:交互式回调以 client 自身作为 tx(事务内写仍命中同一 mock)。
client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg))); client.$transaction = vi.fn(async (arg: any) => (typeof arg === 'function' ? arg(client) : Promise.all(arg)));
const prisma = { client }; 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 { return {
service, service,
storage,
recordAbnormalCreate, recordAbnormalCreate,
recordAbnormalFindMany, recordAbnormalFindMany,
recordAbnormalCount, recordAbnormalCount,
@ -83,6 +92,38 @@ describe('AbnormalService', () => {
expect(res).toEqual({ id: 'a1', visitLat: null, visitLng: null }); 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 () => { it('report 带 clientMd5 命中 → 幂等返回已存在,不重复落库、不转工单', async () => {
const recordAbnormalFindFirst = vi.fn().mockResolvedValue({ const recordAbnormalFindFirst = vi.fn().mockResolvedValue({
id: 'a-exist', id: 'a-exist',

View File

@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service.js';
import { getRequestContext } from '../common/context/request-context.js'; import { getRequestContext } from '../common/context/request-context.js';
import { BusinessException } from '../common/business-exception.js'; import { BusinessException } from '../common/business-exception.js';
import { ErrorCodes } from '../common/error-codes.js'; import { ErrorCodes } from '../common/error-codes.js';
import { StorageService } from '../storage/storage.service.js';
import { ListAbnormalQuery, NOT_MET, ReportAbnormalDto } from './dto.js'; import { ListAbnormalQuery, NOT_MET, ReportAbnormalDto } from './dto.js';
/** ISO datetime 字符串 → Date。业务时区为北京时间(UTC+8),与 order.service 口径一致。 */ /** ISO datetime 字符串 → Date。业务时区为北京时间(UTC+8),与 order.service 口径一致。 */
@ -26,7 +27,10 @@ function toDateTime(v?: string): Date | undefined {
*/ */
@Injectable() @Injectable()
export class AbnormalService { export class AbnormalService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
/** 上报异常(普通 9 宫格或扑空专门形态)。 */ /** 上报异常(普通 9 宫格或扑空专门形态)。 */
async report(dto: ReportAbnormalDto) { async report(dto: ReportAbnormalDto) {
@ -73,6 +77,7 @@ export class AbnormalService {
abnormalType: dto.abnormalType, abnormalType: dto.abnormalType,
abnormalDesc: dto.abnormalDesc ?? null, abnormalDesc: dto.abnormalDesc ?? null,
voiceFile: dto.voiceFile ?? null, voiceFile: dto.voiceFile ?? null,
photoFile: dto.photoFile ?? null,
hasPhoto: dto.hasPhoto ?? false, hasPhoto: dto.hasPhoto ?? false,
orderId: dto.orderId ?? null, orderId: dto.orderId ?? null,
recordId: dto.recordId ?? null, recordId: dto.recordId ?? null,
@ -88,6 +93,14 @@ export class AbnormalService {
return this.serializeAbnormal(created); 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)。 */ /** 异常列表(后台 B4)。 */
async list(q: ListAbnormalQuery) { async list(q: ListAbnormalQuery) {
const where: Record<string, unknown> = {}; const where: Record<string, unknown> = {};

View File

@ -55,7 +55,12 @@ export class ReportAbnormalDto {
@IsString() @IsString()
voiceFile?: string; voiceFile?: string;
@ApiPropertyOptional({ description: '是否附带照片' }) @ApiPropertyOptional({ description: '照片文件 MinIO key(先调 POST /abnormal/photo 上传拿到 key)' })
@IsOptional()
@IsString()
photoFile?: string;
@ApiPropertyOptional({ description: '是否附带照片(有照片则传 photoFile + hasPhoto=true)' })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
hasPhoto?: boolean; hasPhoto?: boolean;

View File

@ -18,6 +18,12 @@ describe('StorageService', () => {
expect(key).toMatch(/^elder\/T001\/\d+-a\.jpg$/); 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 () => { it('presignGet 生成 GET 预签名 URL(默认 1 小时)', async () => {
const presignedGetObject = vi.fn().mockResolvedValue('http://minio/carelink/record/T001/a.jpg?x=1'); const presignedGetObject = vi.fn().mockResolvedValue('http://minio/carelink/record/T001/a.jpg?x=1');
const svc = new StorageService(); const svc = new StorageService();