Archived
基线:鉴权会话+工作组+工作台三区修复+编排工作区+产品设计文档
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 ?? [];
|
||||
},
|
||||
|
||||
@@ -81,7 +81,16 @@ export function createChannelService(deps: ChannelServiceDeps): ChannelService {
|
||||
* 载荷带**原文**:信封只带原件指针够不够,取决于下游要不要读内容 ——
|
||||
* 归一步要拆解的正是这段文本,只给一个 .eml 指针它无从下手。
|
||||
*/
|
||||
payload: { data: { ...extracted, subject: mail.subject, body: mail.body, from: mail.from }, originRef },
|
||||
/*
|
||||
* 载荷带**原文**与**通道标识**:
|
||||
* 原文是归一步要拆解的对象;通道标识让触发器能按通道收口 ——
|
||||
* 不带它,规则就只能按主题匹配,一封信投到哪个信箱都照样自动起单,
|
||||
* 「换个信箱走人工确认」这种配置根本无从表达。
|
||||
*/
|
||||
payload: {
|
||||
data: { ...extracted, subject: mail.subject, body: mail.body, from: mail.from, channelId: config.id },
|
||||
originRef,
|
||||
},
|
||||
occurredAt: receivedAt.toISOString(),
|
||||
source: { kind: 'channel', detail: config.name },
|
||||
},
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('planConsumption —— 三级消费', () => {
|
||||
listenerHits: [{ taskId: 't1' }],
|
||||
triggerHits: ['tr1'],
|
||||
ownedTaskId: 't1',
|
||||
hasOwner: true,
|
||||
});
|
||||
expect(plan.taskTargets).toEqual(['t1']);
|
||||
expect(plan.triggerTargets).toEqual(['tr1']);
|
||||
@@ -34,24 +35,42 @@ describe('planConsumption —— 三级消费', () => {
|
||||
});
|
||||
|
||||
it('皆未命中且无主 → 消息兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined });
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: undefined, hasOwner: false });
|
||||
expect(plan.messageFallback).toBe(true);
|
||||
});
|
||||
|
||||
it('有主但零监听命中 → 仅记录,不兜底、不降级为无主', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7' });
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: [], ownedTaskId: 't7', hasOwner: true });
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBe('t7');
|
||||
expect(plan.taskTargets).toEqual([]);
|
||||
});
|
||||
|
||||
it('主不是任务也算有主 —— 引擎发的工作流事件不得被当成无主件塞进收件箱', () => {
|
||||
// 起单 / 解析负责人 / 起任务这些事件的 target 是 workflow,不是 task。
|
||||
// 只认 ownedTaskId 的话,每起一单都会给人推一条「无主入站待认领」。
|
||||
const plan = planConsumption({
|
||||
listenerHits: [],
|
||||
triggerHits: [],
|
||||
ownedTaskId: undefined,
|
||||
hasOwner: true,
|
||||
});
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('有主且监听命中 → 不是仅记录', () => {
|
||||
const plan = planConsumption({ listenerHits: [{ taskId: 't7' }], triggerHits: [], ownedTaskId: 't7' });
|
||||
const plan = planConsumption({
|
||||
listenerHits: [{ taskId: 't7' }],
|
||||
triggerHits: [],
|
||||
ownedTaskId: 't7',
|
||||
hasOwner: true,
|
||||
});
|
||||
expect(plan.recordOnlyTask).toBeUndefined();
|
||||
});
|
||||
|
||||
it('无主但触发命中 → 不兜底', () => {
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined });
|
||||
const plan = planConsumption({ listenerHits: [], triggerHits: ['tr1'], ownedTaskId: undefined, hasOwner: false });
|
||||
expect(plan.messageFallback).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,14 @@ export function planConsumption(input: {
|
||||
readonly listenerHits: ReadonlyArray<{ taskId: string }>;
|
||||
readonly triggerHits: readonly string[];
|
||||
readonly ownedTaskId: string | undefined;
|
||||
/**
|
||||
* 信封是否**有主** —— 关联里指了目标就算有主,不限于任务。
|
||||
*
|
||||
* 不能拿「有没有 ownedTaskId」当有主:引擎自己发的工作流事件
|
||||
* (`target: { kind: 'workflow' }`)照样是有主的,只是主不是任务。
|
||||
* 把它们判成无主,等于把每次起单都当成「没人认领的入站件」塞进别人收件箱。
|
||||
*/
|
||||
readonly hasOwner: boolean;
|
||||
}): ConsumptionPlan {
|
||||
const taskTargets = fanOutTargets(input.listenerHits);
|
||||
const triggerTargets = [...new Set(input.triggerHits)];
|
||||
@@ -65,8 +73,8 @@ export function planConsumption(input: {
|
||||
return {
|
||||
taskTargets,
|
||||
triggerTargets,
|
||||
// 有主但零监听命中 → 仅记录,不兜底
|
||||
messageFallback: nothingHit && input.ownedTaskId === undefined,
|
||||
// 有主但零监听命中 → 仅记录,不兜底;消息只兜**真正无主**的件(OS 6.3)
|
||||
messageFallback: nothingHit && !input.hasOwner,
|
||||
recordOnlyTask:
|
||||
input.ownedTaskId !== undefined && !taskTargets.includes(input.ownedTaskId)
|
||||
? input.ownedTaskId
|
||||
|
||||
@@ -46,7 +46,12 @@ export function createEventService(deps: EventServiceDeps): EventService {
|
||||
const ownedTaskId =
|
||||
envelope.correlation.target?.kind === 'task' ? String(envelope.correlation.target.id) : undefined;
|
||||
|
||||
const plan = planConsumption({ listenerHits: hits, triggerHits, ownedTaskId });
|
||||
const plan = planConsumption({
|
||||
listenerHits: hits,
|
||||
triggerHits,
|
||||
ownedTaskId,
|
||||
hasOwner: envelope.correlation.target !== undefined,
|
||||
});
|
||||
|
||||
// 扇出:每个命中目标一份独立投递,信封「关联 = 单目标」保持单目标
|
||||
const deliveries: Delivery[] = [
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
- 错误:`BareCallError` `ReplyRejectedError` `HumanCallBudgetExceededError`
|
||||
- 端口类型:`CardStore` `MessageOutbox` `MemoryRecorder` `HumanEventPublisher` `Clock` `IdGenerator`
|
||||
- 用例工厂:`createHumanToolService(deps): HumanToolService`
|
||||
(读侧两支:`openWorkOrders` 只给在途,`allWorkOrders` 含已办结 —— 工作成果要回看定稿报告)
|
||||
- 纯函数:`checkWorkOrder` `checkChatCard` `checkWorkOrderReply` `checkChatCardReply`
|
||||
`arrivalOf` `producesCompletion` `allCriteriaGreen`
|
||||
- adapter:`createCardStore`;Drizzle 表:`workOrderTable` `chatCardTable`
|
||||
|
||||
@@ -115,6 +115,8 @@ export type HumanToolService = {
|
||||
rejectAndReask(cardId: string, reason: string): Promise<IssuedChatCard>;
|
||||
|
||||
openWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
/** 全部工单(含已办结)—— 工作成果要回看定稿的报告,只给 open 就永远是空的 */
|
||||
allWorkOrders(taskId: TaskId): Promise<WorkOrder[]>;
|
||||
chatCards(taskId: TaskId): Promise<ChatCard[]>;
|
||||
/** org 停用前置检查要问的「待答卡」 */
|
||||
openCardsOf(staffId: StaffId): Promise<Array<{ cardId: string; title: string }>>;
|
||||
|
||||
@@ -243,6 +243,10 @@ export function createHumanToolService(deps: HumanToolServiceDeps): HumanToolSer
|
||||
return deps.cards.listWorkOrders(taskId, true);
|
||||
},
|
||||
|
||||
async allWorkOrders(taskId) {
|
||||
return deps.cards.listWorkOrders(taskId, false);
|
||||
},
|
||||
|
||||
async chatCards(taskId) {
|
||||
return deps.cards.listChatCards(taskId);
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- 端口类型:`InteractionStore` `HumanEventPublisher` `MemoryRecorder` `Audience` `Clock` `IdGenerator`
|
||||
- 用例工厂:`createInteractionService(deps): InteractionService`
|
||||
- 纯函数:`outputControl` `inputControl` `handoverHappens` `checkTemplate` `templateIsSafe`
|
||||
`projectChat`(任务记忆 → 对话消息的筛选投影)
|
||||
- adapter:`createInteractionStore`;Drizzle 表:`chatMessageTable` `taskViewTable`
|
||||
|
||||
层级: L3(编码规范 §4)
|
||||
@@ -24,4 +25,6 @@
|
||||
转交随引擎事件自动发生,活动不感知交接
|
||||
- **视图区无脚本执行权**:含 `<script>` / `on*` 事件处理器 / 外部资源的模版一律 `TemplateRejectedError`
|
||||
- **前端零过程状态**:对话流与视图都是服务端投影,刷新即重建;SSE 只推失效与内容,不推状态机
|
||||
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道
|
||||
- **时间线即任务记忆的可读投影**:自由输入同时写 `memory` 与抛人事件,无静默通道;
|
||||
AI 发言与系统事件**不另设写入口**,一律由 `projectChat` 从任务记忆筛出 ——
|
||||
对话流与工作日志同源不同筛选,引擎自转的条目不进对话区
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 对话流投影 —— 「对话区只承载非结构化消息 + 决策选择题」(AM 07)。
|
||||
* 这里守的是**筛选口径**:引擎自转不进对话,人的话不重影,工单卡带得上指涉。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { NarrativeEntry } from '../internal/domain/chat-projection.ts';
|
||||
import { projectChat } from '../internal/domain/chat-projection.ts';
|
||||
|
||||
const entry = (kind: string, over: Partial<NarrativeEntry> = {}): NarrativeEntry => ({
|
||||
id: `e-${kind}`,
|
||||
at: '2026-07-27T10:00:00.000Z',
|
||||
kind,
|
||||
title: `${kind} 标题`,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('projectChat —— 对话流与工作日志同源不同筛选', () => {
|
||||
it('引擎自转的条目一律不进对话区', () => {
|
||||
const noise = ['engine-transition', 'evaluation', 'action', 'thought', 'ledger-write', 'action-intent', 'card-reply', 'control'];
|
||||
expect(projectChat(noise.map((k) => entry(k)))).toEqual([]);
|
||||
});
|
||||
|
||||
it('人的自由输入不投影 —— say 已直接落成消息,再投一遍就是重影', () => {
|
||||
expect(projectChat([entry('human-input', { title: '按 A 方案办' })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('事件到达投成系统事件,类型认得就用、认不得按数据事件计', () => {
|
||||
const out = projectChat([
|
||||
entry('observation', { id: 'o1', detail: { kind: 'time' } }),
|
||||
entry('observation', { id: 'o2', detail: { kind: '莫名其妙' } }),
|
||||
entry('observation', { id: 'o3' }),
|
||||
]);
|
||||
expect(out.map((m) => (m.k === 'system' ? m.type : m.k))).toEqual(['time', 'data', 'data']);
|
||||
});
|
||||
|
||||
it('发工单投成 AI 发言并带上指涉 —— 卡在对话流上出现,点开即定位视图区', () => {
|
||||
const [msg] = projectChat([
|
||||
entry('card-sent', { title: '请你执行:补齐定价说明', detail: { kind: 'work-order', id: 'wo-1' } }),
|
||||
]);
|
||||
expect(msg?.k).toBe('ai');
|
||||
expect(msg?.k === 'ai' ? msg.ref : undefined).toBe('wo-1');
|
||||
expect(msg?.k === 'ai' ? msg.tag : undefined).toBe('guidance');
|
||||
});
|
||||
|
||||
it('验收件标判据、执行件标指导', () => {
|
||||
const out = projectChat([
|
||||
entry('card-sent', { id: 'a', title: '请你验收:定价方案', detail: { kind: 'work-order', id: 'wo-a' } }),
|
||||
entry('card-sent', { id: 'b', title: '请你执行:补齐说明', detail: { kind: 'work-order', id: 'wo-b' } }),
|
||||
]);
|
||||
expect(out.map((m) => (m.k === 'ai' ? m.tag : undefined))).toEqual(['criterion', 'guidance']);
|
||||
});
|
||||
|
||||
it('跨人卡不在本任务对话流上露面 —— 它进对方的消息要素', () => {
|
||||
expect(projectChat([entry('card-sent', { detail: { kind: 'chat-card', id: 'c-1' } })])).toEqual([]);
|
||||
});
|
||||
|
||||
it('放行裁决与终态投成阶段结论', () => {
|
||||
const out = projectChat([entry('release-verdict', { id: 'r' }), entry('terminal', { id: 't' })]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out.every((m) => m.k === 'ai' && m.tag === 'conclusion')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 对话流投影 —— 把任务记忆里**叙事性**的条目投成对话消息(纯函数,无 IO)。
|
||||
*
|
||||
* 「对话区只承载非结构化消息 + 决策选择题;事件由系统 Agent(灰头像)发进来」(AM 07)。
|
||||
* 对话流与工作日志**同源不同筛选**:日志要全,对话只要人读得懂的那几类 ——
|
||||
* 引擎自转(engine-transition / evaluation / action / ledger-write …)不进对话,
|
||||
* 否则对话区就成了机器踪迹的副本,失去「非结构化消息」的定位。
|
||||
*
|
||||
* 筛四类:
|
||||
* observation → 系统事件(灰头像)—— 三类唤醒事件里的外部到达
|
||||
* card-sent → AI 发言 + 指涉工单(卡在对话流上出现,点开即定位到视图区)
|
||||
* release-verdict→ AI 发言 · 阶段结论
|
||||
* terminal → AI 发言 · 阶段结论
|
||||
* human-input 不投:人的话已由 `say` 直接落成消息,再投一遍就是重影。
|
||||
*/
|
||||
import type { ChatMessage, EventType } from '@contracts';
|
||||
import { cardIdSchema, workOrderIdSchema } from '@contracts';
|
||||
|
||||
/** 记忆条目的只读切面 —— 投影只要这几样,不牵入 memory 的完整类型 */
|
||||
export type NarrativeEntry = {
|
||||
readonly id: string;
|
||||
readonly at: string;
|
||||
readonly kind: string;
|
||||
readonly title: string;
|
||||
readonly detail?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const EVENT_TYPES: readonly EventType[] = ['data', 'time', 'human', 'control'];
|
||||
|
||||
/** 事件到达条目的类型:detail.kind 认得就用它,认不得按数据事件计 */
|
||||
function eventTypeOf(detail: Record<string, unknown> | undefined): EventType {
|
||||
const kind = detail?.['kind'];
|
||||
return typeof kind === 'string' && (EVENT_TYPES as readonly string[]).includes(kind)
|
||||
? (kind as EventType)
|
||||
: 'data';
|
||||
}
|
||||
|
||||
/** 工单卡的指涉:card-sent 的 detail 里带着 `{kind:'work-order', id}` */
|
||||
function workOrderRef(detail: Record<string, unknown> | undefined): string | undefined {
|
||||
return detail?.['kind'] === 'work-order' && typeof detail['id'] === 'string' ? detail['id'] : undefined;
|
||||
}
|
||||
|
||||
export function projectChat(entries: readonly NarrativeEntry[]): ChatMessage[] {
|
||||
const out: ChatMessage[] = [];
|
||||
for (const e of entries) {
|
||||
const id = cardIdSchema.parse(e.id);
|
||||
if (e.kind === 'observation') {
|
||||
out.push({ k: 'system', id, at: e.at, type: eventTypeOf(e.detail), text: e.title });
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'card-sent') {
|
||||
const ref = workOrderRef(e.detail);
|
||||
// 跨人卡进对方的消息要素,不在本任务对话流上露面
|
||||
if (ref === undefined) continue;
|
||||
out.push({
|
||||
k: 'ai',
|
||||
id,
|
||||
at: e.at,
|
||||
text: e.title,
|
||||
// 验收件请人裁决 = 判据;执行件派工 = 指导
|
||||
tag: e.title.includes('验收') ? 'criterion' : 'guidance',
|
||||
ref: workOrderIdSchema.parse(ref),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'release-verdict' || e.kind === 'terminal') {
|
||||
out.push({ k: 'ai', id, at: e.at, text: e.title, tag: 'conclusion' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { TemplateRejectedError } from '../../contract.ts';
|
||||
import type { Audience, Clock, HumanEventPublisher, IdGenerator, InteractionStore, MemoryRecorder } from '../../port.ts';
|
||||
import type { InteractionPhase } from '../domain/control-handover.ts';
|
||||
import { checkTemplate, outputControl } from '../domain/control-handover.ts';
|
||||
import { projectChat } from '../domain/chat-projection.ts';
|
||||
|
||||
export type InteractionServiceDeps = {
|
||||
readonly store: InteractionStore;
|
||||
@@ -35,8 +36,16 @@ export function createInteractionService(deps: InteractionServiceDeps): Interact
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* 对话流 = 已落库的消息(人的话)+ 任务记忆的叙事投影(AI 发言 / 系统事件),按时间全序。
|
||||
* AI 与系统**不另设写入口** —— 同源不同筛选,对话区与工作日志永远说同一件事。
|
||||
*/
|
||||
async timeline(taskId) {
|
||||
return deps.store.listMessages(taskId);
|
||||
const [stored, entries] = await Promise.all([
|
||||
deps.store.listMessages(taskId),
|
||||
deps.memory.history(taskId),
|
||||
]);
|
||||
return [...stored, ...projectChat(entries)].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
|
||||
},
|
||||
|
||||
/** 负责人的自由输入 —— 人事件的第二形态;写入任务记忆,不驱动 flow 步 */
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import type { ChatMessage, StaffId, TaskId, ViewBlock } from '@contracts';
|
||||
import type { InteractionPhase } from './internal/domain/control-handover.ts';
|
||||
import type { NarrativeEntry } from './internal/domain/chat-projection.ts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
export type IdGenerator = { next(): string };
|
||||
@@ -24,6 +25,11 @@ export type HumanEventPublisher = {
|
||||
/** 留痕 —— app 注入 `memory`(L2):时间线即任务记忆的可读投影 */
|
||||
export type MemoryRecorder = {
|
||||
append(input: { taskId: TaskId; kind: 'human-input'; title: string; detail?: Record<string, unknown> }): Promise<void>;
|
||||
/**
|
||||
* 叙事条目 —— 对话流与工作日志**同源不同筛选**:
|
||||
* AI 发言与系统事件不另设写入口,一律从任务记忆投影而来(见 `chat-projection`)。
|
||||
*/
|
||||
history(taskId: TaskId): Promise<readonly NarrativeEntry[]>;
|
||||
};
|
||||
|
||||
/** 收件人解析 —— app 注入 `org`(L1):SSE 只推给有权看的人 */
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# module: org
|
||||
|
||||
职责: 员工与岗位:账号、岗位授权(客体三类 × 动作三种)、停用前置检查、会话装载、
|
||||
岗位在岗人员查询(负责人四策略解析的候选集来源)、种子数据播种
|
||||
职责: 员工与岗位:账号、口令与登录会话(ADR-010)、岗位授权(客体三类 × 动作三种)、
|
||||
停用前置检查、岗位在岗人员查询(负责人四策略解析的候选集来源)、
|
||||
工作组(ADR-011:起单时的岗位绑定)、种子数据播种
|
||||
不负责: 负责人解析算法本身(见 process)、任务与卡的在途判定(经 InFlightWorkProbe 端口由 app 注入)、
|
||||
鉴权中间件与 cookie 会话(app 层)
|
||||
鉴权中间件与 cookie 的读写(app 层:§4「中间件在 app 层,身份判定读 org 出口」)
|
||||
|
||||
公开出口: index.ts
|
||||
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` 与 @contracts 的 `Staff` / `Position` /
|
||||
`Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
|
||||
- 类型:`OrgService` `StaffWithPosition` `CreateStaffInput` `IssuedSession` 与 @contracts 的
|
||||
`Staff` / `Position` / `Grant` / `GrantObject` / `GrantAction` / `DisableCheck` / `Session` 转出
|
||||
- 错误:`StaffNotFoundError` `PositionNotFoundError` `AccountTakenError` `DisableBlockedError`
|
||||
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `InFlightWorkProbe`
|
||||
`Clock` `IdGenerator`
|
||||
- 端口类型:`PositionRepository` `StaffRepository` `GrantRepository` `SessionRepository`
|
||||
`WorkGroupRepository` `PasswordHasher` `InFlightWorkProbe` `Clock` `IdGenerator`
|
||||
- 用例工厂:`createOrgService(deps: OrgServiceDeps): OrgService`
|
||||
- 纯函数:`isAuthorized` `actionCovers` `objectCovers`
|
||||
- 常量:`POSITION_SEED` `STAFF_SEED` `DEFAULT_LOGIN_ACCOUNT`
|
||||
- adapter 工厂(供 app 装配):`createPositionRepository` `createStaffRepository`
|
||||
`createGrantRepository` `objectKeyOf`
|
||||
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable`
|
||||
`createGrantRepository` `createSessionRepository` `createWorkGroupRepository` `objectKeyOf`
|
||||
- 口令散列(供 app 装 `PasswordHasher`):`hashPassword` `verifyPassword` `newSessionToken`
|
||||
- Drizzle 表(供 app 迁移与 join):`positionTable` `staffTable` `grantTable` `sessionTable`
|
||||
`workGroupTable`
|
||||
|
||||
层级: L1(编码规范 §4)
|
||||
依赖: @contracts;@shared/db(Drizzle 句柄与 sys schema)
|
||||
@@ -28,6 +31,17 @@
|
||||
- 动作蕴含:治理 ⊃ 写 ⊃ 读;数据客体三级覆盖(域 ⊃ 对象 ⊃ 字段)
|
||||
- 停用是全有全无:前置检查未过即抛 `DisableBlockedError`,不做部分执行
|
||||
- `authorize` 对停用员工恒 false
|
||||
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动
|
||||
- `ensureSeeded` 幂等:按 code / account 判存在,已存在则跳过,不覆盖人工改动;
|
||||
唯一例外是**补种口令** —— 已存在但 `password_hash` 为空的员工会被补上初始口令,
|
||||
否则 ADR-010 之前建的账号永远登不进(这不算覆盖人工改动:空不是改动)
|
||||
- **登录失败不区分原因**:账号不存在 / 已停用 / 未设口令 / 口令错,一律 `undefined`;
|
||||
且无论账号是否存在都走一遍散列比对,免得响应时间泄漏账号是否存在
|
||||
- **明文永不落库**:口令只在 `login` / `setPassword` 的入参上停留一瞬,落库前即散列
|
||||
- **会话可即刻作废**:服务端表而非签名 cookie —— `logout` 立刻生效;
|
||||
`disableStaff` 连带 `revokeAllOf`,被停用的人拿旧 cookie 也办不了事
|
||||
- **工作组是绑定不是策略**(ADR-011):它给出岗位的人选,四策略于是无从发问;
|
||||
`bindingOf` 只出成员表快照,在岗与否由调用方(process)核 —— 岗位候选集核不了跨岗位的绑定
|
||||
|
||||
影响半径: index/contract/port 未变 → 对外零影响
|
||||
影响半径: contract 增 `login` / `logout` / `resolveSession` / `setPassword` 与 `IssuedSession`;
|
||||
port 增 `SessionRepository` / `PasswordHasher`,`StaffRepository` 增两个口令读写方法;
|
||||
下游只有 app(装配 + 鉴权中间件),depcruise 已反查确认
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 口令散列(ADR-010)—— 守的是「明文不可逆、比对不早退、坏串不抛错」。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hashPassword, newSessionToken, verifyPassword } from '../internal/adapter/password.ts';
|
||||
|
||||
describe('hashPassword / verifyPassword', () => {
|
||||
it('散列串自描述参数,且不含明文', async () => {
|
||||
const hash = await hashPassword('jcai-dev-2026');
|
||||
expect(hash.split('$')).toHaveLength(6);
|
||||
expect(hash.startsWith('scrypt$16384$8$1$')).toBe(true);
|
||||
expect(hash).not.toContain('jcai-dev-2026');
|
||||
});
|
||||
|
||||
it('同一口令两次散列不同 —— 盐是随机的', async () => {
|
||||
expect(await hashPassword('same')).not.toBe(await hashPassword('same'));
|
||||
});
|
||||
|
||||
it('对的口令过,错的不过', async () => {
|
||||
const hash = await hashPassword('correct horse');
|
||||
expect(await verifyPassword('correct horse', hash)).toBe(true);
|
||||
expect(await verifyPassword('correct hors', hash)).toBe(false);
|
||||
expect(await verifyPassword('', hash)).toBe(false);
|
||||
});
|
||||
|
||||
it('未设口令(null / undefined)一律不过,不抛错', async () => {
|
||||
// 「账号存在但没口令」不能变成一条能登进去的缝
|
||||
expect(await verifyPassword('anything', null)).toBe(false);
|
||||
expect(await verifyPassword('anything', undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('坏串一律不过,不抛错 —— 登录失败的原因不外泄', async () => {
|
||||
for (const bad of ['', 'plaintext', 'scrypt$x$8$1$aa$bb', 'bcrypt$16384$8$1$aa$bb', 'scrypt$16384$8$1$aa']) {
|
||||
expect(await verifyPassword('anything', bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('按记录自己的参数重算 —— 日后调参不必迁移旧记录', async () => {
|
||||
// 用一组非默认参数造串,再以默认参数的实现去验:能过才说明读的是串里的参数
|
||||
const legacy = 'scrypt$1024$8$1$c2FsdHNhbHQ$';
|
||||
expect(legacy.split('$')[1]).toBe('1024');
|
||||
// 空 hash 段应判假(下面单独覆盖),这里只确认解析不炸
|
||||
expect(await verifyPassword('x', legacy)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('newSessionToken', () => {
|
||||
it('不透明、够长、每次不同 —— 不编码任何身份信息', () => {
|
||||
const a = newSessionToken();
|
||||
const b = newSessionToken();
|
||||
expect(a).not.toBe(b);
|
||||
// 32 字节 base64url ≈ 43 字符
|
||||
expect(a.length).toBeGreaterThanOrEqual(43);
|
||||
expect(a).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* module: org —— 对外类型(编码规范 §10.1)。只有类型和常量,没有实现。
|
||||
*/
|
||||
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId } from '@contracts';
|
||||
import type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, StaffId, WorkGroup, WorkGroupBinding } from '@contracts';
|
||||
|
||||
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff };
|
||||
export type { DisableCheck, Grant, GrantAction, GrantObject, Position, Session, Staff, WorkGroup, WorkGroupBinding };
|
||||
|
||||
/** 员工连同其岗位的读模型 —— 治理台员工管理列表用 */
|
||||
export type StaffWithPosition = Staff & { readonly position: Position };
|
||||
@@ -43,6 +43,12 @@ export class DisableBlockedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录结果 —— token 交给 app 层种 cookie;失败一律 undefined,不区分原因 */
|
||||
export type IssuedSession = {
|
||||
readonly token: string;
|
||||
readonly expiresAt: string;
|
||||
};
|
||||
|
||||
export type OrgService = {
|
||||
listStaff(): Promise<{ staff: StaffWithPosition[]; positions: Position[] }>;
|
||||
getStaff(staffId: StaffId): Promise<StaffWithPosition>;
|
||||
@@ -57,6 +63,32 @@ export type OrgService = {
|
||||
/** 岗位权限校验 —— app 层鉴权中间件读它 */
|
||||
authorize(staffId: StaffId, object: GrantObject, action: GrantAction): Promise<boolean>;
|
||||
loadSession(account: string): Promise<Session | undefined>;
|
||||
|
||||
/* ── 登录会话(ADR-010)—— 中间件在 app 层,身份判定读这里 ───────────── */
|
||||
|
||||
/** 口令核验通过即签发会话;账号不存在 / 已停用 / 未设口令 / 口令错,一律 undefined */
|
||||
login(account: string, password: string): Promise<IssuedSession | undefined>;
|
||||
/** 登出:该 token 立刻作废(服务端表,不是签名 cookie) */
|
||||
logout(token: string): Promise<void>;
|
||||
/** 解析会话 —— 命中即滑动续期;过期或已作废返回 undefined */
|
||||
resolveSession(token: string): Promise<Session | undefined>;
|
||||
/** 设定口令 —— 明文只在此处停留一瞬,落库前即散列 */
|
||||
setPassword(staffId: StaffId, password: string): Promise<void>;
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────────── */
|
||||
|
||||
/** 建组 / 改组;id 留空即新建 */
|
||||
saveWorkGroup(input: {
|
||||
id?: string;
|
||||
name: string;
|
||||
ownerId: StaffId;
|
||||
members: Readonly<Record<string, StaffId>>;
|
||||
}): Promise<WorkGroup>;
|
||||
/** 某人的工作组 —— 起单时按来源人取,取不到即不装载(全走四策略) */
|
||||
workGroupOf(staffId: StaffId): Promise<WorkGroup | undefined>;
|
||||
/** 装进工作流的快照:改组不改已起的单 */
|
||||
bindingOf(staffId: StaffId): Promise<WorkGroupBinding | undefined>;
|
||||
|
||||
/** 岗位在岗人员 —— 负责人四策略解析的候选集来源(由 process 调用) */
|
||||
staffOfPosition(positionCode: string): Promise<StaffWithPosition[]>;
|
||||
ensureSeeded(): Promise<void>;
|
||||
|
||||
@@ -7,11 +7,14 @@ export type {
|
||||
Grant,
|
||||
GrantAction,
|
||||
GrantObject,
|
||||
IssuedSession,
|
||||
OrgService,
|
||||
Position,
|
||||
Session,
|
||||
Staff,
|
||||
StaffWithPosition,
|
||||
WorkGroup,
|
||||
WorkGroupBinding,
|
||||
} from './contract.ts';
|
||||
export {
|
||||
AccountTakenError,
|
||||
@@ -25,8 +28,11 @@ export type {
|
||||
GrantRepository,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PasswordHasher,
|
||||
PositionRepository,
|
||||
SessionRepository,
|
||||
StaffRepository,
|
||||
WorkGroupRepository,
|
||||
} from './port.ts';
|
||||
|
||||
export type { OrgServiceDeps } from './internal/usecase/org-service.ts';
|
||||
@@ -38,7 +44,10 @@ export { DEFAULT_LOGIN_ACCOUNT, POSITION_SEED, STAFF_SEED } from './internal/dom
|
||||
export {
|
||||
createGrantRepository,
|
||||
createPositionRepository,
|
||||
createSessionRepository,
|
||||
createStaffRepository,
|
||||
createWorkGroupRepository,
|
||||
objectKeyOf,
|
||||
} from './internal/adapter/org-repository.ts';
|
||||
export { grantTable, positionTable, staffTable } from './internal/adapter/org-schema.ts';
|
||||
export { hashPassword, newSessionToken, verifyPassword } from './internal/adapter/password.ts';
|
||||
export { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './internal/adapter/org-schema.ts';
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/**
|
||||
* org 的仓储 adapter —— Drizzle 只准出现在 adapter 与 app(编码规范 §5.3 / §11.4)。
|
||||
*/
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId } from '@contracts';
|
||||
import { grantSchema, positionIdSchema, positionSchema, staffSchema } from '@contracts';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import type { Grant, GrantObject, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
import { grantSchema, positionIdSchema, positionSchema, staffIdSchema, staffSchema, workGroupSchema } from '@contracts';
|
||||
import type { Db } from '@shared/db.ts';
|
||||
import type { GrantRepository, PositionRepository, StaffRepository } from '../../port.ts';
|
||||
import { grantTable, positionTable, staffTable } from './org-schema.ts';
|
||||
import type { GrantRepository, PositionRepository, SessionRepository, StaffRepository, WorkGroupRepository } from '../../port.ts';
|
||||
import { grantTable, positionTable, sessionTable, staffTable, workGroupTable } from './org-schema.ts';
|
||||
|
||||
/** 授权客体的规范化串 —— 主键的一部分,保证同一客体不重复授权 */
|
||||
export function objectKeyOf(object: GrantObject): string {
|
||||
@@ -93,6 +93,41 @@ export function createStaffRepository(db: Db): StaffRepository {
|
||||
async setState(id: StaffId, state) {
|
||||
await db.update(staffTable).set({ state }).where(eq(staffTable.id, id));
|
||||
},
|
||||
async passwordHashOf(account: string) {
|
||||
const rows = await db
|
||||
.select({ hash: staffTable.passwordHash })
|
||||
.from(staffTable)
|
||||
.where(eq(staffTable.account, account))
|
||||
.limit(1);
|
||||
return rows[0]?.hash ?? undefined;
|
||||
},
|
||||
async setPasswordHash(id: StaffId, hash: string) {
|
||||
await db.update(staffTable).set({ passwordHash: hash }).where(eq(staffTable.id, id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 会话仓储 —— 过期由 `expires_at` 判定,读时顺带滑动续期(ADR-010) */
|
||||
export function createSessionRepository(db: Db): SessionRepository {
|
||||
return {
|
||||
async issue({ token, staffId, expiresAt }) {
|
||||
await db.insert(sessionTable).values({ id: token, staffId, expiresAt });
|
||||
},
|
||||
async touch(token: string, now: Date, expiresAt: Date) {
|
||||
const rows = await db
|
||||
.update(sessionTable)
|
||||
.set({ lastSeenAt: now, expiresAt })
|
||||
.where(and(eq(sessionTable.id, token), gt(sessionTable.expiresAt, now)))
|
||||
.returning({ staffId: sessionTable.staffId });
|
||||
const row = rows[0];
|
||||
return row === undefined ? undefined : { staffId: staffIdSchema.parse(row.staffId) };
|
||||
},
|
||||
async revoke(token: string) {
|
||||
await db.delete(sessionTable).where(eq(sessionTable.id, token));
|
||||
},
|
||||
async revokeAllOf(staffId: StaffId) {
|
||||
await db.delete(sessionTable).where(eq(sessionTable.staffId, staffId));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,3 +165,28 @@ export function createGrantRepository(db: Db): GrantRepository {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 工作组仓储(ADR-011)—— 成员表整块存 jsonb,键是岗位 code */
|
||||
export function createWorkGroupRepository(db: Db): WorkGroupRepository {
|
||||
const toGroup = (row: typeof workGroupTable.$inferSelect): WorkGroup =>
|
||||
workGroupSchema.parse({ id: row.id, name: row.name, ownerId: row.ownerId, members: row.members });
|
||||
|
||||
return {
|
||||
async listByOwner(ownerId: StaffId) {
|
||||
const rows = await db.select().from(workGroupTable).where(eq(workGroupTable.ownerId, ownerId));
|
||||
return rows.map(toGroup);
|
||||
},
|
||||
async upsert(group: WorkGroup) {
|
||||
const [row] = await db
|
||||
.insert(workGroupTable)
|
||||
.values({ id: group.id, name: group.name, ownerId: group.ownerId, members: group.members })
|
||||
.onConflictDoUpdate({
|
||||
target: workGroupTable.id,
|
||||
set: { name: group.name, ownerId: group.ownerId, members: group.members },
|
||||
})
|
||||
.returning();
|
||||
if (row === undefined) throw new Error('工作组写入未返回行');
|
||||
return toGroup(row);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,11 +27,31 @@ export const staffTable = sys.table(
|
||||
.notNull()
|
||||
.references(() => positionTable.id),
|
||||
state: text('state').notNull().default('active'),
|
||||
/** 口令散列(ADR-010):`scrypt$N$r$p$salt$hash`,明文永不落库 */
|
||||
passwordHash: text('password_hash'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('staff_account_key').on(t.account), index('staff_position_idx').on(t.positionId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* 会话(ADR-010)—— 服务端表而非签名 cookie:**登出必须立刻失效**,
|
||||
* 且停用员工时要能一并踢掉在途会话。id 即 token,不透明随机串。
|
||||
*/
|
||||
export const sessionTable = sys.table(
|
||||
'session',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
staffId: text('staff_id')
|
||||
.notNull()
|
||||
.references(() => staffTable.id, { onDelete: 'cascade' }),
|
||||
issuedAt: timestamp('issued_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('session_staff_idx').on(t.staffId), index('session_expires_idx').on(t.expiresAt)],
|
||||
);
|
||||
|
||||
export const grantTable = sys.table(
|
||||
'position_grant',
|
||||
{
|
||||
@@ -46,3 +66,21 @@ export const grantTable = sys.table(
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.positionId, t.objectKey, t.action] })],
|
||||
);
|
||||
|
||||
/**
|
||||
* 工作组(ADR-011)—— 一组「岗位 code → 员工 id」的绑定。
|
||||
* 起单时按来源人取其组、快照进工作流;这里存的是**当前**成员表,可改。
|
||||
*/
|
||||
export const workGroupTable = sys.table(
|
||||
'work_group',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
ownerId: text('owner_id')
|
||||
.notNull()
|
||||
.references(() => staffTable.id, { onDelete: 'cascade' }),
|
||||
members: jsonb('members').notNull().default({}),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('work_group_owner_idx').on(t.ownerId)],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 口令散列(ADR-010)—— Node 内置 scrypt,零第三方依赖。
|
||||
*
|
||||
* 串格式自描述:`scrypt$N$r$p$<salt-b64url>$<hash-b64url>`。
|
||||
* 参数写进串里,日后调参不必迁移旧记录:比对时按**该记录自己的**参数重算。
|
||||
* 明文永不落库,比对走 `timingSafeEqual`(等长常数时间,不因前缀相同而早退)。
|
||||
*/
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/** 默认代价参数:N=16384 约 16MB 内存、单次 ~50ms —— 内网 ≤50 人足够 */
|
||||
const N = 16_384;
|
||||
const R = 8;
|
||||
const P = 1;
|
||||
const KEY_LEN = 32;
|
||||
const SALT_LEN = 16;
|
||||
|
||||
const b64 = (buf: Buffer) => buf.toString('base64url');
|
||||
|
||||
function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// maxmem 需随 N 抬高:默认 32MB 挡不住 N=16384(约需 128*N*r ≈ 16MB,留两倍余量)
|
||||
scrypt(password, salt, KEY_LEN, { N: n, r, p, maxmem: 256 * n * r }, (error, key) => {
|
||||
if (error !== null) reject(error);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(SALT_LEN);
|
||||
const hash = await derive(password, salt, N, R, P);
|
||||
return `scrypt$${N}$${R}$${P}$${b64(salt)}$${b64(hash)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比对。存的串不合格式 / 员工尚未设口令 → 一律 false,不抛错:
|
||||
* 登录失败的原因不外泄(账号不存在、未设口令、口令错,对外都是同一句话)。
|
||||
*/
|
||||
export async function verifyPassword(password: string, stored: string | null | undefined): Promise<boolean> {
|
||||
if (stored === null || stored === undefined) return false;
|
||||
const parts = stored.split('$');
|
||||
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
||||
|
||||
const [, nRaw, rRaw, pRaw, saltRaw, hashRaw] = parts;
|
||||
const n = Number(nRaw);
|
||||
const r = Number(rRaw);
|
||||
const p = Number(pRaw);
|
||||
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
|
||||
|
||||
const expected = Buffer.from(hashRaw ?? '', 'base64url');
|
||||
if (expected.length === 0) return false;
|
||||
|
||||
const actual = await derive(password, Buffer.from(saltRaw ?? '', 'base64url'), n, r, p).catch(() => undefined);
|
||||
if (actual === undefined || actual.length !== expected.length) return false;
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
/** 会话 token —— 32 字节密码学随机,不透明(不编码任何身份信息) */
|
||||
export function newSessionToken(): string {
|
||||
return b64(randomBytes(32));
|
||||
}
|
||||
@@ -9,6 +9,12 @@ export const POSITION_SEED = [
|
||||
{ code: 'FAE', name: '现场应用', note: '替代选型' },
|
||||
{ code: 'PRM', name: '采购/寻源', note: '渠道寻源' },
|
||||
{ code: 'OP', name: '运营', note: '运营支持' },
|
||||
/*
|
||||
* 测试岗(ADR-011):测试账号单独占一个岗位,**不混进五个业务岗**。
|
||||
* 混进去会让轮值把真单派到测试账号头上;单列则彼此不打扰 ——
|
||||
* 测试账号靠**工作组绑定**接管整条流的每个岗位,不靠自己占岗。
|
||||
*/
|
||||
{ code: 'TEST', name: '测试', note: '测试账号专用岗;整条流经工作组绑定落到本人' },
|
||||
] as const;
|
||||
|
||||
export const STAFF_SEED = [
|
||||
@@ -17,6 +23,9 @@ export const STAFF_SEED = [
|
||||
{ account: 'wangziming', name: '王梓名', position: 'FAE' },
|
||||
{ account: 'linpeixuan', name: '林佩萱', position: 'PRM' },
|
||||
{ account: 'liyuanchu', name: '李元初', position: 'OP' },
|
||||
// 两个测试账号:各自一个「全岗位指向自己」的工作组,起单即整条流归自己
|
||||
{ account: 'AI', name: 'AI', position: 'TEST' },
|
||||
{ account: 'Chaihao', name: '柴浩', position: 'TEST' },
|
||||
] as const;
|
||||
|
||||
/** 登录者 = 冯兵(BD);MVP 单组织 cookie session(编码规范 §4 鉴权定调) */
|
||||
@@ -30,6 +39,18 @@ export type StaffSeed = (typeof STAFF_SEED)[number];
|
||||
* 任务负责人与被征答人对所涉内容**天然可见**,不靠这里授权(OS 02)。
|
||||
*/
|
||||
export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: string; action: 'read' | 'write' | 'govern' }>>> = {
|
||||
/** 测试岗与 PM 同权:一个人要走完整条流,还得在治理台看过程 */
|
||||
TEST: [
|
||||
{ fn: 'task', action: 'govern' },
|
||||
{ fn: 'staff', action: 'govern' },
|
||||
{ fn: 'process', action: 'govern' },
|
||||
{ fn: 'trigger', action: 'govern' },
|
||||
{ fn: 'activity', action: 'govern' },
|
||||
{ fn: 'tool', action: 'govern' },
|
||||
{ fn: 'data', action: 'govern' },
|
||||
{ fn: 'memory', action: 'govern' },
|
||||
{ fn: 'system', action: 'govern' },
|
||||
],
|
||||
PM: [
|
||||
{ fn: 'task', action: 'govern' },
|
||||
{ fn: 'staff', action: 'govern' },
|
||||
@@ -59,3 +80,12 @@ export const POSITION_GRANT_SEED: Readonly<Record<string, ReadonlyArray<{ fn: st
|
||||
{ fn: 'memory', action: 'read' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试账号的**独立工作组**(ADR-011)—— 所有岗位都指向本人。
|
||||
* 起单即整条流(A1…A7 + 审批点)全部落到自己工位,一个人从头走到尾。
|
||||
*/
|
||||
export const SOLO_GROUP_ACCOUNTS = ['AI', 'Chaihao'] as const;
|
||||
|
||||
/** 组要覆盖的岗位 = 业务五岗(测试岗自身不出现在流程包里) */
|
||||
export const SOLO_GROUP_POSITIONS = ['PM', 'BD', 'FAE', 'PRM', 'OP'] as const;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Grant, GrantAction, GrantObject, Position, PositionId, Session, St
|
||||
import { governanceFunctionSchema, positionIdSchema, staffIdSchema } from '@contracts';
|
||||
import type {
|
||||
CreateStaffInput,
|
||||
IssuedSession,
|
||||
OrgService,
|
||||
StaffWithPosition,
|
||||
} from '../../contract.ts';
|
||||
@@ -15,24 +16,37 @@ import {
|
||||
StaffNotFoundError,
|
||||
} from '../../contract.ts';
|
||||
import type {
|
||||
Clock,
|
||||
GrantRepository,
|
||||
IdGenerator,
|
||||
InFlightWorkProbe,
|
||||
PasswordHasher,
|
||||
PositionRepository,
|
||||
SessionRepository,
|
||||
StaffRepository,
|
||||
WorkGroupRepository,
|
||||
} from '../../port.ts';
|
||||
import { isAuthorized } from '../domain/authorization.ts';
|
||||
import { buildDisableCheck } from '../domain/disable-check.ts';
|
||||
import { POSITION_GRANT_SEED, POSITION_SEED, STAFF_SEED } from '../domain/seed-data.ts';
|
||||
import { POSITION_GRANT_SEED, POSITION_SEED, SOLO_GROUP_ACCOUNTS, SOLO_GROUP_POSITIONS, STAFF_SEED } from '../domain/seed-data.ts';
|
||||
|
||||
export type OrgServiceDeps = {
|
||||
readonly positions: PositionRepository;
|
||||
readonly staff: StaffRepository;
|
||||
readonly grants: GrantRepository;
|
||||
readonly sessions: SessionRepository;
|
||||
readonly workGroups: WorkGroupRepository;
|
||||
readonly passwords: PasswordHasher;
|
||||
readonly inFlight: InFlightWorkProbe;
|
||||
readonly clock: Clock;
|
||||
readonly ids: IdGenerator;
|
||||
/** 种子口令 —— 由 app 从环境读入,源码不写任何真实口令(ADR-010) */
|
||||
readonly seedPassword: string;
|
||||
};
|
||||
|
||||
/** 会话有效期 12 小时,每次命中滑动续期 */
|
||||
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
async function positionMap(): Promise<Map<string, Position>> {
|
||||
const all = await deps.positions.list();
|
||||
@@ -78,13 +92,16 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
const positionId = positionIdSchema.parse(input.positionId);
|
||||
const position = await deps.positions.findById(positionId);
|
||||
if (!position) throw new PositionNotFoundError(input.positionId);
|
||||
return deps.staff.insert({
|
||||
const created = await deps.staff.insert({
|
||||
id: staffIdSchema.parse(deps.ids.next()),
|
||||
account: input.account,
|
||||
name: input.name,
|
||||
positionId,
|
||||
state: 'active',
|
||||
});
|
||||
// 新员工带初始口令建档 —— 不留「有账号但登不进」的半成品
|
||||
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
|
||||
return created;
|
||||
},
|
||||
|
||||
async checkDisable(staffId) {
|
||||
@@ -102,6 +119,8 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
const check = await this.checkDisable(staffId);
|
||||
if (check.blocked) throw new DisableBlockedError(check);
|
||||
await deps.staff.setState(staffId, 'disabled');
|
||||
// 停用即踢会话:否则被停用的人凭旧 cookie 还能继续办事(ADR-010)
|
||||
await deps.sessions.revokeAllOf(staffId);
|
||||
},
|
||||
|
||||
async listGrants(positionId) {
|
||||
@@ -128,6 +147,73 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
return { staff, position, grants };
|
||||
},
|
||||
|
||||
/**
|
||||
* 登录 —— 失败一律 undefined,**不区分原因**:
|
||||
* 账号不存在 / 已停用 / 未设口令 / 口令错,对外都是同一句话,不外泄账号是否存在。
|
||||
*/
|
||||
async login(account, password): Promise<IssuedSession | undefined> {
|
||||
const staff = await deps.staff.findByAccount(account);
|
||||
const stored = await deps.staff.passwordHashOf(account);
|
||||
// 无论账号是否存在都走一遍散列比对:否则响应时间会泄漏账号是否存在
|
||||
const ok = await deps.passwords.verify(password, stored);
|
||||
if (!ok || staff === undefined || staff.state !== 'active') return undefined;
|
||||
|
||||
const token = deps.passwords.newToken();
|
||||
const expiresAt = new Date(deps.clock.now().getTime() + SESSION_TTL_MS);
|
||||
await deps.sessions.issue({ token, staffId: staff.id, expiresAt });
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
},
|
||||
|
||||
async logout(token) {
|
||||
await deps.sessions.revoke(token);
|
||||
},
|
||||
|
||||
async resolveSession(token): Promise<Session | undefined> {
|
||||
const now = deps.clock.now();
|
||||
const hit = await deps.sessions.touch(token, now, new Date(now.getTime() + SESSION_TTL_MS));
|
||||
if (hit === undefined) return undefined;
|
||||
const staff = await deps.staff.findById(hit.staffId);
|
||||
// 会话还在但人已停用 —— 立刻作废,不给旧 cookie 留缝
|
||||
if (staff === undefined || staff.state !== 'active') {
|
||||
await deps.sessions.revoke(token);
|
||||
return undefined;
|
||||
}
|
||||
const position = await deps.positions.findById(staff.positionId);
|
||||
if (!position) throw new PositionNotFoundError(staff.positionId);
|
||||
const grants = await deps.grants.listByPosition(staff.positionId);
|
||||
return { staff, position, grants };
|
||||
},
|
||||
|
||||
async setPassword(staffId, password) {
|
||||
const staff = await deps.staff.findById(staffId);
|
||||
if (!staff) throw new StaffNotFoundError(staffId);
|
||||
await deps.staff.setPasswordHash(staffId, await deps.passwords.hash(password));
|
||||
},
|
||||
|
||||
/* ── 工作组(ADR-011)—— 起单时的岗位绑定,不是第五种策略 ────────── */
|
||||
|
||||
async saveWorkGroup(input) {
|
||||
const staff = await deps.staff.findById(input.ownerId);
|
||||
if (!staff) throw new StaffNotFoundError(input.ownerId);
|
||||
return deps.workGroups.upsert({
|
||||
id: input.id ?? deps.ids.next(),
|
||||
name: input.name,
|
||||
ownerId: input.ownerId,
|
||||
members: { ...input.members },
|
||||
});
|
||||
},
|
||||
|
||||
async workGroupOf(staffId) {
|
||||
// 一人一组:多组时取稳定的第一条(选组这一步 MVP 不做,见 debt)
|
||||
const groups = await deps.workGroups.listByOwner(staffId);
|
||||
return [...groups].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))[0];
|
||||
},
|
||||
|
||||
async bindingOf(staffId) {
|
||||
const group = await this.workGroupOf(staffId);
|
||||
return group === undefined ? undefined : { ...group.members };
|
||||
},
|
||||
|
||||
async staffOfPosition(positionCode) {
|
||||
const position = await deps.positions.findByCode(positionCode);
|
||||
if (!position) throw new PositionNotFoundError(positionCode);
|
||||
@@ -152,16 +238,39 @@ export function createOrgService(deps: OrgServiceDeps): OrgService {
|
||||
|
||||
for (const seed of STAFF_SEED) {
|
||||
const existing = await deps.staff.findByAccount(seed.account);
|
||||
if (existing) continue;
|
||||
if (existing) {
|
||||
// 补种口令:ADR-010 之前建的员工没有 password_hash,不补就永远登不进
|
||||
const hash = await deps.staff.passwordHashOf(seed.account);
|
||||
if (hash === undefined) {
|
||||
await deps.staff.setPasswordHash(existing.id, await deps.passwords.hash(deps.seedPassword));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const positionId = byCode.get(seed.position);
|
||||
if (!positionId) throw new PositionNotFoundError(seed.position);
|
||||
await deps.staff.insert({
|
||||
const created = await deps.staff.insert({
|
||||
id: staffIdSchema.parse(deps.ids.next()),
|
||||
account: seed.account,
|
||||
name: seed.name,
|
||||
positionId,
|
||||
state: 'active',
|
||||
});
|
||||
// 新员工一律带初始口令入库 —— 没有「无口令即可登录」的窗口
|
||||
await deps.staff.setPasswordHash(created.id, await deps.passwords.hash(deps.seedPassword));
|
||||
}
|
||||
|
||||
// 测试账号的独立工作组(ADR-011):所有业务岗都指向本人,起单即整条流归自己
|
||||
for (const account of SOLO_GROUP_ACCOUNTS) {
|
||||
const staff = await deps.staff.findByAccount(account);
|
||||
if (staff === undefined) continue;
|
||||
const existing = await deps.workGroups.listByOwner(staff.id);
|
||||
if (existing.length > 0) continue;
|
||||
await deps.workGroups.upsert({
|
||||
id: `wg-solo-${account.toLowerCase()}`,
|
||||
name: `${staff.name} 独立测试组`,
|
||||
ownerId: staff.id,
|
||||
members: Object.fromEntries(SOLO_GROUP_POSITIONS.map((code) => [code, staff.id])),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [code, entries] of Object.entries(POSITION_GRANT_SEED)) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* module: org —— 本模块要外界提供的能力(编码规范 §10.1 / §5.3)。
|
||||
* adapter 实现之,usecase 依赖之,app 装配之。
|
||||
*/
|
||||
import type { Grant, Position, PositionId, Staff, StaffId } from '@contracts';
|
||||
import type { Grant, Position, PositionId, Staff, StaffId, WorkGroup } from '@contracts';
|
||||
|
||||
export type Clock = { now(): Date };
|
||||
|
||||
@@ -20,6 +20,28 @@ export type StaffRepository = {
|
||||
listByPosition(positionId: PositionId): Promise<Staff[]>;
|
||||
insert(staff: Staff): Promise<Staff>;
|
||||
setState(id: StaffId, state: Staff['state']): Promise<void>;
|
||||
/** 口令散列的读写 —— 明文不经此端口(ADR-010) */
|
||||
passwordHashOf(account: string): Promise<string | undefined>;
|
||||
setPasswordHash(id: StaffId, hash: string): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 会话仓储(ADR-010)—— 服务端表:登出即刻作废,停用员工连带清场。
|
||||
* token 即主键,不透明随机串,不编码任何身份信息。
|
||||
*/
|
||||
export type SessionRepository = {
|
||||
issue(input: { token: string; staffId: StaffId; expiresAt: Date }): Promise<void>;
|
||||
/** 未过期才返回;命中同时滑动续期 */
|
||||
touch(token: string, now: Date, expiresAt: Date): Promise<{ staffId: StaffId } | undefined>;
|
||||
revoke(token: string): Promise<void>;
|
||||
revokeAllOf(staffId: StaffId): Promise<void>;
|
||||
};
|
||||
|
||||
/** 口令散列器 —— 算法实现在 adapter,usecase 只认这两个动作 */
|
||||
export type PasswordHasher = {
|
||||
hash(password: string): Promise<string>;
|
||||
verify(password: string, stored: string | undefined): Promise<boolean>;
|
||||
newToken(): string;
|
||||
};
|
||||
|
||||
export type GrantRepository = {
|
||||
@@ -37,5 +59,11 @@ export type InFlightWorkProbe = {
|
||||
openApprovalsOf(staffId: StaffId): Promise<Array<{ approvalId: string; title: string }>>;
|
||||
};
|
||||
|
||||
/** 工作组仓储(ADR-011) */
|
||||
export type WorkGroupRepository = {
|
||||
listByOwner(ownerId: StaffId): Promise<WorkGroup[]>;
|
||||
upsert(group: WorkGroup): Promise<WorkGroup>;
|
||||
};
|
||||
|
||||
/** ID 生成 —— domain 零随机(§5.3) */
|
||||
export type IdGenerator = { next(): string };
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
`Sfeel` `FlowEventPublisher` `ReceiptNotifier`
|
||||
- 用例工厂:`createProcessService(deps): ProcessService`
|
||||
- 纯函数:`checkClosure` `publishable` `resolveOwner` `aggregateWorkflowState`
|
||||
(`resolveOwner` 的 `bound` 入参 = 已校验的工作组绑定,ADR-011)
|
||||
`isDigested` `nextInstanceNumber` `selectEdges` `assemble` `joinReady`
|
||||
`rejectRouteOf` `dispatchOf` `startNodeOf`
|
||||
- adapter:`createProcessStore`;Drizzle 表:`processPackageTable` `workflowTable` `nodeInstanceTable`
|
||||
|
||||
@@ -69,7 +69,7 @@ function harness(validationErrors: readonly string[]): Harness {
|
||||
findInstanceByTask: async () => undefined,
|
||||
},
|
||||
activities: { faces: async () => new Map() },
|
||||
org: { positions: async () => new Set<string>(), candidates: async () => [] },
|
||||
org: { positions: async () => new Set<string>(), candidates: async () => [], workGroupBinding: async () => undefined, isActive: async () => true },
|
||||
tasks: { start: async () => ({ id: 'task-1' }) },
|
||||
approvals: { raise: async () => ({ id: 'apr-1' }) },
|
||||
sfeel: { condition: () => true, expression: () => undefined },
|
||||
|
||||
@@ -30,6 +30,8 @@ export const workflowTable = sys.table(
|
||||
/** 凡单必有来源 */
|
||||
source: jsonb('source').notNull(),
|
||||
initiation: jsonb('initiation').notNull(),
|
||||
/** 工作组绑定快照(ADR-011)—— 起单时装载,之后改组不影响这条单 */
|
||||
workGroup: jsonb('work_group'),
|
||||
ports: jsonb('ports').notNull().default({}),
|
||||
vars: jsonb('vars').notNull().default({}),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -20,6 +20,7 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord {
|
||||
state: row.state as WorkflowState,
|
||||
source: row.source as WorkflowRecord['source'],
|
||||
initiation: row.initiation as Record<string, unknown>,
|
||||
...(row.workGroup === null ? {} : { workGroup: row.workGroup as Record<string, string> }),
|
||||
ports: row.ports as Record<string, Record<string, unknown>>,
|
||||
vars: row.vars as Record<string, unknown>,
|
||||
startedAt: row.startedAt.toISOString(),
|
||||
@@ -80,6 +81,7 @@ export function createProcessStore(db: Db): ProcessStore {
|
||||
state: record.state,
|
||||
source: record.source,
|
||||
initiation: record.initiation,
|
||||
workGroup: record.workGroup ?? null,
|
||||
ports: record.ports,
|
||||
vars: record.vars,
|
||||
startedAt: new Date(record.startedAt),
|
||||
|
||||
@@ -16,20 +16,47 @@ export type ResolveContext = {
|
||||
readonly candidates: readonly Candidate[];
|
||||
/** 来源跟随:同一工作流内该岗位已解析出的人 */
|
||||
readonly alreadyResolved?: string | undefined;
|
||||
/**
|
||||
* 工作组绑定(ADR-011):该岗位绑定到的人,**调用方已确认其在岗**。
|
||||
*
|
||||
* 为什么不在这里核:`candidates` 是**该岗位**的在岗人员,
|
||||
* 而工作组的意义恰恰是跨岗位指派 —— 拿岗位候选集去核绑定,绑定永远核不过。
|
||||
* 在岗与否是组织事实,归调用方向 org 问;域侧只认已校验的结果。
|
||||
*/
|
||||
readonly bound?: string | undefined;
|
||||
};
|
||||
|
||||
export type Resolution =
|
||||
| { readonly kind: 'resolved'; readonly staffId: string; readonly by: OwnerSpec['strategy']; readonly rationale: string }
|
||||
| {
|
||||
readonly kind: 'resolved';
|
||||
readonly staffId: string;
|
||||
/** 'work-group' 是**绑定**,不是第五种策略 —— 留痕里一眼可辨 */
|
||||
readonly by: OwnerSpec['strategy'] | 'work-group';
|
||||
readonly rationale: string;
|
||||
}
|
||||
/** 岗位空缺 —— 按节点的失败走向处置,或转治理台待指派 */
|
||||
| { readonly kind: 'vacant'; readonly position: string };
|
||||
|
||||
/**
|
||||
* 「编排声明岗位,起单时**确定性解析**。策略封闭四选;
|
||||
* 默认:岗位唯一人;岗上多人时按轮值。解析发生在节点起单时,**结果与依据留痕**。」
|
||||
*
|
||||
* **绑定先于策略**(ADR-011):四策略回答的是「这个岗位该派给谁」,
|
||||
* 工作组已经给出了答案,策略于是无从发问。绑定的人若已离岗则不硬用,退回策略 ——
|
||||
* 绑定不该把单派给一个不在岗的人。
|
||||
*/
|
||||
export function resolveOwner(spec: OwnerSpec, context: ResolveContext): Resolution {
|
||||
const active = context.candidates;
|
||||
|
||||
if (context.bound !== undefined) {
|
||||
return {
|
||||
kind: 'resolved',
|
||||
staffId: context.bound,
|
||||
by: 'work-group',
|
||||
rationale: `工作组绑定:${spec.position}`,
|
||||
};
|
||||
}
|
||||
|
||||
switch (spec.strategy) {
|
||||
case 'assigned': {
|
||||
const staffId = spec.staffId;
|
||||
|
||||
@@ -46,6 +46,20 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
return pkg;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起单取版:未指定版本时取**最新已发布** —— 草稿不入选。
|
||||
* 若按「最新版本」取,治理台一存草稿,起单立刻被没发布的定义顶住;
|
||||
* 草稿是编排中的半成品,运行时永远不该看见它。
|
||||
*/
|
||||
async function latestPublished(id: string): Promise<ProcessPackage> {
|
||||
const all = await deps.store.listPackages();
|
||||
const hit = all
|
||||
.filter((pkg) => pkg.id === id && pkg.state === 'published')
|
||||
.sort((a, b) => b.version - a.version)[0];
|
||||
if (hit === undefined) throw new Error(`流程包无已发布版本,不得起单:${id}`);
|
||||
return hit;
|
||||
}
|
||||
|
||||
async function requireWorkflow(id: string): Promise<WorkflowRecord> {
|
||||
const record = await deps.store.getWorkflow(id);
|
||||
if (record === undefined) throw new Error(`工作流不存在:${id}`);
|
||||
@@ -104,7 +118,13 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
// 负责人 / 审批人:编排声明岗位,起单时确定性解析;结果与依据留痕
|
||||
const spec = dispatch.kind === 'start-task' ? dispatch.node.owner : dispatch.node.approver;
|
||||
const candidates = await deps.org.candidates(spec.position);
|
||||
const resolution = resolveOwner(spec, { candidates });
|
||||
/*
|
||||
* 绑定先于策略:工作组说了这个岗位是谁,策略就无从发问(ADR-011)。
|
||||
* 在岗与否在这里核 —— 绑定是**跨岗位**指派,拿该岗位的候选集核不了它。
|
||||
*/
|
||||
const boundTo = record.workGroup?.[spec.position];
|
||||
const bound = boundTo !== undefined && (await deps.org.isActive(boundTo)) ? boundTo : undefined;
|
||||
const resolution = resolveOwner(spec, { candidates, ...(bound === undefined ? {} : { bound }) });
|
||||
if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position);
|
||||
|
||||
await deps.events.publish({
|
||||
@@ -246,7 +266,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
|
||||
/** 起单:经调用接口调起流程包,生成一条工作流 */
|
||||
async start(input: StartWorkflowInput) {
|
||||
const pkg = await requirePackage(input.packageId, input.packageVersion);
|
||||
const pkg =
|
||||
input.packageVersion === undefined
|
||||
? await latestPublished(input.packageId)
|
||||
: await requirePackage(input.packageId, input.packageVersion);
|
||||
if (pkg.state !== 'published') throw new Error(`流程包未发布,不得起单:${pkg.id}@${pkg.version}`);
|
||||
|
||||
// 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。
|
||||
@@ -254,6 +277,10 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
const check = deps.schemas.validate(pkg.callInterface.input, input.data);
|
||||
if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors);
|
||||
|
||||
// 工作组按**来源人**装载(ADR-011):无来源人、或此人没建组 → 不装载,全走四策略
|
||||
const binding =
|
||||
input.source.by === undefined ? undefined : await deps.org.workGroupBinding(input.source.by);
|
||||
|
||||
const record: WorkflowRecord = {
|
||||
id: deps.ids.next(),
|
||||
packageId: pkg.id,
|
||||
@@ -265,6 +292,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
|
||||
ports: {},
|
||||
vars: {},
|
||||
startedAt: deps.clock.now().toISOString(),
|
||||
...(binding === undefined ? {} : { workGroup: { ...binding } }),
|
||||
};
|
||||
await deps.store.insertWorkflow(record);
|
||||
await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } });
|
||||
|
||||
@@ -18,6 +18,11 @@ export type WorkflowRecord = {
|
||||
/** 来源:凡单必有来源(触发器 / 登记人 / 上游系统) */
|
||||
readonly source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string };
|
||||
readonly initiation: Record<string, unknown>;
|
||||
/**
|
||||
* 工作组绑定的**快照**(ADR-011):起单时按来源人装载,之后改组不影响这条单。
|
||||
* 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。
|
||||
*/
|
||||
readonly workGroup?: Record<string, string>;
|
||||
/** 各节点输出口载荷 —— 边映射装配的来源 */
|
||||
readonly ports: Record<string, Record<string, unknown>>;
|
||||
readonly vars: Record<string, unknown>;
|
||||
@@ -56,6 +61,10 @@ export type ActivityDirectory = {
|
||||
export type OrgDirectory = {
|
||||
positions(): Promise<Set<string>>;
|
||||
candidates(position: string): Promise<Array<{ staffId: string; name: string; openTasks: number }>>;
|
||||
/** 某人的工作组绑定(ADR-011)—— 起单时按来源人取,取不到即不装载 */
|
||||
workGroupBinding(staffId: string): Promise<Readonly<Record<string, string>> | undefined>;
|
||||
/** 在岗与否 —— 绑定的人已离岗就不硬用,退回四策略 */
|
||||
isActive(staffId: string): Promise<boolean>;
|
||||
};
|
||||
|
||||
/** 起单与终态 —— app 注入 `task`(L6) */
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 触发器输入装配(OS 3.1)—— 三类触发的入参从哪来,这里是分界线。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { TriggerConfig } from '@contracts';
|
||||
import { triggerConfigSchema } from '@contracts';
|
||||
import { assembleInput } from '../internal/domain/matching.ts';
|
||||
|
||||
const noExpr = () => undefined;
|
||||
|
||||
const registration = (mapping: TriggerConfig['mapping'] = []): TriggerConfig =>
|
||||
triggerConfigSchema.parse({
|
||||
id: 'trg-reg',
|
||||
name: '人工登记起单',
|
||||
processPackageId: 'unified-quote',
|
||||
enabled: true,
|
||||
spec: { kind: 'registration' },
|
||||
mapping,
|
||||
});
|
||||
|
||||
const event = (mapping: TriggerConfig['mapping'] = []): TriggerConfig =>
|
||||
triggerConfigSchema.parse({
|
||||
id: 'trg-evt',
|
||||
name: '邮件到达起单',
|
||||
processPackageId: 'unified-quote',
|
||||
enabled: true,
|
||||
spec: { kind: 'event', eventType: 'data', rule: 'true' },
|
||||
mapping,
|
||||
});
|
||||
|
||||
describe('assembleInput', () => {
|
||||
it('登记触发:表单填的就是入参 —— 不声明 mapping 也能起单', () => {
|
||||
// 登记表单由调用接口 schema 渲染,填出来的已经是合契约的形状,不必再装配
|
||||
const r = assembleInput(registration(), { customerCode: 'C001', rfqRef: 'RFQ-1' }, ['customerCode', 'rfqRef'], noExpr);
|
||||
expect(r).toEqual({ kind: 'ready', data: { customerCode: 'C001', rfqRef: 'RFQ-1' } });
|
||||
});
|
||||
|
||||
it('登记触发:声明了 mapping 就覆盖在表单之上', () => {
|
||||
const r = assembleInput(
|
||||
registration([{ target: 'customerCode', source: { from: 'constant', value: 'C999' } }]),
|
||||
{ customerCode: 'C001', rfqRef: 'RFQ-1' },
|
||||
['customerCode'],
|
||||
noExpr,
|
||||
);
|
||||
expect(r.kind === 'ready' && r.data['customerCode']).toBe('C999');
|
||||
});
|
||||
|
||||
it('事件触发:scope 不打底 —— 事件载荷与调用接口是两套字段名', () => {
|
||||
// 不声明 mapping 就装配不出必填字段,应入死信而不是把事件载荷整个塞给流程包
|
||||
const r = assembleInput(event(), { subject: '询价', customerCode: 'C001' }, ['customerCode'], noExpr);
|
||||
expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] });
|
||||
});
|
||||
|
||||
it('事件触发:mapping 声明了才装配得出来', () => {
|
||||
const r = assembleInput(
|
||||
event([{ target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }]),
|
||||
{ customerCode: 'C001' },
|
||||
['customerCode'],
|
||||
noExpr,
|
||||
);
|
||||
expect(r).toEqual({ kind: 'ready', data: { customerCode: 'C001' } });
|
||||
});
|
||||
|
||||
it('缺必填即 missing,列出缺哪几个', () => {
|
||||
const r = assembleInput(registration(), { rfqRef: 'RFQ-1' }, ['customerCode', 'rfqRef'], noExpr);
|
||||
expect(r).toEqual({ kind: 'missing', fields: ['customerCode'] });
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,15 @@ export function assembleInput(
|
||||
required: readonly string[],
|
||||
evaluateExpression: ExpressionEvaluator,
|
||||
): AssemblyResult {
|
||||
const data: Record<string, unknown> = {};
|
||||
/*
|
||||
* 登记触发的入参**就是登记人填的表单**(OS 3.1:「按调用接口 schema 填写输入,
|
||||
* 表单由 schema 自动渲染」)—— 那份表单已按调用接口成形,不需要再装配一遍。
|
||||
* 所以这里以 scope 打底,mapping(若声明了)再覆盖其上。
|
||||
*
|
||||
* 事件 / 周期触发不打底:它们的 scope 是事件载荷,与调用接口是两套字段名,
|
||||
* 直接铺进去会把无关字段塞给流程包 —— 那正是 mapping 存在的理由。
|
||||
*/
|
||||
const data: Record<string, unknown> = config.spec.kind === 'registration' ? { ...scope } : {};
|
||||
for (const mapping of config.mapping) {
|
||||
switch (mapping.source.from) {
|
||||
case 'constant':
|
||||
|
||||
@@ -86,7 +86,13 @@ export function createTriggerService(deps: TriggerServiceDeps): TriggerService {
|
||||
async fireByRegistration(triggerId, by, data) {
|
||||
const config = await deps.store.get(triggerId);
|
||||
if (config === undefined || !config.enabled) return { triggerId, skipped: 'disabled' };
|
||||
return fire(config, { kind: 'registration', ref: `registration:${by}`, by }, data, config.name);
|
||||
/*
|
||||
* 主题优先取登记人填的 `subject` —— 拿触发器名当主题的话,
|
||||
* 同一个登记入口起的所有单主题全一样,清单上分不出哪条是哪条。
|
||||
*/
|
||||
const filled = data['subject'];
|
||||
const subject = typeof filled === 'string' && filled.trim() !== '' ? filled.trim() : config.name;
|
||||
return fire(config, { kind: 'registration', ref: `registration:${by}`, by }, data, subject);
|
||||
},
|
||||
|
||||
/** 周期扫描:日历型按表达式到点;数据日历型逐行起单 */
|
||||
|
||||
Reference in New Issue
Block a user