修复 SpacetimeDB 定价 record 缺少历史模型回填的问题

- 将 backfill_legacy_gpt_image_2_5_pricing 提升为 pub(crate) 并补充文档说明其覆盖磁盘 override 与 SpacetimeDB record 两条加载路径
- editor_generation_pricing_from_record 在 validate 前先对旧 record 做受控 backfill,避免升级前只写单个 gpt-image-2 key 时被必填校验拒绝
- 新增用例覆盖旧 record 自动补齐两个 GPT Image 2.5 key,以及显式配置不被回填覆盖
- 更新兼容回填的 TODO 注释,说明移除条件还需包含 SpacetimeDB record
This commit is contained in:
2026-09-19 15:18:40 +08:00
parent 6318baf7c6
commit 0459109897
2 changed files with 147 additions and 3 deletions
@@ -393,7 +393,11 @@ fn load_editor_generation_pricing_from_candidates(
Ok(config)
}
fn backfill_legacy_gpt_image_2_5_pricing(
/// 兼容升级前只含单个历史 `gpt-image-2` key 的持久化定价配置。
///
/// 磁盘 override 与 SpacetimeDB record 两条加载路径都要调用,补齐缺失的
/// `gpt-image-2.5-flare-c` / `gpt-image-2.5-sunburst-c` 后才允许 `validate()`。
pub(crate) fn backfill_legacy_gpt_image_2_5_pricing(
config: &mut EditorGenerationPricingConfig,
fallback: &EditorGenerationPricingConfig,
source: &str,
@@ -408,7 +412,8 @@ fn backfill_legacy_gpt_image_2_5_pricing(
return Ok(());
}
// TODO: compatibility backfill for legacy single-key pricing; remove once
// all persisted overrides contain the two explicit GPT Image 2.5 keys.
// every persisted override and SpacetimeDB record contains the two explicit
// GPT Image 2.5 keys.
let pricing = config
.models
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2)
+140 -1
View File
@@ -495,7 +495,14 @@ fn editor_generation_pricing_from_record(
})?;
models.insert(EDITOR_SOUND_EFFECT_MODEL_ELEVENLABS.to_string(), pricing);
}
let config = EditorGenerationPricingConfig { models };
let mut config = EditorGenerationPricingConfig { models };
// 中文注释:升级前写入的 record 可能只有单个历史 gpt-image-2 key
// 与磁盘 override 一样先做受控 backfill,再进入必填校验。
crate::editor_generation_config::backfill_legacy_gpt_image_2_5_pricing(
&mut config,
legacy_fallback,
"SpacetimeDB 模型定价配置",
)?;
config.validate()?;
Ok(config)
}
@@ -2875,6 +2882,138 @@ mod tests {
);
}
#[test]
fn editor_generation_pricing_typed_record_backfills_legacy_gpt_image_keys() {
use crate::editor_generation_config::{
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
};
let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json(
crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON,
"test default pricing",
)
.expect("default pricing should parse");
let mut models =
editor_generation_pricing_to_records(&fallback).expect("pricing should map to records");
// 中文注释:模拟升级前持久化的旧 record:只有单个历史 gpt-image-2 key。
models.retain(|pricing| {
pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION
&& pricing.model != EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT
});
let legacy_pricing = models
.iter()
.find(|pricing| pricing.model == "gpt-image-2")
.cloned()
.expect("legacy record kept the single gpt-image-2 key");
let record = EditorGenerationPricingConfigRecord {
config_id: "global".to_string(),
models,
updated_by_admin_user_id: Some("admin:test".to_string()),
updated_at: "2026-09-18T00:00:00Z".to_string(),
updated_at_micros: 2,
};
let actual = editor_generation_pricing_from_record(record, &fallback)
.expect("legacy single-key record should be backfilled instead of rejected");
assert_eq!(legacy_pricing.unit.as_str(), "perGeneration");
let expected = EditorGenerationModelPricing {
unit: EditorGenerationPricingUnit::PerGeneration,
price: legacy_pricing.price,
prices: legacy_pricing
.prices
.iter()
.map(|tier| (tier.key.clone(), tier.price))
.collect(),
};
assert_eq!(
actual
.models
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION),
Some(&expected)
);
assert_eq!(
actual.models.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT),
Some(&expected)
);
}
#[test]
fn editor_generation_pricing_typed_record_keeps_explicit_gpt_image_keys() {
use crate::editor_generation_config::{
EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT, EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION,
};
let fallback = crate::editor_generation_config::parse_editor_generation_pricing_json(
crate::editor_generation_config::EDITOR_GENERATION_PRICING_DEFAULT_JSON,
"test default pricing",
)
.expect("default pricing should parse");
let mut models =
editor_generation_pricing_to_records(&fallback).expect("pricing should map to records");
for pricing in models.iter_mut() {
if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION {
pricing.prices = vec![
EditorGenerationPricingTierRecord {
key: "1K".to_string(),
price: 71,
},
EditorGenerationPricingTierRecord {
key: "2K".to_string(),
price: 72,
},
];
}
if pricing.model == EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT {
pricing.prices = vec![
EditorGenerationPricingTierRecord {
key: "1K".to_string(),
price: 81,
},
EditorGenerationPricingTierRecord {
key: "2K".to_string(),
price: 82,
},
];
}
if pricing.model == "gpt-image-2" {
pricing.prices = vec![
EditorGenerationPricingTierRecord {
key: "1K".to_string(),
price: 1,
},
EditorGenerationPricingTierRecord {
key: "2K".to_string(),
price: 2,
},
];
}
}
let record = EditorGenerationPricingConfigRecord {
config_id: "global".to_string(),
models,
updated_by_admin_user_id: Some("admin:test".to_string()),
updated_at: "2026-09-18T00:00:00Z".to_string(),
updated_at_micros: 3,
};
let actual = editor_generation_pricing_from_record(record, &fallback)
.expect("explicit GPT Image 2.5 keys should be preserved");
let generation = actual
.models
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_GENERATION)
.expect("generation key should exist");
assert_eq!(generation.prices.get("1K"), Some(&71));
assert_eq!(generation.prices.get("2K"), Some(&72));
let edit = actual
.models
.get(EDITOR_IMAGE_MODEL_GPT_IMAGE_2_5_EDIT)
.expect("edit key should exist");
assert_eq!(edit.prices.get("1K"), Some(&81));
assert_eq!(edit.prices.get("2K"), Some(&82));
}
#[test]
fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() {
let mut config = AppConfig::default();