M1: 底座 —— @contracts 首版 + event / ledger / org / llm
@contracts 首版(§8.1 九个文件): - id 品牌 ID / terminal 终态五名与归侧四标签 / envelope 信封六字段与四类事件 - card 工单两模式 + 对话卡族(形状对齐 2026-07-26 原型定稿) - contract7 契约七件 / ledger 三级寻址与写三分 / process-package 流程包 DSL 与触发器三类 - manifest 活动包清单六区(含 CMMN·DMN·步序声明的资产形状)/ org / api 路由契约 模块(L1): - event 信封存储、归属解析三级短路、投递五诺、监听注册表、三级消费裁定 - ledger schema 自生长(最大化合并/最宽类型/只增不破坏)、任务视图叠加、写三分、冲突检查 - org 岗位授权(客体三类×动作三种)、停用前置检查、会话、种子播种 - llm 四元绑定、ModelGateway 三模式(live/record/replay)—— 两缝之一 app 装配:Hono 服务 + 鉴权中间件 + 治理台/工作台路由;drizzle 迁移;AES-256-GCM 凭证密封 ADR-007 工具链与门禁 · ADR-008 投递队列 · ADR-009 凭证加密 (ADR 编号自 007 续:编码规范 §14 已用到 006,README 写「从 005 续」是笔误) 门禁五道全绿;96 个测试通过,含对真实 PG 的 ledger 集成测试 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
co-authored by
Claude Opus 5
parent
7cd664af3a
commit
14d4fa046a
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* DI 装配 —— `app/` 是**唯一组装层**(编码规范 §5.2)。
|
||||
* 只有这里能同时认识多个模块:new 出 adapter、注入 port、把用例接起来。
|
||||
*
|
||||
* 跨层协作一律经端口注入:`ledger` 要发数据事件 → 注入 `event` 的出口;
|
||||
* `event` 的三级消费下游(trigger L7 / message L2)在其**上**,同样经端口注入。
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
|
||||
import type { EventService, MessageFallback, TriggerMatcher } from '@modules/event';
|
||||
import { createEventService, createEventStore } from '@modules/event';
|
||||
import type { LedgerService } from '@modules/ledger';
|
||||
import { createDraftStore, createLedgerService, createLedgerStore, DRAFT_DDL } from '@modules/ledger';
|
||||
import type { OrgService, InFlightWorkProbe } from '@modules/org';
|
||||
import {
|
||||
createGrantRepository,
|
||||
createOrgService,
|
||||
createPositionRepository,
|
||||
createStaffRepository,
|
||||
} from '@modules/org';
|
||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||
import {
|
||||
createLlmInstanceRepository,
|
||||
createLlmService,
|
||||
createModelGateway,
|
||||
createProviderInvoker,
|
||||
createReplayStore,
|
||||
} from '@modules/llm';
|
||||
|
||||
import type { AppConfig } from './config.ts';
|
||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||
import { createSecretBox, loadSecretKey } from './secret-box.ts';
|
||||
|
||||
export type Container = {
|
||||
readonly config: AppConfig;
|
||||
readonly pool: Pool;
|
||||
readonly db: Db;
|
||||
readonly org: OrgService;
|
||||
readonly ledger: LedgerService;
|
||||
readonly event: EventService;
|
||||
readonly llm: LlmService;
|
||||
readonly modelGateway: ModelGateway;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* M1 的占位实现 —— 在途工作探针要问的是 task(L6)与 human-tool(L3),二者随 M3/M4 落地。
|
||||
* 占位返回空集,停用检查因此在 M1 一律放行;M4 起接真实用例。
|
||||
*/
|
||||
const emptyInFlightProbe: InFlightWorkProbe = {
|
||||
openTasksOf: async () => [],
|
||||
openCardsOf: async () => [],
|
||||
openApprovalsOf: async () => [],
|
||||
};
|
||||
|
||||
/** M1 的占位实现 —— 触发器(L7)随 M5 落地,当下恒不命中 */
|
||||
const noTriggerMatcher: TriggerMatcher = { match: async () => [] };
|
||||
|
||||
/** M1 的占位实现 —— 消息(L2)随 M2 落地;在此之前无主事件只落日志,不静默丢弃 */
|
||||
function createConsoleMessageFallback(): MessageFallback {
|
||||
return {
|
||||
async deadLetter(envelope, error) {
|
||||
process.stderr.write(`[死信] event=${envelope.id} type=${envelope.type} error=${error}\n`);
|
||||
},
|
||||
async unowned(envelope, fallbackPosition) {
|
||||
process.stderr.write(
|
||||
`[无主兜底] event=${envelope.id} type=${envelope.type} 认领岗位=${fallbackPosition ?? '未配置'}\n`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** S-FEEL 求值 —— @sfeel 随 M3 落地;M1 的条件监听一律按「无条件」处理 */
|
||||
const passthroughEvaluator = (): boolean => true;
|
||||
|
||||
export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 10 });
|
||||
const db = drizzle(pool);
|
||||
const ids = createIdGenerator();
|
||||
const secrets = createSecretBox(loadSecretKey());
|
||||
|
||||
// 草稿层的建表:ledger schema 的唯一写者是 ledger adapter,DDL 在其出口给出
|
||||
await pool.query(DRAFT_DDL);
|
||||
|
||||
const event = createEventService({
|
||||
store: createEventStore(db, ids),
|
||||
clock: systemClock,
|
||||
ids,
|
||||
evaluate: passthroughEvaluator,
|
||||
triggers: noTriggerMatcher,
|
||||
messages: createConsoleMessageFallback(),
|
||||
});
|
||||
|
||||
const ledger = createLedgerService({
|
||||
store: createLedgerStore(pool),
|
||||
drafts: createDraftStore(pool),
|
||||
events: { publish: async (envelope) => void (await event.publish(envelope)) },
|
||||
clock: systemClock,
|
||||
ids,
|
||||
});
|
||||
|
||||
const org = createOrgService({
|
||||
positions: createPositionRepository(db),
|
||||
staff: createStaffRepository(db),
|
||||
grants: createGrantRepository(db),
|
||||
inFlight: emptyInFlightProbe,
|
||||
ids,
|
||||
});
|
||||
|
||||
const instances = createLlmInstanceRepository(db);
|
||||
const provider = createProviderInvoker();
|
||||
const llm = createLlmService({ instances, provider, secrets, clock: systemClock, ids });
|
||||
const modelGateway = createModelGateway({
|
||||
instances,
|
||||
provider,
|
||||
replay: createReplayStore(db),
|
||||
secrets,
|
||||
clock: systemClock,
|
||||
mode: 'live',
|
||||
});
|
||||
|
||||
return {
|
||||
config,
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
ledger,
|
||||
event,
|
||||
llm,
|
||||
modelGateway,
|
||||
async close() {
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
|
||||
*/
|
||||
export type AppEnv = { Variables: { account: string } };
|
||||
@@ -1,16 +1,28 @@
|
||||
/**
|
||||
* apps/api 唯一组装层入口(编码规范 §5.2)。
|
||||
* 只有 app/ 能同时认识多个模块:new adapter、注入 port、注册路由 / worker / Mastra 实例。
|
||||
*
|
||||
* M0:骨架 —— 门禁跑通即可,业务装配自 M1 起逐层填入。
|
||||
*/
|
||||
import { serve } from '@hono/node-server';
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
import { createServer } from './server.ts';
|
||||
|
||||
export async function boot(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
process.stdout.write(
|
||||
`JCAI api · M0 骨架 · env=${config.nodeEnv} · port=${config.port} · db=${config.databaseUrl.replace(/:[^:@]*@/, ':***@')}\n`,
|
||||
);
|
||||
const container = await createContainer(config);
|
||||
await container.org.ensureSeeded();
|
||||
|
||||
const app = createServer(container);
|
||||
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
process.stdout.write(`JCAI api · http://localhost:${info.port} · env=${config.nodeEnv}\n`);
|
||||
});
|
||||
|
||||
const shutdown = async (): Promise<void> => {
|
||||
await container.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => void shutdown());
|
||||
process.on('SIGTERM', () => void shutdown());
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* sys schema 迁移执行器 —— drizzle-kit 生成 SQL,本文件应用(ADR-002:迁移随系统版本)。
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from './config.ts';
|
||||
|
||||
/** 从本文件位置向上找 db/migrations —— 源码跑与 dist 跑同一套逻辑 */
|
||||
function findMigrationsFolder(): string {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const candidate = join(dir, 'db', 'migrations');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error('找不到 db/migrations —— 先跑 pnpm db:generate');
|
||||
}
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const pool = new Pool({ connectionString: config.databaseUrl, max: 2 });
|
||||
try {
|
||||
const db = drizzle(pool);
|
||||
await migrate(db, { migrationsFolder: findMigrationsFolder(), migrationsSchema: 'sys' });
|
||||
process.stdout.write('sys schema 迁移完成\n');
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await runMigrations();
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 治理台路由组装(编码规范 §3:治理台九功能不设后端 governance 模块 ——
|
||||
* 每个功能是对应模块的管理用例 + 本文件的路由组装)。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction } from '@contracts';
|
||||
import {
|
||||
createStaffRoute,
|
||||
disableStaffCheckRoute,
|
||||
disableStaffRoute,
|
||||
listGrantsRoute,
|
||||
listLlmInstancesRoute,
|
||||
listStaffRoute,
|
||||
positionIdSchema,
|
||||
probeLlmInstanceRoute,
|
||||
setGrantsRoute,
|
||||
staffIdSchema,
|
||||
upsertLlmInstanceRoute,
|
||||
} from '@contracts';
|
||||
import { DisableBlockedError } from '@modules/org';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** 治理动作要过岗位授权(客体 = 治理功能,动作 = 治理) */
|
||||
function requireGovern(container: Container, fn: GovernanceFunction) {
|
||||
return async (account: string): Promise<ApiError | undefined> => {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' };
|
||||
const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, 'govern');
|
||||
return ok ? undefined : { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」治理权` };
|
||||
};
|
||||
}
|
||||
|
||||
export function registerGovernanceRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
const guardStaff = requireGovern(container, 'staff');
|
||||
const guardSystem = requireGovern(container, 'system');
|
||||
|
||||
/* ── 员工管理 ─────────────────────────────────────────────────────── */
|
||||
|
||||
app.get(listStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json(await container.org.listStaff());
|
||||
});
|
||||
|
||||
app.post(createStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = createStaffRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.org.createStaff(parsed.data));
|
||||
});
|
||||
|
||||
app.get(disableStaffCheckRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
return c.json(await container.org.checkDisable(staffId));
|
||||
});
|
||||
|
||||
app.post(disableStaffRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const staffId = staffIdSchema.parse(c.req.param('staffId'));
|
||||
try {
|
||||
await container.org.disableStaff(staffId);
|
||||
return c.json({ ok: true, check: await container.org.checkDisable(staffId) });
|
||||
} catch (error) {
|
||||
if (error instanceof DisableBlockedError) {
|
||||
// 停用前置检查未过:在途任务与待答卡须先改派完毕(OS 02)
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.check };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.get(listGrantsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
||||
return c.json({ grants: await container.org.listGrants(positionId) });
|
||||
});
|
||||
|
||||
app.post(setGrantsRoute.path, async (c) => {
|
||||
const denied = await guardStaff(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const positionId = positionIdSchema.parse(c.req.param('positionId'));
|
||||
const parsed = setGrantsRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json({ grants: await container.org.setGrants(positionId, parsed.data.grants) });
|
||||
});
|
||||
|
||||
/* ── 系统管理 · LLM 实例 ──────────────────────────────────────────── */
|
||||
|
||||
app.get(listLlmInstancesRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json({ instances: await container.llm.list() });
|
||||
});
|
||||
|
||||
app.post(upsertLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
const parsed = upsertLlmInstanceRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '入参不合法', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
return c.json(await container.llm.upsert(parsed.data));
|
||||
});
|
||||
|
||||
app.post(probeLlmInstanceRoute.path, async (c) => {
|
||||
const denied = await guardSystem(c.get('account'));
|
||||
if (denied) return c.json(denied, denied.code === 'forbidden' ? 403 : 401);
|
||||
return c.json(await container.llm.probe(c.req.param('id')));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { CONTRACTS_VERSION, healthRoute, sessionRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
app.get(healthRoute.path, async (c) => {
|
||||
let db: 'up' | 'down' = 'down';
|
||||
try {
|
||||
await container.pool.query('select 1');
|
||||
db = 'up';
|
||||
} catch {
|
||||
db = 'down';
|
||||
}
|
||||
return c.json({ ok: db === 'up', version: CONTRACTS_VERSION, db });
|
||||
});
|
||||
|
||||
app.get(sessionRoute.path, async (c) => {
|
||||
const session = await container.org.loadSession(c.get('account'));
|
||||
if (session === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效或员工已停用' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
return c.json(session);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 运行时基础件 —— 时钟与 ID 生成器。
|
||||
* domain 零 `Date.now` / `Math.random`(§5.3),二者一律经 port 注入;实现在此。
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
export const systemClock: Clock = { now: () => new Date() };
|
||||
|
||||
export function createIdGenerator(prefix = ''): IdGenerator {
|
||||
return { next: () => `${prefix}${randomUUID()}` };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 凭证密封 —— ADR-009。app 层持有主密钥,模块只见 `SecretBox` 端口。
|
||||
*
|
||||
* 密文格式:`v1.<iv>.<tag>.<cipher>`(均 base64url)。
|
||||
*/
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import type { SecretBox } from '@modules/llm';
|
||||
|
||||
const DEV_SALT = 'jcai-dev-only-not-for-production';
|
||||
|
||||
export function loadSecretKey(env: NodeJS.ProcessEnv = process.env): Buffer {
|
||||
const raw = env['JCAI_SECRET_KEY'];
|
||||
if (raw !== undefined && raw !== '') {
|
||||
const key = Buffer.from(raw, 'base64');
|
||||
if (key.length !== 32) throw new Error('JCAI_SECRET_KEY 必须是 32 字节的 base64');
|
||||
return key;
|
||||
}
|
||||
if (env['NODE_ENV'] === 'production') {
|
||||
throw new Error('生产环境必须设置 JCAI_SECRET_KEY(ADR-009)');
|
||||
}
|
||||
process.stderr.write('⚠️ JCAI_SECRET_KEY 未设置,使用开发派生密钥 —— 切勿用于生产(ADR-009)\n');
|
||||
return createHash('sha256').update(DEV_SALT).digest();
|
||||
}
|
||||
|
||||
export function createSecretBox(key: Buffer): SecretBox {
|
||||
return {
|
||||
seal(plaintext) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), enc.toString('base64url')].join('.');
|
||||
},
|
||||
open(text) {
|
||||
const [version, ivRaw, tagRaw, cipherRaw] = text.split('.');
|
||||
if (version !== 'v1' || ivRaw === undefined || tagRaw === undefined || cipherRaw === undefined) {
|
||||
throw new Error('密文格式非法');
|
||||
}
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagRaw, 'base64url'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(cipherRaw, 'base64url')), decipher.final()]).toString('utf8');
|
||||
},
|
||||
};
|
||||
}
|
||||
+15
-42
@@ -1,49 +1,22 @@
|
||||
/**
|
||||
* 种子数据与播种入口(编码规范 §15 · M0)。
|
||||
*
|
||||
* 数据是常量、写入是端口 —— M0 立骨架:数据在此冻结,写入实现随 M1 的 `org` 模块落地。
|
||||
* 部署边界:单次部署 ≤50 人、单组织、不做多租户(OS 0.3)。
|
||||
* 播种入口(编码规范 §15 · M0/M1)。
|
||||
* 种子数据本体在 `org` 模块的 domain(它是组织主数据,归 org 管);此处只做装配与运行。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
|
||||
/** 岗位:编排层声明岗位,起单时按四策略解析出人(OS 03) */
|
||||
export const POSITION_SEED = [
|
||||
{ code: 'PM', name: '项目经理', note: '定价审批、策略拍板' },
|
||||
{ code: 'BD', name: '商务', note: 'RFQ 受理、成本定价、报价议价、情报归档' },
|
||||
{ code: 'FAE', name: '现场应用', note: '替代选型' },
|
||||
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
|
||||
{ code: 'OP', name: '运营', note: '运营支持' },
|
||||
] as const;
|
||||
|
||||
/** 员工五人;登录者 = 冯兵(BD) */
|
||||
export const STAFF_SEED = [
|
||||
{ account: 'dingziqiao', name: '丁子乔', position: 'PM' },
|
||||
{ account: 'fengbing', name: '冯兵', position: 'BD' },
|
||||
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
|
||||
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
|
||||
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
|
||||
] as const;
|
||||
|
||||
/** 默认登录者(MVP 单组织 cookie session,§4 鉴权定调) */
|
||||
export const DEFAULT_LOGIN_ACCOUNT = 'fengbing';
|
||||
|
||||
export type PositionSeed = (typeof POSITION_SEED)[number];
|
||||
export type StaffSeed = (typeof STAFF_SEED)[number];
|
||||
|
||||
/** 播种端口 —— 实现由 app/ 在 M1 注入(org 模块出口) */
|
||||
export type OrgSeeder = {
|
||||
upsertPosition(position: PositionSeed): Promise<void>;
|
||||
upsertStaff(staff: StaffSeed): Promise<void>;
|
||||
};
|
||||
|
||||
export async function runSeed(seeder: OrgSeeder): Promise<void> {
|
||||
for (const position of POSITION_SEED) await seeder.upsertPosition(position);
|
||||
for (const staff of STAFF_SEED) await seeder.upsertStaff(staff);
|
||||
export async function seed(): Promise<void> {
|
||||
const container = await createContainer(loadConfig());
|
||||
try {
|
||||
await container.org.ensureSeeded();
|
||||
const { staff, positions } = await container.org.listStaff();
|
||||
process.stdout.write(`种子完成:${positions.length} 个岗位 / ${staff.length} 名员工\n`);
|
||||
for (const s of staff) process.stdout.write(` ${s.account.padEnd(12)} ${s.name}(${s.position.code})\n`);
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
process.stdout.write(
|
||||
`种子数据:${POSITION_SEED.length} 个岗位 / ${STAFF_SEED.length} 名员工;` +
|
||||
`登录者=${DEFAULT_LOGIN_ACCOUNT}\n` +
|
||||
'写入实现随 M1 `org` 模块落地(docs/debt.md · M0-1)。\n',
|
||||
);
|
||||
await seed();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Hono 应用 —— 自建应用,Mastra 以库形态嵌入(ADR-001)。
|
||||
* 鉴权、岗位权限、SSE 全归 app 层;不暴露 Mastra 默认端点。
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
||||
import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
export type { AppEnv };
|
||||
|
||||
export function createServer(container: Container): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// 单组织 cookie session(编码规范 §4 鉴权定调)—— MVP 单登录者,cookie 缺省即种子登录者
|
||||
app.use('*', async (c, next) => {
|
||||
const cookie = c.req.header('cookie') ?? '';
|
||||
const match = /(?:^|;\s*)jcai_account=([^;]+)/.exec(cookie);
|
||||
c.set('account', match?.[1] ?? DEFAULT_LOGIN_ACCOUNT);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.onError((error, c) => {
|
||||
const body: ApiError = {
|
||||
code: 'internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
process.stderr.write(`[api] ${body.message}\n`);
|
||||
return c.json(body, 500);
|
||||
});
|
||||
|
||||
app.notFound((c) => {
|
||||
const body: ApiError = { code: 'not-found', message: `无此路由:${c.req.path}` };
|
||||
return c.json(body, 404);
|
||||
});
|
||||
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user