基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-27 20:43:35 +08:00
co-authored by Claude Fable 5
parent 61fe181678
commit ac85ecf2ea
119 changed files with 16634 additions and 1246 deletions
+8
View File
@@ -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,
};
}
+13
View File
@@ -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,
+8 -1
View File
@@ -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 } };
+68
View File
@@ -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);
}
+147 -3
View File
@@ -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'));
+28
View File
@@ -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'));
+62
View File
@@ -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] }),
});
});
}
+38 -4
View File
@@ -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
View File
@@ -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)');
}
+33 -5
View File
@@ -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);
+39
View File
@@ -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 },
);
}
+31
View File
@@ -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);
}
+12 -1
View File
@@ -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 ?? [];
},