/** * 运营中心 · 活动模块路由(`docs/API文档-活动模块.md`)。归属在契约的 access 上(全 ops),中间件统一判; * 这里只做 zod 校验、调用用例、把结果翻成状态码。写路由的广播由 `/api/gov/*` 中间件自动发。 */ import type { Context, Hono } from 'hono'; import type { ApiError } from '@contracts'; import { activateOpsActivityRoute, disableOpsActivityRoute, getOpsActivityRoute, getOpsInstanceRoute, launchOpsInstanceRoute, listOpsActivitiesRoute, listOpsInstancesRoute, opsActivityViewRoute, opsInstanceFileRoute, opsLaunchParamsRoute, terminateOpsInstanceRoute, } from '@contracts'; import type { AppEnv } from '../http-env.ts'; import { staffOf } from '../http-env.ts'; import type { ActivityOps } from '../activity-ops/service.ts'; export type ActivityRouteDeps = { readonly activityOps: ActivityOps }; function invalid(c: Context, message: string, detail: unknown): Response { const body: ApiError = { code: 'validation-failed', message, detail }; return c.json(body, 400); } function notFound(c: Context, message: string): Response { const body: ApiError = { code: 'not-found', message }; return c.json(body, 404); } function precondition(c: Context, message: string): Response { const body: ApiError = { code: 'precondition-failed', message }; return c.json(body, 412); } function unauthenticated(c: Context): Response { const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' }; return c.json(body, 401); } async function jsonBody(c: Context): Promise { const text = await c.req.text(); if (text.trim() === '') return {}; try { return JSON.parse(text) as unknown; } catch { return undefined; } } export function registerActivityRoutes(app: Hono, deps: ActivityRouteDeps): void { const { activityOps } = deps; // 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404 const activityId = (c: Context): string => c.req.param('activityId') ?? ''; const instanceId = (c: Context): string => c.req.param('instanceId') ?? ''; /* ── L1 ── */ app.get(listOpsActivitiesRoute.path, async (c) => { const query = listOpsActivitiesRoute.query.safeParse(c.req.query()); if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); return c.json({ activities: await activityOps.listActivities(query.data.status) }); }); /* ── L2 ── */ app.get(getOpsActivityRoute.path, async (c) => { const detail = await activityOps.getActivity(activityId(c)); return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail); }); app.get(opsActivityViewRoute.path, async (c) => { const view = await activityOps.viewHtml(activityId(c)); if (view.kind === 'no-activity') return notFound(c, `活动不存在:${activityId(c)}`); if (view.kind === 'no-view') return notFound(c, '该活动没配视图'); return c.html(view.html, 200, { 'cache-control': 'no-store' }); }); app.get(listOpsInstancesRoute.path, async (c) => { const query = listOpsInstancesRoute.query.safeParse(c.req.query()); if (!query.success) return invalid(c, '查询参数不合法', query.error.issues); const list = await activityOps.listInstances(activityId(c), query.data); return list === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(list); }); /* ── 启动 ── */ app.get(opsLaunchParamsRoute.path, async (c) => { const table = await activityOps.launchParams(activityId(c)); return table === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(table); }); app.post(launchOpsInstanceRoute.path, async (c) => { const parsed = launchOpsInstanceRoute.body.safeParse(await jsonBody(c)); if (!parsed.success) return invalid(c, '启动入参不合法', parsed.error.issues); const me = staffOf(c); if (me === undefined) return unauthenticated(c); const outcome = await activityOps.launch(activityId(c), parsed.data, { id: me.id, name: me.name }); switch (outcome.kind) { case 'ok': return c.json(outcome.detail); case 'not-found': return notFound(c, `活动不存在:${activityId(c)}`); case 'disabled': return precondition(c, '活动已禁用,先激活再启动'); case 'invalid': return invalid(c, `启动参数不合要求:${outcome.issues.map((i) => `${i.key} ${i.message}`).join('; ')}`, outcome.issues); case 'owner-invalid': return invalid(c, outcome.message, [{ key: 'ownerId', message: outcome.message }]); case 'busy': { const body: ApiError = { code: 'conflict', message: outcome.message }; return c.json(body, 409); } } }); /* ── L3 ── */ app.get(getOpsInstanceRoute.path, async (c) => { const detail = await activityOps.instanceDetail(activityId(c), instanceId(c)); return detail === undefined ? notFound(c, `实例不存在:${instanceId(c)}`) : c.json(detail); }); app.get(opsInstanceFileRoute.path, async (c) => { const params = opsInstanceFileRoute.params.safeParse({ activityId: activityId(c), instanceId: instanceId(c), io: c.req.param('io'), index: c.req.param('index'), }); if (!params.success) return invalid(c, '文件序号不合法', params.error.issues); const file = await activityOps.instanceFile(params.data.activityId, params.data.instanceId, params.data.io, params.data.index); if (file.kind === 'no-instance') return notFound(c, `实例不存在:${params.data.instanceId}`); if (file.kind === 'no-file') return notFound(c, '这个文件不在了'); if (c.req.query('raw') === '1') { return c.body(file.bytes.slice().buffer as ArrayBuffer, 200, { 'content-type': file.contentType, // 文件名走 RFC 5987:中文名在各家浏览器上才落得对 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`, }); } return c.json({ name: file.name, contentType: file.contentType, dataBase64: Buffer.from(file.bytes).toString('base64') }); }); /* ── 禁用 / 激活 / 终止 ── */ const setEnabled = (enabled: boolean) => async (c: Context) => { const detail = await activityOps.setEnabled(activityId(c), enabled); return detail === undefined ? notFound(c, `活动不存在:${activityId(c)}`) : c.json(detail); }; app.post(disableOpsActivityRoute.path, setEnabled(false)); app.post(activateOpsActivityRoute.path, setEnabled(true)); app.post(terminateOpsInstanceRoute.path, async (c) => { const me = staffOf(c); if (me === undefined) return unauthenticated(c); const outcome = await activityOps.terminate(activityId(c), instanceId(c), { id: me.id, name: me.name }); if (outcome.kind === 'not-found') return notFound(c, `实例不存在:${instanceId(c)}`); if (outcome.kind === 'terminal') return precondition(c, '实例已终态,不能再终止'); return c.json(outcome.detail); }); }