feat(ops): 运营中心 · 活动模块接口落地 —— L1/L2/L3 十一条(全 ops)+ 设计端活动库两条改名让位

`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
This commit is contained in:
xcs
2026-08-27 23:20:15 +08:00
co-authored by Claude Fable 5
parent 75553371b0
commit 0923fbfe4d
34 changed files with 5137 additions and 163 deletions
+167
View File
@@ -0,0 +1,167 @@
/**
* 运营中心 · 活动模块路由(`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);
});
}