后台模型定价保存去掉版本比对:保存即整段覆盖
- 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 同步为删除版本比对
This commit is contained in:
@@ -458,15 +458,16 @@ export interface EditorGenerationPricingConfigPayload {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
|
||||
model3d?: EditorGenerationModel3dPricingPayload | null;
|
||||
/** 当前定价版本:保存时原样回传做乐观锁,冲突时后端返回 409。 */
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
/** 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,外加读取到的定价版本。 */
|
||||
/**
|
||||
* 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。
|
||||
*
|
||||
* 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。
|
||||
*/
|
||||
export interface AdminUpsertEditorGenerationPricingRequest {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
model3d: EditorGenerationModel3dPricingPayload | null;
|
||||
expectedUpdatedAtMicros: number;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<EditorGenerationPricingUnitPayload, string> = {
|
||||
@@ -36,9 +34,6 @@ const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
|
||||
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 ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
{isPricingVersionStale ? (
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshPricing}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>重新读取</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 文件**(违背「后台保存不再写文件」的口径,多进程各读各的,且与静态发布物耦合);**加载缺键即失败**(死锁,见上);**连公开读模型一起改成两段**(把发布耦合引入价格真相,收益只是形状统一)。
|
||||
|
||||
@@ -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 后按开发运维文档执行。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 作废,服务端按二选一校验
|
||||
|
||||
|
||||
@@ -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 公开读模型改了存储形状就等于在两条发布线上做破坏性变更
|
||||
|
||||
@@ -643,7 +643,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
|
||||
|
||||
- Rust 结构体:`EditorGenerationPricingConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- - 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec<EditorGenerationModelPricing>` 强类型保存模型、定价单位、单价或档位列表,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<EditorGenerationModelPricing>` 强类型保存模型、定价单位、单价或档位列表,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`
|
||||
|
||||
@@ -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 定稿)
|
||||
|
||||
|
||||
@@ -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<String, EditorGenerationModelPricing>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model3d: Option<Model3dPricingConfig>,
|
||||
pub updated_at_micros: i64,
|
||||
}
|
||||
|
||||
/// 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出,不接受「省略即沿用」。
|
||||
///
|
||||
/// 不带定价版本:保存不做版本比对,整段覆盖就是最终状态(后台同时只有一个管理员在操作)。
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUpsertEditorGenerationPricingRequest {
|
||||
pub models: BTreeMap<String, EditorGenerationModelPricing>,
|
||||
pub model3d: Option<Model3dPricingConfig>,
|
||||
pub expected_updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
/// 后台读取画布生成模型定价配置。
|
||||
@@ -985,13 +983,13 @@ pub async fn admin_get_editor_generation_pricing(
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, 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<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUpsertEditorGenerationPricingRequest>,
|
||||
) -> Result<Json<Value>, 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<EditorGenerationPricingConfig, AppError> {
|
||||
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!({
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<RwLock<EditorGenerationPricingConfig>>,
|
||||
/// 当前配置的定价版本:SpacetimeDB 行的 `updated_at` 微秒值。0 表示还没读到过行
|
||||
/// (表为空或读不到),后台保存的乐观锁就比对它。
|
||||
version_micros: Arc<AtomicI64>,
|
||||
}
|
||||
|
||||
#[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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||||
@@ -338,10 +324,13 @@ impl EditorGenerationPricingStore {
|
||||
.map(|guard| guard.clone())
|
||||
}
|
||||
|
||||
/// 覆盖进程内缓存的定价配置。
|
||||
///
|
||||
/// 这里只是缓存:保存要不要放行不在这里判断(后台同时只有一个管理员在操作,保存不再做
|
||||
/// 版本比对),读取失败时的兜底也直接用这份缓存,所以它不是任何写入的拦截点。
|
||||
pub(crate) fn replace(
|
||||
&self,
|
||||
next: EditorGenerationPricingConfig,
|
||||
version_micros: i64,
|
||||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||||
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}"),
|
||||
}
|
||||
|
||||
@@ -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<i64>,
|
||||
) -> 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<Self, AppStateInitError> {
|
||||
@@ -793,24 +776,16 @@ impl AppState {
|
||||
self.http_request_permit_pools.clone()
|
||||
}
|
||||
|
||||
/// 当前定价配置:优先读 SpacetimeDB 行,读不到就用进程内缓存兜底。
|
||||
///
|
||||
/// 读取不再带版本:后台保存不做版本比对(同一时间只有一个管理员在操作),进程内缓存只
|
||||
/// 承担「读库失败时的兜底」,不是任何写入的拦截点。
|
||||
pub(crate) async fn editor_generation_pricing(
|
||||
&self,
|
||||
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||||
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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||||
#[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<EditorGenerationPricingConfig, EditorGenerationPricingError> {
|
||||
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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -587,8 +587,6 @@ pub struct EditorGenerationPricingConfigUpsertRecordInput {
|
||||
pub bootstrap_secret: String,
|
||||
pub text_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
|
||||
pub image_to_model_pricing: Option<EditorGenerationModel3dPricingRecord>,
|
||||
/// 期望的当前定价版本;后台保存必须带上读取时拿到的版本做乐观锁比对。
|
||||
pub expected_updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<EditorProjectCreateRecordInput> for crate::module_bindings::EditorProjectCreateInput {
|
||||
@@ -1051,7 +1049,6 @@ impl From<EditorGenerationPricingConfigUpsertRecordInput>
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -16,7 +16,6 @@ pub struct EditorGenerationPricingConfigUpsertInput {
|
||||
pub bootstrap_secret: String,
|
||||
pub text_to_model_pricing: Option<EditorGenerationModel3DPricing>,
|
||||
pub image_to_model_pricing: Option<EditorGenerationModel3DPricing>,
|
||||
pub expected_updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorGenerationPricingConfigUpsertInput {
|
||||
|
||||
@@ -1334,9 +1334,6 @@ pub struct EditorGenerationPricingConfigUpsertInput {
|
||||
pub bootstrap_secret: String,
|
||||
pub text_to_model_pricing: Option<EditorGenerationModel3dPricing>,
|
||||
pub image_to_model_pricing: Option<EditorGenerationModel3dPricing>,
|
||||
/// 期望的当前定价版本(行内 `updated_at` 微秒值)。`None` 表示不比对,只用于
|
||||
/// 首次种子写入;后台保存必须带上读取时拿到的版本,不一致就整体拒绝。
|
||||
pub expected_updated_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[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<i64>,
|
||||
expected_updated_at_micros: Option<i64>,
|
||||
) -> 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!(
|
||||
|
||||
Reference in New Issue
Block a user