From ad2b37c6d305df83a79e8d66b4874621a503db95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 19:33:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=A8=A1=E5=9E=8B=E5=AE=9A?= =?UTF-8?q?=E4=BB=B7=E4=BF=9D=E5=AD=98=E5=8E=BB=E6=8E=89=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=AF=94=E5=AF=B9=EF=BC=9A=E4=BF=9D=E5=AD=98=E5=8D=B3=E6=95=B4?= =?UTF-8?q?=E6=AE=B5=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shared-contracts:删掉 `EDITOR_GENERATION_PRICING_VERSION_CONFLICT` 常量 - spacetime-module:`EditorGenerationPricingConfigUpsertInput` 去掉期望版本字段,删掉版本比对方法与它的用例,入库只按请求内容整段覆盖 - spacetime-client:重新生成定价 upsert input 绑定(少一个字段),记录映射同步去掉版本 - api-server:进程内缓存去掉版本字段与 409 冲突错误,`editor_generation_pricing()` / `save_editor_generation_pricing()` 只返回配置本身,`EditorGenerationPricingStore::replace` 注释写明「只是缓存刷新、不做版本校验、不是拦截点」 - api-server admin/app:后台 GET 不再返回 `updatedAtMicros`,POST 不再读 `expectedUpdatedAtMicros`,删掉 409 映射与「旧版本被拒」用例,新增「连续保存两次以后一次为准」 - admin-web:定价类型去掉版本字段,保存请求不再回传版本,删掉 409 分支与「重新读取」提示及其用例 - docs:主规范新增「保存语义」段(整段覆盖、同时只有一个管理员在操作、缓存替换不是拦截点),后端数据契约、ADR 0005、里程碑 / 实施计划、决策记录与 pitfalls 同步为删除版本比对 --- apps/admin-web/src/api/adminApiTypes.ts | 9 +- .../AdminEditorGenerationPricingPage.test.tsx | 20 --- .../AdminEditorGenerationPricingPage.tsx | 24 --- .../adminEditorGenerationPricing.test.ts | 5 +- .../src/pages/adminEditorGenerationPricing.ts | 3 +- ...成定价迁入SpacetimeDB与后台编辑-2026-09-23.md | 6 +- ...®ž施计划】3D生成定价后台可编辑-2026-09-23.md | 33 ++-- ...�里程碑】3D生成定价后台可编辑-2026-09-23.md | 13 +- .../shared-memory/decision-log.md | 11 ++ docs/project-memory/shared-memory/pitfalls.md | 6 +- ...„】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...�编辑器】模型定价配置管理方案-2026-06-22.md | 17 +- server-rs/crates/api-server/src/admin.rs | 50 ++---- server-rs/crates/api-server/src/app.rs | 60 +------ .../src/editor_generation_config.rs | 23 +-- server-rs/crates/api-server/src/state.rs | 159 +++++++----------- .../shared-contracts/src/editor_generation.rs | 7 - .../src/active/mapper/editor_project.rs | 3 - ...ration_pricing_config_upsert_input_type.rs | 1 - .../src/editor_project_storage.rs | 55 ------ 20 files changed, 149 insertions(+), 358 deletions(-) diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 6ca31fa56..8d3b579a1 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -458,15 +458,16 @@ export interface EditorGenerationPricingConfigPayload { models: Record; /** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */ model3d?: EditorGenerationModel3dPricingPayload | null; - /** 当前定价版本:保存时原样回传做乐观锁,冲突时后端返回 409。 */ - updatedAtMicros: number; } -/** 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,外加读取到的定价版本。 */ +/** + * 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。 + * + * 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。 + */ export interface AdminUpsertEditorGenerationPricingRequest { models: Record; model3d: EditorGenerationModel3dPricingPayload | null; - expectedUpdatedAtMicros: number; } export interface AdminEditorAssetListQuery { diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx index 06d286eda..941a995ae 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx @@ -5,7 +5,6 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, expect, test, vi } from 'vitest'; import { - AdminApiError, getAdminEditorGenerationPricing, upsertAdminEditorGenerationPricing, } from '../api/adminApiClient'; @@ -70,7 +69,6 @@ const pricing: EditorGenerationPricingConfigPayload = { }, }, }, - updatedAtMicros: 1_700_000_000_000_000, }; function savedPricing( @@ -78,7 +76,6 @@ function savedPricing( ) { return { ...pricing, - updatedAtMicros: pricing.updatedAtMicros + 1, ...overrides, }; } @@ -163,7 +160,6 @@ test('3D 区块按端点渲染版本底价与加价项,并随保存回传整 expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith( 'admin-token', expect.objectContaining({ - expectedUpdatedAtMicros: pricing.updatedAtMicros, model3d: { textToModelPricing: expect.objectContaining({ versionPrices: expect.objectContaining({ @@ -199,22 +195,6 @@ test('数值小于 1 时禁用保存并给出中文提示', async () => { expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled(); }); -test('保存遇到 409 时提示重新读取且不覆盖本地改动', async () => { - const user = userEvent.setup(); - vi.mocked(upsertAdminEditorGenerationPricing).mockRejectedValue( - new AdminApiError({ message: '定价冲突', status: 409 }), - ); - await renderPage(); - - await user.click(screen.getByRole('button', { name: '保存定价' })); - await user.click(screen.getByRole('button', { name: '确认' })); - - expect( - await screen.findByText('定价已被其他人更新,请重新读取后再编辑保存'), - ).toBeTruthy(); - expect(screen.getByRole('button', { name: '重新读取' })).toBeTruthy(); -}); - test('后端未配置 3D 段时给出提示并禁止保存', async () => { vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({ ...pricing, diff --git a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx index 14633d578..c4c57fc5a 100644 --- a/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx @@ -3,7 +3,6 @@ import { FormEvent, useEffect, useState } from 'react'; import { getAdminEditorGenerationPricing, - isAdminApiError, upsertAdminEditorGenerationPricing, } from '../api/adminApiClient'; import type { @@ -28,7 +27,6 @@ interface AdminEditorGenerationPricingPageProps { const emptyPricing: EditorGenerationPricingConfigPayload = { models: {}, model3d: null, - updatedAtMicros: 0, }; const unitLabels: Record = { @@ -36,9 +34,6 @@ const unitLabels: Record = { perSecond: '按秒', }; -const PRICING_VERSION_CONFLICT_MESSAGE = - '定价已被其他人更新,请重新读取后再编辑保存'; - export function AdminEditorGenerationPricingPage({ token, onUnauthorized, @@ -48,7 +43,6 @@ export function AdminEditorGenerationPricingPage({ const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(''); - const [isPricingVersionStale, setIsPricingVersionStale] = useState(false); const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); useEffect(() => { @@ -59,7 +53,6 @@ export function AdminEditorGenerationPricingPage({ async function refreshPricing() { setIsLoading(true); setErrorMessage(''); - setIsPricingVersionStale(false); try { setPricing(await getAdminEditorGenerationPricing(token)); } catch (error: unknown) { @@ -75,7 +68,6 @@ export function AdminEditorGenerationPricingPage({ return; } setErrorMessage(''); - setIsPricingVersionStale(false); const confirmed = await confirmWrite({ action: '保存模型定价', target: '图片画布生成', @@ -93,11 +85,6 @@ export function AdminEditorGenerationPricingPage({ ), ); } catch (error: unknown) { - if (isAdminApiError(error) && error.status === 409) { - setErrorMessage(PRICING_VERSION_CONFLICT_MESSAGE); - setIsPricingVersionStale(true); - return; - } handlePageError(error, onUnauthorized, setErrorMessage); } finally { setIsSaving(false); @@ -175,17 +162,6 @@ export function AdminEditorGenerationPricingPage({ {errorMessage ? (
{errorMessage} - {isPricingVersionStale ? ( - - ) : null}
) : null} diff --git a/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts b/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts index 028136244..b2a4d6a9a 100644 --- a/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts +++ b/apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts @@ -24,7 +24,6 @@ function pricingFixture(): EditorGenerationPricingConfigPayload { addOnPrices: { hdTexture: 8 }, }, }, - updatedAtMicros: 42, }; } @@ -73,18 +72,16 @@ test('校验覆盖模型档位与 3D 两段的全部数值', () => { ]); }); -test('保存请求整段回传两段与定价版本,3D 缺失时回传 null', () => { +test('保存请求整段回传两段且不带定价版本,3D 缺失时回传 null', () => { const pricing = pricingFixture(); expect(buildAdminEditorGenerationPricingRequest(pricing)).toEqual({ models: pricing.models, model3d: pricing.model3d, - expectedUpdatedAtMicros: 42, }); expect( buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }), ).toEqual({ models: pricing.models, model3d: null, - expectedUpdatedAtMicros: 42, }); }); diff --git a/apps/admin-web/src/pages/adminEditorGenerationPricing.ts b/apps/admin-web/src/pages/adminEditorGenerationPricing.ts index b25f4c777..044c9159f 100644 --- a/apps/admin-web/src/pages/adminEditorGenerationPricing.ts +++ b/apps/admin-web/src/pages/adminEditorGenerationPricing.ts @@ -91,7 +91,7 @@ export function collectInvalidEditorGenerationPricingLabels( } /** - * 组装保存请求:`models` 与 3D 两段整段回传,并带上读取时拿到的定价版本做乐观锁。 + * 组装保存请求:`models` 与 3D 两段整段回传,不带定价版本(保存即覆盖,服务端不比对版本)。 * 3D 段缺失时回传 `null`——服务端会以 400 拒绝,后台不允许在这里静默新建或丢弃整段。 */ export function buildAdminEditorGenerationPricingRequest( @@ -100,7 +100,6 @@ export function buildAdminEditorGenerationPricingRequest( return { models: pricing.models, model3d: pricing.model3d ?? null, - expectedUpdatedAtMicros: pricing.updatedAtMicros, }; } diff --git a/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md b/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md index 627f2ae85..e881c063c 100644 --- a/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md +++ b/docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md @@ -6,8 +6,10 @@ 选择这个方向,是因为文件方案只解决了它诞生时的临时问题:文件是发布物,改价必须重新构建并重启进程,而价格是运营参数。更糟的是后台保存把「`model3d` 省略即沿用」当合法输入,而 admin-web 会把读取到的整份配置原样回传,于是一个陈旧的浏览器标签页保存图片价格时可以静默写回旧的 3D 段。把 3D 纳入与 `models` 同一行、同一事务、同一审计与 writer 授权之后,「谁是权威」只剩一个答案,价格编辑回到已经处理过并发与权限问题的后台路径。 -代价与已知取舍:加载期必须做契约归一化。发布新增模型版本时 SpacetimeDB 行里缺键,会让整段读取失败,而失败点同时打掉后台定价页,形成「进程起不来、后台也补不了价」的死锁。因此读取方向按契约对齐——缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警,只有归一化后仍不合法才判非法;写入方向不做宽松,后台保存要求两段都显式给出且完整合法。覆盖文件从「3D 的生效路径」降级为「表为空或 SpacetimeDB 不可达时的兜底与首次种子」。 +代价与已知取舍:加载期必须做契约归一化。发布新增模型版本时 SpacetimeDB 行里缺键,会让整段读取失败,而失败点同时打掉后台定价页,形成「进程起不来、后台也补不了价」的死锁。因此读取方向按契约对齐——缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警,只有归一化后仍不合法才判非法;写入方向不做宽松,后台保存要求两段都显式给出且完整合法;写入合法性只在入库事务里判断一次,进程内缓存的替换只是刷新、不是第二个拦截点。覆盖文件从「3D 的生效路径」降级为「表为空或 SpacetimeDB 不可达时的兜底与首次种子」。 -公开读模型 `GET /api/editor/generation-pricing` 的 3D 段形状保持不变(仍是按端点判别的 `basePrices` 投影),因为它是画布 3D 入口的唯一价格真相且前端零兜底,而 nginx 静态资源与 api-server 是两条独立发布线,改形状会在错开发布窗口内让入口取不到价。保存语义同时收紧为「两段必填 + 期望版本 CAS,不一致返回 409」,旧 payload 的隐式沿用兼容不保留。 +公开读模型 `GET /api/editor/generation-pricing` 的 3D 段形状保持不变(仍是按端点判别的 `basePrices` 投影),因为它是画布 3D 入口的唯一价格真相且前端零兜底,而 nginx 静态资源与 api-server 是两条独立发布线,改形状会在错开发布窗口内让入口取不到价。保存语义同时收紧为「两段必填、整段覆盖」,旧 payload 的隐式沿用兼容不保留;保存不做版本比对、不返回 409(后台定价同时只有一个管理员在操作,2026-09-24 起删除版本号与比对)。 + +后续修订(2026-09-24):保存不再携带定价版本,`EditorGenerationPricingConfigUpsertInput` 去掉 `expected_updated_at_micros`,后台 GET 不再返回 `updatedAtMicros`,前台 409 提示与后端 409 映射一并删除。理由与取舍见决策记录「模型定价保存去掉版本比对」。 被否决的做法:**新建独立表**(同一份「模型定价」事实被拆成两行,跨行不同步与两套 writer / 审计语义,且仍要在同一逻辑保存里原子提交);**后台保存时改写 override JSON 文件**(违背「后台保存不再写文件」的口径,多进程各读各的,且与静态发布物耦合);**加载缺键即失败**(死锁,见上);**连公开读模型一起改成两段**(把发布耦合引入价格真相,收益只是形状统一)。 diff --git a/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md b/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md index 08a2eb223..de065a6b6 100644 --- a/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md +++ b/docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md @@ -1,8 +1,9 @@ # 3D 生成定价后台可编辑实施计划 -Version: 1.0 +Version: 1.1 Status: active Date: 2026-09-23 +修订: 2026-09-24 —— 保存删除定价版本比对:`SpacetimeType` input 去掉 `expected_updated_at_micros`,后台 GET 不再返回 `updatedAtMicros`,admin-web 去掉 409 分支与「重新读取」提示,改为整段覆盖;见 `docs/project-memory/shared-memory/decision-log.md`。 Milestone Spec: `docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md` ## 边界 @@ -13,16 +14,16 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】3D生成定价后台 | 文件 | 职责 | | --- | --- | -| `server-rs/crates/spacetime-module/src/editor_project_storage.rs` | 表追加两列,新增按端点分段的 `SpacetimeType` 行类型(版本价行 / 加价项行),upsert input / snapshot 扩字段,事务内做期望版本 CAS | +| `server-rs/crates/spacetime-module/src/editor_project_storage.rs` | 表追加两列,新增按端点分段的 `SpacetimeType` 行类型(版本价行 / 加价项行),upsert input / snapshot 扩字段(2026-09-24 修订后 input 不再有期望版本) | | `server-rs/crates/spacetime-module/src/migration.rs` | 行形状变更的迁移登记与白名单 | -| `server-rs/crates/spacetime-client/src/module_bindings/**`、`src/active/mapper/editor_project.rs` | 生成绑定的记录映射:两段行、定价版本字段 | +| `server-rs/crates/spacetime-client/src/module_bindings/**`、`src/active/mapper/editor_project.rs` | 生成绑定的记录映射:两段行 | | `server-rs/crates/api-server/src/tripo3d/pricing.rs` | 两段强类型定价、按段强校验、按端点段查价 | | `server-rs/crates/api-server/config/editor-generation-pricing.default.json` | 两段并列的受控默认价 | -| `server-rs/crates/api-server/src/editor_generation_config.rs` | 两段解析 / 校验 / `model3d_price` / 定价版本字段与 `with_previous_model3d` 的退役 | -| `server-rs/crates/api-server/src/state.rs` | `to_records` / `from_record` 的两段序列化与契约归一化、保存路径接入 CAS | -| `server-rs/crates/api-server/src/admin.rs` | GET 返回定价版本、POST 两段必填、409 错误映射 | -| `apps/admin-web/src/api/adminApiTypes.ts`、`adminApiClient.ts` | 两段类型与 409 透出 | -| `apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx` | 3D 区块、本地校验、409 / 400 提示 | +| `server-rs/crates/api-server/src/editor_generation_config.rs` | 两段解析 / 校验 / `model3d_price` 与 `with_previous_model3d` 的退役(2026-09-24 修订后进程内缓存只剩配置本身,不含版本) | +| `server-rs/crates/api-server/src/state.rs` | `to_records` / `from_record` 的两段序列化与契约归一化、保存路径接入入库(2026-09-24 修订后不做版本比对) | +| `server-rs/crates/api-server/src/admin.rs` | GET 返回两段定价、POST 两段必填(2026-09-24 修订后不再有定价版本与 409 映射) | +| `apps/admin-web/src/api/adminApiTypes.ts`、`adminApiClient.ts` | 两段类型(2026-09-24 修订后不再有定价版本字段) | +| `apps/admin-web/src/pages/AdminEditorGenerationPricingPage.tsx` | 3D 区块、本地校验、400 提示(2026-09-24 修订后删除 409 提示) | ## 步骤 @@ -42,13 +43,13 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】3D生成定价后台 - 验收:结构体 record 往返不丢 3D;手工从 record 删掉一个版本键后读取仍成功且补齐该键;塞入契约外键后读取成功且该键被剔除并告警。 4. **后台契约** - - GET 响应加定价版本;POST 要求两段显式给出,带期望版本做事务内 CAS,不匹配返回 409 与中文文案。 + - GET 响应给两段定价;POST 要求两段显式给出,合法即整段覆盖(2026-09-24 修订:不再带期望版本、不返回 409)。 - 退役 `with_previous_model3d` 与其用例,改为「缺段即 400」。 - - 验收:缺段 400、段内缺键 400、版本不匹配 409 且不落库;保存后公开路由立即返回新价。 + - 验收:缺段 400、段内缺键 400 且不落库;保存后公开路由立即返回新价,连续保存以来后一次为准。 5. **admin-web 3D 区块** - - 类型补两段;页面按端点渲染版本 × 两档价与加价项输入;提交前校验全部数值 ≥ 1;409 提示重新读取。 - - 验收:组件用例覆盖渲染、校验、保存 payload 与 409 提示。 + - 类型补两段;页面按端点渲染版本 × 两档价与加价项输入;提交前校验全部数值 ≥ 1。 + - 验收:组件用例覆盖渲染、校验与保存 payload。 6. **文档与验收** - 同步主规范、表目录、`CONTEXT.md`、ADR 0005 的实现状态,必要时补 `pitfalls.md`(例如「不要把省略即沿用重新引入」)。 @@ -61,7 +62,7 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】3D生成定价后台 | 1 schema 两列 | 完成 | `774b74d3d`;module 表尾部追加两列与展平行类型,绑定重新生成,`npm run check:spacetime-schema` 通过 | | 2 定价类型与默认文件 | 完成 | `9f4a71d2e`;`Model3dPricingConfig` 两段并列 + `Model3dEndpointPricing`,默认 JSON 同步 | | 3 记录转换与归一化 | 完成 | `5e47f0c41`、`952bc1cca`;新增 `editor_generation_model3d_records` 深模块,覆盖文件降级为种子与兜底 | -| 4 后台契约 | 完成 | `ee4a9434f`;GET 带版本、POST 两段必填 + 事务内 CAS → 409,退役 `with_previous_model3d` | +| 4 后台契约 | 完成 | `ee4a9434f`;GET 两段、POST 两段必填并整段覆盖(2026-09-24 删除版本 CAS / 409),退役 `with_previous_model3d` | | 5 admin-web 3D 区块 | 完成 | `c901f6954`;`AdminEditorGenerationModel3dPricingSection` + `adminEditorGenerationPricing` 纯模块 | | 6 文档与验收 | 完成 | 本文件、主规范、ADR 0005、`pitfalls.md`、`decision-log.md` | @@ -74,13 +75,13 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】3D生成定价后台 | 判据 | 证据 | | --- | --- | -| 1 后台 GET 带版本、公开 GET 形状不变 | `cargo test -p api-server app::tests::admin_editor_generation_pricing_route_saves_config_and_updates_public_route`、`app::tests::public_editor_generation_pricing_route_returns_default_config` | -| 2 缺段 400 / 版本不匹配 409 且不落库 | `app::tests::admin_editor_generation_pricing_route_rejects_missing_model3d_section`、`..._rejects_stale_version`、`state::tests::saving_pricing_with_stale_version_is_rejected` | +| 1 后台 GET 两段、公开 GET 形状不变 | `cargo test -p api-server app::tests::admin_editor_generation_pricing_route_saves_config_and_updates_public_route`、`app::tests::public_editor_generation_pricing_route_returns_default_config` | +| 2 缺段 400 且不落库、合法保存整段覆盖 | `app::tests::admin_editor_generation_pricing_route_rejects_missing_model3d_section`、`state::tests::saving_pricing_applies_explicit_model3d` / `saving_pricing_twice_keeps_last_write` | | 3 保存后公开路由立即返回新价 | 同上第一条用例在保存后重读公开路由断言新价 | | 4 缺键补齐、契约外键剔除告警 | `editor_generation_model3d_records::tests::{missing_keys_and_missing_sections_are_backfilled_from_local_config,out_of_contract_keys_are_dropped,missing_key_without_local_value_fails_closed,half_configured_sections_are_rejected,duplicate_keys_are_rejected,writing_requires_the_complete_contract_key_set}` | | 5 覆盖文件兜底与缺段补齐 | `editor_generation_config::tests::editor_generation_pricing_legacy_override_backfills_missing_model3d_section`、`..._default_path_falls_back_to_legacy_override_seed` | | 6 未配置仍 fail closed | `editor_generation_config::tests::editor_generation_pricing_fails_closed_when_model3d_section_is_absent` | -| 7 admin-web 3D 区块渲染 / 校验 / 409 | `npx vitest run apps/admin-web`(210 passed):`AdminEditorGenerationPricingPage.test.tsx`、`adminEditorGenerationPricing.test.ts` | +| 7 admin-web 3D 区块渲染 / 校验 / 保存 payload | `npx vitest run apps/admin-web`(210 passed):`AdminEditorGenerationPricingPage.test.tsx`、`adminEditorGenerationPricing.test.ts` | | 8 门禁 | `cargo test --locked -p api-server`(1183 passed,另有一条与本改动无关的时序敏感用例在整包并行下偶发失败、单跑通过)、`cargo test --locked -p spacetime-module editor_generation`、`npm run check:spacetime-schema`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check` | 尚未执行(需真实环境):手工验收「后台改 3D 价 → 不重启 → 画布 3D 面板价随之变化 → 重启后仍是保存值」;需要在发布 Stdb 模块与 API 后按开发运维文档执行。 diff --git a/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md b/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md index 8b4c56176..9b15a2a66 100644 --- a/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md +++ b/docs/project-memory/plans/【里程碑】3D生成定价后台可编辑-2026-09-23.md @@ -1,8 +1,9 @@ # 3D 生成定价后台可编辑 -Version: 1.0 +Version: 1.1 Status: 已实现,待真实环境手工验收与发布 Date: 2026-09-23 +修订: 2026-09-24 —— 后台保存删除定价版本比对(不再有 `updatedAtMicros` / `expectedUpdatedAtMicros` / 409),改为整段覆盖;理由见 `docs/project-memory/shared-memory/decision-log.md`「模型定价保存去掉版本比对」。下文「范围 / 验收判据」中涉及版本 CAS 与 409 的表述已按此修订。 Parent Spec: `docs/【编辑器】模型定价配置管理方案-2026-06-22.md` Implementation Plan: `docs/project-memory/plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md` Related: `docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md`、`docs/technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md` @@ -16,8 +17,8 @@ Related: `docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑- - 表新增 `text_to_model_pricing` / `image_to_model_pricing` 两列,沿用 `models` 的展平风格;同步 `migration.rs`、表目录文档与生成绑定。 - 定价文件的 3D 段改成两段并列形状,受控默认 JSON 与定向用例同步。 - 读取方向契约归一化:表内缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警。 -- 后台 GET / POST:两段必填,携带读取时得到的定价版本做 CAS,不一致返回 409;保存成功后返回权威快照。 -- admin-web「模型定价」页新增 3D 区块:按端点分组,模型版本 × 两档价 + 六个加价项;提交前本地校验与 400 / 409 中文提示。 +- 后台 GET / POST:两段必填;保存是整段覆盖、不做版本比对(2026-09-24 修订后不再携带定价版本、不返回 409),保存成功后返回权威快照。 +- admin-web「模型定价」页新增 3D 区块:按端点分组,模型版本 × 两档价 + 六个加价项;提交前本地校验与 400 中文提示。 - 覆盖文件降级为种子与兜底,不再是 3D 改价的生效路径。 ## 不做 @@ -29,13 +30,13 @@ Related: `docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑- ## 验收标准 -1. 后台 GET 返回两段 3D 定价与定价版本;公开 GET 的 3D 段形状与本期之前逐字段一致。 -2. 后台 POST 缺少任一段 → 400;段内任一键缺失或非法 → 400;版本不匹配 → 409,且两段与 `models` 均不落库。 +1. 后台 GET 返回两段 3D 定价;公开 GET 的 3D 段形状与本期之前逐字段一致。 +2. 后台 POST 缺少任一段 → 400;段内任一键缺失或非法 → 400,且两段与 `models` 均不落库;合法请求整段覆盖,连续保存两次以后一次为准。 3. 保存成功后不重启进程,`GET /api/editor/generation-pricing` 立即反映新价;重启后仍是保存后的值。 4. 表内两段缺键(模拟发布新增模型版本)时读取自动补齐并记录告警,读取不失败;契约之外的遗留键被剔除并记录告警。 5. 表为空或 SpacetimeDB 不可达时,3D 定价用默认 JSON / 覆盖文件同段值兜底;override 文件的存在不再导致「重启覆盖已保存价格」。 6. 3D 定价未配置时提交仍 fail closed(503 `model3d-pricing-unavailable`),不扣费、不入队。 -7. admin-web 3D 区块渲染两个端点各 5 行 × 2 档价与 6 个加价项;存在 0 或空值时禁用保存并给出中文提示;收到 409 时提示重新读取。 +7. admin-web 3D 区块渲染两个端点各 5 行 × 2 档价与 6 个加价项;存在 0 或空值时禁用保存并给出中文提示。 8. `npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test --locked -p api-server` 定向用例、admin-web 定向 vitest、`node scripts/check-doc-index.mjs`、`git diff --check` 全部通过。 ## 实现与验收记录(2026-09-23) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 97cb2b878..53ac8d659 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,15 @@ # 决策记录 +## 2026-09-24 模型定价保存去掉版本比对:后台同时只有一个管理员在操作 + +- 背景:2026-09-23 引入的「行内 `updated_at` 版本比对」(后台 GET 回 `updatedAtMicros`、POST 带 `expectedUpdatedAtMicros`、procedure 在事务内比对、不一致返回 409)落地后暴露两件事。一是这个版本号没有真实并发场景支撑:后台定价页同时只有一个管理员在操作,409 的后果只是「刷新一下再保存」,却给前后端都加了一条无从处理的失败路径。二是读内容与读版本号原本是两个分开的动作,在「读数据库失败、退回进程内缓存」这条少见分支上,两次读之间若有人写入,调用方会拿到旧内容配新版本号——本该被拦下的写入会被放行,本该能保存的反而被当成版本冲突。 +- 决策:整体删除版本比对与版本号。`EditorGenerationPricingConfigUpsertInput` 去掉 `expected_updated_at_micros`;`EditorGenerationPricingStore` 去掉版本字段与对应方法;后台 GET 不再返回 `updatedAtMicros`、POST 不再带 `expectedUpdatedAtMicros`,冲突 409 分支与前端「重新读取」提示一并删除;`save_editor_generation_pricing` 与 `editor_generation_pricing()` 只返回配置本身。保存语义固定为「整段覆盖」:请求里的 `models` 与两段 3D 价格就是新的最终状态,后一次保存整体取代前一次。`EditorGenerationPricingStore::replace` 明确记为「只刷新进程内缓存,不做版本校验、不是拦截点」,写入判断只发生在 procedure 的入库事务里。 +- 原因:**没有并发就没有版本号的价值**——加它只是把一次普通的覆盖保存变成需要用户处理的中断;**两次读本来就是错的形状**,正确做法是「保存即覆盖」而不是把内容和版本号拆开再拼回去;**拦截点唯一**,写入合法性只在 procedure 的入库事务里判断一次,进程内缓存不该有第二份判断逻辑。 +- 代价与取舍:放弃「两个人同时编辑时提示冲突」的能力,靠「同时只有一个管理员在操作」这个前提兜住;前提一旦不成立(例如以后开多人同时改价),必须重新设计,而不是把版本号加回来。后台不再能区分「我读到的是不是最新」,页面只能在保存后重新读取确认。 +- 影响面:`server-rs/crates/shared-contracts/src/editor_generation.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs` 与生成绑定、`server-rs/crates/api-server/src/{editor_generation_config.rs,state.rs,admin.rs,app.rs}`、`apps/admin-web/src/{api/adminApiTypes.ts,pages/AdminEditorGenerationPricingPage.tsx,pages/adminEditorGenerationPricing.ts}` 及对应测试、主规范「保存语义」段、后端数据契约文档、ADR 0005、`pitfalls.md`。 +- 验证方式:`cargo test --locked -p api-server -- editor_generation_pricing saving_pricing`、`cargo test --locked -p spacetime-module -- editor_generation_pricing`、`npx vitest run apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx apps/admin-web/src/pages/adminEditorGenerationPricing.test.ts`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 +- 关联文档:[ADR 0005](../../adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md)、[编辑器模型定价配置管理方案](../../【编辑器】模型定价配置管理方案-2026-06-22.md)、[后端架构 server-rs 与 SpacetimeDB 数据契约](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。 + ## 2026-09-24 3D 参数面板开放模型选择:版本取「本端点有底价」的键,能力规则抄 provider,端点与提交体由类型绑死 - 背景:3D 面板此前把请求体与报价里的 `model` 一起写死成 `DEFAULT_MODEL3D_MODEL_VERSION`(`v3.1-20260211`),而公开读模型的定价段是按 `endpoint × modelVersion` 展开的;后端加载即校验「每个契约版本 × 两个端点都必须有底价」,于是定价表里已配好的 `v3.0` / `v2.5` / `P1` / `P2` 四个版本在前端永远不可达,用户也看不到任何模型选择入口。同一轮还暴露两个类型层面的口子:提交函数签名是「endpoint 联合 + 提交体联合」两个彼此独立的字段,把图生请求体发到文生地址照样编译通过;`Model3dPricingConfig.basePrices` 内层键写成 `string`,写错的版本号在编译期没有任何反馈。 @@ -9389,6 +9399,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 影响面:`server-rs/crates/api-server/src/{tripo3d/pricing.rs,editor_generation_config.rs,editor_generation_model3d_records.rs,state.rs,admin.rs,app.rs,editor_project.rs}`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs` 与生成绑定、`server-rs/crates/shared-contracts/src/editor_generation.rs`、`apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.ts,pages/AdminEditorGenerationPricingPage.tsx,pages/AdminEditorGenerationModel3dPricingSection.tsx,pages/adminEditorGenerationPricing.ts}`、主规范、里程碑与实施计划、`pitfalls.md`。 - 验证方式:`cargo test --locked -p api-server`(1183 passed;另有一条与本改动无关的时序敏感用例在整包并行下偶发失败、单跑通过)、`cargo test --locked -p spacetime-module editor_generation`、`npm run check:spacetime-schema`、`npm run check:encoding`、`node scripts/check-doc-index.mjs`、`git diff --check`、`npx vitest run apps/admin-web`(210 passed)、`npx tsc --noEmit -p apps/admin-web/tsconfig.json` 与改动文件 eslint。真实环境手工验收与发布尚未执行。 - 关联文档:[ADR 0005](../../adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md)、[编辑器模型定价配置管理方案](../../【编辑器】模型定价配置管理方案-2026-06-22.md)、[实施计划](../plans/【实施计划】3D生成定价后台可编辑-2026-09-23.md)。 +- 后续修订(2026-09-24):本条 ② 的版本比对与 409 已整体删除,保存改为整段覆盖,见同日决策「模型定价保存去掉版本比对」。 ## 2026-09-23 3D 提交落点改为平坦可选字段:`target` tagged enum 作废,服务端按二选一校验 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index dbb8004e9..b2152a47d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -6004,9 +6004,9 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:admin-web 把后台读取到的整份定价原样回传保存;当 3D 段只在进程内存(文件配置)里时,陈旧的浏览器标签页保存图片价格会把旧的 3D 段一并写回,价格在无人察觉的情况下回滚。 - **成因**:后端把「`model3d` 缺席或为 null」解释成「沿用当前 3D 段」(`with_previous_model3d`),于是 payload 的形状本身携带了两种语义:给值是覆盖,不给是沿用;而前端是整份回传,两种语义在真实浏览器里无法区分。 -- **处理(现行口径)**:定价保存不再有隐含沿用语义——`models` 与 3D 两段都必须显式给出,缺段即 400;同时用读取时拿到的定价版本做事务内乐观锁,不一致返回 409。3D 定价存进 SpacetimeDB 与 `models` 同事务后,任何一次保存写的都是页面当时看到的完整状态。 -- **易错点**:① 不要为了「顺手支持只改一个字段」再引入任何「省略 = 沿用」分支,缺段就该报错;② 乐观锁的版本必须由读取回包带出并原样回传,不能在前端自造或缓存;③ 首次种子写入(表为空)显式不带期望版本,表内没有行时不允许凭兜底版本直接创建。 -- **验证**:`server-rs/crates/api-server/src/app.rs` 的 `admin_editor_generation_pricing_route_rejects_missing_model3d_section` / `..._rejects_stale_version` / `..._saves_config_and_updates_public_route`;`state::tests::saving_pricing_with_stale_version_is_rejected`;module 侧 `editor_generation_pricing_expected_version_must_match_current_row`。 +- **处理(现行口径,2026-09-24 修订)**:定价保存不再有隐含沿用语义——`models` 与 3D 两段都必须显式给出,缺段即 400。3D 定价存进 SpacetimeDB 与 `models` 同事务后,任何一次保存写的都是页面当时看到的完整状态;保存本身不做版本比对,写进去的整段直接成为新状态(后台定价同时只有一个管理员在操作,所以不再需要版本号;原先的版本号比对已删除)。 +- **易错点**:① 不要为了「顺手支持只改一个字段」再引入任何「省略 = 沿用」分支,缺段就该报错;② 不要再把版本号加回来——后台定价假设同时只有一个管理员在线,保存是整段覆盖,读到的旧版本号只会制造 409 这类无从处理的失败;③ 表为空时的种子写入仍由 procedure 的入库事务负责,不靠「先读空、再无脑写」的两步流程。 +- **验证**:`server-rs/crates/api-server/src/app.rs` 的 `admin_editor_generation_pricing_route_rejects_missing_model3d_section` / `admin_editor_generation_pricing_route_saves_config_and_updates_public_route`;`state::tests::saving_pricing_applies_explicit_model3d` / `saving_pricing_twice_keeps_last_write`(后一次保存直接覆盖前一次)。 - **关联**:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/adr/【ADR】0005-3D生成定价迁入SpacetimeDB与后台编辑-2026-09-23.md`。 ## 2026-09-23 公开读模型改了存储形状就等于在两条发布线上做破坏性变更 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a8d391a2d..279cc8003 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -643,7 +643,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorGenerationPricingConfig` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- - 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 3D 生成定价按端点拆成 `text_to_model_pricing` / `image_to_model_pricing` 两列(追加在结构体末尾并带显式默认值),列内同样展平为强类型行:版本键一行含无贴图 / 带贴图两档价,加价项一行一条,不在一段里用端点做判别键;两段各自要求全版本 × 两档价与六个加价项键齐全。api-server 读取时按契约归一化——表内缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警,只有归一化后仍不合法才判非法;后台保存要求两段完整合法并与 `models` 同事务写入。`EditorGenerationPricingConfigUpsertInput` 末尾携带可选 `expected_updated_at_micros`:后台保存必须带上读取时拿到的版本,procedure 在事务内与行内 `updated_at` 比对,不一致整笔拒绝(api-server 映射成 409);首次种子写入显式不带该字段,表内没有行时也不允许凭兜底版本创建。公开读模型 `GET /api/editor/generation-pricing` 的 3D 段仍是既有的按端点判别投影,不随存储形状变化。 +- - 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 只保留在 private 表内,记录首次初始化的真实 `ctx.sender()`,不进入 procedure 返回快照;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时只有 HTTP 角色通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;worker / controller 启动只调用受鉴权的 queue-stats procedure 做只读身份预检。后台保存请求携带 `AppConfig` 中的受保护 bootstrap secret,以便配置行意外缺失时原子恢复;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,当前生产 API / worker / controller 因此继承同一 runtime token;迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。 3D 生成定价按端点拆成 `text_to_model_pricing` / `image_to_model_pricing` 两列(追加在结构体末尾并带显式默认值),列内同样展平为强类型行:版本键一行含无贴图 / 带贴图两档价,加价项一行一条,不在一段里用端点做判别键;两段各自要求全版本 × 两档价与六个加价项键齐全。api-server 读取时按契约归一化——表内缺键用默认 JSON / 覆盖文件同段值补齐,契约之外的遗留键剔除并告警,只有归一化后仍不合法才判非法;后台保存要求两段完整合法并与 `models` 同事务写入。`EditorGenerationPricingConfigUpsertInput` 不带版本字段:后台保存不做版本比对,请求里的两段与 `models` 整段覆盖即最终状态(后台定价同时只有一个管理员在操作,不引入版本号与拦截)。`api-server` 侧替换进程内缓存只做缓存刷新,不做版本校验、也不是拦截点。公开读模型 `GET /api/editor/generation-pricing` 的 3D 段仍是既有的按端点判别投影,不随存储形状变化。 - 索引:主键 `config_id`。 ### `editor_generation_operation` diff --git a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md index 86d1b5692..b07ec77da 100644 --- a/docs/【编辑器】模型定价配置管理方案-2026-06-22.md +++ b/docs/【编辑器】模型定价配置管理方案-2026-06-22.md @@ -83,8 +83,8 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 ## 后端契约 - `GET /api/editor/generation-pricing`:主站读取当前模型定价,3D 段按旧形状投影(见「配置结构」)。 -- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价,返回 `models`、3D 两段与 `updatedAtMicros` 定价版本。 -- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,`models` 与 3D 两段都必须显式给出(缺段即 400,不接受「省略即沿用」),并写入 SpacetimeDB `editor_generation_pricing_config`;请求必须携带读取时得到的定价版本(`expectedUpdatedAtMicros`),与服务端当前版本不一致时返回 409 并要求重新读取,不允许静默覆盖他人改动;只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。乐观锁版本复用行内 `updated_at` 微秒值,比对在 procedure 事务内完成;首次种子写入不带期望版本,表内没有行时不允许凭兜底版本直接创建。 +- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价,返回 `models` 与 3D 两段;不返回定价版本。 +- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,`models` 与 3D 两段都必须显式给出(缺段即 400,不接受「省略即沿用」),并写入 SpacetimeDB `editor_generation_pricing_config`;保存**不做版本校验**——请求不带定价版本,写进去的整段就是最终状态,服务端不比对、不返回 409(后台同时只有一个管理员在操作,见「保存语义」)。只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。 后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为表内 `writer_identity`;procedure 对外返回的定价快照不包含该身份字段,公开主站和后台仍只经 BFF 读取价格。表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer;若运行中的配置行意外缺失,保存请求会携带 `AppConfig` 已读取的受保护 bootstrap secret 完成原子首次写入,已有配置不会消费该 secret,也不会隐式轮换 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。 @@ -92,6 +92,13 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝 SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override 可能只有 `audio1.0`。读取这类历史快照时,`api-server` 只允许从当前受控默认配置补入缺失的 `eleven_text_to_sound_v2` 条目,再执行完整配置校验,使旧快照可继续读取;其它必需模型缺失仍失败。该兼容不修改 schema,也不在读取时写数据库或 override;下一次后台保存完整定价矩阵时自然持久化新键。发布前仍应确认运行时配置中的新键和价格已经批准。 +## 保存语义(2026-09-24 定稿) + +- 保存是「整段覆盖」:请求里的 `models` 与 3D 两段直接成为新的权威状态,服务端不做「谁先读谁后读」的比对。 +- 后台定价同时只有一个管理员在操作,因此不引入版本号、不加拦截;上一次保存的内容会被下一次保存整体取代。 +- `api-server` 侧替换进程内缓存的方法(`EditorGenerationPricingStore::replace`)**只是缓存刷新,不做版本校验,也不是拦截点**:它的职责是把刚入库的权威快照同步到本地缓存,任何写入判断都已经在 procedure 的入库事务里完成。 +- 该替换在入库成功之后执行;入库失败时进程内缓存保持原值。 + ## 运行时身份首次授权 模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限;因此当前生产 API、worker 和 controller 必须继承同一份 runtime token。非 HTTP 角色只做 queue procedure 鉴权预检,不具备 seed 或轮换身份的职责。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operator,bootstrap secret 不得再新增或接管 operator。 @@ -117,7 +124,7 @@ SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override - 每组是一张「模型版本 × {无贴图, 带贴图}」的数字表,加上该端点自己的六个加价项单值输入。 - 模型版本键与加价项键都来自接口返回,后台不硬编码版本清单,也不能增删键、不能停用整段;新增模型版本仍是「先改契约枚举与默认 JSON,再发布」的动作。 - 提交前要求全部数值 ≥ 1,否则禁用保存并给出中文提示;服务端 400 的中文错误原样透出,保存成功后用回包覆盖本地状态。 -- 服务端返回 409 时提示「定价已被其他人更新,请刷新后重新编辑」,并提供重新读取入口。 +- 保存不做版本比对:页面不会收到 409,也没有「定价已被其他人更新」这类提示;保存成功后回包直接成为页面状态(同时只有一个管理员在操作后台定价)。 ## 前端展示 @@ -132,8 +139,8 @@ SFX V2 上线前已经存在的 SpacetimeDB 定价快照或旧本地 override - 部署脚本 Bash 语法、生产运维静态门禁、64 位十六进制 secret 校验、manifest 摘要匹配、Build / Publish credential ID 一致性、bootstrap secret 明文日志扫描和 FILE 权限 / 服务重启检查。 - 前端价格读取、运行时覆盖、图片尺寸档位计算测试。 - 管理端模型定价页面单位展示和档位保存测试。 -- 3D 定价两段的加载归一化(缺键补齐、契约外键剔除)、后台保存与重启读取、缺段 400、版本不匹配 409、公开读取仍返回 3D 段且形状不变的测试。 -- 管理端 3D 区块的渲染、数值校验、保存 payload 与 409 提示测试;画布 3D 入口在后台改价后立即读到新价的定向验证。 +- 3D 定价两段的加载归一化(缺键补齐、契约外键剔除)、后台保存与重启读取、缺段 400、公开读取仍返回 3D 段且形状不变的测试;后台连续保存两次时以后一次为准。 +- 管理端 3D 区块的渲染、数值校验与保存 payload 测试;画布 3D 入口在后台改价后立即读到新价的定向验证。 ## 旧形状覆盖文件的处理(2026-09-23 定稿) diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 994d0a06b..9329d07d7 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -958,25 +958,23 @@ pub async fn admin_upsert_feature_gate_config( )) } -/// 后台定价响应:`models` 与 3D 两段原样给出,外加当前定价版本。 -/// -/// 版本是后台保存的乐观锁凭据:页面保存时把读到的值原样回传,服务端比对不上就整体拒绝。 +/// 后台定价响应:`models` 与 3D 两段原样给出。 #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct AdminEditorGenerationPricingResponse { pub models: BTreeMap, #[serde(skip_serializing_if = "Option::is_none")] pub model3d: Option, - pub updated_at_micros: i64, } /// 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,不接受「省略即沿用」。 +/// +/// 不带定价版本:保存不做版本比对,整段覆盖就是最终状态(后台同时只有一个管理员在操作)。 #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AdminUpsertEditorGenerationPricingRequest { pub models: BTreeMap, pub model3d: Option, - pub expected_updated_at_micros: Option, } /// 后台读取画布生成模型定价配置。 @@ -985,13 +983,13 @@ pub async fn admin_get_editor_generation_pricing( Extension(request_context): Extension, Extension(_admin): Extension, ) -> Result, AppError> { - let (pricing, updated_at_micros) = state - .editor_generation_pricing_with_version() + let pricing = state + .editor_generation_pricing() .await .map_err(map_admin_editor_generation_pricing_error)?; Ok(json_success_body( Some(&request_context), - build_admin_editor_generation_pricing_response(pricing, updated_at_micros), + build_admin_editor_generation_pricing_response(pricing), )) } @@ -1002,30 +1000,23 @@ pub async fn admin_upsert_editor_generation_pricing( Extension(admin): Extension, Json(payload): Json, ) -> Result, AppError> { - let (pricing, expected_updated_at_micros) = - validate_admin_editor_generation_pricing_payload(payload)?; - let (pricing, updated_at_micros) = state - .save_editor_generation_pricing( - admin.session().subject.clone(), - pricing, - expected_updated_at_micros, - ) + let pricing = validate_admin_editor_generation_pricing_payload(payload)?; + let pricing = state + .save_editor_generation_pricing(admin.session().subject.clone(), pricing) .await .map_err(map_admin_editor_generation_pricing_error)?; Ok(json_success_body( Some(&request_context), - build_admin_editor_generation_pricing_response(pricing, updated_at_micros), + build_admin_editor_generation_pricing_response(pricing), )) } fn build_admin_editor_generation_pricing_response( pricing: EditorGenerationPricingConfig, - updated_at_micros: i64, ) -> AdminEditorGenerationPricingResponse { AdminEditorGenerationPricingResponse { models: pricing.models, model3d: pricing.model3d, - updated_at_micros, } } @@ -1035,23 +1026,16 @@ fn build_admin_editor_generation_pricing_response( /// 键集合由契约决定,后台只能改数值,因此这里不接受的只是「没给」和「给不齐」。 fn validate_admin_editor_generation_pricing_payload( payload: AdminUpsertEditorGenerationPricingRequest, -) -> Result<(EditorGenerationPricingConfig, i64), AppError> { +) -> Result { let Some(model3d) = payload.model3d else { return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message( "model3d 定价段必须整段给出(含 textToModelPricing 与 imageToModelPricing),不能省略或置空", )); }; - let Some(expected_updated_at_micros) = payload.expected_updated_at_micros else { - return Err(AppError::from_status(StatusCode::BAD_REQUEST) - .with_message("缺少 expectedUpdatedAtMicros,请重新读取当前定价后再保存")); - }; - Ok(( - EditorGenerationPricingConfig { - models: payload.models, - model3d: Some(model3d), - }, - expected_updated_at_micros, - )) + Ok(EditorGenerationPricingConfig { + models: payload.models, + model3d: Some(model3d), + }) } #[cfg(any())] @@ -2101,10 +2085,6 @@ fn map_admin_editor_generation_pricing_error( crate::editor_generation_config::EditorGenerationPricingError::Invalid(_) => { StatusCode::BAD_REQUEST } - // 乐观锁冲突:客户端必须重新读取,不能重试同一个 payload。 - crate::editor_generation_config::EditorGenerationPricingError::Conflict => { - StatusCode::CONFLICT - } _ => StatusCode::INTERNAL_SERVER_ERROR, }; AppError::from_status(status).with_details(serde_json::json!({ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index fd439acc6..89f01a851 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -6228,11 +6228,8 @@ mod tests { async fn admin_editor_generation_pricing_route_saves_config_and_updates_public_route() { let (app, admin_token) = admin_pricing_test_app().await; - // 与后台页面同一套流程:先读旧值与版本,改完原样回传版本做乐观锁。 + // 与后台页面同一套流程:先读旧值,改完整段回传。 let before = read_admin_editor_generation_pricing(&app, &admin_token).await; - let version = before["updatedAtMicros"] - .as_i64() - .expect("后台定价必须返回版本"); let mut model3d = before["model3d"].clone(); assert!(!model3d.is_null(), "默认定价必须带 3D 两段"); // 先按路径读到目标键再改:`Value::index_mut` 在键缺失时直接 panic,默认定价 @@ -6253,7 +6250,6 @@ mod tests { serde_json::json!({ "models": admin_pricing_models_payload(), "model3d": model3d, - "expectedUpdatedAtMicros": version, }), ) .await; @@ -6267,10 +6263,6 @@ mod tests { .to_bytes(); let saved: Value = serde_json::from_slice(&body).expect("pricing save payload should be json"); - let saved_version = saved["updatedAtMicros"] - .as_i64() - .expect("保存回包必须带新版本"); - assert!(saved_version > version, "保存必须推进定价版本"); assert_eq!( saved["model3d"]["textToModelPricing"]["addOnPrices"]["quadMesh"], Value::Number(9.into()) @@ -6308,16 +6300,12 @@ mod tests { async fn admin_editor_generation_pricing_route_rejects_missing_model3d_section() { let (app, admin_token) = admin_pricing_test_app().await; let before = read_admin_editor_generation_pricing(&app, &admin_token).await; - let version = before["updatedAtMicros"] - .as_i64() - .expect("后台定价必须返回版本"); let response = save_admin_editor_generation_pricing( &app, &admin_token, serde_json::json!({ "models": admin_pricing_models_payload(), - "expectedUpdatedAtMicros": version, }), ) .await; @@ -6336,58 +6324,14 @@ mod tests { .is_some_and(|message| message.contains("定价段必须整段给出")), "缺 3D 段必须以中文文案拒绝,实际为 {payload}" ); - // 拒绝的保存不能落库:旧价与旧版本都必须原样保留。 + // 拒绝的保存不能落库:旧价必须原样保留。 let after = read_admin_editor_generation_pricing(&app, &admin_token).await; - assert_eq!(after["updatedAtMicros"], before["updatedAtMicros"]); assert_eq!( after["models"]["gpt-image-2"]["prices"]["2K"], before["models"]["gpt-image-2"]["prices"]["2K"] ); } - #[tokio::test] - async fn admin_editor_generation_pricing_route_rejects_stale_version() { - let (app, admin_token) = admin_pricing_test_app().await; - let before = read_admin_editor_generation_pricing(&app, &admin_token).await; - let version = before["updatedAtMicros"] - .as_i64() - .expect("后台定价必须返回版本"); - let model3d = before["model3d"].clone(); - - let first = save_admin_editor_generation_pricing( - &app, - &admin_token, - serde_json::json!({ - "models": admin_pricing_models_payload(), - "model3d": model3d, - "expectedUpdatedAtMicros": version, - }), - ) - .await; - assert_eq!(first.status(), StatusCode::OK); - - // 第二个客户端拿着过期版本保存:必须 409,且不能把已保存的价格覆盖回去。 - let mut stale_models = admin_pricing_models_payload(); - stale_models["gpt-image-2"]["prices"]["2K"] = Value::Number(1.into()); - let stale = save_admin_editor_generation_pricing( - &app, - &admin_token, - serde_json::json!({ - "models": stale_models, - "model3d": model3d, - "expectedUpdatedAtMicros": version, - }), - ) - .await; - assert_eq!(stale.status(), StatusCode::CONFLICT); - - let after = read_admin_editor_generation_pricing(&app, &admin_token).await; - assert_eq!( - after["models"]["gpt-image-2"]["prices"]["2K"], - Value::Number(62.into()) - ); - } - /// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。 #[cfg(any())] #[tokio::test] diff --git a/server-rs/crates/api-server/src/editor_generation_config.rs b/server-rs/crates/api-server/src/editor_generation_config.rs index 614411d3b..988d6ff63 100644 --- a/server-rs/crates/api-server/src/editor_generation_config.rs +++ b/server-rs/crates/api-server/src/editor_generation_config.rs @@ -2,10 +2,7 @@ use std::{ collections::BTreeMap, fmt, fs, io, path::{Path, PathBuf}, - sync::{ - Arc, RwLock, - atomic::{AtomicI64, Ordering}, - }, + sync::{Arc, RwLock}, }; use serde::{Deserialize, Serialize}; @@ -102,9 +99,6 @@ pub(crate) struct EditorGenerationPricingPublicResponse { #[derive(Clone, Debug)] pub(crate) struct EditorGenerationPricingStore { current: Arc>, - /// 当前配置的定价版本:SpacetimeDB 行的 `updated_at` 微秒值。0 表示还没读到过行 - /// (表为空或读不到),后台保存的乐观锁就比对它。 - version_micros: Arc, } #[derive(Debug)] @@ -114,8 +108,6 @@ pub(crate) enum EditorGenerationPricingError { Invalid(String), /// 3D 定价查询失败:结构化保留「未配置 / 缺底价 / 缺加价项」三种语义。 Model3d(Model3dPricingError), - /// 保存时发现定价已被他人改动:后台必须重新读取后再保存,不能静默覆盖。 - Conflict, #[cfg_attr(test, allow(dead_code))] Persistence(String), LockPoisoned, @@ -320,15 +312,9 @@ impl EditorGenerationPricingStore { let current = load_editor_generation_pricing_from_paths(Some(&override_path))?; Ok(Self { current: Arc::new(RwLock::new(current)), - version_micros: Arc::new(AtomicI64::new(0)), }) } - /// 当前定价版本。后台把它发给前端,保存时原样回传做乐观锁比对。 - pub(crate) fn version_micros(&self) -> i64 { - self.version_micros.load(Ordering::Acquire) - } - pub(crate) fn snapshot( &self, ) -> Result { @@ -338,10 +324,13 @@ impl EditorGenerationPricingStore { .map(|guard| guard.clone()) } + /// 覆盖进程内缓存的定价配置。 + /// + /// 这里只是缓存:保存要不要放行不在这里判断(后台同时只有一个管理员在操作,保存不再做 + /// 版本比对),读取失败时的兜底也直接用这份缓存,所以它不是任何写入的拦截点。 pub(crate) fn replace( &self, next: EditorGenerationPricingConfig, - version_micros: i64, ) -> Result { next.validate()?; let mut guard = self @@ -349,7 +338,6 @@ impl EditorGenerationPricingStore { .write() .map_err(|_| EditorGenerationPricingError::LockPoisoned)?; *guard = next.clone(); - self.version_micros.store(version_micros, Ordering::Release); Ok(next) } } @@ -716,7 +704,6 @@ impl fmt::Display for EditorGenerationPricingError { Self::Invalid(message) => write!(f, "模型定价配置不合法:{message}"), Self::Persistence(message) => write!(f, "模型定价配置入库失败:{message}"), Self::LockPoisoned => write!(f, "模型定价配置锁已损坏"), - Self::Conflict => write!(f, "定价已被其他人更新,请重新读取后再保存"), // 直接透传内层文案,保持接口响应里的 message 与改造前一致。 Self::Model3d(error) => write!(f, "{error}"), } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 81ca0b913..64bbe3e7c 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -538,7 +538,6 @@ fn editor_generation_pricing_upsert_input( admin_user_id: String, records: EditorGenerationPricingRecordInput, updated_at_micros: i64, - expected_updated_at_micros: Option, ) -> EditorGenerationPricingConfigUpsertRecordInput { EditorGenerationPricingConfigUpsertRecordInput { admin_user_id, @@ -550,25 +549,9 @@ fn editor_generation_pricing_upsert_input( .unwrap_or_default(), text_to_model_pricing: records.model3d.text_to_model_pricing, image_to_model_pricing: records.model3d.image_to_model_pricing, - expected_updated_at_micros, } } -/// procedure 只能返回字符串错误,乐观锁冲突靠共享常量识别,其余仍按持久化失败处理。 -#[cfg_attr(test, allow(dead_code))] -fn map_editor_generation_pricing_persistence_error( - error: SpacetimeClientError, -) -> EditorGenerationPricingError { - if matches!( - &error, - SpacetimeClientError::Procedure(message) - if message == shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT - ) { - return EditorGenerationPricingError::Conflict; - } - EditorGenerationPricingError::Persistence(error.to_string()) -} - impl AppState { #[cfg(test)] pub fn new(config: AppConfig) -> Result { @@ -793,24 +776,16 @@ impl AppState { self.http_request_permit_pools.clone() } + /// 当前定价配置:优先读 SpacetimeDB 行,读不到就用进程内缓存兜底。 + /// + /// 读取不再带版本:后台保存不做版本比对(同一时间只有一个管理员在操作),进程内缓存只 + /// 承担「读库失败时的兜底」,不是任何写入的拦截点。 pub(crate) async fn editor_generation_pricing( &self, ) -> Result { - self.editor_generation_pricing_with_version() - .await - .map(|(pricing, _)| pricing) - } - - /// 定价配置 + 当前定价版本(SpacetimeDB 行的 `updated_at` 微秒值)。 - /// - /// 后台读取走这里:前端保存时要把版本原样回传,服务端在同一事务里比对,不一致就整笔拒绝。 - pub(crate) async fn editor_generation_pricing_with_version( - &self, - ) -> Result<(EditorGenerationPricingConfig, i64), EditorGenerationPricingError> { #[cfg(test)] { - let store = &self.editor_generation_pricing_store; - return Ok((store.snapshot()?, store.version_micros())); + return self.editor_generation_pricing_store.snapshot(); } #[cfg(not(test))] @@ -820,12 +795,11 @@ impl AppState { .await { Ok(Some(record)) => { - let version_micros = record.updated_at_micros; let legacy_fallback = self.editor_generation_pricing_store.snapshot()?; let pricing = editor_generation_pricing_from_record(record, &legacy_fallback)?; self.editor_generation_pricing_store - .replace(pricing.clone(), version_micros)?; - Ok((pricing, version_micros)) + .replace(pricing.clone())?; + Ok(pricing) } Ok(None) => self.seed_editor_generation_pricing_config().await, Err(error) => { @@ -833,61 +807,43 @@ impl AppState { error = %error, "读取 SpacetimeDB 模型定价配置失败,使用本地缓存兜底" ); - let store = &self.editor_generation_pricing_store; - Ok((store.snapshot()?, store.version_micros())) + self.editor_generation_pricing_store.snapshot() } } } - /// 保存完整定价配置(`models` 与 3D 两段)并做乐观锁比对。 + /// 保存完整定价配置(`models` 与 3D 两段)。 /// - /// `expected_updated_at_micros` 是前端读取时拿到的版本;与库内当前版本不一致时保存整体 - /// 拒绝,返回 [`EditorGenerationPricingError::Conflict`],绝不静默覆盖他人改动。 + /// 保存就是整段覆盖,不再做版本比对:后台同时只有一个管理员在操作,读到旧值再保存的 + /// 并发窗口按「后写覆盖先写」处理;进程内缓存只做读取失败时的兜底,不是拦截点。 pub(crate) async fn save_editor_generation_pricing( &self, admin_user_id: String, next: EditorGenerationPricingConfig, - expected_updated_at_micros: i64, - ) -> Result<(EditorGenerationPricingConfig, i64), EditorGenerationPricingError> { + ) -> Result { #[cfg(test)] { let _ = admin_user_id; - let store = &self.editor_generation_pricing_store; - if store.version_micros() != expected_updated_at_micros { - return Err(EditorGenerationPricingError::Conflict); - } - let next_version_micros = store.version_micros().saturating_add(1); - return Ok(( - store.replace(next, next_version_micros)?, - next_version_micros, - )); + return self.editor_generation_pricing_store.replace(next); } #[cfg(not(test))] { let records = editor_generation_pricing_to_records(&next)?; - // 版本取「当前时间」与「期望版本 + 1」的较大值:同一微秒内的连续保存也必须推进版本, - // 否则旧的期望版本会在下一次保存时被误判为仍然有效。 - // expected_updated_at_micros 来自后台请求体,只有「必须给出」的校验、没有范围校验: - // 直接 +1 在 i64::MAX 上会溢出(debug 下 panic,release 下回绕成 i64::MIN)。 - let updated_at_micros = crate::editor_project::current_utc_micros() - .max(expected_updated_at_micros.saturating_add(1)); let record = self .spacetime_client .upsert_editor_generation_pricing_config(editor_generation_pricing_upsert_input( &self.config, admin_user_id, records, - updated_at_micros, - Some(expected_updated_at_micros), + crate::editor_project::current_utc_micros(), )) .await - .map_err(map_editor_generation_pricing_persistence_error)?; - let version_micros = record.updated_at_micros; + .map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?; let pricing = editor_generation_pricing_from_record(record, &next)?; self.editor_generation_pricing_store - .replace(pricing.clone(), version_micros)?; - Ok((pricing, version_micros)) + .replace(pricing.clone())?; + Ok(pricing) } } @@ -901,7 +857,7 @@ impl AppState { #[cfg_attr(test, allow(dead_code))] async fn seed_editor_generation_pricing_config( &self, - ) -> Result<(EditorGenerationPricingConfig, i64), EditorGenerationPricingError> { + ) -> Result { let fallback = self.editor_generation_pricing_store.snapshot()?; let records = editor_generation_pricing_to_records(&fallback)?; let record = self @@ -912,17 +868,14 @@ impl AppState { "system:editor-generation-pricing".to_string(), records, crate::editor_project::current_utc_micros(), - // 首次种子写入没有可比的版本,显式声明不比对。 - None, ), ) .await .map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?; - let version_micros = record.updated_at_micros; let pricing = editor_generation_pricing_from_record(record, &fallback)?; self.editor_generation_pricing_store - .replace(pricing.clone(), version_micros)?; - Ok((pricing, version_micros)) + .replace(pricing.clone())?; + Ok(pricing) } pub fn is_ready(&self) -> bool { @@ -2279,8 +2232,6 @@ async fn initialize_editor_generation_runtime_service_identity_for_startup( "system:editor-generation-pricing".to_string(), records, crate::editor_project::current_utc_micros(), - // 启动种子只在表为空时写入,没有可比的版本。 - None, ), ) .await @@ -2893,12 +2844,12 @@ mod tests { use super::*; use crate::tripo3d::pricing::Model3dAddOn; - /// 3D 段按 payload 生效(不再有「省略即沿用」),且每次保存都把定价版本推进。 + /// 3D 段按 payload 生效(不再有「省略即沿用」)。 #[tokio::test] - async fn saving_pricing_applies_explicit_model3d_and_bumps_version() { + async fn saving_pricing_applies_explicit_model3d() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let (before, version) = state - .editor_generation_pricing_with_version() + let before = state + .editor_generation_pricing() .await .expect("默认定价必须可读"); let default_model3d = before.model3d.clone().expect("默认配置必须带 3D 定价段"); @@ -2911,44 +2862,66 @@ mod tests { let mut next = before; next.model3d = Some(custom.clone()); - let (saved, saved_version) = state - .save_editor_generation_pricing("admin:test".to_string(), next, version) + let saved = state + .save_editor_generation_pricing("admin:test".to_string(), next) .await .expect("显式 3D 段的保存必须成功"); assert_eq!(saved.model3d.as_ref(), Some(&custom)); - assert!(saved_version > version, "保存必须推进定价版本"); - let (reread, reread_version) = state - .editor_generation_pricing_with_version() + let reread = state + .editor_generation_pricing() .await .expect("定价必须可读"); assert_eq!(reread.model3d.as_ref(), Some(&custom)); - assert_eq!(reread_version, saved_version); } - /// 带着读旧的版本保存时必须整体拒绝,不能静默覆盖别人的改动。 + /// 保存不再做版本比对:同一份旧值连存两次按「后写覆盖先写」处理,不做拒绝。 #[tokio::test] - async fn saving_pricing_with_stale_version_is_rejected() { + async fn saving_pricing_twice_keeps_last_write() { let state = AppState::new(AppConfig::default()).expect("state should build"); - let (before, version) = state - .editor_generation_pricing_with_version() + let before = state + .editor_generation_pricing() .await .expect("默认定价必须可读"); - let (_, bumped_version) = state - .save_editor_generation_pricing("admin:test".to_string(), before.clone(), version) + let mut first_next = before.clone(); + first_next + .model3d + .as_mut() + .expect("默认配置必须带 3D 定价段") + .text_to_model_pricing + .add_on_prices + .insert(Model3dAddOn::QuadMesh, 7); + state + .save_editor_generation_pricing("admin:test".to_string(), first_next) .await .expect("首次保存必须成功"); - let error = state - .save_editor_generation_pricing("admin:test".to_string(), before, version) + let mut second_next = before; + second_next + .model3d + .as_mut() + .expect("默认配置必须带 3D 定价段") + .text_to_model_pricing + .add_on_prices + .insert(Model3dAddOn::QuadMesh, 11); + state + .save_editor_generation_pricing("admin:test".to_string(), second_next) .await - .expect_err("用被覆盖的版本保存必须失败"); - assert!( - matches!(error, EditorGenerationPricingError::Conflict), - "应判定为定价版本冲突,实际为 {error:?}" + .expect("用读到过的旧值再保存一次必须成功(不再有版本冲突)"); + + let reread = state + .editor_generation_pricing() + .await + .expect("定价必须可读"); + assert_eq!( + reread.model3d.as_ref().and_then(|model3d| model3d + .text_to_model_pricing + .add_on_prices + .get(&Model3dAddOn::QuadMesh)), + Some(&11), + "后写的保存必须生效" ); - assert!(bumped_version > version); } #[test] @@ -3193,7 +3166,6 @@ mod tests { "admin:test".to_string(), empty_pricing_record_input(), 123, - None, ); assert_eq!(without_secret.bootstrap_secret, ""); @@ -3203,7 +3175,6 @@ mod tests { "admin:test".to_string(), empty_pricing_record_input(), 123, - None, ); assert_eq!(with_secret.bootstrap_secret, "11".repeat(32)); diff --git a/server-rs/crates/shared-contracts/src/editor_generation.rs b/server-rs/crates/shared-contracts/src/editor_generation.rs index e8a3df598..ca541cf2e 100644 --- a/server-rs/crates/shared-contracts/src/editor_generation.rs +++ b/server-rs/crates/shared-contracts/src/editor_generation.rs @@ -26,13 +26,6 @@ pub const EDITOR_GENERATION_OPERATION_KINDS: [&str; 12] = [ "model3d_image_to_model", ]; -/// 定价后台保存的乐观锁冲突信号。 -/// -/// procedure 只能返回字符串错误,所以 api-server 与 spacetime-module 共用这一个常量来区分 -/// 「定价已被他人改过」和其它保存失败;两侧各写一份文案会静默分叉,这里只允许一处声明。 -pub const EDITOR_GENERATION_PRICING_VERSION_CONFLICT: &str = - "EDITOR_GENERATION_PRICING_VERSION_CONFLICT"; - pub fn editor_generation_request_fingerprint( operation_kind: &str, request_payload_json: &str, diff --git a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs index 12b1a814f..9072075f0 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/editor_project.rs @@ -587,8 +587,6 @@ pub struct EditorGenerationPricingConfigUpsertRecordInput { pub bootstrap_secret: String, pub text_to_model_pricing: Option, pub image_to_model_pricing: Option, - /// 期望的当前定价版本;后台保存必须带上读取时拿到的版本做乐观锁比对。 - pub expected_updated_at_micros: Option, } impl From for crate::module_bindings::EditorProjectCreateInput { @@ -1051,7 +1049,6 @@ impl From image_to_model_pricing: input .image_to_model_pricing .map(map_editor_generation_model3d_pricing_record), - expected_updated_at_micros: input.expected_updated_at_micros, } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs index dddd6406f..38046ada2 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_generation_pricing_config_upsert_input_type.rs @@ -16,7 +16,6 @@ pub struct EditorGenerationPricingConfigUpsertInput { pub bootstrap_secret: String, pub text_to_model_pricing: Option, pub image_to_model_pricing: Option, - pub expected_updated_at_micros: Option, } impl __sdk::InModule for EditorGenerationPricingConfigUpsertInput { diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 90d7fee84..612866448 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1334,9 +1334,6 @@ pub struct EditorGenerationPricingConfigUpsertInput { pub bootstrap_secret: String, pub text_to_model_pricing: Option, pub image_to_model_pricing: Option, - /// 期望的当前定价版本(行内 `updated_at` 微秒值)。`None` 表示不比对,只用于 - /// 首次种子写入;后台保存必须带上读取时拿到的版本,不一致就整体拒绝。 - pub expected_updated_at_micros: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -6820,13 +6817,6 @@ fn upsert_editor_generation_pricing_config( )?; } - require_expected_editor_generation_pricing_version( - existing - .as_ref() - .map(|row| row.updated_at.to_micros_since_unix_epoch()), - input.expected_updated_at_micros, - )?; - let admin_user_id = normalize_required( &input.admin_user_id, "editor_generation_pricing_config.updated_by_admin_user_id", @@ -7994,23 +7984,6 @@ fn editor_generation_pricing_config_snapshot_from_row( } } -/// 定价保存的乐观锁比对:后台带上读取时拿到的版本,与当前行不一致就整笔拒绝。 -/// -/// `expected` 为 `None` 表示不比对(首次种子写入);行不存在而调用方声称有版本,同样算冲突, -/// 避免「读到兜底缓存 → 直接覆盖」的路径绕开检查。 -fn require_expected_editor_generation_pricing_version( - current_updated_at_micros: Option, - expected_updated_at_micros: Option, -) -> Result<(), String> { - let Some(expected) = expected_updated_at_micros else { - return Ok(()); - }; - if current_updated_at_micros == Some(expected) { - return Ok(()); - } - Err(shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT.to_string()) -} - /// 3D 生成定价两个端点的段级校验:段允许整体缺失,非空时必须同时给出底价与加价项、 /// 键唯一且价格为正。契约支持的模型版本与加价项集合由 api-server 侧的枚举负责, /// 模块这里只守存储形状,避免把模型版本清单复制成第二份真相。 @@ -20156,34 +20129,6 @@ mod tests { assert!(!format!("{snapshot:?}").contains("writer_identity")); } - #[test] - fn editor_generation_pricing_expected_version_must_match_current_row() { - assert_eq!( - require_expected_editor_generation_pricing_version(Some(7), Some(7)), - Ok(()) - ); - assert_eq!( - require_expected_editor_generation_pricing_version(Some(7), None), - Ok(()), - "不带期望版本只用于首次种子写入" - ); - assert_eq!( - require_expected_editor_generation_pricing_version(Some(8), Some(7)), - Err( - shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT - .to_string() - ) - ); - assert_eq!( - require_expected_editor_generation_pricing_version(None, Some(0)), - Err( - shared_contracts::editor_generation::EDITOR_GENERATION_PRICING_VERSION_CONFLICT - .to_string() - ), - "表内没有行时不能凭兜底版本直接创建" - ); - } - #[test] fn normalize_editor_generation_model3d_pricing_accepts_absent_and_complete_sections() { assert_eq!(