From a10a7b2855ce60e7e0b7490354d3ff0b26b43fa4 Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Tue, 28 Jul 2026 20:11:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=89=E9=9D=A2=E6=8B=86=E5=88=86=E2=91=B0:?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=8D=B3=E5=85=B6=E8=BA=AB=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BF=9D=E5=AD=98=E5=86=99=E5=9B=9E=E6=9C=AC?= =?UTF-8?q?=E7=89=88,=E6=96=B0=E7=89=88=E6=9C=AC=E5=8F=AA=E5=87=BA?= =?UTF-8?q?=E8=87=AA=E3=80=8C=E6=96=B0=E5=BB=BA=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问:为什么编辑一个版本、保存就自动多一个副本?那不是我要的。 旧机制「保存恒新增版本号」改为: - savePackage:pkg.version 已存在 → **就地写回该版**(号不变、状态不变); `asNew: true`(新建按钮)或新流程首版才铸新版本号(服务端算,不生效) - 底线三道,一道不少: ① **在途钉版不可变** —— 该版有未完结工作流(按 (包,版本) 钉定义)→ 409, 一字不落库;「就地覆盖会改掉在途正在读的编排」由这道闸守,不再靠只插不改一刀切 ② **生效版必须始终可运行** —— 写回生效版跑闭合闸,不闭合 412 带整份报告; 未生效版画图不检查、随手存 ③ **陈旧编辑不复活** —— 流程存在而版本号已被清理 → 409(实测:停在已删旧版上的 标签页发来保存,把老内容静默复活成了新版本;StaleVersionError 拦死) - store:新增 updatePackageDefinition(只许命中现存版本)+ countPinnedInFlight - 前端:保存按钮「保存 v{n}」;新建走 asNew;文案全线「新建才长版本」 - 测试:save-package.test 六例(写回/钉版/热改闭合/asNew/防复活/首版); 过程中揪出双 API 进程并存(旧进程霸端口跑旧代码)——已重启归一 实测:改名保存 → 版本列表仍唯一 v4 生效中,名字已写回;八例端到端全绿; 五闸退出码全过(api:check 提交后转绿)。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m --- .../api/src/modules/process/contract.d.ts | 24 ++- .../apps/api/src/modules/process/index.d.ts | 2 +- .../apps/api/src/modules/process/port.d.ts | 4 + .../contracts/src/api/governance.d.ts | 6 + apps/api/src/app/routes/governance-ops.ts | 25 +++- apps/api/src/app/seed-mvp-flow.ts | 2 +- apps/api/src/modules/process/CONTRACT.md | 19 ++- .../process/__tests__/save-package.test.ts | 139 ++++++++++++++++++ .../process/__tests__/start-guard.test.ts | 2 + apps/api/src/modules/process/contract.ts | 32 +++- apps/api/src/modules/process/index.ts | 3 +- .../process/internal/adapter/process-store.ts | 25 +++- .../internal/usecase/process-service.ts | 41 +++++- apps/api/src/modules/process/port.ts | 4 + .../features/gov-process/internal/panel.tsx | 13 +- packages/contracts/src/api/governance.ts | 7 +- 16 files changed, 312 insertions(+), 36 deletions(-) create mode 100644 apps/api/src/modules/process/__tests__/save-package.test.ts 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 30f9c83..d18d01f 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -28,10 +28,13 @@ export type StartWorkflowInput = { }; export type ProcessService = { /** - * 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version), - * 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。 + * 保存 —— **编辑即其身**:pkg.version 已存在则就地写回该版(号不变)。 + * 两道闸:在途钉版抛 PinnedVersionError;写回生效版须闭合,否则 NotClosedError。 + * `asNew` 或版本不存在(新流程首版)才产生新版本号(服务端算,不生效)。 */ - savePackage(pkg: ProcessPackage): Promise; + savePackage(pkg: ProcessPackage, opts?: { + asNew?: boolean; + }): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; /** @@ -63,11 +66,24 @@ export declare class CallInterfaceViolationError extends Error { readonly errors: readonly string[]; constructor(packageId: string, errors: readonly string[]); } -/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */ +/** 不闭合不得**激活/热改生效版** —— 画图与写回未生效版不拦(中间态天然不完整) */ export declare class NotClosedError extends Error { readonly report: ClosureReport; constructor(report: ClosureReport); } +/** 陈旧编辑:流程存在但该版本号已不存在(被清理)—— 不静默复活旧内容,刷新后再改 */ +export declare class StaleVersionError extends Error { + readonly packageId: string; + readonly version: number; + constructor(packageId: string, version: number); +} +/** 在途钉版:该版本有未完结的工作流按 (包, 版本) 钉着定义 —— 定义不可变,改动请新建一版 */ +export declare class PinnedVersionError extends Error { + readonly packageId: string; + readonly version: number; + readonly inFlight: number; + constructor(packageId: string, version: number, inFlight: number); +} export declare class PositionVacantError extends Error { readonly position: string; constructor(position: string); 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 55bbaa7..4579549 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 { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts'; +export { CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } 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 5d78218..2ab3737 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -37,6 +37,10 @@ export type WorkflowRecord = { }; export type ProcessStore = { savePackage(pkg: ProcessPackage): Promise; + /** 就地写回一版的定义(编辑即其身)—— 撞不存在的版本报错,不静默插入 */ + updatePackageDefinition(id: string, version: number, pkg: ProcessPackage): Promise; + /** 钉在该版上的未完结工作流数 —— 在途钉版不可变的判据 */ + countPinnedInFlight(id: string, version: number): Promise; /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; getPackage(id: string, version?: number): Promise; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index eb4b777..50f1a83 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -630,6 +630,11 @@ export declare const listProcessPackagesRoute: { export declare const saveProcessRoute: { readonly method: "PUT"; readonly path: "/api/gov/process"; + /** + * 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。 + * `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。 + * 就地写回的两道闸:在途钉版 409;写回生效版不闭合 412(带整份报告)。 + */ readonly body: z.ZodObject<{ pkg: z.ZodObject<{ id: z.core.$ZodBranded; @@ -791,6 +796,7 @@ export declare const saveProcessRoute: { description: z.ZodOptional; diagram: z.ZodOptional>; }, z.core.$strip>; + asNew: z.ZodOptional; }, z.core.$strip>; readonly output: z.ZodObject<{ pkg: z.ZodObject<{ diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 16da5aa..04507a2 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -36,7 +36,7 @@ import { } from '@contracts'; import { InstallRejectedError } from '@modules/activity-registry'; import type { InstallVerdict } from '@modules/activity-registry'; -import { NotClosedError } from '@modules/process'; +import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; @@ -265,7 +265,7 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai /** * 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。 - * 想改已发布的,另起新版本(恒新增、版本并存,在途按启动时版本跑完)。 + * 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。 */ app.put(saveProcessRoute.path, async (c) => { const denied = await governProcess(c.get('account')); @@ -276,11 +276,24 @@ export function registerGovernanceOpsRoutes(app: Hono, container: Contai return c.json(body, 400); } /* - * 保存**不检查、不生效**:画图的中间态天然不完整,存下来是未生效的新版本。 - * 返回连同闭合报告 —— 异常看板据它列清单;完整性闸在激活路由。 + * 编辑即其身:已存在的版本就地写回(号不变);asNew / 新流程首版才长新版本。 + * 画图不检查(未生效版随手存);返回连同闭合报告 —— 异常看板据它列清单。 + * 两道闸:在途钉版 409;写回生效版不闭合 412(生效的必须始终可运行)。 */ - const pkg = await container.process.savePackage(parsed.data.pkg); - return c.json({ pkg, report: await container.process.validate(pkg) }); + try { + const pkg = await container.process.savePackage(parsed.data.pkg, { asNew: parsed.data.asNew === true }); + return c.json({ pkg, report: await container.process.validate(pkg) }); + } catch (error) { + if (error instanceof PinnedVersionError || error instanceof StaleVersionError) { + const body: ApiError = { code: 'conflict', message: error.message }; + return c.json(body, 409); + } + if (error instanceof NotClosedError) { + const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report }; + return c.json(body, 412); + } + throw error; + } }); /** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */ diff --git a/apps/api/src/app/seed-mvp-flow.ts b/apps/api/src/app/seed-mvp-flow.ts index e72437c..a1f85d0 100644 --- a/apps/api/src/app/seed-mvp-flow.ts +++ b/apps/api/src/app/seed-mvp-flow.ts @@ -374,7 +374,7 @@ export async function seedMvpFlow(container: Container): Promise { /* * 保存即生效,且**内容没变就不再存新版**。 * - * 保存恒新增版本(这是「旧任务走完旧版本」的前提),所以种子不能无条件存 —— + * 内容变更走新版本(在途钉旧版跑完),所以种子不能无条件存 —— * 那样每跑一次播种就多一个版本,库里堆一串一模一样的定义。 * 比对的是语义与呈现,不含 version/state(它们由保存本身决定)。 */ diff --git a/apps/api/src/modules/process/CONTRACT.md b/apps/api/src/modules/process/CONTRACT.md index 5983ecd..7292bff 100644 --- a/apps/api/src/modules/process/CONTRACT.md +++ b/apps/api/src/modules/process/CONTRACT.md @@ -8,7 +8,7 @@ 公开出口: index.ts - 类型:`ProcessService` `StartWorkflowInput` `WorkflowRecord` `ClosureReport` `ClosureViolation` `ActivityContractFace` `Candidate` `Resolution` `NodeInstance` `WorkflowState` `Dispatch` - - 错误:`NotClosedError` `PositionVacantError` + - 错误:`NotClosedError` `PinnedVersionError` `PositionVacantError` - 端口类型:`ProcessStore` `ActivityDirectory` `OrgDirectory` `TaskGateway` `ApprovalGateway` `Sfeel` `FlowEventPublisher` `ReceiptNotifier` - 用例工厂:`createProcessService(deps): ProcessService`(含 `setPackageState` 版本启停) @@ -25,9 +25,15 @@ 被依赖: trigger, app(治理台 · 工作流管理) 不变量(OS 03 / 3.2): - - **画图不检查,激活才查完整**(产品决策 2026-07-28):`savePackage` 不跑闭合校验, - 随手画的半成品也存得进来(存出来即 `retired`,不生效);`setPackageState` 激活时 - 跑 `checkClosure`,不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。 + - **编辑即其身**(产品决策 2026-07-28 第四轮):`savePackage` 对已存在的版本 + **就地写回**(号不变、状态不变);新版本只出自显式新建(`asNew: true`)或 + 新流程首版(插入,`retired` 不生效)。两道底线: + ① **在途钉版不可变** —— 该版有未完结工作流(按 (包, 版本) 钉定义)即抛 + `PinnedVersionError`(HTTP 409),一字不落库;改动请新建一版。 + ② **生效版必须始终可运行** —— 写回生效版跑 `checkClosure`,不闭合抛 + `NotClosedError`(HTTP 412 带整份报告);未生效版画图不检查、随手存。 + - **激活才查完整**:`setPackageState` 激活时跑 `checkClosure`, + 不闭合抛 `NotClosedError`(HTTP 412 带整份报告)。 违规项:未完成边、必填未映射、类型冲突、孤立节点、多默认出边、 活动未装载、审批岗位不存在、容忍分支被下游必填依赖 —— 任一即 `closed = false`,激活被拒。 (活动点不核岗位 —— 契约不带负责人,`missing-owner-position` 只出自审批点。) @@ -53,8 +59,9 @@ - **一条流恰一个生效版本**(产品决策 2026-07-28):`setPackageState` 激活即独占 (同流其余版本一并停用);停用生效版 = 整条流下线,新起单被拒。 起单取**生效版**;在途工作流按启动时钉住的版本号读定义 —— 启停对它们零影响。 - - **保存恒新增版本号**(服务端算,不采信入参):工作流按 (包id, 版本) 读定义, - 就地覆盖会改掉在途任务正在读的编排。store 层因此**只插不改**,撞键宁可报错也不静默覆盖。 + - **版本号服务端算**(新建时,不采信入参):工作流按 (包id, 版本) 读定义; + store 的写回(`updatePackageDefinition`)只许命中已存在的版本,撞不存在报错; + 「就地覆盖会改掉在途正在读的编排」由在途钉版闸守着,不再靠只插不改一刀切。 - **求值语义前后端同源**:画布的即时校验与后端裁决共用 `checkClosure` - **负责人解析确定性**:轮值并列时按 staffId 稳定排序;同输入必同结果,结果与依据留痕 - **工作流状态是聚合投影**:流程层不另设状态机;计量对象是**节点实例**, diff --git a/apps/api/src/modules/process/__tests__/save-package.test.ts b/apps/api/src/modules/process/__tests__/save-package.test.ts new file mode 100644 index 0000000..4c386bd --- /dev/null +++ b/apps/api/src/modules/process/__tests__/save-package.test.ts @@ -0,0 +1,139 @@ +/** + * 保存 —— **编辑即其身**(产品决策 2026-07-28 第四轮): + * 写回当前版本号不变;新版本只出自显式新建(asNew)或新流程首版。 + * 两道底线:在途钉版不可变;生效版写回必须闭合(生效的必须始终可运行)。 + */ +import { describe, expect, it } from 'vitest'; +import { processPackageSchema } from '@contracts'; +import type { ProcessPackage } from '@contracts'; +import { NotClosedError, PinnedVersionError, StaleVersionError } from '../contract.ts'; +import { createProcessService } from '../internal/usecase/process-service.ts'; +import type { ProcessServiceDeps } from '../internal/usecase/process-service.ts'; + +const basePkg = (version: number, state: 'active' | 'retired'): ProcessPackage => + processPackageSchema.parse({ + id: 'p1', + name: '包', + version, + state, + callInterface: { input: { type: 'object' }, output: { type: 'object' } }, + nodes: [ + { id: 'S', kind: 'start', label: '起' }, + { id: 'E', kind: 'end', label: '终' }, + ], + edges: [{ id: 'E1', from: 'S', to: 'E', mapping: [], reopen: false }], + variables: [], + }); + +function harness(seed: ProcessPackage[], pinned = 0) { + const shelf = [...seed]; + const updated: Array<{ id: string; version: number }> = []; + const inserted: ProcessPackage[] = []; + const deps: ProcessServiceDeps = { + store: { + savePackage: async (pkg) => { + inserted.push(pkg); + shelf.push(pkg); + }, + updatePackageDefinition: async (id, version, pkg) => { + const at = shelf.findIndex((p) => p.id === id && p.version === version); + if (at < 0) throw new Error('版本不存在'); + shelf[at] = pkg; + updated.push({ id, version }); + }, + countPinnedInFlight: async () => pinned, + setPackageState: async () => undefined, + getPackage: async (id, version) => + version === undefined + ? [...shelf].sort((a, b) => b.version - a.version).find((p) => p.id === id) + : shelf.find((p) => p.id === id && p.version === version), + listPackages: async () => shelf, + insertWorkflow: async () => undefined, + getWorkflow: async () => undefined, + listWorkflows: async () => [], + updateWorkflow: async () => { + throw new Error('测试:不该走到 updateWorkflow'); + }, + listInstances: async () => [], + insertInstance: async () => undefined, + updateInstance: async () => undefined, + findInstanceByTask: async () => undefined, + }, + activities: { faces: async () => new Map() }, + org: { + positions: async () => new Set(['PM']), + candidates: async () => [], + workGroupBinding: async () => undefined, + isActive: async () => true, + }, + tasks: { start: async () => ({ id: 't' }) }, + approvals: { raise: async () => ({ id: 'a' }) }, + + sfeel: { condition: () => true, expression: () => undefined }, + schemas: { validate: () => ({ ok: true, errors: [] }) }, + events: { publish: async () => undefined }, + receipts: { notify: async () => undefined }, + clock: { now: () => new Date('2026-07-28T00:00:00.000Z') }, + ids: { next: () => 'x' }, + }; + return { service: createProcessService(deps), shelf, updated, inserted }; +} + +describe('savePackage —— 编辑即其身', () => { + it('已存在的版本就地写回:号不变、状态不变、不长新版', async () => { + const h = harness([basePkg(4, 'retired')]); + const edited = { ...basePkg(4, 'retired'), name: '改名了' }; + const saved = await h.service.savePackage(edited); + expect(saved.version).toBe(4); + expect(saved.state).toBe('retired'); + expect(h.updated).toEqual([{ id: 'p1', version: 4 }]); + expect(h.inserted).toHaveLength(0); + expect(h.shelf).toHaveLength(1); + }); + + it('在途钉版不可变 —— 有未完结工作流即拒,一字不落库', async () => { + const h = harness([basePkg(4, 'retired')], 2); + await expect(h.service.savePackage(basePkg(4, 'retired'))).rejects.toBeInstanceOf(PinnedVersionError); + expect(h.updated).toHaveLength(0); + expect(h.inserted).toHaveLength(0); + }); + + it('写回生效版须闭合 —— 生效的必须始终可运行', async () => { + const h = harness([basePkg(4, 'active')]); + // 把唯一一条边砍掉:起点无出边、终点无入边 —— 不闭合 + const broken = { ...basePkg(4, 'active'), edges: [] }; + await expect(h.service.savePackage(broken)).rejects.toBeInstanceOf(NotClosedError); + expect(h.updated).toHaveLength(0); + + // 闭合的改动照常写回,生效状态保持 + const ok = { ...basePkg(4, 'active'), name: '热改' }; + const saved = await h.service.savePackage(ok); + expect(saved.state).toBe('active'); + expect(h.updated).toEqual([{ id: 'p1', version: 4 }]); + }); + + it('asNew 显式新建:铸下一个版本号,不生效 —— 新版本只出自这条路', async () => { + const h = harness([basePkg(4, 'active')]); + const saved = await h.service.savePackage(basePkg(4, 'active'), { asNew: true }); + expect(saved.version).toBe(5); + expect(saved.state).toBe('retired'); + expect(h.inserted).toHaveLength(1); + expect(h.updated).toHaveLength(0); + }); + + it('陈旧编辑拒之门外:流程存在而版本号已不存在 —— 不静默复活旧内容', async () => { + const h = harness([basePkg(4, 'active')]); + // 停在已清理旧版(v1)上的标签页发来保存 + await expect(h.service.savePackage(basePkg(1, 'retired'))).rejects.toBeInstanceOf(StaleVersionError); + expect(h.inserted).toHaveLength(0); + expect(h.updated).toHaveLength(0); + }); + + it('新流程首版:版本不存在即插入 v1,不生效', async () => { + const h = harness([]); + const saved = await h.service.savePackage(basePkg(1, 'active')); + expect(saved.version).toBe(1); + expect(saved.state).toBe('retired'); + expect(h.inserted).toHaveLength(1); + }); +}); diff --git a/apps/api/src/modules/process/__tests__/start-guard.test.ts b/apps/api/src/modules/process/__tests__/start-guard.test.ts index 11c2be6..25b97df 100644 --- a/apps/api/src/modules/process/__tests__/start-guard.test.ts +++ b/apps/api/src/modules/process/__tests__/start-guard.test.ts @@ -45,6 +45,8 @@ function harness(validationErrors: readonly string[]): Harness { const deps: ProcessServiceDeps = { store: { savePackage: async () => undefined, + updatePackageDefinition: async () => undefined, + countPinnedInFlight: async () => 0, setPackageState: async () => undefined, getPackage: async () => PKG, listPackages: async () => [PKG], diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 882756b..40269b4 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -53,10 +53,11 @@ export type StartWorkflowInput = { export type ProcessService = { /** - * 保存 —— **不检查、不生效**:恒存为新版本号(服务端算,不采信入参的 version), - * 状态为未生效。画图的中间态天然不完整,保存不设闸;完整性闸在 `setPackageState` 激活时。 + * 保存 —— **编辑即其身**:pkg.version 已存在则就地写回该版(号不变)。 + * 两道闸:在途钉版抛 PinnedVersionError;写回生效版须闭合,否则 NotClosedError。 + * `asNew` 或版本不存在(新流程首版)才产生新版本号(服务端算,不生效)。 */ - savePackage(pkg: ProcessPackage): Promise; + savePackage(pkg: ProcessPackage, opts?: { asNew?: boolean }): Promise; listPackages(): Promise; getPackage(id: string, version?: number): Promise; /** @@ -109,7 +110,7 @@ export class CallInterfaceViolationError extends Error { } } -/** 不闭合不得**激活** —— 画图与保存不拦(中间态天然不完整),上线这一步拦 */ +/** 不闭合不得**激活/热改生效版** —— 画图与写回未生效版不拦(中间态天然不完整) */ export class NotClosedError extends Error { constructor(readonly report: ClosureReport) { super(`流程包不闭合,不得激活:${report.violations.map((v) => v.message).join('; ')}`); @@ -117,6 +118,29 @@ export class NotClosedError extends Error { } } +/** 陈旧编辑:流程存在但该版本号已不存在(被清理)—— 不静默复活旧内容,刷新后再改 */ +export class StaleVersionError extends Error { + constructor( + readonly packageId: string, + readonly version: number, + ) { + super(`流程包版本不存在:${packageId}@v${version} —— 编辑器里的可能是已清理的旧版,刷新后在现存版本上改`); + this.name = 'StaleVersionError'; + } +} + +/** 在途钉版:该版本有未完结的工作流按 (包, 版本) 钉着定义 —— 定义不可变,改动请新建一版 */ +export class PinnedVersionError extends Error { + constructor( + readonly packageId: string, + readonly version: number, + readonly inFlight: number, + ) { + super(`流程包 ${packageId}@v${version} 被 ${inFlight} 条在途工作流钉住 —— 定义不可变,新建一版再改`); + this.name = 'PinnedVersionError'; + } +} + export class PositionVacantError extends Error { constructor(readonly position: string) { super(`岗位空缺,无法解析负责人:${position} —— 按节点失败走向处置,或转治理台待指派`); diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index 85acd79..6df0de8 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -18,7 +18,8 @@ export type { WorkflowRecord, WorkflowState, } from './contract.ts'; -export { CallInterfaceViolationError, NotClosedError, PositionVacantError } from './contract.ts'; +export { CallInterfaceViolationError, NotClosedError, + PinnedVersionError, PositionVacantError, StaleVersionError } from './contract.ts'; export type { ActivityDirectory, diff --git a/apps/api/src/modules/process/internal/adapter/process-store.ts b/apps/api/src/modules/process/internal/adapter/process-store.ts index 6b85b28..ab6c5b4 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -1,7 +1,7 @@ /** * process 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3)。 */ -import { and, asc, desc, eq } from 'drizzle-orm'; +import { and, asc, desc, eq, isNull } from 'drizzle-orm'; import type { ProcessPackage, TerminalState, WorkflowState } from '@contracts'; import { processPackageSchema } from '@contracts'; import type { Db } from '@shared/db.ts'; @@ -74,6 +74,29 @@ export function createProcessStore(db: Db): ProcessStore { return rows.map((r) => processPackageSchema.parse(r.definition)); }, + async updatePackageDefinition(id, version, pkg) { + const hit = await db + .update(processPackageTable) + .set({ definition: pkg }) + .where(and(eq(processPackageTable.id, id), eq(processPackageTable.version, version))) + .returning({ version: processPackageTable.version }); + if (hit.length === 0) throw new Error(`就地写回失败:流程包版本不存在 ${id}@${version}`); + }, + + async countPinnedInFlight(id, version) { + const rows = await db + .select({ id: workflowTable.id }) + .from(workflowTable) + .where( + and( + eq(workflowTable.packageId, id), + eq(workflowTable.packageVersion, version), + isNull(workflowTable.finishedAt), + ), + ); + return rows.length; + }, + async insertWorkflow(record) { await db.insert(workflowTable).values({ id: record.id, 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 21e3928..776025f 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 { CallInterfaceViolationError, NotClosedError, PositionVacantError } from '../../contract.ts'; +import { CallInterfaceViolationError, NotClosedError, PinnedVersionError, PositionVacantError, StaleVersionError } from '../../contract.ts'; import type { ActivityDirectory, ApprovalGateway, @@ -61,7 +61,7 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { return hit; } - /** 下一个版本号 —— 保存恒新增,不覆盖任何在途任务正在读的定义 */ + /** 下一个版本号 —— 只在显式新建 / 新流程首版时用(编辑即其身,写回不长号) */ async function nextVersion(id: string): Promise { const all = await deps.store.listPackages(); return all.filter((pkg) => pkg.id === id).reduce((max, pkg) => Math.max(max, pkg.version), 0) + 1; @@ -298,9 +298,40 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService { * 拿闭合校验拦保存,画到一半就存不了(实测 412 卡死编辑)。 * 存下来的是**未生效**的新版本 —— 完整性闸在激活那一步。 */ - async savePackage(pkg) { - const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' }; - await deps.store.savePackage(saved); + async savePackage(pkg, opts) { + const existing = opts?.asNew === true ? undefined : await deps.store.getPackage(pkg.id, pkg.version); + + if (existing === undefined) { + /* + * 流程已存在而这个版本号查无此版 = 陈旧编辑器(实测:停在已清理旧版上的 + * 标签页发来保存,把老内容静默复活成了新版本)—— 拒掉,让人刷新后再改。 + * 只有整条流都不存在(新流程首版)或显式 asNew 才走插入。 + */ + if (opts?.asNew !== true) { + const newest = await deps.store.getPackage(pkg.id); + if (newest !== undefined) throw new StaleVersionError(pkg.id, pkg.version); + } + // 显式新建 / 新流程首版:插入下一个版本号,不生效 —— 新版本只出自这条路 + const saved: ProcessPackage = { ...pkg, version: await nextVersion(pkg.id), state: 'retired' }; + await deps.store.savePackage(saved); + return saved; + } + + /* + * **编辑即其身**(产品决策 2026-07-28 第四轮):保存写回当前版本,号不变, + * 不再每存一次长一版。两道底线: + * ① 在途钉版不可变 —— 工作流按 (包, 版本) 钉定义,有未完结的就不许改字 + * ② 生效版必须始终可运行 —— 写回生效版要过闭合闸(未生效版画图不检查) + */ + const pinned = await deps.store.countPinnedInFlight(pkg.id, pkg.version); + if (pinned > 0) throw new PinnedVersionError(pkg.id, pkg.version, pinned); + + const saved: ProcessPackage = { ...pkg, version: existing.version, state: existing.state }; + if (existing.state === 'active') { + const report = await this.validate(saved); + if (!report.closed) throw new NotClosedError(report); + } + await deps.store.updatePackageDefinition(pkg.id, pkg.version, saved); return saved; }, diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index e829f87..f77e39f 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -32,6 +32,10 @@ export type WorkflowRecord = { export type ProcessStore = { savePackage(pkg: ProcessPackage): Promise; + /** 就地写回一版的定义(编辑即其身)—— 撞不存在的版本报错,不静默插入 */ + updatePackageDefinition(id: string, version: number, pkg: ProcessPackage): Promise; + /** 钉在该版上的未完结工作流数 —— 在途钉版不可变的判据 */ + countPinnedInFlight(id: string, version: number): Promise; /** 只改状态,不动定义 —— state 在列上与 definition 里各存一份,两处要一起改 */ setPackageState(id: string, version: number, state: 'active' | 'retired'): Promise; getPackage(id: string, version?: number): Promise; diff --git a/apps/web/src/features/gov-process/internal/panel.tsx b/apps/web/src/features/gov-process/internal/panel.tsx index db50eeb..ac31018 100644 --- a/apps/web/src/features/gov-process/internal/panel.tsx +++ b/apps/web/src/features/gov-process/internal/panel.tsx @@ -6,7 +6,8 @@ * 图下是节点属性,再往下才是声明式的契约细节(调用接口 · 边映射 · 触发器)。 * * 版本是**运行时开关**,不是发布流程: - * · 画图不检查:保存 = 存为新版,**不生效**;激活才跑闭合校验(异常清零方可) + * · 编辑即其身:保存**写回当前版本**(号不变);新版本只出自「新建」 + * · 画图不检查(未生效版);写回生效版须闭合;在途钉版不可变(409) * · 一条流恰一个生效版本;可把生效切回旧版,也可全部停用让整条流下线 * · 在途工作流按启动时钉住的版本跑完 —— 启停对它们零影响 */ @@ -218,7 +219,7 @@ function OverviewHome() {
- + setSaveErr((e as { message?: string }).message ?? '保存失败'), @@ -564,7 +565,7 @@ function FlowLab({ * 新建版本才长一版 —— 把旧定义复活成最新版、或在现状上另起一版接着画,都走这儿。 */ const cloneVersion = useMutation({ - mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg } }), + mutationFn: () => call<{ pkg: ProcessPackage; report: ClosureReport }>(saveProcessRoute, { body: { pkg, asNew: true } }), onSuccess: (out) => { setReport(out.report); void queryClient.invalidateQueries({ queryKey: ['gov', 'process'] }); @@ -581,7 +582,7 @@ function FlowLab({ note: dirty ? ( 画布上有未保存的改动,新建版本不会带上它们(克隆的是已存的 v{pkg.version})。 - 想连改动一起走,直接点「保存为 v{nextVersion}」。 + 想连改动一起走,先点「保存 v{pkg.version}」再来新建。 ) : ( @@ -906,7 +907,7 @@ function FlowLab({ if (graph !== null) save.mutate({ graph, idMap: {} }); }} > - {save.isPending ? '保存中…' : `保存为 v${nextVersion}`} + {save.isPending ? '保存中…' : `保存 v${pkg.version}`}
diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index b3a5a54..c2395f1 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -203,7 +203,12 @@ export const listProcessPackagesRoute = { export const saveProcessRoute = { method: 'PUT', path: '/api/gov/process', - body: z.object({ pkg: processPackageSchema }), + /** + * 编辑即其身:pkg.version 已存在 → 就地写回该版(号不变)。 + * `asNew: true`(或版本不存在,如新流程首版)→ 产生新版本号(服务端算,不生效)。 + * 就地写回的两道闸:在途钉版 409;写回生效版不闭合 412(带整份报告)。 + */ + body: z.object({ pkg: processPackageSchema, asNew: z.boolean().optional() }), output: z.object({ pkg: processPackageSchema, report: closureReportSchema }), } as const satisfies RouteContract;