forked from ChaiHao/BPOS-MVP
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -11,9 +11,16 @@ export type AppConfig = {
|
||||
readonly attachmentRoot: string;
|
||||
/** 派发器轮询间隔(毫秒) */
|
||||
readonly dispatchIntervalMs: number;
|
||||
/**
|
||||
* 播种口令(ADR-010)—— 新建员工的初始口令。
|
||||
* 源码不写任何真实口令;生产部署必须经环境变量覆盖缺省值。
|
||||
*/
|
||||
readonly seedPassword: string;
|
||||
};
|
||||
|
||||
const DEFAULT_DATABASE_URL = 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
/** 开发缺省口令 —— 播种时在 stdout 明示,生产须以 `JCAI_SEED_PASSWORD` 覆盖 */
|
||||
const DEFAULT_SEED_PASSWORD = 'jcai-dev-2026';
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const nodeEnv = env['NODE_ENV'];
|
||||
@@ -23,5 +30,6 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
databaseUrl: env['DATABASE_URL'] ?? DEFAULT_DATABASE_URL,
|
||||
attachmentRoot: env['JCAI_ATTACHMENT_ROOT'] ?? join(process.cwd(), '.data', 'attachments'),
|
||||
dispatchIntervalMs: Number(env['JCAI_DISPATCH_INTERVAL_MS'] ?? 2000),
|
||||
seedPassword: env['JCAI_SEED_PASSWORD'] ?? DEFAULT_SEED_PASSWORD,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
createGrantRepository,
|
||||
createOrgService,
|
||||
createPositionRepository,
|
||||
createSessionRepository,
|
||||
createStaffRepository,
|
||||
createWorkGroupRepository,
|
||||
hashPassword,
|
||||
newSessionToken,
|
||||
verifyPassword,
|
||||
} from '@modules/org';
|
||||
import type { LlmService, ModelGateway } from '@modules/llm';
|
||||
import {
|
||||
@@ -172,7 +177,13 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
positions: createPositionRepository(db),
|
||||
staff: createStaffRepository(db),
|
||||
grants: createGrantRepository(db),
|
||||
sessions: createSessionRepository(db),
|
||||
workGroups: createWorkGroupRepository(db),
|
||||
// 散列算法在 adapter,usecase 只认这三个动作(ADR-010)
|
||||
passwords: { hash: hashPassword, verify: verifyPassword, newToken: newSessionToken },
|
||||
inFlight: inFlightProbe,
|
||||
clock: systemClock,
|
||||
seedPassword: config.seedPassword,
|
||||
ids,
|
||||
});
|
||||
|
||||
@@ -308,6 +319,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
...input,
|
||||
stamp: { contractVersion: '1', businessModelVersion: '1', skillVersions: [] },
|
||||
})),
|
||||
// 对话流的 AI 发言与系统事件由此投影而来 —— 与工作日志同源
|
||||
history: (taskId) => memory.history(taskId),
|
||||
},
|
||||
audience: { canSee: async () => true },
|
||||
clock: systemClock,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
/**
|
||||
* Hono 的请求上下文变量 —— 单独成文件,避免 server 与 routes 互相 import 成环(§0.1 第 3 条)。
|
||||
*/
|
||||
export type AppEnv = { Variables: { account: string } };
|
||||
import type { Session } from '@contracts';
|
||||
|
||||
/**
|
||||
* 请求上下文(ADR-010)。
|
||||
* `session` 由鉴权中间件从**服务端会话表**解出 —— 不再有「cookie 里写谁就是谁」的明文身份;
|
||||
* `account` 保留为便捷读法,值恒取自已核验的会话,免鉴权路由上为空串。
|
||||
*/
|
||||
export type AppEnv = { Variables: { account: string; session: Session | undefined } };
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 清运行数据 —— 开发期反复用:把跑过的单清空,配置原样留着。
|
||||
*
|
||||
* pnpm --filter @app/api reset-runs
|
||||
*
|
||||
* **清的是「跑出来的」**:任务、工作流、卡、任务记忆、事件、监听、台账行、活动记忆…
|
||||
* **留的是「配置好的」**:员工与岗位授权、会话、工作组、流程包、活动库、工具、
|
||||
* 触发器、通道、模型实例、纪要。
|
||||
*
|
||||
* 直接走 SQL 而不绕模块:模块出口里没有、也不该有「删掉所有运行痕迹」这种动作 ——
|
||||
* 留痕只增不改是系统的规矩,清库是**开发期的外科手术**,不是系统的一项能力。
|
||||
*/
|
||||
import { Pool } from 'pg';
|
||||
import { loadConfig } from './config.ts';
|
||||
|
||||
/** 顺序无所谓:一条 TRUNCATE 里的表会一起截断,外键互指也不冲突 */
|
||||
const RUN_TABLES = [
|
||||
'sys.task',
|
||||
'sys.task_memory',
|
||||
'sys.task_memory_seal',
|
||||
'sys.task_view',
|
||||
'sys.work_order',
|
||||
'sys.chat_card',
|
||||
'sys.chat_message',
|
||||
'sys.system_message',
|
||||
'sys.node_instance',
|
||||
'sys.workflow',
|
||||
'sys.flow_state',
|
||||
'sys.loop_state',
|
||||
'sys.listener',
|
||||
'sys.action_intent',
|
||||
'sys.event',
|
||||
'sys.event_delivery',
|
||||
'sys.event_consumption',
|
||||
'sys.outbound_mail',
|
||||
'sys.outbound_ref',
|
||||
'sys.trigger_claim',
|
||||
'sys.report',
|
||||
'sys.replay_model',
|
||||
'sys.replay_tool',
|
||||
/* 活动记忆是跑出来的蒸馏物,跟着运行数据一起清 */
|
||||
'sys.activity_memory',
|
||||
'sys.asset_increment',
|
||||
/* 纪要是人记的,但开发期同属「跑出来的东西」—— 清库要清得干净,不留半场旧会 */
|
||||
'sys.minute',
|
||||
'sys.minute_resolution',
|
||||
];
|
||||
|
||||
export async function resetRuns(): Promise<void> {
|
||||
const pool = new Pool({ connectionString: loadConfig().databaseUrl, max: 2 });
|
||||
try {
|
||||
// 台账的业务表是动态建的,名字问库不写死
|
||||
const ledger = await pool.query<{ name: string }>(
|
||||
`select quote_ident(schemaname) || '.' || quote_ident(tablename) as name
|
||||
from pg_tables where schemaname = 'ledger'`,
|
||||
);
|
||||
const all = [...RUN_TABLES, ...ledger.rows.map((r) => r.name)];
|
||||
await pool.query(`truncate table ${all.join(', ')} restart identity cascade`);
|
||||
process.stdout.write(`已清运行数据:${all.length} 张表(配置与组织数据原样保留)\n`);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await resetRuns();
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -4,16 +4,19 @@
|
||||
* 「web 是纯呈现与输入面」:这里出的每一样都是服务端投影,前端零过程状态、刷新即重建(§9.2)。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, ChatMessage, TaskCard, ViewBlock } from '@contracts';
|
||||
import type { ApiError, ChainNode, ChatMessage, Deliverable, FileDoc, TaskCard, ViewBlock } from '@contracts';
|
||||
import {
|
||||
ATTRIBUTION_SIDE_LABEL,
|
||||
answerMessageRoute,
|
||||
chatCardReplySchema,
|
||||
listMessagesRoute,
|
||||
markMessageReadRoute,
|
||||
listTasksRoute,
|
||||
replyChatCardRoute,
|
||||
replyWorkOrderRoute,
|
||||
sayRoute,
|
||||
taskDetailRoute,
|
||||
staffIdSchema,
|
||||
taskIdSchema,
|
||||
workOrderReplySchema,
|
||||
} from '@contracts';
|
||||
@@ -62,6 +65,79 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 流程链 —— 人要看见自己在整条流程的哪一环,以及前后是谁。
|
||||
* 别人负责的节点在链上只读呈现,不进任务清单(清单只装我负责的)。
|
||||
*/
|
||||
async function toChain(task: Awaited<ReturnType<Container['task']['get']>>): Promise<ChainNode[]> {
|
||||
if (task.workflowId === undefined) return [];
|
||||
const workflow = await container.process.getWorkflow(task.workflowId).catch(() => undefined);
|
||||
if (workflow === undefined) return [];
|
||||
const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion);
|
||||
|
||||
/*
|
||||
* 链的呈现顺序:**按流程列位排,同列按节点编号排** —— 不吃 `nodes` 数组的书写顺序。
|
||||
*
|
||||
* 并行分支(如 G2 分出的 A2 / A3)在图上本无先后,链却必须挑一个顺序。
|
||||
* 挑「作者数组里怎么写」是最不稳的:换个人写包,链的读法就变了。
|
||||
* 列位 `layout.col` 是流程的推进次序(拓扑的可视化),同列的并行支再按编号定 ——
|
||||
* 于是链永远读作 A1 → A2 → A3 → A4,与人对活动编号的预期一致。
|
||||
* `layout` 声明为「不影响语义」,这里也只拿它排**呈现**,不参与任何流转判断。
|
||||
*/
|
||||
const declared = (pkg?.nodes ?? []).filter((n) => n.kind === 'activity' || n.kind === 'approval');
|
||||
const nodes = declared
|
||||
.map((n, index) => ({ n, index }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.n.layout?.col ?? a.index) - (b.n.layout?.col ?? b.index) ||
|
||||
(a.n.id < b.n.id ? -1 : a.n.id > b.n.id ? 1 : 0),
|
||||
)
|
||||
.map((x) => x.n);
|
||||
|
||||
/*
|
||||
* 状态取**真实的节点实例**,不按下标推。
|
||||
* 按下标推(「排在当前之前的就算做完」)在并行支上会说谎:人在 A3 时 A2 可能还在跑,
|
||||
* 却被标成已完成。宁可把在跑的并行支报作待办(少报),也不能把没做完的说成做完了。
|
||||
*/
|
||||
const instances = await container.process.instancesOf(workflow.id).catch(() => []);
|
||||
const finished = new Set(instances.filter((x) => x.terminal !== undefined).map((x) => x.nodeId));
|
||||
|
||||
return await Promise.all(
|
||||
nodes.map(async (n) => {
|
||||
const spec = n.kind === 'approval' ? n.approver : n.owner;
|
||||
|
||||
/*
|
||||
* 报谁,要和**真正派单时的口径一致**(ADR-011:绑定先于策略)。
|
||||
* 只按岗位报人的话,装了工作组的单会当面说谎:整条流实际都归起单人,
|
||||
* 链上却写着各岗位的常驻人。所以这里也先看工作组绑定,再退回岗位解析。
|
||||
*/
|
||||
const bound = workflow.workGroup?.[spec.position];
|
||||
const boundStaff =
|
||||
bound === undefined
|
||||
? undefined
|
||||
: await container.org.getStaff(staffIdSchema.parse(bound)).catch(() => undefined);
|
||||
|
||||
// 岗位唯一解析(sole)即报人名;否则只报岗位 —— 不编造具体承接人
|
||||
const holders =
|
||||
boundStaff !== undefined ? [] : await container.org.staffOfPosition(spec.position).catch(() => []);
|
||||
const who = boundStaff ?? (holders.length === 1 ? holders[0] : undefined);
|
||||
return {
|
||||
nodeId: n.id,
|
||||
label: n.label,
|
||||
kind: n.kind === 'approval' ? ('approval' as const) : ('activity' as const),
|
||||
ownerName: who?.name ?? spec.position,
|
||||
ownerRole: who?.position.name ?? spec.position,
|
||||
state:
|
||||
n.id === task.nodeId
|
||||
? ('current' as const)
|
||||
: finished.has(n.id)
|
||||
? ('done' as const)
|
||||
: ('todo' as const),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
app.get(listTasksRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
@@ -83,12 +159,49 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
const task = await container.task.get(taskId);
|
||||
|
||||
const [workOrders, chat, lines, pushed] = await Promise.all([
|
||||
container.humanTool.openWorkOrders(taskId),
|
||||
// 全部工单:待处理要翻页办,已办结要回看定稿报告 —— 两摞同源
|
||||
container.humanTool.allWorkOrders(taskId),
|
||||
container.interaction.timeline(taskId),
|
||||
container.memory.taskLog(taskId),
|
||||
container.interaction.viewOf(taskId),
|
||||
]);
|
||||
const cards = await container.humanTool.chatCards(taskId);
|
||||
const chain = await toChain(task);
|
||||
|
||||
/*
|
||||
* 工作成果 = 一组**文件**,大部分是 HTML 汇报报告。
|
||||
* 两处来源:
|
||||
* ① 活动推的那一屏 —— 它就是这单当下的汇报报告(AM 07);
|
||||
* ② 过程成果里声明的 `files` 块 —— 活动按需生成的目标输出(报价单 / 台账 / 档案)。
|
||||
* 活动尚未产出文件时给空列表,界面落空态,不编造条目。
|
||||
*/
|
||||
/*
|
||||
* 过程成果尚无来源:活动还不发布 deliverable(一个个活动开发时逐步补上)。
|
||||
* 先声明出来并接进成果文件的装配 —— 活动一开始产出,文件列表就自动有了。
|
||||
*/
|
||||
const deliverables: Deliverable[] = [];
|
||||
|
||||
const results: Array<{ label: string; desc: string; at?: string; file: FileDoc }> = [];
|
||||
if (pushed?.html !== undefined) {
|
||||
results.push({
|
||||
label: `${task.title} · 汇报报告`,
|
||||
desc: 'HTML · 活动按当下局面组织的一屏',
|
||||
file: { kind: 'html', name: `${task.activityId}-report.html`, html: pushed.html },
|
||||
});
|
||||
}
|
||||
for (const deliverable of deliverables) {
|
||||
for (const block of deliverable.blocks) {
|
||||
if (block.t !== 'files') continue;
|
||||
for (const item of block.items) {
|
||||
results.push({
|
||||
label: item.label,
|
||||
desc: item.desc,
|
||||
...(deliverable.at === undefined ? {} : { at: deliverable.at }),
|
||||
file: item.file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对话流 = 时间线 + 对话卡族,按时间全序 —— 与任务记忆同源、不同筛选
|
||||
const merged: ChatMessage[] = [...chat, ...cards].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
@@ -111,6 +224,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
return c.json({
|
||||
card: await toCard(task),
|
||||
goal: task.title,
|
||||
owner: { name: staff.name, role: staff.position.name },
|
||||
chain,
|
||||
budget: {
|
||||
steps: `${task.used.steps}/${task.caps.steps}`,
|
||||
tokens: `${task.used.tokens}/${task.caps.tokens}`,
|
||||
@@ -118,7 +233,8 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
humanCalls: `${task.used.humanCalls}/${task.caps.humanCalls}`,
|
||||
},
|
||||
stages: [],
|
||||
deliverables: [],
|
||||
deliverables,
|
||||
results,
|
||||
workOrders,
|
||||
chat: merged,
|
||||
view: pushed?.blocks !== undefined ? [...pushed.blocks] : view,
|
||||
@@ -159,6 +275,34 @@ export function registerDeskRoutes(app: Hono<AppEnv>, container: Container): voi
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 对话卡回传(决策 / 补全 / 评价)。
|
||||
* 与工单回传同一条规矩:校验用**同 schema**,拒收由 human-tool 判定并给出违规明细。
|
||||
*/
|
||||
app.post(replyChatCardRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
if (staff === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
const parsed = chatCardReplySchema.safeParse((await c.req.json())['reply']);
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '回传不合协议', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
try {
|
||||
await container.humanTool.replyChatCard(parsed.data, staff.id);
|
||||
return c.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (error instanceof ReplyRejectedError) {
|
||||
// 补全两条路必走其一 / 已答不得再答 —— 三规矩的回传面
|
||||
const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.violations };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/** 自由输入 —— 人事件的第二形态:写入任务记忆,loop 作为观察;不驱动 flow 步 */
|
||||
app.post(sayRoute.path, async (c) => {
|
||||
const staff = await me(c.get('account'));
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
publishProcessRoute,
|
||||
saveProcessRoute,
|
||||
reassignTaskRoute,
|
||||
resumeTaskRoute,
|
||||
staffIdSchema,
|
||||
@@ -255,6 +256,33 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
return c.json({ packages: await container.process.listPackages() });
|
||||
});
|
||||
|
||||
/**
|
||||
* 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。
|
||||
* 想改已发布的,另起新版本(恒新增、版本并存,在途按启动时版本跑完)。
|
||||
*/
|
||||
app.put(saveProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = saveProcessRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const incoming = parsed.data.pkg;
|
||||
const existing = await container.process.getPackage(incoming.id, incoming.version);
|
||||
if (existing !== undefined && existing.state === 'published') {
|
||||
const body: ApiError = {
|
||||
code: 'precondition-failed',
|
||||
message: `v${incoming.version} 已发布,不可回改 —— 另存为 v${incoming.version + 1} 再编排`,
|
||||
};
|
||||
return c.json(body, 412);
|
||||
}
|
||||
// 保存的永远是草稿:发布是另一道门(闭合校验),不在保存里顺带
|
||||
const pkg = await container.process.savePackage({ ...incoming, state: 'draft' });
|
||||
const report = await container.process.validate(pkg);
|
||||
return c.json({ pkg, report });
|
||||
});
|
||||
|
||||
/** 画图即校验 —— 与前端画布同源 */
|
||||
app.post(validateProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 登记触发的路由(编码规范 §3;语义出处 OS 3.1)。
|
||||
*
|
||||
* 「登记触发 = 人工起单的统一形态:登记人选择流程包、按调用接口 schema 填写输入、
|
||||
* 提交即起单。」**凡单必有来源** —— 登记起单的来源就是登记人,记在工作流上。
|
||||
*
|
||||
* 与事件触发同走一条 `trigger.fire` 通路:差别只在**时机由谁给定**,不在起单本身。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { fireRegistrationRoute, listRegistrationTriggersRoute, triggerKindOf } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerRegistrationRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' };
|
||||
|
||||
/** 可登记起单的触发器 + 各自流程包的调用接口 schema(表单由它自动渲染) */
|
||||
app.get(listRegistrationTriggersRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
|
||||
const triggers = await container.trigger.list();
|
||||
const registration = triggers.filter((t) => triggerKindOf(t.spec) === 'registration');
|
||||
const rows = await Promise.all(
|
||||
registration.map(async (t) => {
|
||||
const pkg = await container.process.getPackage(t.processPackageId);
|
||||
return {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
processPackageId: t.processPackageId,
|
||||
enabled: t.enabled,
|
||||
inputSchema: (pkg?.callInterface.input ?? {}) as Record<string, unknown>,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return c.json({ triggers: rows });
|
||||
});
|
||||
|
||||
app.post(fireRegistrationRoute.path, async (c) => {
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
if (me === undefined) return c.json(unauthenticated, 401);
|
||||
const parsed = fireRegistrationRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '登记入参不合法' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
// 登记人即来源 —— 由会话给定,不由前端传
|
||||
const outcome = await container.trigger.fireByRegistration(
|
||||
c.req.param('triggerId'),
|
||||
me.id,
|
||||
parsed.data.data,
|
||||
);
|
||||
return c.json({
|
||||
ok: outcome.workflowId !== undefined,
|
||||
...(outcome.workflowId === undefined ? {} : { workflowId: outcome.workflowId }),
|
||||
...(outcome.skipped === undefined ? {} : { skipped: outcome.skipped }),
|
||||
...(outcome.missing === undefined ? {} : { missing: [...outcome.missing] }),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 工作台路由组装 —— 路由契约在 @contracts/api,实现在此(编码规范 §3 / §9.2)。
|
||||
* 登录 / 登出属鉴权面:中间件在 app 层,身份判定读 `org` 出口(§4 / ADR-010)。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { CONTRACTS_VERSION, healthRoute, sessionRoute } from '@contracts';
|
||||
import { CONTRACTS_VERSION, healthRoute, loginRoute, logoutRoute, sessionRoute } from '@contracts';
|
||||
import type { Container } from '../container.ts';
|
||||
import { SESSION_COOKIE, clearSessionCookie, setSessionCookie } from '../session-cookie.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
@@ -19,12 +21,44 @@ export function registerWorkbenchRoutes(app: Hono<AppEnv>, container: Container)
|
||||
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'));
|
||||
/** 我是谁 —— 会话由中间件解好放进上下文,这里只回投影 */
|
||||
app.get(sessionRoute.path, (c) => {
|
||||
const session = c.get('session');
|
||||
if (session === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '会话无效或员工已停用' };
|
||||
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
return c.json(session);
|
||||
});
|
||||
|
||||
app.post(loginRoute.path, async (c) => {
|
||||
const parsed = loginRoute.body.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '请填写账号与口令' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
|
||||
const issued = await container.org.login(parsed.data.account, parsed.data.password);
|
||||
// 失败只有一句话:不区分账号不存在 / 已停用 / 口令错,不外泄账号是否存在
|
||||
if (issued === undefined) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '账号或口令不对' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
|
||||
const session = await container.org.resolveSession(issued.token);
|
||||
if (session === undefined) {
|
||||
const body: ApiError = { code: 'internal', message: '会话签发后立即失效' };
|
||||
return c.json(body, 500);
|
||||
}
|
||||
setSessionCookie(c, issued.token, new Date(issued.expiresAt));
|
||||
return c.json(session);
|
||||
});
|
||||
|
||||
app.post(logoutRoute.path, async (c) => {
|
||||
const token = c.req.header('cookie')?.match(new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`))?.[1];
|
||||
// 服务端表先作废,再清 cookie —— 顺序反了会留下「cookie 没了但会话还活着」的窗口
|
||||
if (token !== undefined) await container.org.logout(token);
|
||||
clearSessionCookie(c);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
|
||||
+56
-140
@@ -211,7 +211,7 @@ export async function seedQuote(container: Container): Promise<void> {
|
||||
const pkg = processPackageSchema.parse({
|
||||
id: 'unified-quote',
|
||||
name: '统一报价',
|
||||
version: 1,
|
||||
version: 2,
|
||||
state: 'draft',
|
||||
callInterface: {
|
||||
input: {
|
||||
@@ -221,44 +221,15 @@ export async function seedQuote(container: Container): Promise<void> {
|
||||
},
|
||||
output: { type: 'object', properties: { landingRate: { type: 'number' } } },
|
||||
},
|
||||
/*
|
||||
* **当前只展开第一个活动**(v2)。
|
||||
* 后续活动一个一个加回来:每加一个,补它的节点、边映射与出口口载荷,
|
||||
* 版本随之上抬 —— 在途的旧单仍跑各自起单时那一版,不受影响。
|
||||
*/
|
||||
nodes: [
|
||||
{ id: 'start', kind: 'start', label: '客户RFQ询价', layout: { row: 0, col: 0 } },
|
||||
activityNode('A1', 'A1 RFQ需求分析', 'quote-intake', 'BD', 'terminate', 1),
|
||||
// 已裁定 ②:G2 料号分流 = **并行网关**,三道并行、每边载荷按 Type 过滤
|
||||
{ id: 'G2', kind: 'gateway', label: 'G2 料号分流', gateway: 'parallel', mode: 'fork', layout: { row: 0, col: 2 } },
|
||||
// 某类条目为空的分支按「容忍」处置 —— A4 汇合处的必填映射不得依赖可空分支
|
||||
activityNode('A3', 'A3 渠道寻源', 'quote-sourcing', 'PRM', 'tolerate', 3, -1),
|
||||
activityNode('A2', 'A2 替代选型', 'quote-alternative', 'FAE', 'tolerate', 3, 1),
|
||||
{ id: 'G2J', kind: 'gateway', label: 'G2 汇合', gateway: 'parallel', mode: 'join', layout: { row: 0, col: 4 } },
|
||||
activityNode('A4', 'A4 成本定价', 'quote-pricing', 'BD', 'terminate', 5),
|
||||
// 已裁定 ①:A5 定价审批 = **审批点**;G3 审批结果并入驳回走向(驳回 → 回 A4 重开新实例)
|
||||
{
|
||||
id: 'AP',
|
||||
kind: 'approval',
|
||||
label: 'AP 定价审批',
|
||||
approver: { position: 'PM', strategy: 'sole' },
|
||||
// 驳回走向的载荷映射不能空:重开的是**新的 A4 实例**,它照样要过 A4 的入参契约。
|
||||
// 空映射 = 重开必失败,等于把「驳回回 A4」写成了一句空话。
|
||||
reject: {
|
||||
kind: 'reopen',
|
||||
nodeId: 'A4',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||||
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
|
||||
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
|
||||
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
|
||||
],
|
||||
},
|
||||
nudge: { deadline: 'PT30M', steps: [{ after: 'PT20M', action: 'nudge' }] },
|
||||
layout: { row: 0, col: 6 },
|
||||
},
|
||||
activityNode('A6', 'A6 报价议价', 'quote-negotiate', 'BD', 'terminate', 7),
|
||||
// G4 议价结果 = 异或网关:成交 / 有异议(回边 A4)/ 拒绝超时
|
||||
{ id: 'G4', kind: 'gateway', label: 'G4 议价结果', gateway: 'exclusive', mode: 'fork', layout: { row: 0, col: 8 } },
|
||||
activityNode('A7', 'A7 情报归档', 'quote-archive', 'BD', 'terminate', 9),
|
||||
{ id: 'end', kind: 'end', label: '归档闭环', layout: { row: 0, col: 10 } },
|
||||
activityNode('A1', '客户RFQ受理', 'quote-intake', 'BD', 'terminate', 1),
|
||||
{ id: 'end', kind: 'end', label: '受理闭环', layout: { row: 0, col: 2 } },
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
@@ -274,107 +245,7 @@ export async function seedQuote(container: Container): Promise<void> {
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e-a1-g2', from: 'A1', to: 'G2', mapping: [], label: '统一受理记录', reopen: false },
|
||||
|
||||
// 三道并行:TYPE-A 直通定价 / TYPE-B 寻源 / TYPE-C 选型
|
||||
// TYPE-A 直通:不经活动,直接进汇合 —— A4 因此只有一条并发入边,来源无歧义
|
||||
{ id: 'e-g2-j', from: 'G2', to: 'G2J', label: 'TYPE-A 直通定价', mapping: [], reopen: false },
|
||||
{
|
||||
id: 'e-g2-a3',
|
||||
from: 'G2',
|
||||
to: 'A3',
|
||||
label: 'TYPE-B 转寻源',
|
||||
mapping: [
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||||
{ target: 'scenario', source: { from: 'constant', value: 'BOM后期' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{
|
||||
id: 'e-g2-a2',
|
||||
from: 'G2',
|
||||
to: 'A2',
|
||||
label: 'TYPE-C 转选型',
|
||||
mapping: [{ target: 'originalPn', source: { from: 'output-port', port: 'intake', path: 'pn' } }],
|
||||
reopen: false,
|
||||
},
|
||||
|
||||
// 汇合:容忍分支的输出**不进 A4 的必填映射**(静态校验会拦)
|
||||
{ id: 'e-a3-j', from: 'A3', to: 'G2J', mapping: [], reopen: false },
|
||||
{ id: 'e-a2-j', from: 'A2', to: 'G2J', mapping: [], reopen: false },
|
||||
{
|
||||
id: 'e-j-a4',
|
||||
from: 'G2J',
|
||||
to: 'A4',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
// records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行
|
||||
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
|
||||
// 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||||
// P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值;
|
||||
// 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档,
|
||||
// P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。
|
||||
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
|
||||
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
|
||||
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
|
||||
{ id: 'e-a4-ap', from: 'A4', to: 'AP', mapping: [], label: '报价草案提审', reopen: false },
|
||||
{
|
||||
id: 'e-ap-a6',
|
||||
from: 'AP',
|
||||
to: 'A6',
|
||||
label: '通过',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
{ target: 'items', source: { from: 'output-port', port: 'draft', path: 'items' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
|
||||
{ id: 'e-a6-g4', from: 'A6', to: 'G4', mapping: [], reopen: false },
|
||||
{ id: 'e-g4-end', from: 'G4', to: 'end', condition: 'outcome = "won"', label: '成交 → 转订单履约', reopen: false },
|
||||
// 已裁定 ④:议价回环 = **流程层回边**,人裁决后重开新任务实例
|
||||
{
|
||||
id: 'e-g4-a4',
|
||||
from: 'G4',
|
||||
to: 'A4',
|
||||
condition: 'outcome = "revise"',
|
||||
label: '有异议 → 返回调整',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
// records 来自 A1 的输出口(受理归一的产物),不是起单入参 —— 起单时还没有这些行
|
||||
{ target: 'records', source: { from: 'output-port', port: 'intake', path: 'records' } },
|
||||
// 逐条办的料号:MVP 单条 RFQ,取受理归一出的那一条
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||||
// P-01 利润模型的三个因子:物料类型就是 A1 打的标(A/B/C),真实取值;
|
||||
// 客户等级与数量档在 MVP 取默认档 —— 接 CRM 后改为读 shared.customer 与按 EAU 分档,
|
||||
// P-01 决策表本就有兜底行('-' = 任意),默认档不会让求值失真。
|
||||
{ target: 'materialType', source: { from: 'output-port', port: 'intake', path: 'type_tag' } },
|
||||
{ target: 'customerGrade', source: { from: 'constant', value: '普通客户' } },
|
||||
{ target: 'volumeTier', source: { from: 'constant', value: '大批量' } },
|
||||
],
|
||||
reopen: true,
|
||||
},
|
||||
{
|
||||
id: 'e-g4-a7',
|
||||
from: 'G4',
|
||||
to: 'A7',
|
||||
condition: 'outcome = "lost"',
|
||||
label: '拒绝/超时 → 情报归档',
|
||||
mapping: [
|
||||
{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } },
|
||||
{ target: 'quoteNo', source: { from: 'initiation', path: 'rfqRef' } },
|
||||
// 归档写的是 (pn, quoted_at) 这一行 —— 两个主键都得传到,它自己造不出来
|
||||
{ target: 'pn', source: { from: 'output-port', port: 'intake', path: 'pn' } },
|
||||
{ target: 'quotedAt', source: { from: 'initiation', path: 'quotedAt' } },
|
||||
],
|
||||
reopen: false,
|
||||
},
|
||||
{ id: 'e-a7-end', from: 'A7', to: 'end', mapping: [], reopen: false },
|
||||
{ id: 'e-a1-end', from: 'A1', to: 'end', mapping: [], label: '统一受理记录', reopen: false },
|
||||
],
|
||||
variables: [],
|
||||
});
|
||||
@@ -385,7 +256,7 @@ export async function seedQuote(container: Container): Promise<void> {
|
||||
log(' 闭合校验未过,不得发布:' + report.violations.map((v) => v.message).join('; '));
|
||||
} else {
|
||||
await container.process.publish(pkg.id, pkg.version);
|
||||
log('④ 流程包「统一报价」v1 已发布 —— 7 节点 + 3 网关 + 1 审批点,边映射闭合校验通过');
|
||||
log(`④ 流程包「统一报价」v${pkg.version} 已发布 —— ${pkg.nodes.length} 节点,边映射闭合校验通过`);
|
||||
}
|
||||
|
||||
/* 事件触发器「客户RFQ询价」 */
|
||||
@@ -409,7 +280,13 @@ export async function seedQuote(container: Container): Promise<void> {
|
||||
*/
|
||||
{ target: 'quotedAt', source: { from: 'expression', sfeel: 'occurredAt' } },
|
||||
],
|
||||
spec: { kind: 'event', rule: 'contains(subject, "询价") or contains(subject, "RFQ")', eventType: 'data' },
|
||||
// 按**通道**收口:只有投进「公司询价邮箱」的才自动起单;
|
||||
// 同一封信投到「询价待确认邮箱」不命中,落人工确认那条路
|
||||
spec: {
|
||||
kind: 'event',
|
||||
rule: 'channelId = "ch-rfq-mail" and (contains(subject, "询价") or contains(subject, "RFQ"))',
|
||||
eventType: 'data',
|
||||
},
|
||||
});
|
||||
await container.trigger.upsert(trigger);
|
||||
log(' 事件触发器「客户RFQ询价」已配置');
|
||||
@@ -433,4 +310,43 @@ export async function seedQuote(container: Container): Promise<void> {
|
||||
addressWindowMs: 7 * 86_400_000,
|
||||
});
|
||||
log(' 邮件通道实例「公司询价邮箱」已配置(默认认领岗位:OP)');
|
||||
|
||||
/*
|
||||
* ⑥ 人工确认通道 + 登记触发器 —— 三类触发里的**登记触发**(OS 3.1)。
|
||||
*
|
||||
* 同一封 RFQ,两条起单路径:
|
||||
* · 走「公司询价邮箱」→ 事件触发器命中 → **自动起单**;
|
||||
* · 走「询价待确认邮箱」→ 无触发器命中 → 按「宁交人,不错投」落**消息兜底**到
|
||||
* 默认认领岗位(PM)→ 由人确认后**登记起单**,「登记人即来源」。
|
||||
* 后者是给需要人过一道目的场景用的:同样的信,起不起单由人定。
|
||||
*/
|
||||
const pm = positions.find((p) => p.code === 'PM');
|
||||
await container.channel.upsert({
|
||||
id: 'ch-rfq-review',
|
||||
name: '询价待确认邮箱',
|
||||
kind: 'mail',
|
||||
address: 'rfq-review@we.com',
|
||||
enabled: true,
|
||||
// 归属键规则与正式通道一致:识别照做,只是不自动起单
|
||||
extract: [
|
||||
{ key: 'customerCode', regex: '\\bC\\d{3}\\b', source: 'both' },
|
||||
{ key: 'rfqRef', regex: 'RFQ-[A-Z0-9]+', source: 'both' },
|
||||
],
|
||||
mapping: [],
|
||||
...(pm === undefined ? {} : { fallbackPosition: pm.id }),
|
||||
addressWindowMs: 7 * 86_400_000,
|
||||
});
|
||||
|
||||
await container.trigger.upsert(
|
||||
triggerConfigSchema.parse({
|
||||
id: 'trg-rfq-manual',
|
||||
name: '人工登记起单(统一报价)',
|
||||
enabled: true,
|
||||
processPackageId: 'unified-quote',
|
||||
// 登记触发以**登记表单**为输入,不做字段映射:表单由调用接口 schema 自动渲染
|
||||
mapping: [],
|
||||
spec: { kind: 'registration' },
|
||||
}),
|
||||
);
|
||||
log(' 人工确认通道「询价待确认邮箱」+ 登记触发器已配置(默认认领岗位:PM)');
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import type { ApiError } from '@contracts';
|
||||
import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
||||
import { healthRoute, ingestMailRoute, loginRoute } from '@contracts';
|
||||
import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { readSessionToken } from './session-cookie.ts';
|
||||
import { registerDeskRoutes } from './routes/desk.ts';
|
||||
import { registerDeskElementRoutes } from './routes/desk-elements.ts';
|
||||
import { registerDeskApprovalRoutes } from './routes/desk-approval.ts';
|
||||
import { registerRegistrationRoutes } from './routes/registration.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
@@ -18,15 +20,40 @@ import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
export type { AppEnv };
|
||||
|
||||
/**
|
||||
* 免鉴权白名单(ADR-010)—— 只三条,其余一律 401。
|
||||
* 入站邮件是**外界边界**:它代替真实 IMAP 拉件,不是人的请求,没有会话可言。
|
||||
*/
|
||||
const INGEST_PREFIX = ingestMailRoute.path.slice(0, ingestMailRoute.path.indexOf(':'));
|
||||
|
||||
function isPublic(method: string, path: string): boolean {
|
||||
if (method === 'GET' && path === healthRoute.path) return true;
|
||||
if (method === 'POST' && path === loginRoute.path) return true;
|
||||
if (method === 'POST' && path.startsWith(INGEST_PREFIX)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createServer(container: Container): Hono<AppEnv> {
|
||||
const app = new Hono<AppEnv>();
|
||||
|
||||
// 单组织 cookie session(编码规范 §4 鉴权定调)—— MVP 单登录者,cookie 缺省即种子登录者
|
||||
/**
|
||||
* 鉴权中间件(编码规范 §4:中间件在 app 层,身份判定读 `org` 出口;ADR-010)。
|
||||
*
|
||||
* 身份**只能**来自服务端会话表:cookie 里是一枚不透明 token,改它只会让会话解不出来,
|
||||
* 冒充不了任何人 —— 这正是明文 `jcai_account` 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);
|
||||
const token = readSessionToken(c.req.header('cookie'));
|
||||
const session = token === undefined ? undefined : await container.org.resolveSession(token);
|
||||
c.set('session', session);
|
||||
c.set('account', session?.staff.account ?? '');
|
||||
|
||||
if (session === undefined && !isPublic(c.req.method, c.req.path)) {
|
||||
const body: ApiError = { code: 'unauthenticated', message: '尚未登录' };
|
||||
return c.json(body, 401);
|
||||
}
|
||||
await next();
|
||||
return undefined;
|
||||
});
|
||||
|
||||
app.onError((error, c) => {
|
||||
@@ -48,6 +75,7 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerDeskRoutes(app, container);
|
||||
registerDeskElementRoutes(app, container);
|
||||
registerDeskApprovalRoutes(app, container);
|
||||
registerRegistrationRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 会话 cookie 的读写(ADR-010)—— 只此一处拼 `Set-Cookie`,属性不许各写各的。
|
||||
*
|
||||
* `HttpOnly`:脚本读不到 token,XSS 拿不走会话。
|
||||
* `SameSite=Lax`:跨站表单 POST 不带 cookie,挡掉 CSRF 的常见形态;
|
||||
* 同站导航仍带,登录后跳转不受影响。
|
||||
* 开发期走 http://localhost,故 `Secure` 随部署环境开关(生产必开)。
|
||||
*/
|
||||
import type { Context } from 'hono';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
|
||||
export const SESSION_COOKIE = 'jcai_session';
|
||||
|
||||
/** 生产环境必带 Secure;开发期 localhost 是 http,带了浏览器就不存 */
|
||||
function secureAttr(): string {
|
||||
return process.env['NODE_ENV'] === 'production' ? '; Secure' : '';
|
||||
}
|
||||
|
||||
export function readSessionToken(cookieHeader: string | undefined): string | undefined {
|
||||
if (cookieHeader === undefined) return undefined;
|
||||
const match = new RegExp(`(?:^|;\\s*)${SESSION_COOKIE}=([^;]+)`).exec(cookieHeader);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export function setSessionCookie(c: Context<AppEnv>, token: string, expiresAt: Date): void {
|
||||
c.header(
|
||||
'Set-Cookie',
|
||||
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Expires=${expiresAt.toUTCString()}`,
|
||||
{ append: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function clearSessionCookie(c: Context<AppEnv>): void {
|
||||
c.header(
|
||||
'Set-Cookie',
|
||||
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax${secureAttr()}; Max-Age=0`,
|
||||
{ append: true },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 改密 CLI(ADR-010)—— 治理台的改密界面还没做,先给运维一条路。
|
||||
*
|
||||
* pnpm --filter @app/api exec node --experimental-strip-types src/app/set-password.ts <账号> <新口令>
|
||||
*
|
||||
* 口令从**命令行参数**取,不写进任何文件;落库前即散列,明文只在进程内存里停留一瞬。
|
||||
*/
|
||||
import { loadConfig } from './config.ts';
|
||||
import { createContainer } from './container.ts';
|
||||
|
||||
export async function setPassword(account: string, password: string): Promise<void> {
|
||||
const container = await createContainer(loadConfig());
|
||||
try {
|
||||
const staff = await container.org.findByAccount(account);
|
||||
if (staff === undefined) throw new Error(`账号不存在:${account}`);
|
||||
await container.org.setPassword(staff.id, password);
|
||||
process.stdout.write(`已改密:${staff.name}(${account})\n`);
|
||||
} finally {
|
||||
await container.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const [account, password] = process.argv.slice(2);
|
||||
if (account === undefined || password === undefined) {
|
||||
process.stderr.write('用法:set-password.ts <账号> <新口令>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
await setPassword(account, password);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import type { TriggerService } from '@modules/trigger';
|
||||
|
||||
import { evaluate as evalSfeel, evaluateCondition, evaluateUnaryTest } from '@sfeel';
|
||||
import type { StaffId, TaskId } from '@contracts';
|
||||
import { staffIdSchema } from '@contracts';
|
||||
import { createAgentRunner } from '../agent-runner.ts';
|
||||
import type { Clock, IdGenerator } from '../runtime.ts';
|
||||
|
||||
@@ -656,6 +657,12 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
);
|
||||
return withLoad;
|
||||
},
|
||||
// 起单时按来源人取其工作组(ADR-011);没建组即 undefined,全走四策略
|
||||
workGroupBinding: async (staffId) => deps.org.bindingOf(staffIdSchema.parse(staffId)),
|
||||
isActive: async (staffId) => {
|
||||
const staff = await deps.org.getStaff(staffIdSchema.parse(staffId)).catch(() => undefined);
|
||||
return staff?.state === 'active';
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
start: async (input) =>
|
||||
@@ -732,7 +739,11 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
|
||||
process: {
|
||||
start: async (input) => process.start(input),
|
||||
requiredInputs: async (packageId) => {
|
||||
const pkg = await process.getPackage(packageId);
|
||||
// 与起单取版同口径:读**最新已发布**的调用接口 —— 草稿的 schema 不该影响装配判定
|
||||
const all = await process.listPackages();
|
||||
const pkg = all
|
||||
.filter((x) => x.id === packageId && x.state === 'published')
|
||||
.sort((a, b) => b.version - a.version)[0];
|
||||
const input = pkg?.callInterface.input as { required?: string[] } | undefined;
|
||||
return input?.required ?? [];
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user