写入Tripo 3D泥点默认价并锁定credit换算口径

- 受控默认配置新增 model3d 段:底价按端点 × 模型版本 × 是否有贴图,add-on 六项独立计价

- 换算口径固定为泥点 = ceil(0.8 × Tripo 官方 credit),H 系列 10/20 与 20/30、P1 30/40 与 40/50、P2 100/110

- editor_generation_config 定向用例按 credit 表反算默认泥点价,改价必须先改 credit 表

- 段缺失语义改为显式摘段验证,仍保持 fail closed 不扣费不入队

- 主规范、里程碑、实施计划与定价管理方案同步默认价已确认与部署侧 override 风险
This commit is contained in:
2026-09-21 22:07:55 +08:00
parent 2691ca8aca
commit 9fc55a5cac
6 changed files with 166 additions and 13 deletions
@@ -73,7 +73,7 @@ Milestone Spec: `docs/project-memory/plans/【里程碑】Tripo生成Worker执
- 图片输入在提交时已补元数据预检(`tripo3d::image_source::preflight_image_source`):按 `source.kind` 分支配对记录类型并复用既有定点引用查询 `resolve_editor_reference_record_by_id_for_owner`,取代原先“列工程 / 列素材库再筛”的宽查询;跨 owner、未登记、已删除与 `kind` 不符都在扣费与入队前返回 400,worker 执行时再重新确认同一事实。
- 提交路由已补应用级验收用例:走真实 `build_router` 断言两个端点未鉴权 401、缺 `Idempotency-Key` 400、组合校验与定价必填字段冲突在定价与 provider 之前 400,因此路由 merge、鉴权中间件与校验顺序都有可执行证据,不依赖 provider 与 SpacetimeDB 环境。
- 默认配置里的 `model3d` 泥点定价数值仍未确认,缺段时提交按 fail closed 拒绝。
- `model3d` 泥点定价已写入受控默认配置:换算口径 `ceil(0.8 × Tripo 官方 credit)`H 系列文生 `10 / 20`、图生 `20 / 30`P1 文生 `30 / 40`、图生 `40 / 50`P2 两端均 `100 / 110`add-on 六项按同一系数换算;`editor_generation_config` 的定向用例按 credit 表反算默认价,段缺失仍按 fail closed 拒绝。
- worker 的纯逻辑部分已补定向用例:checkpoint 只在 provider task id 非空时才算存在、完成结果按端点严格区分且不含 provider 事实、预览图尺寸必须能真实解码;全量 `cargo test -p api-server` 1092 通过。
- `npm run check:spacetime-schema` 通过(144 张表,锚点 `6f161a6b3626588a197208ea6dd68316815c01b5`),`external_generation_job` 的 provider checkpoint 追加字段没有破坏 schema guard。
- TS 绑定仍是 `npm run contracts:model3d:generate` 目录化生成:重跑后 `packages/shared` 无 diff`packages/shared/src/contracts/model3d/``contracts/editor-canvas/` 与 Rust 契约一致,没有手写 TS。
@@ -93,7 +93,7 @@ git diff --check
## 风险与回退
- **真实扣费**定价数值未写入前 `model3d` 段缺失,提交 fail closed;接口开放前必须先完成定价配置
- **真实扣费**默认价已按 `ceil(0.8 × credit)` 写入并随代码发布,部署侧若用 override 文件覆盖定价,必须确保 override 里保留完整的 `model3d` 段,否则该段整体缺失会让 3D 提交 fail closed
- **provider 长任务**:单次 attempt 内轮询依赖 lease 续租与长任务超时;超时按现有语义收口为失败并退款。
- **共用 worker 分支**:新分支只消费自己的 job kind,不改既有分支的入队与写回路径。
- **worker 占用**3D 任务在单次 attempt 内阻塞轮询数分钟,会占住一个 worker 并发位;默认并发为 2,接入真实流量前需要确认并发与 `external_generation_worker_long_job_timeout` 是否够用。
@@ -33,4 +33,4 @@ Implementation Plan: `docs/project-memory/plans/【实施计划】Tripo生成Wor
## 依赖
- `docs/project-memory/plans/【里程碑】Tripo生成API契约与数据模型-2026-09-21.md` 验收通过。
- 生产泥点定价数值已确认并写入默认配置。
- 生产泥点定价数值已确认并写入默认配置:换算口径为 `ceil(0.8 × Tripo 官方 credit)`,已落到 `editor-generation-pricing.default.json``model3d`
@@ -131,7 +131,9 @@ price = basePrices[endpoint][modelVersion][texture ? "texture" : "noTexture"]
1. **加载即校验**`model3d` 段一旦存在,两个 endpoint × 每个受支持模型版本 × 两种贴图态与全部 add-on 键必须同时存在,缺一即配置非法、服务拒绝启动;段内不再写 `unit`3D 生成恒为按次计价。
2. **提交即拒绝**:价格计算发生在调用 provider 之前;拿不到价格就不提交、不扣费、不入队。
业务给出泥点数值之前,受控默认配置里不写 `model3d` 段:此时 3D 接口不开放,提交直接被拒绝(fail closed),既不会扣费也不会调用 provider;数值写入后即为上一条的强校验路径。
泥点数值已确认并写入受控默认配置 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`。换算口径固定为 **泥点 = `ceil(0.8 × Tripo 官方 credit)`**credit 取自 Tripo 官方价目表的 H 系列与 P 系列:文生 3D 的 H 系列 `10 / 20`、P1 `30 / 40`、P2 `100 / 110`,图生 3D 的 H 系列 `20 / 30`、P1 `40 / 50`、P2 `100 / 110`(依次为无贴图 / 标准贴图),六个 add-on 为 `HD Texture 10``8K Ultra Texture 20``HD Geometry 20``Quad Mesh 5``Smart Low-poly 10``Generate Parts 20``editor_generation_config` 的定向用例按这张 credit 表反算默认泥点价,改价必须先改 credit 表,避免默认值脱离换算口径。
`model3d` 段缺失仍是合法形态:缺失即 3D 定价未配置,提交被拒绝(fail closed),既不扣费也不调用 provider;段存在时走上面的强校验路径。
扣费依据固定为**提交时定价**:用户在提交前就能得到确定价格,失败退款是单次冲正,不需要按实际消耗退差额。provider 返回的实际消耗只写日志用于成本对账,不进入契约、不进 API 响应、不进资源行。
@@ -30,7 +30,7 @@ Tripo 3D 生成的计费形态是「底价 + 可叠加 add-on」,现有「模
- 事实源仍是同一份文件配置(默认 JSON 与旧 override 种子),但**不写入 SpacetimeDB `models` 记录**`editor_generation_pricing_to_records` 只序列化 `models`,运行时读取时由 `editor_generation_pricing_from_record` 用本地文件配置补回 `model3d`。因此后台「模型定价」页面改价**不会覆盖** 3D 段,改 3D 价格必须改文件并重启进程。
- `model3d` 段存在时按强校验解析:两个端点 × 契约支持的全部模型版本都必须给出 `noTexture` / `texture` 两个底价键,缺键即启动失败;段缺失则 3D 提交一律 fail closed,不扣费也不调用 provider。
- 泥点数值是产品定价,不是 provider 的 credit 数值;未确认前保持段缺失,禁止写入猜测值或用 provider 计费数字顶替
- 默认价已确认并写入受控默认 JSON,换算口径固定为 **泥点 = `ceil(0.8 × Tripo 官方 credit)`**:文生 3D 的 H 系列 `10 / 20`、P1 `30 / 40`、P2 `100 / 110`,图生 3D 的 H 系列 `20 / 30`、P1 `40 / 50`、P2 `100 / 110`add-on 为 `HD Texture 10``8K Ultra Texture 20``HD Geometry 20``Quad Mesh 5``Smart Low-poly 10``Generate Parts 20`。泥点数值是产品定价,不是 provider 的 credit 数值;改价必须先改 credit 表并让 `editor_generation_config` 的定向用例同步,禁止写入未确认的猜测值
- 后续把 `models` 记录形状扩展成能表达「端点 × 模型版本 × 是否有贴图 + add-on」后,再把 3D 段迁入 SpacetimeDB 并接后台编辑,届时删除本节的例外说明。
- 关联规范:[技术方案 Tripo 3D生成API集成](technical/【技术方案】Tripo 3D生成API集成-2026-09-21.md)、[后端架构 server-rs与SpacetimeDB数据契约](【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。
@@ -75,5 +75,31 @@
"unit": "perGeneration",
"price": 12
}
},
"model3d": {
"basePrices": {
"text-to-model": {
"v3.1-20260211": { "noTexture": 8, "texture": 16 },
"v3.0-20250812": { "noTexture": 8, "texture": 16 },
"v2.5-20250123": { "noTexture": 8, "texture": 16 },
"P1-20260311": { "noTexture": 24, "texture": 32 },
"P2-20260801": { "noTexture": 80, "texture": 88 }
},
"image-to-model": {
"v3.1-20260211": { "noTexture": 16, "texture": 24 },
"v3.0-20250812": { "noTexture": 16, "texture": 24 },
"v2.5-20250123": { "noTexture": 16, "texture": 24 },
"P1-20260311": { "noTexture": 32, "texture": 40 },
"P2-20260801": { "noTexture": 80, "texture": 88 }
}
},
"addOnPrices": {
"hdTexture": 8,
"ultraTexture": 16,
"hdGeometry": 16,
"quadMesh": 4,
"smartLowPoly": 8,
"generateParts": 16
}
}
}
@@ -663,7 +663,8 @@ mod tests {
};
use crate::tripo3d::pricing::{
Model3dAddOn, Model3dEndpoint, Model3dPricingError, Model3dPricingQuery, model3d_add_ons,
Model3dAddOn, Model3dAddOnSet, Model3dEndpoint, Model3dPricingError, Model3dPricingQuery,
model3d_add_ons,
};
/// 最小完整 3D 价格段:每个模型版本两个端点 × 两种贴图态 + 全部 add-on。
@@ -717,14 +718,26 @@ mod tests {
}
}
#[test]
fn editor_generation_pricing_keeps_model3d_optional_until_configured() {
let config = default_runtime_pricing();
/// Tripo 官方 credit → 泥点的换算系数:泥点 = `ceil(系数 × credit)`。
const MODEL3D_CREDIT_TO_MUD_FACTOR: f64 = 0.8;
assert!(
config.model3d.is_none(),
"业务给出数值前,受控默认配置不带 3D 定价段"
);
fn mud_from_credit(credit: u32) -> u32 {
(f64::from(credit) * MODEL3D_CREDIT_TO_MUD_FACTOR).ceil() as u32
}
/// `model3d` 段缺失是合法形态,代表 3D 定价尚未配置:查价必须失败关闭,不扣费。
/// 受控默认配置现在带真实数值,所以这里显式摘掉该段来验证缺失语义。
#[test]
fn editor_generation_pricing_fails_closed_when_model3d_section_is_absent() {
let mut json: Value =
serde_json::from_str(EDITOR_GENERATION_PRICING_DEFAULT_JSON).expect("默认配置是 JSON");
json.as_object_mut()
.expect("默认配置应是对象")
.remove("model3d");
let config = parse_editor_generation_pricing_json(&json.to_string(), "测试配置")
.expect("缺少 3D 段仍应可加载");
assert!(config.model3d.is_none());
let error = config
.model3d_price(&query(
false,
@@ -741,6 +754,118 @@ mod tests {
assert_eq!(error, Model3dPricingError::NotConfigured);
}
/// 受控默认配置里的 3D 泥点价必须严格等于 `ceil(0.8 × Tripo 官方 credit)`
/// 底价按「端点 × 模型版本 × 是否有贴图」,add-on 按项叠加。改价必须先改这里的
/// credit 表,否则这条断言会失败,避免默认价脱离已确认的换算口径。
#[test]
fn default_model3d_prices_convert_tripo_credits_at_configured_factor() {
let config = default_runtime_pricing();
let model3d = config.model3d.as_ref().expect("默认配置必须带 3D 定价段");
// (端点, 模型版本, 无贴图 credit, 带贴图 credit)
let base_credits = [
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::H31,
10,
20,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::H30,
10,
20,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::H25,
10,
20,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::P1,
30,
40,
),
(
Model3dEndpoint::TextToModel,
Model3dModelVersion::P2,
100,
110,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::H31,
20,
30,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::H30,
20,
30,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::H25,
20,
30,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::P1,
40,
50,
),
(
Model3dEndpoint::ImageToModel,
Model3dModelVersion::P2,
100,
110,
),
];
for (endpoint, model_version, no_texture_credit, texture_credit) in base_credits {
for (texture, credit) in [(false, no_texture_credit), (true, texture_credit)] {
let price = config
.model3d_price(&Model3dPricingQuery {
endpoint,
model_version,
texture,
add_ons: Model3dAddOnSet::default(),
})
.expect("默认配置必须能查出底价");
assert_eq!(
price,
mud_from_credit(credit),
"{} {model_version:?} texture={texture} 的底价应为 ceil(0.8 × {credit}) 泥点",
endpoint.as_str(),
);
}
}
let add_on_credits = [
(Model3dAddOn::HdTexture, 10),
(Model3dAddOn::UltraTexture, 20),
(Model3dAddOn::HdGeometry, 20),
(Model3dAddOn::QuadMesh, 5),
(Model3dAddOn::SmartLowPoly, 10),
(Model3dAddOn::GenerateParts, 20),
];
for (add_on, credit) in add_on_credits {
let price = model3d
.add_on_prices
.get(&add_on)
.copied()
.unwrap_or_else(|| panic!("默认配置缺少 {add_on:?} 的价格"));
assert_eq!(
price,
mud_from_credit(credit),
"{add_on:?} 的加价应为 ceil(0.8 × {credit}) 泥点"
);
}
}
#[test]
fn editor_generation_pricing_validates_model3d_section_when_present() {
let json = pricing_json_with_model3d(model3d_pricing_json());