修复模型定价运行时身份边界
仅允许 HTTP 角色初始化定价,非 HTTP 角色改用队列只读鉴权 移除定价 procedure 快照中的 writer identity 并同步 bindings 后台保存复用受保护 bootstrap secret 支持缺失配置自愈 补充回归测试与架构运维文档
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -543,7 +543,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`EditorGenerationPricingConfig`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
|
||||
- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `config_id = global`。`models: Vec<EditorGenerationModelPricing>` 强类型保存模型、定价单位、单价或档位列表,procedure / `spacetime-client` 边界不传递不透明 JSON;模块事务会再次校验完整正式模型矩阵、必需档位、单位、正价格和重复键。`writer_identity` 记录首次初始化的真实 `ctx.sender()`;后续 `upsert_editor_generation_pricing_config_and_return` 只允许同一 identity 或已授权迁移操作员修改价格,但始终保留原 writer。表为空时 `api-server` 通过 `initialize_editor_generation_pricing_config_if_missing_and_return` 在单事务内仅缺失时种子入库;表存在时 bootstrap secret 不能接管 writer。原始 bootstrap secret 固定为 64 位十六进制;WASM 只嵌入其 SHA-256,procedure 对入参原文重新计算摘要并做常量时间比较。runtime queue / 钱包 procedure 只接受精确 writer,迁移操作员不自动获得在线运行权限,且 operator / writer 身份必须互斥。SpacetimeDB 不可达时仅使用默认 JSON 或旧 override 缓存兜底。
|
||||
- 说明:图片画布生成类模型定价全局配置表,当前使用固定 `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 缓存兜底。
|
||||
- 索引:主键 `config_id`。
|
||||
|
||||
### `editor_generation_runtime_identity_rotation`
|
||||
|
||||
@@ -57,7 +57,7 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg
|
||||
|
||||
本地排查外部内容生成 worker 队列时,默认同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`,以及图片画布 `editor_image_generation` / `editor_image_edit` / `editor_background_removal` / `editor_icon_spritesheet_generation` / `editor_ui_design_asset_extraction` / `editor_character_animation_generation` / `editor_video_generation` / `editor_sound_effect_generation` / `editor_background_music_generation`。非外部 provider 生成动作继续 inline,不进入队列。显式把本地进程角色设为 `api` 且没有 worker 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
|
||||
|
||||
生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。
|
||||
生产拆分角色时,`external-generation-worker` 和 `external-generation-controller` 的专属 env 示例会把 `GENARRATIVE_SPACETIME_POOL_SIZE` 覆盖为 `1`;非 HTTP 角色只保留 `external_generation_job` 队列窄订阅作为响应式唤醒信号,实际抢占和扩缩容判断仍走 SpacetimeDB procedure,且不再订阅 API 读模型连接池。worker / controller 不执行模型定价 seed,启动时先调用受 runtime writer 鉴权的 queue-stats procedure 做只读预检,身份不匹配时 fail-fast;当前正式 systemd unit 通过共同加载 `/etc/genarrative/api-server.env` 继承同一 `GENARRATIVE_SPACETIME_TOKEN`,专属角色 env 示例不重复配置该 token。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 与 controller poll interval 只作为订阅失效、漏事件和 lease 过期这类时间条件的兜底,不作为正常领取任务的主路径。
|
||||
|
||||
生产 worker 默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS=600`,只覆盖 worker 心跳抖动和短暂断连窗口,不再把 lease 当成完整任务时长;默认 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS=900`,角色动画 / 视频类长任务使用 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS=1800`。worker 在单次尝试超过执行预算后会停止当前尝试、写入失败 / 重试状态并释放 worker 槽位;如果 SpacetimeDB 当时不可写,当前租约最多再保留到 lease 过期,之后任务重新变为可领取。生产部署和 provision 脚本会给 `/etc/genarrative/api-server.env` 与 `/etc/genarrative/external-generation-worker.env` 补齐这些变量;已有自定义值不覆盖,只会把历史旧默认 `3600` 迁移为 `600`。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- 运行时事实源:SpacetimeDB `editor_generation_pricing_config` 全局配置表,固定 `config_id = global`,以强类型 `models` 列保存模型、单位、单价和档位列表。
|
||||
- 旧运行时覆盖文件仅作为迁移兼容种子读取;当前兼容路径为 `/var/lib/genarrative/editor-generation-pricing/editor-generation-pricing.override.json`,并在默认新路径不存在时兼容读取旧 `.app/editor-generation-pricing.override.json`。后台保存不再写文件。
|
||||
|
||||
默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。首次读取发现表为空时,`api-server` 会用当前本地缓存尝试写入 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。
|
||||
默认文件进入 Git,作为空表或 SpacetimeDB 暂不可达时的兜底。只有 HTTP 角色会在启动恢复阶段用当前本地缓存尝试初始化空的 `editor_generation_pricing_config`;本地缓存可来自默认 JSON,也可来自旧 override 文件。`external-generation-worker` / `external-generation-controller` 不调用定价 initializer,而是在启动时通过只读的 queue-stats procedure 验证当前 SpacetimeDB identity 是否具备队列运行权限。生产 API 发布脚本会在切换 `current` 前把旧 release 下的 `.app/editor-generation-pricing.override.json` 迁移到 `/var/lib/genarrative/editor-generation-pricing/`,避免既有后台自定义价格被默认值覆盖。
|
||||
|
||||
## 配置结构
|
||||
|
||||
@@ -57,13 +57,13 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝
|
||||
- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。
|
||||
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 SpacetimeDB `editor_generation_pricing_config`;只有 procedure 入库成功后才更新进程内缓存并返回成功,不能把“仅内存生效”当作保存成功。
|
||||
|
||||
后端 `AppState` 启动时加载默认配置和旧 override 作为本地缓存;接口读取优先走 SpacetimeDB。表为空时调用 `initialize_editor_generation_pricing_config_if_missing_and_return`,在单事务内仅缺失时种子入库,不能使用“先读空、再无条件 upsert”的两事务流程。首次写入把真实 `ctx.sender()` 保存为 `writer_identity`;表已存在时 initializer 只接受同一 writer,bootstrap secret 和迁移操作员都不能借该入口接管既有 writer。后续后台保存只允许同一 writer identity 或已授权迁移操作员,但即使由迁移操作员修复价格也必须保留原 writer。后台用户 ID 只记录审计信息,不能充当数据库授权。SpacetimeDB 暂不可达时才使用本地缓存兜底。
|
||||
后端 `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 暂不可达时才使用本地缓存兜底。
|
||||
|
||||
所有会调用外部生成 provider 的编辑器生成请求都必须由后端计算价格,前端请求不提交价格字段;同步执行按当前运行时配置进入 `execute_billable_asset_operation_with_cost` 预扣泥点,预扣失败不得继续调用上游。外部生成队列在入队时把价格写入 `external_generation_job.price_mud_points`,worker 必须用该冻结价格完成扣费、退款、响应和资产成本持久化,配置更新不得改变已入队任务金额。普通图片、规范、角色、UI 设计、宣发素材、快速编辑 / 图片修改、图标 spritesheet、UI 设计图提取素材、视频、角色动作、音效和背景音乐均遵循该规则。背景色决策(gpt-5-mini)本身也是一次上游调用,同样必须在预扣泥点之后发起:预扣前只做颜色无关的算价 / 校验(动画用默认色占位算价),决策放进 billable 闭包,余额不足则决策不跑、决策失败走失败退款。需要向前端展示实际扣费时,由后端在响应中返回 `priceMudPoints`。
|
||||
|
||||
## 运行时身份首次授权
|
||||
|
||||
模型定价 writer、外部生成队列和钱包调用都以真实 SpacetimeDB `ctx.sender()` 校验运行时服务 identity。原始 bootstrap secret 固定为 64 位十六进制;首次授权使用与当前 `spacetime_module.wasm` 构建时注入 SHA-256 摘要对应的原始值,模块收到原始值后重新计算 SHA-256 并做常量时间比较,WASM 只嵌入摘要、不嵌入原文。bootstrap secret 只能在配置表为空时建立首个受信身份,表存在后不能重复使用。queue 和钱包 runtime guard 只接受精确 `writer_identity`,迁移操作员身份不自动获得在线生成或钱包权限。migration operator 与 runtime writer 必须互斥:任何已登记 operator 都不能成为 writer,当前 writer 也不能被授权为 operator;一旦已有 operator,bootstrap secret 不得再新增或接管 operator。
|
||||
模型定价 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。
|
||||
|
||||
运行时 token 轮换必须由已授权迁移操作员显式调用 `rotate_editor_generation_runtime_service_identity_and_return`,传入新 identity、操作员用户 ID 和原因。该 procedure 会拒绝把新 writer 设成当前 writer 或任一已登记 migration operator,只修改 writer,不覆盖模型价格,并向 `editor_generation_runtime_identity_rotation` 写入旧 writer、新 writer、迁移操作员 identity、操作人、原因和服务端时间。生产使用 `scripts/deploy/production-runtime-writer-identity-rotate.mjs`:要求当前 SpacetimeDB CLI 登录 identity 与 `--operator-identity` 一致,并通过 `--next-writer-identity` / `--confirm-next-writer-identity` 双录确认;执行成功后必须核对审计行,再切换 API token。不得把 API 启动、普通定价 upsert 或 bootstrap secret 复用成隐式轮换流程。
|
||||
|
||||
@@ -89,7 +89,7 @@ SpacetimeDB 模块会在事务内重复执行同等强度的校验,并拒绝
|
||||
|
||||
## 验证
|
||||
|
||||
- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、路由保存与公开读取测试。
|
||||
- 后端配置解析、模块强校验与 writer 授权、SpacetimeDB 保存 / 重启读取、仅 HTTP 角色 seed、非 HTTP 角色 queue-stats 鉴权预检、路由保存与公开读取不返回 `writer_identity` 的测试。
|
||||
- 外部生成任务在配置变更前后仍按入队价格扣费并落资产的测试;旧 attempt 退款先于迟到 consume 时,持久化 settlement intent 必须阻止迟到扣费;最终 attempt lease 过期后必须直接失败结算且不再返回 provider executor。
|
||||
- 部署脚本 Bash 语法、生产运维静态门禁、64 位十六进制 secret 校验、manifest 摘要匹配、Build / Publish credential ID 一致性、bootstrap secret 明文日志扫描和 FILE 权限 / 服务重启检查。
|
||||
- 前端价格读取、运行时覆盖、图片尺寸档位计算测试。
|
||||
|
||||
@@ -203,9 +203,12 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
|
||||
))
|
||||
})?;
|
||||
state
|
||||
.ensure_editor_generation_runtime_service_identity()
|
||||
.spacetime_client()
|
||||
.get_external_generation_queue_stats()
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("初始化模型定价服务身份失败:{error}")))?;
|
||||
.map_err(|error| {
|
||||
io::Error::other(format!("验证外部生成队列运行时服务身份失败:{error}"))
|
||||
})?;
|
||||
spawn_common_app_state_background_workers(&state);
|
||||
info!(
|
||||
process_role = process_role.as_str(),
|
||||
@@ -239,6 +242,10 @@ fn should_restore_auth_store_for_startup(process_role: ProcessRole) -> bool {
|
||||
process_role.runs_http()
|
||||
}
|
||||
|
||||
fn should_initialize_editor_generation_pricing_for_startup(process_role: ProcessRole) -> bool {
|
||||
process_role.runs_http()
|
||||
}
|
||||
|
||||
async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
|
||||
let bind_address = config.bind_socket_addr();
|
||||
let listen_backlog = config.listen_backlog;
|
||||
@@ -470,6 +477,7 @@ async fn restore_app_state_for_startup(
|
||||
async fn try_restore_app_state_for_startup(
|
||||
config: AppConfig,
|
||||
) -> Result<AppState, state::AppStateInitError> {
|
||||
let process_role = config.process_role;
|
||||
let state = match timeout(
|
||||
AUTH_STORE_STARTUP_RESTORE_TIMEOUT,
|
||||
AppState::try_restore_auth_store_from_spacetime(config),
|
||||
@@ -487,14 +495,16 @@ async fn try_restore_app_state_for_startup(
|
||||
));
|
||||
}
|
||||
};
|
||||
state
|
||||
.ensure_editor_generation_runtime_service_identity()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
state::AppStateInitError::DependencyUnavailable(format!(
|
||||
"初始化模型定价服务身份失败:{error}"
|
||||
))
|
||||
})?;
|
||||
if should_initialize_editor_generation_pricing_for_startup(process_role) {
|
||||
state
|
||||
.ensure_editor_generation_runtime_service_identity()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
state::AppStateInitError::DependencyUnavailable(format!(
|
||||
"初始化模型定价服务身份失败:{error}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -574,6 +584,7 @@ fn is_valid_env_key(key: &str) -> bool {
|
||||
mod tests {
|
||||
use super::{
|
||||
AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from,
|
||||
should_initialize_editor_generation_pricing_for_startup,
|
||||
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
|
||||
strip_env_value,
|
||||
};
|
||||
@@ -636,6 +647,22 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_initialization_is_limited_to_http_roles() {
|
||||
assert!(should_initialize_editor_generation_pricing_for_startup(
|
||||
ProcessRole::Api
|
||||
));
|
||||
assert!(should_initialize_editor_generation_pricing_for_startup(
|
||||
ProcessRole::All
|
||||
));
|
||||
assert!(!should_initialize_editor_generation_pricing_for_startup(
|
||||
ProcessRole::ExternalGenerationWorker
|
||||
));
|
||||
assert!(!should_initialize_editor_generation_pricing_for_startup(
|
||||
ProcessRole::ExternalGenerationController
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_recharge_expiration_listener_is_limited_to_http_roles() {
|
||||
assert!(should_start_profile_recharge_expiration_listener(ProcessRole::Api));
|
||||
|
||||
@@ -406,6 +406,23 @@ fn editor_generation_pricing_from_record(
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn editor_generation_pricing_upsert_input(
|
||||
config: &AppConfig,
|
||||
admin_user_id: String,
|
||||
models: Vec<EditorGenerationModelPricingRecord>,
|
||||
updated_at_micros: i64,
|
||||
) -> EditorGenerationPricingConfigUpsertRecordInput {
|
||||
EditorGenerationPricingConfigUpsertRecordInput {
|
||||
admin_user_id,
|
||||
models,
|
||||
updated_at_micros,
|
||||
bootstrap_secret: config
|
||||
.spacetime_runtime_service_bootstrap_secret
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
#[cfg(test)]
|
||||
pub fn new(config: AppConfig) -> Result<Self, AppStateInitError> {
|
||||
@@ -605,12 +622,12 @@ impl AppState {
|
||||
let record = self
|
||||
.spacetime_client
|
||||
.upsert_editor_generation_pricing_config(
|
||||
EditorGenerationPricingConfigUpsertRecordInput {
|
||||
editor_generation_pricing_upsert_input(
|
||||
&self.config,
|
||||
admin_user_id,
|
||||
models,
|
||||
updated_at_micros: crate::editor_project::current_utc_micros(),
|
||||
bootstrap_secret: String::new(),
|
||||
},
|
||||
crate::editor_project::current_utc_micros(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?;
|
||||
@@ -637,16 +654,12 @@ impl AppState {
|
||||
let record = self
|
||||
.spacetime_client
|
||||
.initialize_editor_generation_pricing_config_if_missing(
|
||||
EditorGenerationPricingConfigUpsertRecordInput {
|
||||
admin_user_id: "system:editor-generation-pricing".to_string(),
|
||||
editor_generation_pricing_upsert_input(
|
||||
&self.config,
|
||||
"system:editor-generation-pricing".to_string(),
|
||||
models,
|
||||
updated_at_micros: crate::editor_project::current_utc_micros(),
|
||||
bootstrap_secret: self
|
||||
.config
|
||||
.spacetime_runtime_service_bootstrap_secret
|
||||
.clone()
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
crate::editor_project::current_utc_micros(),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| EditorGenerationPricingError::Persistence(error.to_string()))?;
|
||||
@@ -2002,7 +2015,6 @@ mod tests {
|
||||
updated_by_admin_user_id: Some("admin:test".to_string()),
|
||||
updated_at: "2026-07-10T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1,
|
||||
writer_identity_hex: "11".repeat(32),
|
||||
};
|
||||
|
||||
let actual = editor_generation_pricing_from_record(record)
|
||||
@@ -2011,6 +2023,30 @@ mod tests {
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_upsert_input_uses_runtime_service_bootstrap_secret() {
|
||||
let mut config = AppConfig::default();
|
||||
let without_secret = editor_generation_pricing_upsert_input(
|
||||
&config,
|
||||
"admin:test".to_string(),
|
||||
Vec::new(),
|
||||
123,
|
||||
);
|
||||
assert_eq!(without_secret.bootstrap_secret, "");
|
||||
|
||||
config.spacetime_runtime_service_bootstrap_secret = Some("11".repeat(32));
|
||||
let with_secret = editor_generation_pricing_upsert_input(
|
||||
&config,
|
||||
"admin:test".to_string(),
|
||||
Vec::new(),
|
||||
123,
|
||||
);
|
||||
|
||||
assert_eq!(with_secret.bootstrap_secret, "11".repeat(32));
|
||||
assert_eq!(with_secret.admin_user_id, "admin:test");
|
||||
assert_eq!(with_secret.updated_at_micros, 123);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_typed_record_rejects_duplicate_model() {
|
||||
let config = crate::editor_generation_config::parse_editor_generation_pricing_json(
|
||||
@@ -2027,7 +2063,6 @@ mod tests {
|
||||
updated_by_admin_user_id: None,
|
||||
updated_at: "2026-07-10T00:00:00Z".to_string(),
|
||||
updated_at_micros: 1,
|
||||
writer_identity_hex: "11".repeat(32),
|
||||
};
|
||||
|
||||
let error =
|
||||
|
||||
@@ -197,7 +197,6 @@ pub struct EditorGenerationPricingConfigRecord {
|
||||
pub updated_by_admin_user_id: Option<String>,
|
||||
pub updated_at: String,
|
||||
pub updated_at_micros: i64,
|
||||
pub writer_identity_hex: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
@@ -1341,7 +1340,6 @@ fn map_editor_generation_pricing_config_snapshot(
|
||||
updated_by_admin_user_id: snapshot.updated_by_admin_user_id,
|
||||
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
|
||||
updated_at_micros: snapshot.updated_at_micros,
|
||||
writer_identity_hex: snapshot.writer_identity.to_hex().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -13,7 +13,6 @@ pub struct EditorGenerationPricingConfigSnapshot {
|
||||
pub models: Vec<EditorGenerationModelPricing>,
|
||||
pub updated_by_admin_user_id: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
pub writer_identity: __sdk::Identity,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EditorGenerationPricingConfigSnapshot {
|
||||
|
||||
@@ -536,7 +536,6 @@ pub struct EditorGenerationPricingConfigSnapshot {
|
||||
pub models: Vec<EditorGenerationModelPricing>,
|
||||
pub updated_by_admin_user_id: Option<String>,
|
||||
pub updated_at_micros: i64,
|
||||
pub writer_identity: Identity,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -3057,7 +3056,6 @@ fn editor_generation_pricing_config_snapshot_from_row(
|
||||
models: row.models,
|
||||
updated_by_admin_user_id: row.updated_by_admin_user_id,
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
writer_identity: row.writer_identity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4151,6 +4149,18 @@ mod tests {
|
||||
assert_eq!(normalized[0].model, EDITOR_GENERATION_SOUND_EFFECT_MODEL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_procedure_snapshot_omits_writer_identity() {
|
||||
let snapshot = EditorGenerationPricingConfigSnapshot {
|
||||
config_id: EDITOR_GENERATION_PRICING_CONFIG_ID.to_string(),
|
||||
models: valid_editor_generation_pricing_models(),
|
||||
updated_by_admin_user_id: Some("admin:test".to_string()),
|
||||
updated_at_micros: 1,
|
||||
};
|
||||
|
||||
assert!(!format!("{snapshot:?}").contains("writer_identity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_editor_generation_pricing_models_rejects_missing_required_model() {
|
||||
let mut models = valid_editor_generation_pricing_models();
|
||||
|
||||
Reference in New Issue
Block a user