feat(abnormal): 异常照片上传端点(POST /abnormal/photo)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bc6910bff6
commit
989b113a4c
@ -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;
|
||||||
@ -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)
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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> = {};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user