feat(M08-B): 补保洁照片上传和自动建单

This commit is contained in:
Codex
2026-06-26 15:36:05 +08:00
parent f7e0c8d063
commit fe1a08880e
16 changed files with 373 additions and 57 deletions
+60 -1
View File
@@ -3,6 +3,7 @@ import { z } from 'zod';
import type { AuthRepository } from '../auth/auth-repository.js';
import { authenticateAccessToken } from '../auth/authenticate.js';
import type { AccessProfile } from '../auth/rbac-repository.js';
import { MediaStorage, MediaValidationError } from '../content/media-storage.js';
import {
CleaningTaskError,
cleaningTaskStatuses,
@@ -22,7 +23,9 @@ const submitSchema = z.object({
});
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository, 'listHall' | 'listMine' | 'claim' | 'start' | 'submit' | 'stats'>;
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'claim' | 'start' | 'rework' | 'submit' | 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
@@ -32,6 +35,14 @@ export async function registerCleaningRoutes(
app: FastifyInstance,
options: CleaningRouteOptions
): Promise<void> {
if (options.mediaStorage && !app.hasContentTypeParser('application/octet-stream')) {
app.addContentTypeParser(
'application/octet-stream',
{ parseAs: 'buffer', bodyLimit: 8 * 1024 * 1024 },
(_request, body, done) => done(null, body)
);
}
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
@@ -90,6 +101,43 @@ export async function registerCleaningRoutes(
}));
});
app.post('/app-api/cleaning/tasks/:taskId/rework', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.rework({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/photos', async (request, reply) => {
if (!options.mediaStorage) return reply.status(501).send({
code: 'CLEANING_PHOTO_UPLOAD_UNAVAILABLE',
message: 'Cleaning photo upload is not configured.',
traceId: request.traceId
});
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const originalName = singleHeader(request.headers['x-file-name']);
if (!params.success || !originalName || !Buffer.isBuffer(request.body)) {
return invalid(reply, request.traceId);
}
return handle(reply, request.traceId, async () => {
await options.repository.assertCanUploadPhoto({ ...actor, taskId: params.data.taskId });
const image = await options.mediaStorage!.storeImage({
tenantId: actor.tenantId,
originalName,
contentType: singleHeader(request.headers['x-image-content-type']) ?? '',
body: request.body as Buffer
});
return reply.status(201).send({ code: 0, data: image, traceId: request.traceId });
});
});
app.post('/app-api/cleaning/tasks/:taskId/submit', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -150,6 +198,13 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
try {
return await work();
} catch (error) {
if (error instanceof MediaValidationError) {
return reply.status(400).send({
code: error.code,
message: 'The cleaning task request cannot be completed.',
traceId
});
}
if (!(error instanceof CleaningTaskError)) throw error;
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
return reply.status(statusCode).send({
@@ -160,6 +215,10 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
}
}
function singleHeader(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_CLEANING_TASK_REQUEST',