feat(M08-B): 接入保洁任务端基础流程

This commit is contained in:
Codex
2026-06-25 23:19:04 +08:00
parent 9f7c2e4be7
commit 2a4dcd0fd8
19 changed files with 1081 additions and 10 deletions
+169
View File
@@ -0,0 +1,169 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
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 {
CleaningTaskError,
cleaningTaskStatuses,
type CleaningActor,
type CleaningTaskRepository
} from '../cleaning/cleaning-task-repository.js';
const listSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: z.enum(cleaningTaskStatuses).optional()
});
const paramsSchema = z.object({ taskId: z.string().regex(/^[1-9]\d{0,19}$/) });
const submitSchema = z.object({
photoUrls: z.array(z.string().url()).max(9).default([]),
note: z.string().trim().max(512).optional()
});
export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository, 'listHall' | 'listMine' | 'claim' | 'start' | 'submit' | 'stats'>;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string;
}
export async function registerCleaningRoutes(
app: FastifyInstance,
options: CleaningRouteOptions
): Promise<void> {
app.get('/app-api/cleaning/tasks/hall', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listHall({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/tasks/mine', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = listSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listMine({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/app-api/cleaning/stats', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.stats(actor),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', 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.claim({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/start', 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.start({ ...actor, taskId: params.data.taskId }),
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;
const params = paramsSchema.safeParse(request.params);
const body = submitSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.submit({
...actor,
taskId: params.data.taskId,
photoUrls: body.data.photoUrls,
note: body.data.note
}),
traceId: request.traceId
}));
});
}
async function requireActor(
request: FastifyRequest,
reply: FastifyReply,
options: CleaningRouteOptions,
mode: 'read' | 'write'
): Promise<CleaningActor | null> {
const auth = await authenticateAccessToken(
request.headers.authorization,
options.authRepository,
options.jwtSecret
);
if (!auth) {
reply.status(401).send({
code: 'AUTH_SESSION_INVALID', message: 'Authentication required.', traceId: request.traceId
});
return null;
}
const access = await options.accessControl.getAccessProfile(auth.session.tenantId, auth.session.user.id);
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
reply.status(403).send({
code: 'CLEANING_TASK_FORBIDDEN',
message: 'Cleaning task permission is required.',
traceId: request.traceId
});
return null;
}
return {
tenantId: auth.session.tenantId,
userId: auth.session.user.id,
access,
traceId: request.traceId
};
}
async function handle(reply: FastifyReply, traceId: string, work: () => Promise<unknown>) {
try {
return await work();
} catch (error) {
if (!(error instanceof CleaningTaskError)) throw error;
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409;
return reply.status(statusCode).send({
code: error.code,
message: 'The cleaning task request cannot be completed.',
traceId
});
}
}
function invalid(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_CLEANING_TASK_REQUEST',
message: 'The cleaning task request is invalid.',
traceId
});
}