diff --git a/api-report/apps/api/src/modules/process/contract.d.ts b/api-report/apps/api/src/modules/process/contract.d.ts index df57c52..9759748 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -49,6 +49,12 @@ export type ProcessService = { refreshState(workflowId: string): Promise; terminate(workflowId: string, reason: string): Promise; }; +/** 起单入参不合调用接口契约 —— 在建工作流之前判掉,不留半条在途单 */ +export declare class CallInterfaceViolationError extends Error { + readonly packageId: string; + readonly errors: readonly string[]; + constructor(packageId: string, errors: readonly string[]); +} export declare class NotPublishableError extends Error { readonly report: ClosureReport; constructor(report: ClosureReport); diff --git a/api-report/apps/api/src/modules/process/index.d.ts b/api-report/apps/api/src/modules/process/index.d.ts index 120b990..4ccac1a 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -2,7 +2,7 @@ * module: process —— 唯一出口。只做 re-export,不写逻辑(编码规范 §10.1)。 */ export type { ActivityContractFace, AssembleScope, Candidate, ClosureInput, ClosureReport, ClosureViolation, NodeInstance, OwnerSpec, ProcessPackage, ProcessService, ResolveContext, Resolution, StartWorkflowInput, WorkflowRecord, WorkflowState, } from './contract.ts'; -export { NotPublishableError, PositionVacantError } from './contract.ts'; +export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts'; export type { ActivityDirectory, ApprovalGateway, Clock, FlowEventPublisher, IdGenerator, OrgDirectory, ProcessStore, ReceiptNotifier, Sfeel, TaskGateway, } from './port.ts'; export type { ProcessServiceDeps } from './internal/usecase/process-service.ts'; export { createProcessService } from './internal/usecase/process-service.ts'; diff --git a/api-report/apps/api/src/modules/process/port.d.ts b/api-report/apps/api/src/modules/process/port.d.ts index 7ef7014..78ed55c 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -95,6 +95,17 @@ export type ApprovalGateway = { id: string; }>; }; +/** + * 调用接口的入参校验 —— app 注入 ajv。 + * 「凡单必有来源」的另一半是「凡单必合契约」:不合契约**不得起单**, + * 且必须在**建工作流之前**判掉,否则会留下一条永远推不动的在途单。 + */ +export type SchemaValidator = { + validate(schema: Record, value: unknown): { + ok: boolean; + errors: string[]; + }; +}; /** S-FEEL —— app 注入 `@sfeel`:流转零 LLM */ export type Sfeel = { condition(expression: string, scope: Readonly>): boolean; diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index 13cfd53..f579bc7 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -2010,6 +2010,51 @@ export declare const sayRoute: { }, z.core.$strip>], "k">; }; /** 消息要素 */ +/** 消息是**信息**,不产生责任;跨任务征答卡以系统消息送达,**就地作答** */ +export declare const systemMessageSchema: z.ZodObject<{ + id: z.ZodString; + kind: z.ZodString; + title: z.ZodString; + body: z.ZodString; + at: z.ZodString; + read: z.ZodBoolean; + card: z.ZodOptional; + placeholder: z.ZodOptional; + options: z.ZodOptional; + }, z.core.$strip>>>; + group: z.ZodOptional; + hint: z.ZodOptional; + ai: z.ZodOptional; + unit: z.ZodOptional; + required: z.ZodOptional; + cols: z.ZodOptional, z.ZodLiteral<2>]>>; + }, z.core.$strip>>>; + ask: z.ZodDefault; + answered: z.ZodBoolean; + rejectedReason: z.ZodOptional; + supersededBy: z.ZodOptional; + }, z.core.$strip>>; +}, z.core.$strip>; +export type SystemMessageView = z.infer; export declare const listMessagesRoute: { readonly method: "GET"; readonly path: "/api/desk/messages"; @@ -2021,9 +2066,69 @@ export declare const listMessagesRoute: { body: z.ZodString; at: z.ZodString; read: z.ZodBoolean; + card: z.ZodOptional; + placeholder: z.ZodOptional; + options: z.ZodOptional; + }, z.core.$strip>>>; + group: z.ZodOptional; + hint: z.ZodOptional; + ai: z.ZodOptional; + unit: z.ZodOptional; + required: z.ZodOptional; + cols: z.ZodOptional, z.ZodLiteral<2>]>>; + }, z.core.$strip>>>; + ask: z.ZodDefault; + answered: z.ZodBoolean; + rejectedReason: z.ZodOptional; + supersededBy: z.ZodOptional; + }, z.core.$strip>>; }, z.core.$strip>>; }, z.core.$strip>; }; +export declare const markMessageReadRoute: { + readonly method: "POST"; + readonly path: "/api/desk/messages/:messageId/read"; + readonly params: z.ZodObject<{ + messageId: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; +/** 就地作答 —— 回传经统一入口写回来源任务,调用与回传全程在任务侧留痕 */ +export declare const answerMessageRoute: { + readonly method: "POST"; + readonly path: "/api/desk/messages/:messageId/answer"; + readonly params: z.ZodObject<{ + messageId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + answer: z.ZodRecord; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + }, z.core.$strip>; +}; +export type ListMessagesOutput = z.infer; export declare const minuteResolutionSchema: z.ZodObject<{ id: z.ZodString; text: z.ZodString; @@ -2403,3 +2508,75 @@ export declare const submitReportRoute: { }; export type ListMinutesOutput = z.infer; export type ListReportsOutput = z.infer; +export declare const approvalItemSchema: z.ZodObject<{ + id: z.ZodString; + workflowId: z.ZodString; + workflowSubject: z.ZodString; + nodeId: z.ZodString; + title: z.ZodString; + basis: z.ZodArray>; + ask: z.ZodString; + state: z.ZodEnum<{ + pending: "pending"; + rejected: "rejected"; + passed: "passed"; + }>; + rejectRoute: z.ZodString; + raisedAt: z.ZodString; +}, z.core.$strip>; +export type ApprovalItem = z.infer; +export declare const listApprovalsForMeRoute: { + readonly method: "GET"; + readonly path: "/api/approvals"; + readonly output: z.ZodObject<{ + approvals: z.ZodArray>; + ask: z.ZodString; + state: z.ZodEnum<{ + pending: "pending"; + rejected: "rejected"; + passed: "passed"; + }>; + rejectRoute: z.ZodString; + raisedAt: z.ZodString; + }, z.core.$strip>>; + }, z.core.$strip>; +}; +/** 通过 / 驳回,附依据 —— 驳回必附意见 */ +export declare const decideApprovalRoute: { + readonly method: "POST"; + readonly path: "/api/approvals/:workflowId/:nodeId/decide"; + readonly params: z.ZodObject<{ + workflowId: z.ZodString; + nodeId: z.ZodString; + }, z.core.$strip>; + readonly body: z.ZodObject<{ + verdict: z.ZodEnum<{ + pass: "pass"; + reject: "reject"; + }>; + comment: z.ZodOptional; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + ok: z.ZodBoolean; + workflowState: z.ZodEnum<{ + terminated: "terminated"; + running: "running"; + waiting: "waiting"; + completed: "completed"; + abnormal: "abnormal"; + }>; + }, z.core.$strip>; +}; +export type ListApprovalsForMeOutput = z.infer; diff --git a/apps/api/src/app/routes/desk-approval.ts b/apps/api/src/app/routes/desk-approval.ts new file mode 100644 index 0000000..930edd7 --- /dev/null +++ b/apps/api/src/app/routes/desk-approval.ts @@ -0,0 +1,108 @@ +/** + * 审批要素的路由(编码规范 §3;语义出处 OS 2.1 / 3.2)。 + * + * 「工作台层面的『审批』**专指工作流级的审批点**」——它卡到审批人工位,产出审批单: + * 通过 / 驳回,附依据;没有预算、没有 Harness、没有任务日志,所以不是任务。 + * 驳回走向是**声明式**的(流程包里写死),不由临场决定。 + */ +import type { Hono } from 'hono'; +import type { ApiError, ApprovalItem, ProcessPackage, ProcessNode } from '@contracts'; +import { decideApprovalRoute, listApprovalsForMeRoute } from '@contracts'; +import type { Container } from '../container.ts'; +import type { AppEnv } from '../http-env.ts'; + +/** 驳回走向的人读说明 —— 声明式,写在流程包里,不由临场决定 */ +function rejectRouteOf(pkg: ProcessPackage, reject: Extract['reject']): string { + if (reject.kind !== 'reopen') return '驳回 → 终止流'; + const target = pkg.nodes.find((n) => n.id === reject.nodeId); + return `驳回 → 回 ${target?.label ?? reject.nodeId} 重开新实例`; +} + +export function registerDeskApprovalRoutes(app: Hono, container: Container): void { + const unauthenticated: ApiError = { code: 'unauthenticated', message: '会话无效' }; + + /** 我岗位上待批的审批点 —— 审批人由节点声明的岗位解析而来 */ + app.get(listApprovalsForMeRoute.path, async (c) => { + const me = await container.org.findByAccount(c.get('account')); + if (me === undefined) return c.json(unauthenticated, 401); + + const workflows = await container.process.listWorkflows(50); + const nested = await Promise.all( + workflows.map(async (w) => { + const pkg = await container.process.getPackage(w.packageId, w.packageVersion); + if (pkg === undefined) return []; + const instances = await container.process.instancesOf(w.id); + return instances + .filter((i) => i.activated) + .flatMap((i) => { + const node = pkg.nodes.find((n) => n.id === i.nodeId); + if (node?.kind !== 'approval') return []; + // 审批人卡到岗位 —— 不是我的岗位就不进我的审批要素 + if (node.approver.position !== me.position.code) return []; + return [ + { + id: `${w.id}:${i.nodeId}#${i.instance}`, + workflowId: w.id, + workflowSubject: w.subject, + nodeId: i.nodeId, + title: node.label, + // 依据快照取自上游节点的输出口载荷 + basis: Object.entries(w.ports[i.nodeId] ?? w.initiation ?? {}) + .slice(0, 8) + .map(([k, v]) => ({ k, v: String(v) })), + ask: '请裁决:通过 / 驳回(驳回必附意见)', + state: + i.terminal === undefined + ? ('pending' as const) + : i.terminal === 'released' + ? ('passed' as const) + : ('rejected' as const), + rejectRoute: rejectRouteOf(pkg, node.reject), + raisedAt: w.startedAt, + }, + ]; + }); + }), + ); + return c.json({ approvals: nested.flat() }); + }); + + app.post(decideApprovalRoute.path, async (c) => { + const me = await container.org.findByAccount(c.get('account')); + if (me === undefined) return c.json(unauthenticated, 401); + const parsed = decideApprovalRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '裁决不合协议' }; + return c.json(body, 400); + } + // 驳回必附意见 —— 与人类工具三规矩同一条:否定必须带理由 + if (parsed.data.verdict === 'reject' && (parsed.data.comment ?? '').trim() === '') { + const body: ApiError = { code: 'validation-failed', message: '驳回必附意见' }; + return c.json(body, 400); + } + + const workflowId = c.req.param('workflowId'); + const nodeId = c.req.param('nodeId'); + + // 审批权按岗位校验:节点声明的审批岗位必须是我的岗位 + const workflow = await container.process.getWorkflow(workflowId); + const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); + const node = pkg?.nodes.find((n) => n.id === nodeId); + if (node?.kind !== 'approval') { + const body: ApiError = { code: 'not-found', message: `节点不是审批点:${nodeId}` }; + return c.json(body, 404); + } + if (node.approver.position !== me.position.code) { + const body: ApiError = { code: 'forbidden', message: `审批点卡在 ${node.approver.position} 工位,岗位 ${me.position.code} 不得裁决` }; + return c.json(body, 403); + } + + const state = await container.process.onApprovalVerdict(workflowId, nodeId, parsed.data.verdict, { + by: me.id, + ...(parsed.data.comment === undefined ? {} : { comment: parsed.data.comment }), + }); + // 审批一裁,流就动了 —— 让在线的浏览器立刻重取投影 + container.broadcaster.publish({ kind: 'workflow', id: workflowId, reason: 'approval-verdict' }); + return c.json({ ok: true, workflowState: state }); + }); +} diff --git a/apps/api/src/app/routes/desk-elements.ts b/apps/api/src/app/routes/desk-elements.ts index 0db8404..de2ae34 100644 --- a/apps/api/src/app/routes/desk-elements.ts +++ b/apps/api/src/app/routes/desk-elements.ts @@ -17,6 +17,7 @@ import { staffIdSchema, submitReportRoute, } from '@contracts'; +import { CallInterfaceViolationError } from '@modules/process'; import type { Minute } from '@modules/minute'; import { MinuteNotFoundError, ResolutionAlreadyRegisteredError } from '@modules/minute'; import type { Report } from '@modules/report'; @@ -150,6 +151,11 @@ export function registerDeskElementRoutes(app: Hono, container: Containe const body: ApiError = { code: 'precondition-failed', message: error.message }; return c.json(body, 412); } + if (error instanceof CallInterfaceViolationError) { + // 不合调用接口契约:一条工作流都没建,直接回 400 让登记人补齐 + const body: ApiError = { code: 'validation-failed', message: error.message, detail: error.errors }; + return c.json(body, 400); + } if (error instanceof MinuteNotFoundError) { const body: ApiError = { code: 'not-found', message: error.message }; return c.json(body, 404); diff --git a/apps/api/src/app/routes/desk.ts b/apps/api/src/app/routes/desk.ts index 9829235..be5154e 100644 --- a/apps/api/src/app/routes/desk.ts +++ b/apps/api/src/app/routes/desk.ts @@ -7,7 +7,9 @@ import type { Hono } from 'hono'; import type { ApiError, ChatMessage, TaskCard, ViewBlock } from '@contracts'; import { ATTRIBUTION_SIDE_LABEL, + answerMessageRoute, listMessagesRoute, + markMessageReadRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, @@ -185,7 +187,55 @@ export function registerDeskRoutes(app: Hono, container: Container): voi body: m.body, at: m.at, read: m.readAt !== undefined, + // 跨任务征答卡随消息一并出:就地作答,不必跳进别人的任务 + ...(m.card === undefined + ? {} + : { + card: { + cardId: m.card.cardId, + sourceTaskId: m.card.sourceTaskId, + fields: (m.card.payload['fields'] as unknown[] | undefined) ?? [], + ask: String(m.card.payload['ask'] ?? ''), + answered: m.card.answeredAt !== undefined, + ...(m.card.rejectedReason === undefined ? {} : { rejectedReason: m.card.rejectedReason }), + ...(m.card.supersededBy === undefined ? {} : { supersededBy: m.card.supersededBy }), + }, + }), })), }); }); + + app.post(markMessageReadRoute.path, async (c) => { + const staff = await me(c.get('account')); + if (staff === undefined) { + const body: ApiError = { code: 'unauthenticated', message: '会话无效' }; + return c.json(body, 401); + } + await container.message.markRead(c.req.param('messageId')); + return c.json({ ok: true }); + }); + + /** + * 跨任务征答的**就地作答**(OS 2.1)。 + * 「作答权限只及卡面与决策包,**不开放任务对话流**」——所以这里只收卡面回传, + * 由 message 经统一入口写回来源任务;调用与回传全程在任务侧留痕。 + */ + app.post(answerMessageRoute.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 = answerMessageRoute.body.safeParse(await c.req.json()); + if (!parsed.success) { + const body: ApiError = { code: 'validation-failed', message: '回传不合协议' }; + return c.json(body, 400); + } + const answered = await container.message.answer(c.req.param('messageId'), staff.id, parsed.data.answer); + // 跨人卡一答,来源任务就可能续跑 —— 让那边的浏览器重取投影 + if (answered.card !== undefined) { + container.broadcaster.publish({ kind: 'task', id: answered.card.sourceTaskId, reason: 'elicitation-answer' }); + } + return c.json({ ok: true }); + }); } diff --git a/apps/api/src/app/server.ts b/apps/api/src/app/server.ts index afd1b3e..a3289ab 100644 --- a/apps/api/src/app/server.ts +++ b/apps/api/src/app/server.ts @@ -9,6 +9,7 @@ import type { Container } from './container.ts'; import type { AppEnv } from './http-env.ts'; import { registerDeskRoutes } from './routes/desk.ts'; import { registerDeskElementRoutes } from './routes/desk-elements.ts'; +import { registerDeskApprovalRoutes } from './routes/desk-approval.ts'; import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts'; import { registerGovernanceRoutes } from './routes/governance.ts'; import { registerIngestRoutes } from './routes/ingest.ts'; @@ -46,6 +47,7 @@ export function createServer(container: Container): Hono { registerGovernanceRoutes(app, container); registerDeskRoutes(app, container); registerDeskElementRoutes(app, container); + registerDeskApprovalRoutes(app, container); registerGovernanceOpsRoutes(app, container); registerIngestRoutes(app, container); registerStreamRoute(app, container.broadcaster); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index f6762f9..cfd3612 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -479,6 +479,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring { }, }, sfeel, + schemas: deps.schemas, events: { publish: async (input) => { await deps.event.publish({ diff --git a/apps/api/src/modules/process/__tests__/start-guard.test.ts b/apps/api/src/modules/process/__tests__/start-guard.test.ts new file mode 100644 index 0000000..dfdb22d --- /dev/null +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -0,0 +1,121 @@ +/** + * 起单守卫:不合调用接口契约的输入,**一条工作流都不留**。 + * + * 用例层测试 —— 端口给的是**真实实现**(内存版),不 mock 内部机制; + * 类型一律走 schema 解析而来的真值,不用断言绕过(§12.5#4)。 + */ +import { describe, expect, it } from 'vitest'; +import { processPackageSchema } from '@contracts'; +import type { ProcessPackage } from '@contracts'; +import { CallInterfaceViolationError } from '../contract.ts'; +import { createProcessService } from '../internal/usecase/process-service.ts'; +import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; +import type { WorkflowRecord } from '../port.ts'; +import type { NodeInstance } from '../internal/domain/owner-resolution.ts'; + +/** 最小可发布包:一个开始、一个结束、一条边 —— 起单守卫不需要更复杂的图 */ +const PKG: ProcessPackage = processPackageSchema.parse({ + id: 'p1', + name: '测试包', + version: 1, + state: 'published', + callInterface: { + input: { type: 'object', required: ['customerCode'], properties: { customerCode: { type: 'string' } } }, + output: { type: 'object' }, + }, + nodes: [ + { kind: 'start', id: 'S', label: '开始' }, + { kind: 'end', id: 'E', label: '结束' }, + ], + edges: [{ id: 'e1', from: 'S', to: 'E', mapping: [], reopen: false }], + variables: [], +}); + +type Harness = { + readonly service: ReturnType; + readonly inserted: WorkflowRecord[]; + readonly published: unknown[]; +}; + +function harness(validationErrors: readonly string[]): Harness { + const inserted: WorkflowRecord[] = []; + const published: unknown[] = []; + const instances: NodeInstance[] = []; + + const deps: ProcessServiceDeps = { + store: { + savePackage: async () => undefined, + getPackage: async () => PKG, + listPackages: async () => [PKG], + publishPackage: async () => undefined, + insertWorkflow: async (record) => { + inserted.push(record); + }, + getWorkflow: async (id) => inserted.find((w) => w.id === id), + listWorkflows: async () => inserted, + // 内存版要**真的落笔**:用例读回它自己刚写的状态,假实现敷衍就测不出真行为 + updateWorkflow: async (id, patch) => { + const at = inserted.findIndex((w) => w.id === id); + if (at < 0) throw new Error(`测试:工作流不存在 ${id}`); + const next = { ...inserted[at]!, ...patch }; + inserted[at] = next; + return next; + }, + listInstances: async () => instances, + insertInstance: async (_workflowId, instance) => { + instances.push(instance); + }, + updateInstance: async () => undefined, + findInstanceByTask: async () => undefined, + }, + activities: { faces: async () => new Map() }, + org: { positions: async () => new Set(), candidates: async () => [] }, + tasks: { start: async () => ({ id: 'task-1' }) }, + approvals: { raise: async () => ({ id: 'apr-1' }) }, + sfeel: { condition: () => true, expression: () => undefined }, + schemas: { + validate: () => ({ ok: validationErrors.length === 0, errors: [...validationErrors] }), + }, + events: { + publish: async (input) => { + published.push(input); + }, + }, + receipts: { notify: async () => undefined }, + clock: { now: () => new Date('2026-07-27T00:00:00.000Z') }, + ids: { next: () => 'wf-1' }, + }; + + return { service: createProcessService(deps), inserted, published }; +} + +describe('起单:凡单必合契约', () => { + const input = { + packageId: 'p1', + subject: '测试单', + source: { kind: 'registration' as const, ref: 'p1', by: 's1' }, + data: {}, + }; + + it('入参不合调用接口 → 抛错,且**不建工作流、不发事件**', async () => { + const { service, inserted, published } = harness(["must have required property 'customerCode'"]); + await expect(service.start(input)).rejects.toBeInstanceOf(CallInterfaceViolationError); + // 校验若放在建单之后,这里就会留下一条永远推不动的在途单,污染在途口径 + expect(inserted).toHaveLength(0); + expect(published).toHaveLength(0); + }); + + it('错误带上缺了哪个字段 —— 登记人据此补齐,而不是猜', async () => { + const { service } = harness(["must have required property 'customerCode'"]); + await expect(service.start(input)).rejects.toThrow(/customerCode/); + }); + + it('入参合契约 → 正常建单,来源记在单上', async () => { + const { service, inserted } = harness([]); + const record = await service.start({ ...input, data: { customerCode: 'ACME' } }); + expect(record.id).toBe('wf-1'); + expect(inserted).toHaveLength(1); + // 凡单必有来源:登记起单的来源就是登记人 + expect(inserted[0]?.source).toEqual({ kind: 'registration', ref: 'p1', by: 's1' }); + }); +}); diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 7cc33f1..f03dcc7 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -90,6 +90,17 @@ export type ProcessService = { terminate(workflowId: string, reason: string): Promise; }; +/** 起单入参不合调用接口契约 —— 在建工作流之前判掉,不留半条在途单 */ +export class CallInterfaceViolationError extends Error { + constructor( + readonly packageId: string, + readonly errors: readonly string[], + ) { + super(`输入不合调用接口契约,不得起单:${errors.join('; ')}`); + this.name = 'CallInterfaceViolationError'; + } +} + export class NotPublishableError extends Error { constructor(readonly report: ClosureReport) { super(`流程包不闭合,不得发布:${report.violations.map((v) => v.message).join('; ')}`); diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index ed6d63e..62eb2e5 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -18,7 +18,7 @@ export type { WorkflowRecord, WorkflowState, } from './contract.ts'; -export { NotPublishableError, PositionVacantError } from './contract.ts'; +export { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from './contract.ts'; export type { ActivityDirectory, diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 74cc3ff..b1a6a54 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -5,7 +5,7 @@ */ import type { ProcessNode, ProcessPackage, StaffId, TerminalState, WorkflowState } from '@contracts'; import type { ProcessService, StartWorkflowInput } from '../../contract.ts'; -import { NotPublishableError, PositionVacantError } from '../../contract.ts'; +import { CallInterfaceViolationError, NotPublishableError, PositionVacantError } from '../../contract.ts'; import type { ActivityDirectory, ApprovalGateway, @@ -15,6 +15,7 @@ import type { OrgDirectory, ProcessStore, ReceiptNotifier, + SchemaValidator, Sfeel, TaskGateway, WorkflowRecord, @@ -31,6 +32,7 @@ export type ProcessServiceDeps = { readonly tasks: TaskGateway; readonly approvals: ApprovalGateway; readonly sfeel: Sfeel; + readonly schemas: SchemaValidator; readonly events: FlowEventPublisher; readonly receipts: ReceiptNotifier; readonly clock: Clock; @@ -216,6 +218,11 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { const pkg = await requirePackage(input.packageId, input.packageVersion); if (pkg.state !== 'published') throw new Error(`流程包未发布,不得起单:${pkg.id}@${pkg.version}`); + // 先校验后建单:不合调用接口契约的输入**一条工作流都不留**。 + // (校验若放在建单之后,失败就会留下一条永远推不动的在途单,污染在途口径。) + const check = deps.schemas.validate(pkg.callInterface.input, input.data); + if (!check.ok) throw new CallInterfaceViolationError(pkg.id, check.errors); + const record: WorkflowRecord = { id: deps.ids.next(), packageId: pkg.id, diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index 5c385ee..609f787 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -82,6 +82,15 @@ export type ApprovalGateway = { }): Promise<{ id: string }>; }; +/** + * 调用接口的入参校验 —— app 注入 ajv。 + * 「凡单必有来源」的另一半是「凡单必合契约」:不合契约**不得起单**, + * 且必须在**建工作流之前**判掉,否则会留下一条永远推不动的在途单。 + */ +export type SchemaValidator = { + validate(schema: Record, value: unknown): { ok: boolean; errors: string[] }; +}; + /** S-FEEL —— app 注入 `@sfeel`:流转零 LLM */ export type Sfeel = { condition(expression: string, scope: Readonly>): boolean; diff --git a/apps/web/src/app/app-shell.tsx b/apps/web/src/app/app-shell.tsx index ca0379d..5010112 100644 --- a/apps/web/src/app/app-shell.tsx +++ b/apps/web/src/app/app-shell.tsx @@ -9,8 +9,12 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { Session, TaskCard, TaskDetail } from '@contracts'; -import { listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts'; +import { listApprovalsForMeRoute, listMessagesRoute, listTasksRoute, replyWorkOrderRoute, sayRoute, sessionRoute, taskDetailRoute } from '@contracts'; import { TaskChat, TaskList, ViewRegion } from '@features/desk-task'; +import { ApprovalDesk } from '@features/desk-approval'; +import { MessageDesk } from '@features/desk-message'; +import { MinuteDesk } from '@features/desk-minute'; +import { ReportDesk } from '@features/desk-report'; import { Ico } from '@web-shared/Ico.tsx'; import { call } from '@web-shared/typed-fetch.ts'; import { useChatWidth } from '@web-shared/useChatWidth.ts'; @@ -43,12 +47,14 @@ export function AppShell() { queryFn: () => call<{ tasks: TaskCard[] }>(listTasksRoute), }); + // 角标常驻:责任面要一眼看见「什么在等我」,不能等切过去才知道 const messages = useQuery({ queryKey: queryKeys.messages, - queryFn: () => call<{ messages: Array<{ id: string; title: string; body: string; at: string; read: boolean }> }>( - listMessagesRoute, - ), - enabled: module === 'messages', + queryFn: () => call<{ messages: Array<{ id: string; read: boolean }> }>(listMessagesRoute), + }); + const approvals = useQuery({ + queryKey: ['approvals'], + queryFn: () => call<{ approvals: Array<{ state: string }> }>(listApprovalsForMeRoute), }); const list = tasks.data?.tasks ?? []; @@ -89,7 +95,14 @@ export function AppShell() { JCAI {NAV_MODULES.map((m) => { - const badge = m.key === 'tasks' ? active.length : undefined; + const badge = + m.key === 'tasks' + ? active.length + : m.key === 'approvals' + ? (approvals.data?.approvals ?? []).filter((a) => a.state === 'pending').length + : m.key === 'messages' + ? (messages.data?.messages ?? []).filter((x) => !x.read).length + : undefined; return ( + + + + + {visible.map((a) => { + const chip = STATE_CHIP[a.state]; + return ( + + ); + })} + {visible.length === 0 && ( + + {filter === 'todo' ? '待批清空了 —— 没有等你放行的事项' : '暂无已处理记录'} + + )} + + + + {sel === undefined ? ( +
+ 没有卡在你工位上的审批点 +
+ ) : ( + + + + {sel.workflowSubject} + 工作流 {sel.workflowId.slice(0, 8)} + + } + chip={} + /> + +
+
+ {sel.basis.map((b) => ( +
+ {b.k} + {b.v} +
+ ))} + {sel.basis.length === 0 && 上游尚无输出口载荷} +
+
+ +
+ {sel.rejectRoute} +
+ + {sel.state === 'pending' ? ( +
+