Archived
`apps/api/src/app/activity-ops/*`(service / store / instance-state / instance-steps / launch-params / summaries / view-html / view-scope)+ `routes/activities.ts`;契约 `activity-ops.ts` / `api/activity-ops.ts` (与队友按文档补的那版 43 个导出同名,多 INSTANCE_REASON_KINDS / STEP_RESULTS / launchOptionSchema); 迁移 0035_activity_ops;《API文档-活动模块》按契约回写。 设计端活动库 GET/POST `/api/gov/activities` 改名 `/api/gov/activity-packages`,`/api/gov/activities/*` 整个让给运营端(前端 import 路由对象,零改动);activity-registry CONTRACT.md 与路由总表同步。 2026-08-27 另一会话做的,一直留在工作树没入库;与同批另两条提交互相依赖,以合并提交为验证单位。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0167qWjeyBwvfdGovd7FWbCu
168 lines
7.1 KiB
TypeScript
168 lines
7.1 KiB
TypeScript
/**
|
|
* 运营中心 · 活动模块路由(`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<AppEnv>, message: string, detail: unknown): Response {
|
|
const body: ApiError = { code: 'validation-failed', message, detail };
|
|
return c.json(body, 400);
|
|
}
|
|
|
|
function notFound(c: Context<AppEnv>, message: string): Response {
|
|
const body: ApiError = { code: 'not-found', message };
|
|
return c.json(body, 404);
|
|
}
|
|
|
|
function precondition(c: Context<AppEnv>, message: string): Response {
|
|
const body: ApiError = { code: 'precondition-failed', message };
|
|
return c.json(body, 412);
|
|
}
|
|
|
|
function unauthenticated(c: Context<AppEnv>): Response {
|
|
const body: ApiError = { code: 'unauthenticated', message: '这一步要知道是谁按的 —— 请求没带身份' };
|
|
return c.json(body, 401);
|
|
}
|
|
|
|
async function jsonBody(c: Context<AppEnv>): Promise<unknown> {
|
|
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<AppEnv>, deps: ActivityRouteDeps): void {
|
|
const { activityOps } = deps;
|
|
// 通用 Context 上 param 是 string | undefined;路径模版里有这一段,空串只会落成 404
|
|
const activityId = (c: Context<AppEnv>): string => c.req.param('activityId') ?? '';
|
|
const instanceId = (c: Context<AppEnv>): 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<AppEnv>) => {
|
|
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);
|
|
});
|
|
}
|