合并 origin/master 到 AGC 渲染层下沉分支并完成三处对齐
- 活动回合事实源统一到 Direct 线程管理器:删除 direct_runtime 的第二份快照,接单、进度内容变化与收口各广播一次活动回合变更事件
- 平台维护态判定移入 Rust 并在渲染层只订阅单一事件:新增 platform_maintenance 模块与各平台 facade 错误分支的分类入口
- 封面生成请求补 generationInputs.source,保持队列回填后仍能拿到平台素材 ID
- 渲染层按 master 5398a53e6 退役诊断详情入口:删除 agentRuntimeErrorDetail 与「查看详情」交互及其专属用例
- 冲突收口:nginx SPA 白名单、.gitignore、mobile 检查脚本、capabilities 描述取上游,两个已退役计划随上游删除,文档保留双方条目
- 新增并回写本里程碑取证、decision-log 与 pitfalls 的 2026-09-28 记录
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"find_canvas_projects": {
|
||||
"title": "查找画布项目",
|
||||
"description": "查找已有画布项目。action=list 返回项目摘要,recent 读取最近项目,get 按 projectId 读取完整项目、画布、图层和资源。先定位再读取;名称有歧义时明确候选,不静默创建替代项目。当前不提供分页、limit 或名称搜索参数。"
|
||||
},
|
||||
"manage_canvas_projects": {
|
||||
"title": "管理画布项目",
|
||||
"description": "创建或重命名画布项目。action=create 可选 title 和顶层 idempotencyKey,只创建项目,不自动创建同名素材文件夹;rename 必须提供 projectId 和 title。返回现有 API 的业务结果。删除使用 delete_resources。"
|
||||
},
|
||||
"find_assets": {
|
||||
"title": "查找与读取素材",
|
||||
"description": "查看素材记录或获取临时下载地址。action=list_library 读取账号素材库;get_project_resources 按 projectId 返回完整项目详情(含 resources),不裁剪;get_download_url 通过 objectKey 或兼容的 legacyPublicPath 获取临时访问 URL,可选 expireSeconds。优先稳定 objectKey;临时 URL 不作为持久生成引用。读取记录不等于查看媒体,不提供本地下载、关键词或相似素材搜索。"
|
||||
},
|
||||
"prepare_asset_upload": {
|
||||
"title": "办理素材上传",
|
||||
"description": "办理素材上传的两个独立步骤。action=create_upload_ticket 申请凭证,调用方按返回的 OSS 表单参数传输文件,再用 confirm_upload 确认对象;每次调用只执行一个步骤。工具不接受本地路径或 base64、不代传文件。owner 由 API Key 决定。确认对象不等于登记项目资源、素材库记录或创建画布图层,需要时另外登记。"
|
||||
},
|
||||
"generate_image": {
|
||||
"title": "生成图片",
|
||||
"description": "根据 prompt 和可选参考图付费生成图片。省略 kind 为普通图;spec、character、quick-edit、ui-design、publication-material 分别用于规范、角色、参考变体、UI 设计和宣发,不支持 scene。screenColor 是生成/抠图使用的纯色背景,不保证最终保留底色。使用支持的项目、素材库和 canvasCompletion 字段指定目标。必填稳定 idempotencyKey;返回异步任务 ID,用 check_generation 查询完成结果和告警。"
|
||||
},
|
||||
"modify_image": {
|
||||
"title": "修改图片",
|
||||
"description": "付费修改图片。action=edit 定向修改,sourceReferenceId 必须为已登记项目资源或素材 ID,不能用 objectKey/URL;variation 参考生成新版本,使用 referenceImageSrcs,固定 kind=quick-edit,无需传 kind,与 generate_image 的 quick-edit 相同;remove_background 对静态图片去背景,sourceImageSrc 可用所属 objectKey、资源或素材 ID。去背景默认 complex,只有 flat 可传非 null screenColor。edit/去背景的原位替换遵守 projectId+targetLayerId 来源绑定;变体不承诺原位替换。顶层 idempotencyKey 必填,异步结果用 check_generation 查询。"
|
||||
},
|
||||
"generate_icon_spritesheet": {
|
||||
"title": "生成图标图集",
|
||||
"description": "按已登记 icon-spec 规范 referenceId 与 iconDescriptions 付费生成图集并尝试切片;主 referenceId 不能用 objectKey/URL 或辅助 referenceImageSrcs 替代。sliceMode 必填无默认:grid 提供需求指定的 gridX/gridY(1–32),不传 sliceCount;connected-components 可传 sliceCount(1–256),不传 gridX/gridY。这不是已有图片通用裁切。稳定 idempotencyKey 必填,异步查询 check_generation;保留 warning 与 sliceWarning,图集成功不等于切片成功。"
|
||||
},
|
||||
"extract_ui_assets": {
|
||||
"title": "提取 UI 素材",
|
||||
"description": "以 sourceImageSrc 中的 UI 设计图为参考,付费生成组件素材图集并尝试切片;aspectRatio、imageSize 必填,可选 spritesheetLabel 命名。包含生成步骤,不保证逐像素原样裁出。明确图标清单和规范生成用 generate_icon_spritesheet。稳定 idempotencyKey 必填,异步查询 check_generation,分别判断完整图集与切片结果并保留告警。"
|
||||
},
|
||||
"generate_character_animation": {
|
||||
"title": "生成角色动画",
|
||||
"description": "根据角色源图和 promptText 付费生成动画预览与帧序列。sourceLayerId、sourceImageSrc、sourceWidth、sourceHeight 必须来自真实资源,不伪造;按 schema 提供输出参数。不能编辑已有动画文件。稳定 idempotencyKey 必填,异步查询 check_generation;消费正式帧序列结果,不用第一帧重复登记动画。"
|
||||
},
|
||||
"generate_video": {
|
||||
"title": "生成视频",
|
||||
"description": "根据文字和模型支持的参考图片、视频或音频付费生成视频片段。prompt、model、aspectRatio、durationSeconds、resolution、mode、sound 必填;mode 是视频业务模式。参考媒体、声音与输出组合依所选模型的现有能力,不能假定所有模型均支持。稳定 idempotencyKey 必填,返回异步任务 ID,通过 check_generation 查询。"
|
||||
},
|
||||
"generate_audio": {
|
||||
"title": "生成音频",
|
||||
"description": "付费生成音效或背景音乐。action=sound_effect 用于短声音、环境声和交互反馈,必填 prompt,duration 省略/null 自动,手动 0.5–30 秒;background_music 用于配乐,必填 gptDescriptionPrompt、makeInstrumental,保留各分支原字段。顶层稳定 idempotencyKey 必填,返回异步任务 ID,通过 check_generation 查询结果。"
|
||||
},
|
||||
"edit_canvas": {
|
||||
"title": "编辑画布",
|
||||
"description": "action=get 读取 projectId 对应项目;save_layout 基于最新 expectedRevision 保存完整 viewport 和 layers,不是单图层 patch,冲突时重新读取并处理;register_resource 登记已有媒体为项目资源,可选顶层 idempotencyKey,只登记资源不自动创建图层。正常生成结果落画布优先使用生成工具的 canvasCompletion。返回现有业务结果或真实版本冲突。"
|
||||
},
|
||||
"organize_asset_library": {
|
||||
"title": "整理素材库",
|
||||
"description": "整理素材文件夹和记录。action=create_folder 新建文件夹(可选顶层 idempotencyKey);update_folder 修改 label/collapsed;create_asset 登记已有媒体元数据,不上传或生成,不支持幂等键;update_asset 修改名称或通过 folderId 移动素材。已经入库的生成结果不要重复登记。删除用 delete_resources。"
|
||||
},
|
||||
"check_generation": {
|
||||
"title": "查看生成进度与结果",
|
||||
"description": "按 operationId 查询一次生成任务状态,不阻塞等待完成。queued/running 按 pollAfterMs 再查;completed 才消费 result,保留 warning、sliceWarning;failed 如实返回安全错误。跨账号与不存在任务同样不可见。已知任务 ID 时直接查询;提交响应丢失而没有 ID 时,用原 API、原请求和原 idempotencyKey 重试提交取得受理结果,不换键重提。查询超时不改变任务状态。"
|
||||
},
|
||||
"delete_resources": {
|
||||
"title": "删除资源",
|
||||
"description": "按精确 ID 删除,调用前明确目标范围并取得相应用户授权。action=delete_project 删除项目并级联清理默认画布和项目资源元数据;delete_folder 将其素材移到默认文件夹后删除文件夹,默认文件夹不可删除;delete_asset 删除素材记录并处理关联精选审核状态。文件夹/素材记录删除不等于删除 OSS 文件。不支持批量、模糊匹配或按名称删除。"
|
||||
}
|
||||
}
|
||||
@@ -2215,7 +2215,16 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
|
||||
"/admin/api/editor-assets" => AnyTab(&["editor-assets"]),
|
||||
"/admin/api/assets/read-url" => AnyTab(&["editor-assets", "editor-showcase"]),
|
||||
path if path.starts_with("/admin/api/editor-showcase/") => AnyTab(&["editor-showcase"]),
|
||||
path if path.starts_with("/admin/api/game-distribution/") => AnyTab(&["editor-showcase"]),
|
||||
path if path.starts_with("/admin/api/game-distribution/reviews") => {
|
||||
AnyTab(&["editor-showcase"])
|
||||
}
|
||||
path if path.starts_with("/admin/api/game-distribution/versions/") => {
|
||||
AnyTab(&["editor-showcase"])
|
||||
}
|
||||
// 游戏管理页与审核页共享 games/* 面(列表、恢复、安全下架)。
|
||||
path if path.starts_with("/admin/api/game-distribution/games") => {
|
||||
AnyTab(&["editor-showcase", "game-management"])
|
||||
}
|
||||
"/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => {
|
||||
AnyTab(&["redeem"])
|
||||
}
|
||||
@@ -7435,6 +7444,11 @@ mod tests {
|
||||
Method::GET,
|
||||
"/admin/api/editor-showcase/assets",
|
||||
),
|
||||
(
|
||||
"game-management",
|
||||
Method::GET,
|
||||
"/admin/api/game-distribution/games",
|
||||
),
|
||||
("editor-assets", Method::GET, "/admin/api/editor-assets"),
|
||||
];
|
||||
|
||||
@@ -7453,6 +7467,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn game_management_tab_is_separate_from_game_review_queue() {
|
||||
// 游戏管理页可以读全量游戏与恢复;待审队列仍只属于游戏审核页。
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["game-management".to_string()],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/game-distribution/games",
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["game-management".to_string()],
|
||||
&[],
|
||||
&Method::POST,
|
||||
"/admin/api/game-distribution/games/game_1/restore",
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["game-management".to_string()],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/game-distribution/reviews",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_consumption_reconcile_requires_its_standalone_action_permission() {
|
||||
assert!(
|
||||
|
||||
@@ -7,26 +7,208 @@ use axum::{
|
||||
extract::{Extension, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use module_runtime::AgcModelCatalog;
|
||||
use module_runtime::{
|
||||
AGC_MODEL_CATALOG_CONFLICT, AGC_MODEL_CATALOG_NOT_INITIALIZED, AgcModelCatalog,
|
||||
};
|
||||
use shared_contracts::admin::{AdminAgcModel, AdminAgcModelCatalog};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
use std::time::Duration;
|
||||
use tracing::warn;
|
||||
|
||||
/// 目录未初始化时对外统一的失败文案:目录只能来自上游同步或后台保存。
|
||||
pub(crate) const AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE: &str =
|
||||
"模型目录未初始化,服务端正在尝试从上游同步,请稍后重试";
|
||||
/// 上游模型列表请求超时与响应大小上限;越界按同步失败处理。
|
||||
///
|
||||
/// 同步发生在启动期、且在开始对外服务之前,超时必须足够短:上游挂起时不能让
|
||||
/// 每个 API/All 实例都延迟三十秒才可用。单次失败只记录 error,下次启动会重试。
|
||||
const AGC_MODEL_LIST_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const AGC_MODEL_LIST_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// 只读 `revision`:存量目录内容不合法时,覆盖写入仍需对齐乐观锁版本。
|
||||
#[derive(serde::Deserialize)]
|
||||
struct StoredCatalogRevision {
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
pub(crate) async fn load_catalog(state: &AppState) -> Result<AgcModelCatalog, AppError> {
|
||||
let json = state
|
||||
.spacetime_client()
|
||||
.read_agc_model_catalog()
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用")
|
||||
})?;
|
||||
let catalog: AgcModelCatalog = serde_json::from_str(&json).map_err(|_| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录格式无效")
|
||||
})?;
|
||||
catalog.validate().map_err(|message| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message(message)
|
||||
let stored = read_stored_catalog(state).await.map_err(|_| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("模型目录暂不可用")
|
||||
})?;
|
||||
let Some(json) = stored else {
|
||||
return Err(uninitialized_error());
|
||||
};
|
||||
parse_catalog(&json).map_err(|_| uninitialized_error())
|
||||
}
|
||||
|
||||
fn uninitialized_error() -> AppError {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message(AGC_MODEL_CATALOG_NOT_INITIALIZED_MESSAGE)
|
||||
}
|
||||
|
||||
/// 解析并校验目录内容;解析或校验失败都按“未初始化”处理,由启动期重新同步。
|
||||
fn parse_catalog(json: &str) -> Result<AgcModelCatalog, String> {
|
||||
let catalog: AgcModelCatalog =
|
||||
serde_json::from_str(json).map_err(|_| "模型目录格式无效".to_string())?;
|
||||
catalog.validate()?;
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
async fn read_stored_catalog(state: &AppState) -> Result<Option<String>, SpacetimeClientError> {
|
||||
match state.spacetime_client().read_agc_model_catalog().await {
|
||||
Ok(json) => Ok(Some(json)),
|
||||
Err(SpacetimeClientError::Procedure(message))
|
||||
if message == AGC_MODEL_CATALOG_NOT_INITIALIZED =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// 启动期确保目录已初始化:未初始化时从上游模型列表重建,失败只返回错误由调用方记录。
|
||||
///
|
||||
/// 目录已可用时不做任何写入;只有缺失、结构与当前定义不符或校验不通过才重建,因此上游模型
|
||||
/// 变化不会自动覆盖后台维护过的目录。
|
||||
pub(crate) async fn ensure_agc_model_catalog_initialized(state: &AppState) -> Result<(), String> {
|
||||
let stored = read_stored_catalog(state)
|
||||
.await
|
||||
.map_err(|error| format!("读取 AGC 模型目录失败:{error}"))?;
|
||||
let revision = match stored.as_deref() {
|
||||
Some(json) => match parse_catalog(json) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(message) => {
|
||||
warn!(
|
||||
error = %message,
|
||||
"AGC 模型目录内容与当前定义不符,按未初始化处理并从上游重建"
|
||||
);
|
||||
stored_catalog_revision(json)
|
||||
}
|
||||
},
|
||||
None => Some(0),
|
||||
};
|
||||
let revision = revision.ok_or_else(|| {
|
||||
"存量 AGC 模型目录缺少可解析的 revision,需要先清理该行再重启".to_string()
|
||||
})?;
|
||||
|
||||
let models = fetch_upstream_model_names(state).await?;
|
||||
let catalog = AgcModelCatalog::from_upstream_models(models, revision)?;
|
||||
let payload =
|
||||
serde_json::to_string(&catalog).map_err(|_| "AGC 模型目录序列化失败".to_string())?;
|
||||
match state
|
||||
.spacetime_client()
|
||||
.save_agc_model_catalog(payload)
|
||||
.await
|
||||
{
|
||||
Ok(saved) => {
|
||||
let saved: AgcModelCatalog = serde_json::from_str(&saved)
|
||||
.map_err(|_| "AGC 模型目录写回结果格式无效".to_string())?;
|
||||
tracing::info!(
|
||||
revision = saved.revision,
|
||||
model_count = saved.models.len(),
|
||||
"已按上游模型列表初始化 AGC 模型目录"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
// 多实例同时启动时只有一个写入成功:接受既有目录,但仍要确认它可用,
|
||||
// 否则会静默地把「每次启动都冲突、目录一直不可用」变成没有任何线索的黑洞。
|
||||
Err(SpacetimeClientError::Procedure(message)) if message == AGC_MODEL_CATALOG_CONFLICT => {
|
||||
let stored = read_stored_catalog(state)
|
||||
.await
|
||||
.map_err(|error| format!("写入冲突后重读 AGC 模型目录失败:{error}"))?;
|
||||
if stored
|
||||
.as_deref()
|
||||
.map(parse_catalog)
|
||||
.is_some_and(|result| result.is_ok())
|
||||
{
|
||||
warn!("AGC 模型目录写入冲突:已接受其它实例写入的目录");
|
||||
Ok(())
|
||||
} else {
|
||||
Err("AGC 模型目录写入冲突后仍不可用,需要人工检查该行内容与 revision".to_string())
|
||||
}
|
||||
}
|
||||
Err(error) => Err(format!("写入 AGC 模型目录失败:{error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn stored_catalog_revision(json: &str) -> Option<u64> {
|
||||
serde_json::from_str::<StoredCatalogRevision>(json)
|
||||
.ok()
|
||||
.map(|stored| stored.revision)
|
||||
}
|
||||
|
||||
/// 上游在售模型列表:`GET {Router 控制面}/api/pricing?group=taonier` 的 `data[].model_name`。
|
||||
///
|
||||
/// 取“该分组可见的在售模型”,而不是管理面模型注册表:注册表里会残留已下线、没有路由绑定的
|
||||
/// 条目(例如已从上游移除的 `gpt-6-astra`/`gpt-6-luna`),而定价列表就是 AGC 账号实际能调用的集合。
|
||||
/// 该端点是公开只读接口,不需要管理凭据。
|
||||
async fn fetch_upstream_model_names(state: &AppState) -> Result<Vec<String>, String> {
|
||||
crate::external_api_keys::ensure_llm_router_url_allowed(state)?;
|
||||
let origin =
|
||||
crate::external_api_keys::router_control_origin(&state.config.llm_router_base_url)?;
|
||||
let url = format!(
|
||||
"{origin}/api/pricing?group={}",
|
||||
crate::external_api_keys::LLM_ROUTER_TOKEN_GROUP
|
||||
);
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(AGC_MODEL_LIST_REQUEST_TIMEOUT)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|error| format!("构建 LLM Router 客户端失败:{error}"))?;
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("请求上游模型列表失败:{error}"))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(format!("上游模型列表返回 HTTP {status}"));
|
||||
}
|
||||
let bytes = read_bounded_json_body(response).await?;
|
||||
let payload: serde_json::Value =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "上游模型列表格式无效".to_string())?;
|
||||
parse_upstream_model_names(&payload)
|
||||
}
|
||||
|
||||
async fn read_bounded_json_body(mut response: reqwest::Response) -> Result<Vec<u8>, String> {
|
||||
// 先按 Content-Length 快速拒绝,再流式累加做兜底:不信任上游声明的长度,
|
||||
// 逐块累计超阈值立即中断,避免 `bytes()` 一次性分配任意大小响应撑爆内存。
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > AGC_MODEL_LIST_MAX_BYTES as u64)
|
||||
{
|
||||
return Err("上游模型列表响应超过大小上限".to_string());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("读取上游模型列表失败:{error}"))?
|
||||
{
|
||||
if bytes.len().saturating_add(chunk.len()) > AGC_MODEL_LIST_MAX_BYTES {
|
||||
return Err("上游模型列表响应超过大小上限".to_string());
|
||||
}
|
||||
bytes.extend_from_slice(chunk.as_ref());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn parse_upstream_model_names(payload: &serde_json::Value) -> Result<Vec<String>, String> {
|
||||
let data = payload
|
||||
.get("data")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| "上游模型列表缺少 data 数组".to_string())?;
|
||||
let models = data
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("model_name").and_then(serde_json::Value::as_str))
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
if models.iter().all(|model| model.trim().is_empty()) {
|
||||
return Err("上游模型列表为空".to_string());
|
||||
}
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
pub async fn admin_get_agc_models(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
@@ -44,6 +226,8 @@ pub async fn admin_save_agc_models(
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminAgcModelCatalog>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
// 目录只来自上游同步:未初始化时后台写入同样失败关闭,避免出现第二条绕过同步的写入口。
|
||||
load_catalog(&state).await?;
|
||||
let catalog = AgcModelCatalog {
|
||||
revision: payload.revision,
|
||||
default_model_id: payload.default_model_id,
|
||||
@@ -68,7 +252,7 @@ pub async fn admin_save_agc_models(
|
||||
.save_agc_model_catalog(payload)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if matches!(error, spacetime_client::SpacetimeClientError::Procedure(ref message) if message == module_runtime::AGC_MODEL_CATALOG_CONFLICT) {
|
||||
if matches!(&error, SpacetimeClientError::Procedure(message) if message == AGC_MODEL_CATALOG_CONFLICT) {
|
||||
AppError::from_status(StatusCode::CONFLICT).with_message("模型目录已被更新,请重新读取")
|
||||
} else {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_message("保存模型目录失败,请稍后重试")
|
||||
@@ -95,3 +279,168 @@ fn catalog_dto(catalog: AgcModelCatalog) -> AdminAgcModelCatalog {
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn upstream_model_names_come_from_pricing_data_array() {
|
||||
let payload = json!({
|
||||
"auto_groups": ["default"],
|
||||
"data": [
|
||||
{"model_name": "glm-5.3", "model_ratio": 1.0},
|
||||
{"model_name": "deepseek-flash", "model_ratio": 0.075},
|
||||
{"model_ratio": 1.0}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
parse_upstream_model_names(&payload).unwrap(),
|
||||
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
parse_upstream_model_names(&json!({"data": []})).unwrap_err(),
|
||||
"上游模型列表为空"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_upstream_model_names(&json!({"data": [{"model_name": " "}]})).unwrap_err(),
|
||||
"上游模型列表为空"
|
||||
);
|
||||
assert!(parse_upstream_model_names(&json!({"object": "list"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_catalog_revision_reads_row_revision() {
|
||||
assert_eq!(
|
||||
stored_catalog_revision(
|
||||
r#"{"revision":4,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":true}]}"#
|
||||
),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(stored_catalog_revision("not json"), None);
|
||||
assert_eq!(stored_catalog_revision(r#"{"models":[]}"#), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_parsing_marks_unusable_content_as_uninitialized() {
|
||||
// 后台保存过的目录结构必须能直接解析。
|
||||
let catalog = AgcModelCatalog::from_upstream_models(
|
||||
vec!["deepseek-v4-pro".to_string(), "glm-5.3".to_string()],
|
||||
4,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parse_catalog(&serde_json::to_string(&catalog).unwrap()).unwrap(),
|
||||
catalog
|
||||
);
|
||||
|
||||
// 结构或内容不合法(例如被外部工具改过)都按未初始化处理,由启动期重新同步。
|
||||
assert!(
|
||||
parse_catalog(r#"{"revision":1,"defaultModel":"deepseek-v4-pro","models":[]}"#)
|
||||
.is_err()
|
||||
);
|
||||
assert!(parse_catalog(
|
||||
r#"{"revision":1,"defaultModelId":"quality","models":[{"id":"quality","alias":"高质量","modelId":"gpt-6-astra","enabled":false}]}"#
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
struct MockModelListServer {
|
||||
base_url: String,
|
||||
captured: std::sync::Arc<std::sync::Mutex<Option<String>>>,
|
||||
_handle: std::thread::JoinHandle<()>,
|
||||
}
|
||||
|
||||
fn spawn_mock_model_list_server(status_line: &str, body: &str) -> MockModelListServer {
|
||||
use std::io::{Read, Write};
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("mock listener binds");
|
||||
let address = listener.local_addr().expect("mock address");
|
||||
let captured = std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||
let captured_for_thread = std::sync::Arc::clone(&captured);
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status_line}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("mock accept");
|
||||
let mut buffer = [0u8; 8192];
|
||||
let read = stream.read(&mut buffer).unwrap_or_default();
|
||||
*captured_for_thread.lock().expect("captured lock") =
|
||||
Some(String::from_utf8_lossy(&buffer[..read]).to_string());
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
});
|
||||
MockModelListServer {
|
||||
base_url: format!("http://{address}/v1"),
|
||||
captured,
|
||||
_handle: handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn model_list_state(base_url: &str) -> AppState {
|
||||
AppState::new(crate::config::AppConfig {
|
||||
llm_router_base_url: base_url.to_string(),
|
||||
..crate::config::AppConfig::default()
|
||||
})
|
||||
.expect("state should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_upstream_model_names_reads_group_pricing_without_credentials() {
|
||||
let server = spawn_mock_model_list_server(
|
||||
"200 OK",
|
||||
&json!({"data": [{"model_name": "glm-5.3"}, {"model_name": "deepseek-flash"}]})
|
||||
.to_string(),
|
||||
);
|
||||
let state = model_list_state(&server.base_url);
|
||||
|
||||
assert_eq!(
|
||||
fetch_upstream_model_names(&state).await.unwrap(),
|
||||
vec!["glm-5.3".to_string(), "deepseek-flash".to_string()]
|
||||
);
|
||||
|
||||
let request = server
|
||||
.captured
|
||||
.lock()
|
||||
.expect("captured lock")
|
||||
.clone()
|
||||
.expect("mock server should capture request");
|
||||
// 控制面路径由 base_url 推导(去掉 /v1),并显式带 AGC 账号所在分组。
|
||||
assert!(
|
||||
request.starts_with("GET /api/pricing?group=taonier HTTP/1.1"),
|
||||
"{request}"
|
||||
);
|
||||
// 定价列表是公开只读接口:不得把任何凭据发过去。
|
||||
assert!(
|
||||
!request.to_ascii_lowercase().contains("authorization:"),
|
||||
"{request}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_upstream_model_names_fails_closed_when_upstream_unavailable_or_empty() {
|
||||
let unauthorized = spawn_mock_model_list_server("401 Unauthorized", "{}");
|
||||
let state = model_list_state(&unauthorized.base_url);
|
||||
assert_eq!(
|
||||
fetch_upstream_model_names(&state).await.unwrap_err(),
|
||||
"上游模型列表返回 HTTP 401 Unauthorized"
|
||||
);
|
||||
|
||||
let empty = spawn_mock_model_list_server("200 OK", &json!({"data": []}).to_string());
|
||||
let state = model_list_state(&empty.base_url);
|
||||
assert_eq!(
|
||||
fetch_upstream_model_names(&state).await.unwrap_err(),
|
||||
"上游模型列表为空"
|
||||
);
|
||||
|
||||
let failing = spawn_mock_model_list_server("500 Internal Server Error", "{}");
|
||||
let state = model_list_state(&failing.base_url);
|
||||
assert_eq!(
|
||||
fetch_upstream_model_names(&state).await.unwrap_err(),
|
||||
"上游模型列表返回 HTTP 500 Internal Server Error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2749,6 +2749,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_scene_generation_rejects_invalid_idempotency_key_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
..AppConfig::default()
|
||||
})
|
||||
.expect("state should build");
|
||||
let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_idempotency");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = build_router(state.clone());
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/editor/scenes/generations")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.header("idempotency-key", "contains space")
|
||||
.body(Body::from(
|
||||
serde_json::json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"generationInputs": { "source": "ai-game-creator-client" },
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should complete");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(state.test_editor_generation_enqueue_attempts(), 0);
|
||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert!(String::from_utf8_lossy(&body).contains("Idempotency-Key"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
|
||||
let state = AppState::new(AppConfig {
|
||||
|
||||
@@ -826,6 +826,19 @@ mod tests {
|
||||
editor_generation_idempotency_namespace(&game_creator),
|
||||
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX
|
||||
);
|
||||
let scene = crate::editor_project::build_editor_scene_image_generation_payload(
|
||||
serde_json::from_value(json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE },
|
||||
}))
|
||||
.expect("scene request should deserialize"),
|
||||
)
|
||||
.expect("scene payload should build");
|
||||
assert_eq!(
|
||||
editor_generation_idempotency_namespace(&scene),
|
||||
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX,
|
||||
);
|
||||
assert_eq!(
|
||||
editor_generation_idempotency_namespace(&ordinary),
|
||||
EXTERNAL_API_GENERATION_DEDUPE_PREFIX
|
||||
|
||||
@@ -2658,12 +2658,23 @@ fn build_editor_scene_generation_inputs(
|
||||
{
|
||||
fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style }));
|
||||
}
|
||||
json!({
|
||||
let mut inputs = json!({
|
||||
"version": 2,
|
||||
"action": "scene.generate",
|
||||
"fields": fields,
|
||||
"references": references,
|
||||
})
|
||||
});
|
||||
// AGC 账号任务依赖来源标记选择幂等命名空间和可下载的队列结果;其余字段仍由服务端重建。
|
||||
if payload
|
||||
.generation_inputs
|
||||
.as_ref()
|
||||
.and_then(|inputs| inputs.get("source"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE)
|
||||
{
|
||||
inputs["source"] = json!(GAME_CREATOR_CLIENT_GENERATION_SOURCE);
|
||||
}
|
||||
inputs
|
||||
}
|
||||
|
||||
fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
|
||||
@@ -2692,13 +2703,9 @@ fn normalize_editor_scene_asset_label(asset_label: Option<String>) -> String {
|
||||
resolve_editor_generated_asset_label(asset_label, "游戏场景")
|
||||
}
|
||||
|
||||
pub async fn generate_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
pub(crate) fn build_editor_scene_image_generation_payload(
|
||||
payload: EditorSceneGenerateRequest,
|
||||
) -> Result<EditorImageGenerationRequest, AppError> {
|
||||
let generation_options = normalize_editor_scene_generation_options(
|
||||
payload.model.as_deref(),
|
||||
payload.aspect_ratio.as_deref(),
|
||||
@@ -2717,8 +2724,7 @@ pub async fn generate_editor_scene(
|
||||
}))
|
||||
})?;
|
||||
let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options);
|
||||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||||
let image_payload = EditorImageGenerationRequest {
|
||||
Ok(EditorImageGenerationRequest {
|
||||
prompt,
|
||||
size: None,
|
||||
kind: Some("scene".to_string()),
|
||||
@@ -2737,14 +2743,27 @@ pub async fn generate_editor_scene(
|
||||
asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)),
|
||||
source_resource_id: None,
|
||||
canvas_completion: payload.canvas_completion,
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn generate_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
let idempotency_key = optional_editor_idempotency_key(&headers)?;
|
||||
let image_payload = build_editor_scene_image_generation_payload(payload)?;
|
||||
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
|
||||
if !state.config.external_generation_mode.is_inline() {
|
||||
let queue_job = enqueue_editor_image_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
&caller,
|
||||
image_payload,
|
||||
None,
|
||||
idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
return Ok(json_success_body(
|
||||
@@ -13269,6 +13288,98 @@ mod tests {
|
||||
thread,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn scene_image_generation_payload_uses_shared_scene_contract() {
|
||||
let payload = build_editor_scene_image_generation_payload(EditorSceneGenerateRequest {
|
||||
scene_content: "雨夜小镇的街道".to_string(),
|
||||
style_preset: "custom".to_string(),
|
||||
custom_style: Some("像素水彩混合".to_string()),
|
||||
model: None,
|
||||
aspect_ratio: None,
|
||||
image_size: None,
|
||||
reference_image_srcs: vec!["resource-ref-1".to_string()],
|
||||
project_id: Some("project-1".to_string()),
|
||||
generation_inputs: None,
|
||||
asset_folder_id: Some("folder-1".to_string()),
|
||||
asset_label: Some(" ".to_string()),
|
||||
canvas_completion: None,
|
||||
})
|
||||
.expect("合法场景意图必须组装成功");
|
||||
|
||||
assert_eq!(payload.kind.as_deref(), Some("scene"));
|
||||
assert_eq!(payload.asset_kind.as_deref(), Some("scene"));
|
||||
assert_eq!(payload.aspect_ratio.as_deref(), Some("16:9"));
|
||||
assert_eq!(payload.image_size.as_deref(), Some("1K"));
|
||||
assert_eq!(payload.asset_label.as_deref(), Some("游戏场景"));
|
||||
assert_eq!(
|
||||
payload.reference_image_srcs.as_deref(),
|
||||
Some(["resource-ref-1".to_string()].as_slice())
|
||||
);
|
||||
assert!(payload.prompt.contains("雨夜小镇的街道"));
|
||||
assert!(payload.prompt.contains("像素水彩混合"));
|
||||
let generation_inputs = payload
|
||||
.generation_inputs
|
||||
.as_ref()
|
||||
.expect("场景 generationInputs 必须存在");
|
||||
assert_eq!(generation_inputs["version"], json!(2));
|
||||
assert_eq!(generation_inputs["action"], json!("scene.generate"));
|
||||
assert_eq!(
|
||||
generation_inputs["references"],
|
||||
json!([{ "id": "reference" }])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_generation_preserves_agc_downloadable_queue_result() {
|
||||
let request = serde_json::from_value::<EditorSceneGenerateRequest>(json!({
|
||||
"sceneContent": "雨夜小镇",
|
||||
"stylePreset": "anime",
|
||||
"referenceImageSrcs": ["art-spec-resource"],
|
||||
"generationInputs": {
|
||||
"source": GAME_CREATOR_CLIENT_GENERATION_SOURCE,
|
||||
"action": "client-supplied-action",
|
||||
"fields": [{ "id": "prompt", "value": "不可信配方" }],
|
||||
"references": [{ "refId": "untrusted-resource" }],
|
||||
},
|
||||
}))
|
||||
.expect("scene request should deserialize");
|
||||
let mut image_payload = build_editor_scene_image_generation_payload(request)
|
||||
.expect("scene payload should build");
|
||||
image_payload.generation_inputs =
|
||||
sanitize_editor_queued_generation_inputs(image_payload.generation_inputs);
|
||||
let inputs = image_payload.generation_inputs.as_ref().unwrap();
|
||||
assert_eq!(inputs["source"], GAME_CREATOR_CLIENT_GENERATION_SOURCE);
|
||||
assert_eq!(inputs["action"], "scene.generate");
|
||||
assert_eq!(inputs["fields"][0]["value"], "雨夜小镇");
|
||||
assert_eq!(inputs["references"], json!([{ "id": "reference" }]));
|
||||
|
||||
let mut job = atomic_editor_generation_job_fixture();
|
||||
job.request_payload_json = serde_json::to_string(&image_payload).unwrap();
|
||||
let context = EditorGenerationQueueResultContext::from_job(&job);
|
||||
assert_eq!(
|
||||
context.consumer,
|
||||
EditorGenerationQueueConsumer::GameCreatorResourceEditor
|
||||
);
|
||||
let result: Value = serde_json::from_str(
|
||||
&serialize_atomic_editor_generation_job_result(
|
||||
&context,
|
||||
&json!({
|
||||
"ok": true,
|
||||
"objectKey": "generated/scene.png",
|
||||
"resource": {
|
||||
"resourceId": "scene-resource",
|
||||
"objectKey": "generated/scene.png",
|
||||
"assetObjectId": "scene-object",
|
||||
},
|
||||
}),
|
||||
)
|
||||
.expect("AGC scene result should serialize"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result["result"]["objectKey"], "generated/scene.png");
|
||||
assert_eq!(result["result"]["resource"]["resourceId"], "scene-resource");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_removal_options_preserve_queue_parameters_and_legacy_identity() {
|
||||
for (fields, mode, color) in [
|
||||
|
||||
@@ -49,7 +49,7 @@ const EXTERNAL_API_KEY_SCOPES: [&str; 4] = [
|
||||
const LLM_ROUTER_TOKEN_IDENTIFIER: &str = "agc_auto_generate";
|
||||
/// Router 用户(账号)与它名下固定 Token / API Key 都归属同一分组 `taonier`。
|
||||
const LLM_ROUTER_USER_GROUP: &str = "taonier";
|
||||
const LLM_ROUTER_TOKEN_GROUP: &str = "taonier";
|
||||
pub(crate) const LLM_ROUTER_TOKEN_GROUP: &str = "taonier";
|
||||
const LLM_ROUTER_API_KEY_SCOPES: [&str; 1] = ["llm:responses"];
|
||||
const LLM_ROUTER_SUBSCRIPTION_PLAN_ID: i64 = 1;
|
||||
const LLM_ROUTER_SUBSCRIPTION_RENEWAL_THRESHOLD_SECONDS: i64 = 24 * 60 * 60;
|
||||
@@ -1624,7 +1624,7 @@ async fn ensure_router_token_contract(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn router_control_origin(base_url: &str) -> Result<String, String> {
|
||||
pub(crate) fn router_control_origin(base_url: &str) -> Result<String, String> {
|
||||
let mut url = reqwest::Url::parse(base_url.trim_end_matches('/'))
|
||||
.map_err(|error| format!("LLM Router 地址无效:{error}"))?;
|
||||
let is_loopback = url.host_str().is_some_and(|host| {
|
||||
@@ -1643,7 +1643,11 @@ fn router_control_origin(base_url: &str) -> Result<String, String> {
|
||||
Ok(url.to_string().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
|
||||
/// 只校验 LLM Router 目标地址是否允许(官方路由 / loopback、scheme),不校验固定模型。
|
||||
///
|
||||
/// 与具体模型无关的调用(例如按分组定价列表同步 AGC 模型目录)用这个入口,
|
||||
/// 避免被“必须使用官方固定模型”的哨兵常量挡住。
|
||||
pub(crate) fn ensure_llm_router_url_allowed(state: &AppState) -> Result<(), String> {
|
||||
let base_url = state.config.llm_router_base_url.trim_end_matches('/');
|
||||
let url =
|
||||
reqwest::Url::parse(base_url).map_err(|error| format!("LLM Router 地址无效:{error}"))?;
|
||||
@@ -1664,9 +1668,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
|
||||
if base_url != OFFICIAL_LLM_ROUTER_BASE_URL {
|
||||
return Err("生产环境 LLM Router 必须使用官方固定路由".to_string());
|
||||
}
|
||||
if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
|
||||
return Err("生产环境 LLM Router 必须使用官方固定模型".to_string());
|
||||
}
|
||||
if url.scheme() != "https" {
|
||||
return Err("生产环境 LLM Router 只允许 HTTPS 地址".to_string());
|
||||
}
|
||||
@@ -1674,9 +1675,6 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
|
||||
}
|
||||
|
||||
if base_url == OFFICIAL_LLM_ROUTER_BASE_URL {
|
||||
if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
|
||||
return Err("LLM Router 必须使用官方固定模型".to_string());
|
||||
}
|
||||
if url.scheme() != "https" {
|
||||
return Err("官方 LLM Router 只允许 HTTPS 地址".to_string());
|
||||
}
|
||||
@@ -1698,6 +1696,19 @@ fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_llm_router_target_allowed(state: &AppState) -> Result<(), String> {
|
||||
ensure_llm_router_url_allowed(state)?;
|
||||
if state.config.llm_router_model.trim() != OFFICIAL_LLM_ROUTER_MODEL {
|
||||
if state.config.is_production() {
|
||||
return Err("生产环境 LLM Router 必须使用官方固定模型".to_string());
|
||||
}
|
||||
if state.config.llm_router_base_url.trim_end_matches('/') == OFFICIAL_LLM_ROUTER_BASE_URL {
|
||||
return Err("LLM Router 必须使用官方固定模型".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn router_username_for_owner(owner_user_id: &str) -> String {
|
||||
// New API 的 User.Username 校验上限是 20 个字符。保留可读前缀后只
|
||||
// 能放 11 个字符;使用完整 owner id 做 SHA-256,再编码成 8 字节的
|
||||
|
||||
@@ -7,7 +7,9 @@ use axum::{
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload;
|
||||
use shared_contracts::assets::{
|
||||
EditorCanvasGenerationCompletionPayload, EditorSceneGenerateRequest,
|
||||
};
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
|
||||
ExternalGenerationJobStatus,
|
||||
@@ -38,12 +40,13 @@ use crate::{
|
||||
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
|
||||
EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView,
|
||||
EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse,
|
||||
EditorUiDesignAssetExtractionRequest, current_utc_micros,
|
||||
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record,
|
||||
editor_asset_payload_from_record, editor_idempotent_create_id,
|
||||
editor_project_payload_from_record, editor_project_resource_payload_from_record,
|
||||
editor_project_summary_from_record, enqueue_editor_background_removal_for_owner,
|
||||
enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner,
|
||||
EditorUiDesignAssetExtractionRequest, build_editor_scene_image_generation_payload,
|
||||
current_utc_micros, editor_asset_folder_payload_from_record,
|
||||
editor_asset_library_payload_from_record, editor_asset_payload_from_record,
|
||||
editor_idempotent_create_id, editor_project_payload_from_record,
|
||||
editor_project_resource_payload_from_record, editor_project_summary_from_record,
|
||||
enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner,
|
||||
enqueue_editor_image_generation_for_owner,
|
||||
enqueue_editor_ui_design_asset_extraction_for_owner,
|
||||
ensure_generic_editor_image_generation_contract, map_editor_project_error,
|
||||
normalize_editor_persisted_media_src, normalize_optional_string,
|
||||
@@ -750,6 +753,29 @@ pub async fn generate_external_editor_image(
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn generate_external_editor_scene(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(principal): Extension<ExternalApiPrincipal>,
|
||||
headers: HeaderMap,
|
||||
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Json(payload) = parse_editor_generation_json_payload(payload)?;
|
||||
require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?;
|
||||
let idempotency_key = require_idempotency_key(&headers)?;
|
||||
let project_id = payload.project_id.clone();
|
||||
let image_payload = build_editor_scene_image_generation_payload(payload)?;
|
||||
let job = enqueue_editor_image_generation_for_owner(
|
||||
&state,
|
||||
&request_context,
|
||||
&editor_generation_caller(&principal, project_id),
|
||||
image_payload,
|
||||
Some(idempotency_key),
|
||||
)
|
||||
.await?;
|
||||
Ok(external_generation_accepted_response(&request_context, job))
|
||||
}
|
||||
|
||||
pub async fn edit_external_editor_image(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1792,6 +1818,61 @@ mod tests {
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_scene_generation_rejects_invalid_intent_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
.expect("external scene test state should build");
|
||||
state.fail_test_editor_generation_enqueue();
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
post(generate_external_editor_scene),
|
||||
)
|
||||
.layer(Extension(request_context(false)))
|
||||
.layer(Extension(ExternalApiPrincipal::for_test(
|
||||
"user-external-scene",
|
||||
&[SCOPE_EDITOR_IMAGE_GENERATE],
|
||||
)))
|
||||
.with_state(state.clone());
|
||||
|
||||
for (case_name, request_body) in [
|
||||
(
|
||||
"empty sceneContent",
|
||||
json!({"sceneContent": " ", "stylePreset": "anime"}),
|
||||
),
|
||||
(
|
||||
"unknown stylePreset",
|
||||
json!({"sceneContent": "雨夜小镇", "stylePreset": "oil-painting"}),
|
||||
),
|
||||
(
|
||||
"custom without customStyle",
|
||||
json!({"sceneContent": "雨夜小镇", "stylePreset": "custom"}),
|
||||
),
|
||||
("missing sceneContent", json!({"stylePreset": "anime"})),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/external/v1/editor/scenes/generations")
|
||||
.header("content-type", "application/json")
|
||||
.header(IDEMPOTENCY_KEY_HEADER, "scene-contract-test")
|
||||
.body(Body::from(request_body.to_string()))
|
||||
.expect("external scene request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("external scene response should return");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}");
|
||||
assert_eq!(
|
||||
state.test_editor_generation_enqueue_attempts(),
|
||||
0,
|
||||
"{case_name} must fail before queueing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_background_removal_rejects_invalid_parameters_before_queueing() {
|
||||
let state = AppState::new(crate::config::AppConfig::default())
|
||||
@@ -2221,6 +2302,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_openapi_documents_dedicated_scene_generation_route() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
let operation = &parsed["paths"]["/api/external/v1/editor/scenes/generations"]["post"];
|
||||
assert_eq!(
|
||||
operation["requestBody"]["content"]["application/json"]["schema"]["$ref"],
|
||||
json!("#/components/schemas/EditorSceneGenerationRequest")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed["components"]["schemas"]["EditorSceneGenerationRequest"]["required"],
|
||||
json!(["sceneContent", "stylePreset"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_openapi_json_contains_external_editor_routes_and_security() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
|
||||
@@ -25,6 +25,8 @@ use tower::ServiceExt;
|
||||
|
||||
use crate::{modules, request_context::RequestContext, state::AppState};
|
||||
|
||||
mod semantic;
|
||||
|
||||
const OPENAPI_JSON: &str =
|
||||
include_str!("../../../../docs/openapi/genarrative-external-v1.openapi.json");
|
||||
const SKILL_MD: &str =
|
||||
@@ -54,7 +56,15 @@ const SKILL_REQUESTS_AND_OUTPUTS_URI: &str =
|
||||
"genarrative://external-editor/skill/references/requests-and-outputs.md";
|
||||
const MAX_MCP_REST_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
const MCP_INSTRUCTIONS: &str = r#"陶泥儿外部编辑器工具。先创建或复用画布项目,并创建与画布同名的素材文件夹;生成结果应同时写入画布和素材库。参考本地文件时先走上传票据和对象确认,不要把 Data URL、Blob URL 或临时签名 URL写入生成参数。所有生成工具都是异步提交:必须提供 idempotencyKey,提交后按 pollAfterMs 调用 get_external_editor_generation_job,只有 status=completed 时消费 result;查询超时不能重新提交。图集生成必须显式声明 sliceMode,没有默认值:需求要求等分网格、固定槽位或指定行列数时用 grid 并提供来自需求的 gridX/gridY,自由排布或数量不定时用 connected-components(可用 sliceCount 约束张数),connected-components 不接受 gridX/gridY;缺失、越界或自相矛盾在计费前返回 400。warning 表示主结果可用但存在降级,sliceWarning 表示完整透明图集可用但切片未完成。详细说明、OpenAPI、Skill 主入口和分主题 references 见 resources/list;需要本地文件编排或不支持 MCP 时再下载 skill.zip。"#;
|
||||
const MCP_INSTRUCTIONS: &str = r#"陶泥儿提供画布项目管理、素材库管理,以及图片、角色动画、视频和音频生成能力。
|
||||
|
||||
按用户任务需要创建或复用项目、素材文件夹,不默认创建。生成结果需要进入画布或素材库时,使用对应生成工具支持的目标字段;已有落库结果不要重复登记。
|
||||
|
||||
生成操作会产生费用,采用异步提交。每次独立生成使用稳定的 idempotencyKey;取得任务 ID 后,使用 check_generation 按 pollAfterMs 查询,直到 completed 或 failed。查询超时不代表生成失败,不要因此重新提交或更换幂等键。
|
||||
|
||||
本地参考文件通过 prepare_asset_upload 获取上传票据,由调用方实际上传后确认对象。后续引用遵循各工具要求,使用稳定的对象键或资源、素材 ID;不要把临时下载 URL 当作持久引用。
|
||||
|
||||
以实际返回的结果和告警判断完成情况,部分产物成功不代表所有处理步骤成功。具体参数以工具 schema 和说明为准;需要详细流程、示例或 API 契约时,通过 resources/list 查找相关文档。"#;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct McpOperation {
|
||||
@@ -125,7 +135,11 @@ impl ServerHandler for GenarrativeExternalMcp {
|
||||
_context: McpRequestContext<RoleServer>,
|
||||
) -> Result<ListToolsResult, ErrorData> {
|
||||
Ok(ListToolsResult::with_all_items(
|
||||
MCP_OPERATIONS.iter().map(mcp_operation_tool).collect(),
|
||||
MCP_OPERATIONS
|
||||
.iter()
|
||||
.map(mcp_operation_tool)
|
||||
.chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone()))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -134,6 +148,7 @@ impl ServerHandler for GenarrativeExternalMcp {
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == name)
|
||||
.map(mcp_operation_tool)
|
||||
.or_else(|| semantic::find(name).map(|entry| entry.tool.clone()))
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
@@ -141,12 +156,30 @@ impl ServerHandler for GenarrativeExternalMcp {
|
||||
request: CallToolRequestParams,
|
||||
context: McpRequestContext<RoleServer>,
|
||||
) -> Result<CallToolResult, ErrorData> {
|
||||
if let Some(tool) = semantic::find(request.name.as_ref()) {
|
||||
let result = match tool.prepare(request.arguments.unwrap_or_default()) {
|
||||
Ok(call) => {
|
||||
dispatch_operation(
|
||||
call.operation,
|
||||
call.arguments,
|
||||
&context,
|
||||
call.optional_idempotency_key,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
return Ok(match result {
|
||||
Ok(value) => CallToolResult::structured(value),
|
||||
Err(value) => CallToolResult::structured_error(value),
|
||||
});
|
||||
}
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.tool_name == request.name.as_ref())
|
||||
.ok_or_else(|| ErrorData::invalid_params("未知的陶泥儿外部 API 工具", None))?;
|
||||
let arguments = request.arguments.unwrap_or_default();
|
||||
match dispatch_operation(operation, arguments, &context).await {
|
||||
match dispatch_operation(operation, arguments, &context, None).await {
|
||||
Ok(value) => Ok(CallToolResult::structured(value)),
|
||||
Err(value) => Ok(CallToolResult::structured_error(value)),
|
||||
}
|
||||
@@ -475,6 +508,7 @@ async fn dispatch_operation(
|
||||
operation: &McpOperation,
|
||||
arguments: Map<String, Value>,
|
||||
context: &McpRequestContext<RoleServer>,
|
||||
optional_idempotency_key: Option<axum::http::HeaderValue>,
|
||||
) -> Result<Value, Value> {
|
||||
validate_required_body(operation, &arguments)?;
|
||||
|
||||
@@ -498,6 +532,51 @@ async fn dispatch_operation(
|
||||
.cloned()
|
||||
.ok_or_else(|| json!({"error": "Authorization 请求头缺失"}))?;
|
||||
|
||||
let request = build_operation_request(
|
||||
operation,
|
||||
&arguments,
|
||||
authorization,
|
||||
request_context,
|
||||
optional_idempotency_key,
|
||||
)?;
|
||||
let response = modules::external_api::router(state.clone())
|
||||
.with_state(state)
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap_or_else(|never| match never {});
|
||||
let status = response.status();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|_| json!({"error": "读取外部 API 响应失败"}))?
|
||||
.to_bytes();
|
||||
if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES {
|
||||
return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"}));
|
||||
}
|
||||
let payload = serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"status": status.as_u16(),
|
||||
"message": "外部 API 返回了非 JSON 响应"
|
||||
})
|
||||
});
|
||||
if status.is_success() {
|
||||
Ok(unwrap_external_api_success_payload(payload))
|
||||
} else {
|
||||
Err(json!({
|
||||
"status": status.as_u16(),
|
||||
"response": payload,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_operation_request(
|
||||
operation: &McpOperation,
|
||||
arguments: &Map<String, Value>,
|
||||
authorization: axum::http::HeaderValue,
|
||||
request_context: RequestContext,
|
||||
optional_idempotency_key: Option<axum::http::HeaderValue>,
|
||||
) -> Result<Request<Body>, Value> {
|
||||
let mut path = operation.path_template.clone();
|
||||
if let Some(path_parameters) = arguments.get("pathParameters").and_then(Value::as_object) {
|
||||
for (name, value) in path_parameters {
|
||||
@@ -532,37 +611,12 @@ async fn dispatch_operation(
|
||||
"application/json".parse().expect("valid content type"),
|
||||
);
|
||||
}
|
||||
apply_operation_headers(operation, &arguments, request.headers_mut())?;
|
||||
apply_operation_headers(operation, arguments, request.headers_mut())?;
|
||||
if let Some(key) = optional_idempotency_key {
|
||||
request.headers_mut().insert("idempotency-key", key);
|
||||
}
|
||||
|
||||
let response = modules::external_api::router(state.clone())
|
||||
.with_state(state)
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap_or_else(|never| match never {});
|
||||
let status = response.status();
|
||||
let bytes = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.map_err(|_| json!({"error": "读取外部 API 响应失败"}))?
|
||||
.to_bytes();
|
||||
if bytes.len() > MAX_MCP_REST_RESPONSE_BYTES {
|
||||
return Err(json!({"error": "外部 API 响应超过 MCP 返回上限"}));
|
||||
}
|
||||
let payload = serde_json::from_slice::<Value>(&bytes).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"status": status.as_u16(),
|
||||
"message": "外部 API 返回了非 JSON 响应"
|
||||
})
|
||||
});
|
||||
if status.is_success() {
|
||||
Ok(unwrap_external_api_success_payload(payload))
|
||||
} else {
|
||||
Err(json!({
|
||||
"status": status.as_u16(),
|
||||
"response": payload,
|
||||
}))
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
fn apply_operation_headers(
|
||||
@@ -977,15 +1031,16 @@ mod tests {
|
||||
let tools = MCP_OPERATIONS
|
||||
.iter()
|
||||
.map(mcp_operation_tool)
|
||||
.chain(semantic::TOOLS.iter().map(|entry| entry.tool.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
let serialized = serde_json::to_vec(&tools).expect("tool catalog should serialize");
|
||||
assert!(serialized.len() < 512 * 1024);
|
||||
for operation in MCP_OPERATIONS.iter() {
|
||||
let serialized = serde_json::to_string(&operation.input_schema)
|
||||
for tool in tools {
|
||||
let serialized = serde_json::to_string(&tool.input_schema)
|
||||
.expect("tool input schema should serialize");
|
||||
assert!(serialized.len() < 64 * 1024, "{}", operation.tool_name);
|
||||
assert!(!serialized.contains("\"$ref\""), "{}", operation.tool_name);
|
||||
assert_eq!(operation.input_schema.get("type"), Some(&json!("object")));
|
||||
assert!(serialized.len() < 64 * 1024, "{}", tool.name);
|
||||
assert!(!serialized.contains("\"$ref\""), "{}", tool.name);
|
||||
assert_eq!(tool.input_schema.get("type"), Some(&json!("object")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,6 +1079,269 @@ mod tests {
|
||||
})),
|
||||
json!({"operationId": "task-1", "status": "queued"})
|
||||
);
|
||||
let result = json!({
|
||||
"operationId": "task-1", "status": "completed",
|
||||
"result": {"objectKey": "media/sheet.png", "warning": {"code": "source_preserved"},
|
||||
"sliceWarning": {"code": "slice_failed"}, "project": {"revision": 7}}
|
||||
});
|
||||
assert_eq!(
|
||||
unwrap_external_api_success_payload(json!({"ok": true, "data": result})),
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
fn rpc_request(method: &str, params: Value) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/api/external/v1/mcp")
|
||||
.header(HOST, "localhost")
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(ACCEPT, "application/json, text/event-stream")
|
||||
.header("mcp-protocol-version", "2025-11-25")
|
||||
.body(Body::from(
|
||||
json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params}).to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn rpc_payload(response: axum::response::Response) -> Value {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_catalog_appends_tools_without_changing_legacy_definitions() {
|
||||
let payload = rpc_payload(
|
||||
service()
|
||||
.oneshot(rpc_request("tools/list", json!({})))
|
||||
.await
|
||||
.unwrap()
|
||||
.map(Body::new),
|
||||
)
|
||||
.await;
|
||||
let tools = payload["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), MCP_OPERATIONS.len() + 15);
|
||||
for op in MCP_OPERATIONS.iter() {
|
||||
let expected = serde_json::to_value(mcp_operation_tool(op)).unwrap();
|
||||
assert_eq!(
|
||||
tools.iter().find(|tool| tool["name"] == op.tool_name),
|
||||
Some(&expected)
|
||||
);
|
||||
}
|
||||
for entry in semantic::TOOLS.iter() {
|
||||
assert_eq!(
|
||||
GenarrativeExternalMcp.get_tool(&entry.tool.name),
|
||||
Some(entry.tool.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_invalid_arguments_fail_before_http_context_or_side_effects() {
|
||||
for (name, arguments) in [
|
||||
(
|
||||
"modify_image",
|
||||
json!({"action": "edit", "input": {"prompt": "修改", "sourceImageSrc": "wrong-reference"}, "idempotencyKey": "test"}),
|
||||
),
|
||||
(
|
||||
"delete_resources",
|
||||
json!({"action": "delete_project", "input": {}}),
|
||||
),
|
||||
(
|
||||
"manage_canvas_projects",
|
||||
json!({"action": "rename", "input": {"projectId": "project", "title": "新名"}, "idempotencyKey": "not-supported"}),
|
||||
),
|
||||
("generate_image", json!({"prompt": "test"})),
|
||||
] {
|
||||
let payload = rpc_payload(
|
||||
service()
|
||||
.oneshot(rpc_request(
|
||||
"tools/call",
|
||||
json!({"name": name, "arguments": arguments}),
|
||||
))
|
||||
.await
|
||||
.unwrap()
|
||||
.map(Body::new),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(payload["result"]["isError"], true, "{name}: {payload}");
|
||||
assert!(payload["result"]["structuredContent"]["error"].is_string());
|
||||
assert!(!payload.to_string().contains("上下文缺失"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_adapter_builds_real_rest_paths_bodies_and_optional_headers() {
|
||||
for (name, args, method, path, body, key) in [
|
||||
(
|
||||
"manage_canvas_projects",
|
||||
json!({"action":"create","input":{},"idempotencyKey":"create-project"}),
|
||||
Method::POST,
|
||||
"/api/external/v1/editor/projects",
|
||||
json!({}),
|
||||
Some("create-project"),
|
||||
),
|
||||
(
|
||||
"manage_canvas_projects",
|
||||
json!({"action":"rename","input":{"projectId":"project/a","title":"新名"}}),
|
||||
Method::PATCH,
|
||||
"/api/external/v1/editor/projects/project%2Fa/metadata",
|
||||
json!({"title":"新名"}),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"find_assets",
|
||||
json!({"action":"get_download_url","input":{"objectKey":"images/a b.png","expireSeconds":60}}),
|
||||
Method::GET,
|
||||
"/api/external/v1/assets/read-url?expireSeconds=60&objectKey=images%2Fa+b.png",
|
||||
Value::Null,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"find_canvas_projects",
|
||||
json!({"action":"list","input":{}}),
|
||||
Method::GET,
|
||||
"/api/external/v1/editor/projects?view=summary",
|
||||
Value::Null,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"modify_image",
|
||||
json!({"action":"variation","input":{"prompt":"变体","referenceImageSrcs":["ref"]},"idempotencyKey":"same-generation"}),
|
||||
Method::POST,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
json!({"prompt":"变体","referenceImageSrcs":["ref"],"kind":"quick-edit"}),
|
||||
Some("same-generation"),
|
||||
),
|
||||
] {
|
||||
let call = semantic::find(name)
|
||||
.unwrap()
|
||||
.prepare(args.as_object().unwrap().clone())
|
||||
.unwrap();
|
||||
let context = RequestContext::new(
|
||||
"test-request".into(),
|
||||
"POST /api/external/v1/mcp".into(),
|
||||
std::time::Duration::ZERO,
|
||||
false,
|
||||
);
|
||||
let request = build_operation_request(
|
||||
call.operation,
|
||||
&call.arguments,
|
||||
"Bearer fixture".parse().unwrap(),
|
||||
context,
|
||||
call.optional_idempotency_key,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), method);
|
||||
assert_eq!(request.uri().to_string(), path);
|
||||
assert_eq!(request.headers()[AUTHORIZATION], "Bearer fixture");
|
||||
assert_eq!(
|
||||
request
|
||||
.headers()
|
||||
.get("idempotency-key")
|
||||
.map(|v| v.to_str().unwrap()),
|
||||
key
|
||||
);
|
||||
assert_eq!(
|
||||
request
|
||||
.extensions()
|
||||
.get::<RequestContext>()
|
||||
.unwrap()
|
||||
.request_id(),
|
||||
"test-request"
|
||||
);
|
||||
let bytes = request.into_body().collect().await.unwrap().to_bytes();
|
||||
if body.is_null() {
|
||||
assert!(bytes.is_empty());
|
||||
} else {
|
||||
assert_eq!(serde_json::from_slice::<Value>(&bytes).unwrap(), body);
|
||||
}
|
||||
}
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|op| op.operation_id == "createEditorProject")
|
||||
.unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
apply_operation_headers(
|
||||
operation,
|
||||
&json!({"idempotencyKey": "legacy-ignored"})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone(),
|
||||
&mut headers,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
headers.get("idempotency-key").is_none(),
|
||||
"old optional-header behavior must remain unchanged"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn semantic_calls_reuse_rest_scope_checks_and_structured_errors() {
|
||||
use crate::state::external_api_auth::ExternalApiKeyAuthenticator;
|
||||
use futures_util::future::BoxFuture;
|
||||
use spacetime_client::{
|
||||
ExternalApiKeyAuthenticateRecordInput, ExternalApiKeyRecord, SpacetimeClientError,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
struct NoScopes(AtomicUsize);
|
||||
impl ExternalApiKeyAuthenticator for NoScopes {
|
||||
fn authenticate_external_api_key(
|
||||
&self,
|
||||
_: ExternalApiKeyAuthenticateRecordInput,
|
||||
) -> BoxFuture<'_, Result<ExternalApiKeyRecord, SpacetimeClientError>> {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
Box::pin(async {
|
||||
Ok(ExternalApiKeyRecord {
|
||||
key_id: "fixture-key".into(),
|
||||
owner_user_id: "owner-from-store".into(),
|
||||
name: "测试".into(),
|
||||
key_prefix: "tnr_sk_fixture".into(),
|
||||
scopes: vec![],
|
||||
created_at: "0.000000Z".into(),
|
||||
last_used_at: None,
|
||||
revoked_at: None,
|
||||
updated_at: "0.000000Z".into(),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
let auth = Arc::new(NoScopes(AtomicUsize::new(0)));
|
||||
let state = AppState::new(AppConfig::default())
|
||||
.unwrap()
|
||||
.with_external_api_auth_state(crate::state::ExternalApiAuthState::new(auth.clone()));
|
||||
let router = modules::external_api::router(state.clone())
|
||||
.with_state(state)
|
||||
.layer(middleware::from_fn(attach_request_context));
|
||||
for (name, arguments) in [
|
||||
(
|
||||
"delete_resources",
|
||||
json!({"action":"delete_project","input":{"projectId":"fixture-project"}}),
|
||||
),
|
||||
(
|
||||
"delete_editor_project",
|
||||
json!({"pathParameters":{"projectId":"fixture-project"}}),
|
||||
),
|
||||
] {
|
||||
let mut request = rpc_request("tools/call", json!({"name":name,"arguments":arguments}));
|
||||
request
|
||||
.headers_mut()
|
||||
.insert(AUTHORIZATION, "Bearer tnr_sk_fixture".parse().unwrap());
|
||||
let payload = rpc_payload(router.clone().oneshot(request).await.unwrap()).await;
|
||||
assert_eq!(payload["result"]["isError"], true, "{payload}");
|
||||
assert_eq!(
|
||||
payload["result"]["structuredContent"]["status"], 403,
|
||||
"{payload}"
|
||||
);
|
||||
assert!(!payload.to_string().contains("owner-from-store"));
|
||||
}
|
||||
assert_eq!(
|
||||
auth.0.load(Ordering::Relaxed),
|
||||
4,
|
||||
"outer MCP and inner REST both authenticate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
//! 语义入口只负责操作选择和参数位置转换,业务校验与副作用仍由 External router 承担。
|
||||
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
pub(super) static TOOLS: LazyLock<Vec<SemanticTool>> = LazyLock::new(build_tools);
|
||||
|
||||
pub(super) struct SemanticTool {
|
||||
pub(super) tool: Tool,
|
||||
actions: Vec<Action>,
|
||||
}
|
||||
|
||||
struct Action {
|
||||
name: Option<&'static str>,
|
||||
operation: &'static McpOperation,
|
||||
input_schema: Value,
|
||||
key_schema: Option<Value>,
|
||||
fixed_body: Map<String, Value>,
|
||||
destructive: bool,
|
||||
}
|
||||
|
||||
pub(super) struct PreparedCall {
|
||||
pub(super) operation: &'static McpOperation,
|
||||
pub(super) arguments: Map<String, Value>,
|
||||
pub(super) optional_idempotency_key: Option<HeaderValue>,
|
||||
}
|
||||
|
||||
pub(super) fn find(name: &str) -> Option<&'static SemanticTool> {
|
||||
TOOLS.iter().find(|entry| entry.tool.name == name)
|
||||
}
|
||||
|
||||
fn build_tools() -> Vec<SemanticTool> {
|
||||
let openapi: Value = serde_json::from_str(OPENAPI_JSON).expect("embedded OpenAPI must parse");
|
||||
let descriptions: Value = serde_json::from_str(include_str!(
|
||||
"../../prompts/external_mcp/semantic_tools.json"
|
||||
))
|
||||
.expect("semantic tool descriptions must parse");
|
||||
let definitions: &[(&str, &[(&str, &str)])] = &[
|
||||
(
|
||||
"find_canvas_projects",
|
||||
&[
|
||||
("list", "listEditorProjects"),
|
||||
("recent", "loadRecentEditorProject"),
|
||||
("get", "getEditorProject"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"manage_canvas_projects",
|
||||
&[
|
||||
("create", "createEditorProject"),
|
||||
("rename", "renameEditorProject"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"find_assets",
|
||||
&[
|
||||
("list_library", "getEditorAssetLibrary"),
|
||||
("get_project_resources", "getEditorProject"),
|
||||
("get_download_url", "getExternalAssetReadUrl"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"prepare_asset_upload",
|
||||
&[
|
||||
("create_upload_ticket", "createExternalDirectUploadTicket"),
|
||||
("confirm_upload", "confirmExternalAssetObject"),
|
||||
],
|
||||
),
|
||||
("generate_image", &[("", "generateExternalEditorImage")]),
|
||||
(
|
||||
"modify_image",
|
||||
&[
|
||||
("edit", "editExternalEditorImage"),
|
||||
("variation", "generateExternalEditorImage"),
|
||||
("remove_background", "removeExternalEditorImageBackground"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"generate_icon_spritesheet",
|
||||
&[("", "generateExternalEditorIconSpritesheet")],
|
||||
),
|
||||
(
|
||||
"extract_ui_assets",
|
||||
&[("", "extractExternalEditorUiDesignAssets")],
|
||||
),
|
||||
(
|
||||
"generate_character_animation",
|
||||
&[("", "generateExternalEditorCharacterAnimation")],
|
||||
),
|
||||
("generate_video", &[("", "generateExternalEditorVideo")]),
|
||||
(
|
||||
"generate_audio",
|
||||
&[
|
||||
("sound_effect", "generateExternalEditorSoundEffect"),
|
||||
("background_music", "generateExternalEditorBackgroundMusic"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"edit_canvas",
|
||||
&[
|
||||
("get", "getEditorProject"),
|
||||
("save_layout", "saveEditorProjectCanvas"),
|
||||
("register_resource", "createEditorProjectResource"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"organize_asset_library",
|
||||
&[
|
||||
("create_folder", "createEditorAssetFolder"),
|
||||
("update_folder", "updateEditorAssetFolder"),
|
||||
("create_asset", "createEditorAsset"),
|
||||
("update_asset", "updateEditorAsset"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"check_generation",
|
||||
&[("", "getExternalEditorGenerationJob")],
|
||||
),
|
||||
(
|
||||
"delete_resources",
|
||||
&[
|
||||
("delete_project", "deleteEditorProject"),
|
||||
("delete_folder", "deleteEditorAssetFolder"),
|
||||
("delete_asset", "deleteEditorAsset"),
|
||||
],
|
||||
),
|
||||
];
|
||||
definitions
|
||||
.iter()
|
||||
.map(|(name, operations)| {
|
||||
let actions = operations
|
||||
.iter()
|
||||
.map(|(action, operation)| {
|
||||
Action::new((!action.is_empty()).then_some(*action), operation, &openapi)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let read_only = actions.iter().all(|a| a.operation.method == Method::GET);
|
||||
let generation = actions.iter().any(|a| a.operation.requires_idempotency_key);
|
||||
let destructive = actions.iter().any(|a| a.destructive);
|
||||
let schema = tool_schema(&actions);
|
||||
let mut tool = Tool::new(
|
||||
name.to_string(),
|
||||
descriptions[name]["description"]
|
||||
.as_str()
|
||||
.expect("tool description")
|
||||
.to_string(),
|
||||
Arc::new(schema.as_object().expect("object schema").clone()),
|
||||
);
|
||||
tool.title = Some(
|
||||
descriptions[name]["title"]
|
||||
.as_str()
|
||||
.expect("tool title")
|
||||
.to_string(),
|
||||
);
|
||||
tool.annotations = Some(
|
||||
ToolAnnotations::new()
|
||||
.read_only(read_only)
|
||||
.destructive(destructive)
|
||||
.idempotent(
|
||||
read_only || actions.iter().all(|a| a.operation.requires_idempotency_key),
|
||||
)
|
||||
.open_world(
|
||||
generation || *name == "prepare_asset_upload" || *name == "find_assets",
|
||||
),
|
||||
);
|
||||
SemanticTool { tool, actions }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl Action {
|
||||
fn new(name: Option<&'static str>, operation_id: &str, openapi: &Value) -> Self {
|
||||
// 按实际副作用声明;POST 也可能覆盖已有记录,参数名不能代表风险。
|
||||
let destructive = match operation_id {
|
||||
"listEditorProjects"
|
||||
| "loadRecentEditorProject"
|
||||
| "getEditorProject"
|
||||
| "getEditorAssetLibrary"
|
||||
| "getExternalAssetReadUrl"
|
||||
| "getExternalEditorGenerationJob"
|
||||
| "createEditorProject"
|
||||
| "createEditorProjectResource"
|
||||
| "createEditorAssetFolder"
|
||||
| "createEditorAsset"
|
||||
| "createExternalDirectUploadTicket" => false,
|
||||
// 对象确认允许更新同一 owner 的已有对象元数据。
|
||||
"confirmExternalAssetObject"
|
||||
| "renameEditorProject"
|
||||
| "saveEditorProjectCanvas"
|
||||
| "updateEditorAssetFolder"
|
||||
| "updateEditorAsset"
|
||||
| "deleteEditorProject"
|
||||
| "deleteEditorAssetFolder"
|
||||
| "deleteEditorAsset" => true,
|
||||
// 生成完成可修改已有画布状态,编辑与抠图还支持原位替换。
|
||||
"generateExternalEditorImage"
|
||||
| "editExternalEditorImage"
|
||||
| "removeExternalEditorImageBackground"
|
||||
| "generateExternalEditorIconSpritesheet"
|
||||
| "extractExternalEditorUiDesignAssets"
|
||||
| "generateExternalEditorCharacterAnimation"
|
||||
| "generateExternalEditorVideo"
|
||||
| "generateExternalEditorSoundEffect"
|
||||
| "generateExternalEditorBackgroundMusic" => true,
|
||||
_ => panic!("semantic operation must declare destructive risk: {operation_id}"),
|
||||
};
|
||||
let operation = MCP_OPERATIONS
|
||||
.iter()
|
||||
.find(|op| op.operation_id == operation_id)
|
||||
.expect("semantic tools must map to existing operations");
|
||||
let wrapped = &operation.input_schema["properties"];
|
||||
// 保留 body 的 if/then/allOf 等约束;仅合并位置包装,不重建字段定义。
|
||||
let mut input_schema = wrapped
|
||||
.get("body")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({"type": "object", "properties": {}}));
|
||||
let mut required = input_schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for location in ["pathParameters", "queryParameters"] {
|
||||
if let Some(schema) = wrapped.get(location) {
|
||||
for (name, field) in schema["properties"]
|
||||
.as_object()
|
||||
.expect("parameter properties")
|
||||
{
|
||||
assert!(
|
||||
input_schema["properties"].get(name).is_none(),
|
||||
"ambiguous field {name}"
|
||||
);
|
||||
input_schema["properties"][name] = inline_openapi_schema(openapi, field, 0);
|
||||
}
|
||||
required.extend(
|
||||
schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut fixed_body = Map::new();
|
||||
if name == Some("variation") {
|
||||
input_schema["properties"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("kind");
|
||||
required.retain(|field| field != "kind");
|
||||
fixed_body.insert("kind".into(), json!("quick-edit"));
|
||||
}
|
||||
if operation_id == "confirmExternalAssetObject" {
|
||||
input_schema["properties"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("ownerUserId");
|
||||
}
|
||||
input_schema["required"] = Value::Array(required);
|
||||
input_schema["additionalProperties"] = json!(false);
|
||||
let path = operation.path_template.split('?').next().unwrap();
|
||||
let path_item = &openapi["paths"][path];
|
||||
let rest = &path_item[operation.method.as_str().to_ascii_lowercase()];
|
||||
let key_schema = path_item
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.chain(
|
||||
rest.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
.filter_map(|p| resolve_openapi_reference(openapi, p))
|
||||
.find(|p| p["in"] == "header" && p["name"] == "Idempotency-Key")
|
||||
.map(|p| {
|
||||
let mut schema = inline_openapi_schema(openapi, &p["schema"], 0);
|
||||
if let Some(description) = p.get("description") {
|
||||
schema["description"] = description.clone();
|
||||
}
|
||||
schema
|
||||
});
|
||||
Self {
|
||||
name,
|
||||
operation,
|
||||
input_schema,
|
||||
key_schema,
|
||||
fixed_body,
|
||||
destructive,
|
||||
}
|
||||
}
|
||||
|
||||
fn call_schema(&self) -> Value {
|
||||
let mut schema = match self.name {
|
||||
Some(name) => json!({
|
||||
"type": "object",
|
||||
"properties": {"action": {"type": "string", "const": name}, "input": self.input_schema},
|
||||
"required": ["action", "input"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
None => self.input_schema.clone(),
|
||||
};
|
||||
if let Some(key) = &self.key_schema {
|
||||
schema["properties"]["idempotencyKey"] = key.clone();
|
||||
if self.operation.requires_idempotency_key {
|
||||
schema["required"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!("idempotencyKey"));
|
||||
}
|
||||
}
|
||||
schema
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_schema(actions: &[Action]) -> Value {
|
||||
if actions[0].name.is_none() {
|
||||
return actions[0].call_schema();
|
||||
}
|
||||
let mut schema = json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": actions.iter().map(|a| a.name.unwrap()).collect::<Vec<_>>()},
|
||||
"input": {"type": "object"}
|
||||
},
|
||||
"required": ["action", "input"],
|
||||
"additionalProperties": false,
|
||||
"oneOf": actions.iter().map(Action::call_schema).collect::<Vec<_>>()
|
||||
});
|
||||
if let Some(key) = actions.iter().find_map(|a| a.key_schema.as_ref()) {
|
||||
schema["properties"]["idempotencyKey"] = key.clone();
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
impl SemanticTool {
|
||||
pub(super) fn prepare(&self, mut arguments: Map<String, Value>) -> Result<PreparedCall, Value> {
|
||||
let action = if self.actions[0].name.is_none() {
|
||||
&self.actions[0]
|
||||
} else {
|
||||
let name = arguments
|
||||
.get("action")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| json!({"error": "必须提供字符串 action"}))?;
|
||||
self.actions
|
||||
.iter()
|
||||
.find(|a| a.name == Some(name))
|
||||
.ok_or_else(|| json!({"error": "未知 action"}))?
|
||||
};
|
||||
validate_fields(&action.call_schema(), &arguments)?;
|
||||
let key = arguments.remove("idempotencyKey");
|
||||
let mut optional_idempotency_key = None;
|
||||
if let Some(key) = &key {
|
||||
let key = key
|
||||
.as_str()
|
||||
.ok_or_else(|| json!({"error": "idempotencyKey 必须是字符串"}))?;
|
||||
if key.is_empty() || key.len() > 128 || !key.bytes().all(|c| (b'!'..=b'~').contains(&c))
|
||||
{
|
||||
return Err(json!({"error": "idempotencyKey 必须为 1–128 个非空白 ASCII 字符"}));
|
||||
}
|
||||
if !action.operation.requires_idempotency_key {
|
||||
optional_idempotency_key = Some(
|
||||
HeaderValue::from_str(key)
|
||||
.map_err(|_| json!({"error": "idempotencyKey 不是合法 HTTP 头值"}))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
let input = if action.name.is_some() {
|
||||
arguments
|
||||
.remove("input")
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.ok_or_else(|| json!({"error": "input 必须是 JSON 对象"}))?
|
||||
} else {
|
||||
arguments
|
||||
};
|
||||
validate_fields(&action.input_schema, &input)?;
|
||||
let wrapped = &action.operation.input_schema["properties"];
|
||||
let mut mapped = Map::new();
|
||||
for location in ["pathParameters", "queryParameters", "body"] {
|
||||
if let Some(schema) = wrapped.get(location) {
|
||||
let mut fields = input
|
||||
.iter()
|
||||
.filter(|(name, _)| schema["properties"].get(*name).is_some())
|
||||
.map(|(name, value)| (name.clone(), value.clone()))
|
||||
.collect::<Map<_, _>>();
|
||||
if location == "body" {
|
||||
fields.extend(action.fixed_body.clone());
|
||||
}
|
||||
// 有请求体的操作始终发送对象,包括无字段的项目创建。
|
||||
if location == "body" || !fields.is_empty() {
|
||||
mapped.insert(location.into(), Value::Object(fields));
|
||||
}
|
||||
}
|
||||
}
|
||||
if action.operation.requires_idempotency_key {
|
||||
if let Some(key) = key {
|
||||
mapped.insert("idempotencyKey".into(), key);
|
||||
}
|
||||
}
|
||||
Ok(PreparedCall {
|
||||
operation: action.operation,
|
||||
arguments: mapped,
|
||||
optional_idempotency_key,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 只校验适配层结构和直接字段,不实现第二套业务 schema 验证器。
|
||||
// 嵌套字段与跨字段条件在现有 REST DTO/业务入口中校验,完整 schema 仍向客户端提供。
|
||||
fn validate_fields(schema: &Value, input: &Map<String, Value>) -> Result<(), Value> {
|
||||
let properties = schema["properties"]
|
||||
.as_object()
|
||||
.expect("input schema properties");
|
||||
for name in schema["required"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
{
|
||||
if !input.contains_key(name) {
|
||||
return Err(json!({"error": "缺少必填字段", "field": name}));
|
||||
}
|
||||
}
|
||||
for (name, value) in input {
|
||||
let field = properties
|
||||
.get(name)
|
||||
.ok_or_else(|| json!({"error": "当前操作不接受此字段", "field": name}))?;
|
||||
let matches_type = |kind: &str| match kind {
|
||||
"string" => value.is_string(),
|
||||
"object" => value.is_object(),
|
||||
"array" => value.is_array(),
|
||||
"boolean" => value.is_boolean(),
|
||||
"number" => value.is_number(),
|
||||
"integer" => {
|
||||
value.is_i64() || value.is_u64() || value.as_f64().is_some_and(|v| v.fract() == 0.0)
|
||||
}
|
||||
"null" => value.is_null(),
|
||||
_ => true,
|
||||
};
|
||||
let valid_type = match &field["type"] {
|
||||
Value::String(kind) => matches_type(kind),
|
||||
Value::Array(kinds) => kinds.iter().filter_map(Value::as_str).any(matches_type),
|
||||
_ => true,
|
||||
};
|
||||
if !valid_type
|
||||
|| field
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|values| !values.contains(value))
|
||||
|| field.get("const").is_some_and(|expected| expected != value)
|
||||
{
|
||||
return Err(json!({"error": "字段类型或取值不符合当前操作", "field": name}));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,18 +37,24 @@ mod model_catalog_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn public_catalog_only_exposes_alias_and_stable_id() {
|
||||
let mut catalog = module_runtime::AgcModelCatalog::default();
|
||||
catalog.revision = 7;
|
||||
fn public_catalog_exposes_stable_id_and_upstream_alias() {
|
||||
let mut catalog = module_runtime::AgcModelCatalog::from_upstream_models(
|
||||
vec!["gpt-5.6-sol".to_string(), "gpt-5.6-terra".to_string()],
|
||||
7,
|
||||
)
|
||||
.expect("catalog should build");
|
||||
catalog.models[1].enabled = false;
|
||||
let payload = serde_json::to_value(public_model_catalog(catalog)).unwrap();
|
||||
// 客户端拿到稳定标识 + 别名(别名就是上游原始模型名),实际模型名不下发。
|
||||
assert_eq!(
|
||||
payload["models"],
|
||||
json!([{"id": "quality", "displayName": "高质量"}])
|
||||
json!([{"id": "gpt-5-6-sol", "displayName": "gpt-5.6-sol"}])
|
||||
);
|
||||
assert_eq!(payload["defaultModelId"], "quality");
|
||||
assert_eq!(payload["defaultModelId"], "gpt-5-6-sol");
|
||||
assert_eq!(payload["revision"], json!(7));
|
||||
assert!(!payload.to_string().contains("gpt-"));
|
||||
assert!(payload.get("defaultModel").is_none());
|
||||
assert!(payload["models"][0].get("enabled").is_none());
|
||||
assert!(payload["models"][0].get("modelId").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +200,7 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
|
||||
.filter(|model| model.enabled)
|
||||
.map(|model| LlmModelSummary {
|
||||
id: model.id,
|
||||
// 初始目录里别名就是上游原始模型名(不再填“高质量/快速”这类人工别名)。
|
||||
display_name: model.alias,
|
||||
})
|
||||
.collect(),
|
||||
@@ -201,6 +208,29 @@ fn public_model_catalog(catalog: module_runtime::AgcModelCatalog) -> LlmModelsRe
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试用目录:两项。上游模型名带 `.`,标识是它的 slug —— 既验证「客户端只回传目录标识」,
|
||||
/// 也验证标识 → 实际模型名的映射;默认项是排序后的第一项,`TEST_AGC_MODEL_ID` 不是默认项。
|
||||
#[cfg(test)]
|
||||
pub(crate) const TEST_AGC_MODEL_ID: &str = "test-router-model";
|
||||
#[cfg(test)]
|
||||
pub(crate) const TEST_AGC_MODEL_MODEL_ID: &str = "test-router.model";
|
||||
#[cfg(test)]
|
||||
pub(crate) const TEST_AGC_MODEL_DEFAULT_ID: &str = "test-router-default";
|
||||
#[cfg(test)]
|
||||
pub(crate) const TEST_AGC_MODEL_DEFAULT_MODEL_ID: &str = "test-router.default";
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_agc_model_catalog() -> module_runtime::AgcModelCatalog {
|
||||
module_runtime::AgcModelCatalog::from_upstream_models(
|
||||
vec![
|
||||
TEST_AGC_MODEL_DEFAULT_MODEL_ID.to_string(),
|
||||
TEST_AGC_MODEL_MODEL_ID.to_string(),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.expect("test catalog should build")
|
||||
}
|
||||
|
||||
async fn load_llm_catalog(
|
||||
state: &AppState,
|
||||
owner: &str,
|
||||
@@ -211,7 +241,7 @@ async fn load_llm_catalog(
|
||||
.expect("fixture lock")
|
||||
.contains_key(owner)
|
||||
{
|
||||
return Ok(module_runtime::AgcModelCatalog::default());
|
||||
return Ok(test_agc_model_catalog());
|
||||
}
|
||||
let _ = owner;
|
||||
crate::agc_models::load_catalog(state).await
|
||||
@@ -283,9 +313,8 @@ pub async fn proxy_llm_responses(
|
||||
] {
|
||||
object.remove(field);
|
||||
}
|
||||
// The AGC client may select a model from the server-provided Router
|
||||
// directory. Older callers without the reserved marker remain pinned to
|
||||
// the official default model.
|
||||
// AGC 客户端可以在服务端目录内选择模型;`model` 就是上游原始模型名。
|
||||
// 老客户端存的历史稳定标识与目录外模型一律拒绝,不回退其它模型。
|
||||
let agc_client = headers
|
||||
.get("x-genarrative-client")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
@@ -293,16 +322,13 @@ pub async fn proxy_llm_responses(
|
||||
let catalog = load_llm_catalog(&state, authenticated.claims().user_id())
|
||||
.await
|
||||
.map_err(|error| llm_error_response(&request_context, error))?;
|
||||
let selected_id = if agc_client {
|
||||
requested_model
|
||||
.as_deref()
|
||||
.filter(|id| *id != "platform-default")
|
||||
let requested_model = if agc_client {
|
||||
requested_model.as_deref()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.unwrap_or(&catalog.default_model_id);
|
||||
};
|
||||
let selected_model = catalog
|
||||
.resolve(selected_id)
|
||||
.resolve_requested(requested_model)
|
||||
.map_err(|message| {
|
||||
llm_error_response(
|
||||
&request_context,
|
||||
@@ -847,7 +873,7 @@ async fn resolve_llm_router_client(
|
||||
let catalog = load_llm_catalog(state, owner_user_id)
|
||||
.await
|
||||
.map_err(|_| "模型目录暂不可用".to_string())?;
|
||||
let model = catalog.resolve(&catalog.default_model_id)?;
|
||||
let model = catalog.resolve_requested(None)?;
|
||||
let config = platform_llm::LlmConfig::new(
|
||||
platform_llm::LlmProvider::OpenAiCompatible,
|
||||
base_url.to_string(),
|
||||
@@ -1304,11 +1330,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_responses_proxy_forces_official_model_and_keeps_router_key_server_side() {
|
||||
async fn llm_responses_without_agc_marker_uses_catalog_default_and_keeps_router_key_server_side()
|
||||
{
|
||||
let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "application/json; charset=utf-8",
|
||||
body: r#"{"id":"resp_proxy_01","model":"gpt-6-astra","output":[]}"#.to_string(),
|
||||
body: format!(
|
||||
r#"{{"id":"resp_proxy_01","model":"{TEST_AGC_MODEL_DEFAULT_MODEL_ID}","output":[]}}"#
|
||||
),
|
||||
extra_headers: Vec::new(),
|
||||
});
|
||||
let (state, user_id) = seed_authenticated_state(AppConfig {
|
||||
@@ -1373,12 +1402,64 @@ mod tests {
|
||||
.expect("upstream request body");
|
||||
let upstream_payload: Value =
|
||||
serde_json::from_str(upstream_body).expect("upstream body should be json");
|
||||
assert_eq!(upstream_payload["model"], "gpt-6-astra");
|
||||
assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID);
|
||||
assert_ne!(upstream_payload["model"], "client-must-not-control");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_responses_rejects_upstream_names_and_unknown_catalog_ids() {
|
||||
async fn llm_responses_forwards_catalog_model_selected_by_agc_client() {
|
||||
let (server_url, captured_request) = spawn_capturing_mock_server(MockResponse {
|
||||
status_line: "200 OK",
|
||||
content_type: "application/json; charset=utf-8",
|
||||
body: format!(
|
||||
r#"{{"id":"resp_proxy_02","model":"{TEST_AGC_MODEL_MODEL_ID}","output":[]}}"#
|
||||
),
|
||||
extra_headers: Vec::new(),
|
||||
});
|
||||
let (state, user_id) = seed_authenticated_state(AppConfig {
|
||||
llm_router_base_url: server_url.clone(),
|
||||
llm_router_api_key_encryption_secret: Some("fixture-encryption-secret".to_string()),
|
||||
..AppConfig::default()
|
||||
})
|
||||
.await;
|
||||
install_test_provisioned_router_credential(&user_id, server_url, "fixture-router-key");
|
||||
let token = issue_access_token(&state, &user_id);
|
||||
let app = build_router(state);
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/llm/responses")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-genarrative-client", "agc")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({"model": TEST_AGC_MODEL_ID, "input": "hello"}).to_string(),
|
||||
))
|
||||
.expect("request should build"),
|
||||
)
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let upstream_request = captured_request
|
||||
.lock()
|
||||
.expect("captured request lock")
|
||||
.clone()
|
||||
.expect("mock server should capture upstream request");
|
||||
let (_, upstream_body) = upstream_request
|
||||
.split_once("\r\n\r\n")
|
||||
.expect("upstream request body");
|
||||
let upstream_payload: Value =
|
||||
serde_json::from_str(upstream_body).expect("upstream body should be json");
|
||||
// 客户端只能回传目录标识,服务端映射成上游实际模型名;默认项不参与。
|
||||
assert_eq!(upstream_payload["model"], TEST_AGC_MODEL_MODEL_ID);
|
||||
assert_ne!(upstream_payload["model"], TEST_AGC_MODEL_DEFAULT_MODEL_ID);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn llm_responses_rejects_models_outside_catalog() {
|
||||
let (state, user_id) = seed_authenticated_state(AppConfig::default()).await;
|
||||
install_test_provisioned_router_credential(
|
||||
&user_id,
|
||||
@@ -1387,7 +1468,13 @@ mod tests {
|
||||
);
|
||||
let token = issue_access_token(&state, &user_id);
|
||||
let app = build_router(state);
|
||||
for model in ["gpt-6-astra", "unlisted"] {
|
||||
// 历史稳定标识、目录外名称、以及「直接拿上游实际模型名当标识」都必须拒绝。
|
||||
for model in [
|
||||
"quality",
|
||||
"gpt-6-astra",
|
||||
"unlisted",
|
||||
TEST_AGC_MODEL_MODEL_ID,
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
|
||||
@@ -500,6 +500,10 @@ fn should_initialize_editor_generation_pricing_for_startup(process_role: Process
|
||||
process_role.runs_http()
|
||||
}
|
||||
|
||||
fn should_initialize_agc_model_catalog_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;
|
||||
@@ -764,6 +768,16 @@ async fn try_restore_app_state_for_startup(
|
||||
))
|
||||
})?;
|
||||
}
|
||||
// AGC 模型目录只来自上游同步或后台保存;这里同步失败不阻塞启动,由下一次启动重试,
|
||||
// 未初始化期间 AGC 相关接口失败关闭。
|
||||
if should_initialize_agc_model_catalog_for_startup(process_role) {
|
||||
if let Err(error) = crate::agc_models::ensure_agc_model_catalog_initialized(&state).await {
|
||||
error!(
|
||||
error = %error,
|
||||
"AGC 模型目录未初始化:本次启动未从上游同步到模型列表,AGC 目录与对话接口将失败关闭,下次启动会重试"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ use crate::{
|
||||
delete_external_editor_project, edit_external_editor_image,
|
||||
extract_external_editor_ui_design_assets, generate_external_editor_background_music,
|
||||
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
|
||||
generate_external_editor_image, generate_external_editor_sound_effect,
|
||||
generate_external_editor_video, get_external_editor_asset_library,
|
||||
get_external_editor_generation_job, get_external_editor_project,
|
||||
list_external_editor_projects, load_recent_external_editor_project, openapi_json,
|
||||
remove_external_editor_image_background, rename_external_editor_project,
|
||||
save_external_editor_canvas, update_external_editor_asset,
|
||||
generate_external_editor_image, generate_external_editor_scene,
|
||||
generate_external_editor_sound_effect, generate_external_editor_video,
|
||||
get_external_editor_asset_library, get_external_editor_generation_job,
|
||||
get_external_editor_project, list_external_editor_projects,
|
||||
load_recent_external_editor_project, openapi_json, remove_external_editor_image_background,
|
||||
rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
|
||||
update_external_editor_asset_folder,
|
||||
},
|
||||
external_mcp,
|
||||
@@ -111,6 +111,10 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/api/external/v1/editor/images/generations",
|
||||
post(generate_external_editor_image),
|
||||
),
|
||||
(
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
post(generate_external_editor_scene),
|
||||
),
|
||||
(
|
||||
"/api/external/v1/editor/images/edits",
|
||||
post(edit_external_editor_image),
|
||||
@@ -257,6 +261,7 @@ mod route_contract_tests {
|
||||
),
|
||||
("/api/external/v1/generations/{operation_id}", &["GET"]),
|
||||
("/api/external/v1/editor/images/generations", &["POST"]),
|
||||
("/api/external/v1/editor/scenes/generations", &["POST"]),
|
||||
("/api/external/v1/editor/images/edits", &["POST"]),
|
||||
(
|
||||
"/api/external/v1/editor/images/background-removals",
|
||||
|
||||
@@ -31,10 +31,12 @@ use shared_contracts::game_distribution::{
|
||||
GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest,
|
||||
};
|
||||
use spacetime_client::{
|
||||
GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput,
|
||||
GameDistributionGameRecord, GameDistributionGetGameRecordInput,
|
||||
GameDistributionPublicGameListRecordInput, GameDistributionPublicGameRecord,
|
||||
GameDistributionRejectRecordInput, GameDistributionSubmitReviewRecordInput,
|
||||
GameDistributionAdminGameListRecordInput, GameDistributionAdminGameRecord,
|
||||
GameDistributionAdminVersionRecord, GameDistributionApproveRecordInput,
|
||||
GameDistributionCancelVersionRecordInput, GameDistributionGameRecord,
|
||||
GameDistributionGetGameRecordInput, GameDistributionPublicGameListRecordInput,
|
||||
GameDistributionPublicGameRecord, GameDistributionRejectRecordInput,
|
||||
GameDistributionRestoreRecordInput, GameDistributionSubmitReviewRecordInput,
|
||||
GameDistributionSuspendRecordInput, GameDistributionUnpublishRecordInput,
|
||||
GameDistributionVersionRecord, SpacetimeClientError,
|
||||
};
|
||||
@@ -60,6 +62,8 @@ pub(crate) const MAX_PACKAGE_CHUNK_REQUEST_BODY_BYTES: usize = PACKAGE_UPLOAD_CH
|
||||
/// 分片偏移由客户端显式声明,服务端以对象当前长度为唯一权威。
|
||||
const PACKAGE_UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
|
||||
const MAX_LIST_LIMIT: u32 = 48;
|
||||
/// 后台游戏管理页全量列表上限,与 spacetime-module 的 admin game list limit 保持同口径。
|
||||
const MAX_ADMIN_GAME_LIST_LIMIT: u32 = 200;
|
||||
const MAX_IDEMPOTENCY_KEY_CHARS: usize = 128;
|
||||
const MAX_PACKAGE_MANIFEST_JSON_BYTES: usize = 2 * 1024 * 1024;
|
||||
/// 首版截图上限,与主规范冻结口径一致。
|
||||
@@ -156,8 +160,6 @@ struct AdminReviewRequest {
|
||||
expected_publication_revision: u64,
|
||||
#[serde(default)]
|
||||
review_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
entry_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -176,6 +178,17 @@ struct AdminSuspendRequest {
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AdminGameListQuery {
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AdminRestoreGameRequest {
|
||||
expected_publication_revision: u64,
|
||||
}
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
let protected = Router::new()
|
||||
.route(
|
||||
@@ -243,10 +256,15 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
"/admin/api/game-distribution/versions/{version_id}",
|
||||
get(admin_get_version),
|
||||
)
|
||||
.route("/admin/api/game-distribution/games", get(admin_list_games))
|
||||
.route(
|
||||
"/admin/api/game-distribution/games/{game_id}/suspend",
|
||||
post(admin_suspend_game),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/game-distribution/games/{game_id}/restore",
|
||||
post(admin_restore_game),
|
||||
)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
@@ -1395,6 +1413,37 @@ async fn admin_list_reviews(
|
||||
))
|
||||
}
|
||||
|
||||
async fn admin_list_games(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Query(query): Query<AdminGameListQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let limit = query
|
||||
.limit
|
||||
.unwrap_or(MAX_ADMIN_GAME_LIST_LIMIT)
|
||||
.min(MAX_ADMIN_GAME_LIST_LIMIT);
|
||||
let games = state
|
||||
.spacetime_client()
|
||||
.list_admin_game_distribution_games(GameDistributionAdminGameListRecordInput { limit })
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
info!(
|
||||
request_id = ctx.request_id(),
|
||||
operation = "admin_games_listed",
|
||||
games = games.len(),
|
||||
limit,
|
||||
elapsed_ms = ctx.elapsed(),
|
||||
"后台读取全量发行游戏"
|
||||
);
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
json!({
|
||||
"games": games.iter().map(admin_game_payload).collect::<Vec<_>>(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn admin_review_version(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
@@ -1416,25 +1465,21 @@ async fn admin_review_version(
|
||||
decision.as_str(),
|
||||
payload.expected_publication_revision,
|
||||
payload.review_reason.as_deref(),
|
||||
payload.entry_url.as_deref(),
|
||||
))
|
||||
.map_err(|error| internal(error.to_string()))?,
|
||||
);
|
||||
let (version, replayed) = if decision == "approve" {
|
||||
// 回滚窗口里“关闭新版本激活”,但拒绝审核与安全下架必须始终可用。
|
||||
ensure_publish_enabled(&state, None).await?;
|
||||
let entry_url = payload
|
||||
.entry_url
|
||||
.as_deref()
|
||||
.ok_or_else(|| bad_request("审核通过必须提供发行网关 HTTPS 入口"))?;
|
||||
validate_release_entry_url(entry_url, !state.config.is_production())?;
|
||||
// 发行入口由部署模板和 gameId 派生,管理员不填地址,也不做二次确认。
|
||||
let entry_url = derive_release_entry_url(&state, &version_id).await?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.approve_game_distribution_version(GameDistributionApproveRecordInput {
|
||||
version_id,
|
||||
admin_user_id,
|
||||
expected_publication_revision: payload.expected_publication_revision,
|
||||
entry_url: entry_url.to_string(),
|
||||
entry_url,
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
now_micros: now_micros(),
|
||||
@@ -1506,34 +1551,30 @@ async fn admin_get_version(
|
||||
))
|
||||
}
|
||||
|
||||
/// 校验管理员提交的发行入口。
|
||||
/// 审核通过时派生的发行入口:平台同源路径 `/games/{gameId}/`。
|
||||
///
|
||||
/// 生产环境只接受绝对 HTTPS 地址;非生产环境额外允许 http 回环地址,口径与前端
|
||||
/// `normalizeGameEntryUrl` 一致,便于本地把发行网关跑在 127.0.0.1 上验证内嵌游玩。
|
||||
/// 任何环境都拒绝凭据、query 和 fragment,也不允许服务端自行拼默认地址。
|
||||
fn validate_release_entry_url(value: &str, allow_loopback_http: bool) -> Result<(), AppError> {
|
||||
let parsed =
|
||||
url::Url::parse(value.trim()).map_err(|_| bad_request("发行入口必须是有效 URL"))?;
|
||||
let host = parsed.host_str();
|
||||
let scheme_allowed = parsed.scheme() == "https"
|
||||
|| (allow_loopback_http
|
||||
&& parsed.scheme() == "http"
|
||||
&& matches!(
|
||||
host,
|
||||
Some("127.0.0.1") | Some("localhost") | Some("[::1]") | Some("::1")
|
||||
));
|
||||
if !scheme_allowed
|
||||
|| host.is_none()
|
||||
|| parsed.username() != ""
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
/// 存相对路径而不是绝对 URL,部署侧就不需要提供发行域名;dev / release / 预览环境
|
||||
/// 口径一致,由客户端按当前 origin 解析成绝对地址后再交给 iframe。
|
||||
async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result<String, AppError> {
|
||||
let version = state
|
||||
.spacetime_client()
|
||||
.get_game_distribution_version(version_id.to_string())
|
||||
.await
|
||||
.map_err(map_spacetime_error)?
|
||||
.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?;
|
||||
build_release_entry_url(&version.game_id)
|
||||
}
|
||||
|
||||
/// 发行入口固定走平台同源路径,游戏标识必须能安全落在路径段里。
|
||||
fn build_release_entry_url(game_id: &str) -> Result<String, AppError> {
|
||||
if game_id.is_empty()
|
||||
|| !game_id.chars().all(|character| {
|
||||
character.is_ascii_alphanumeric() || character == '-' || character == '_'
|
||||
})
|
||||
{
|
||||
return Err(bad_request(
|
||||
"发行入口必须是无凭据、无查询参数的 HTTPS URL;仅非生产环境允许回环 http",
|
||||
));
|
||||
return Err(internal("游戏标识不适用于发行路径"));
|
||||
}
|
||||
Ok(())
|
||||
Ok(format!("/games/{game_id}/"))
|
||||
}
|
||||
|
||||
async fn admin_suspend_game(
|
||||
@@ -1597,6 +1638,51 @@ async fn admin_suspend_game(
|
||||
))
|
||||
}
|
||||
|
||||
async fn admin_restore_game(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
headers: HeaderMap,
|
||||
Path(game_id): Path<String>,
|
||||
Json(payload): Json<AdminRestoreGameRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let idempotency_key = idempotency_key(&headers)?;
|
||||
let admin_user_id = admin.session().subject.clone();
|
||||
let request_digest = compute_request_digest(
|
||||
&serde_json::to_vec(&(game_id.as_str(), payload.expected_publication_revision))
|
||||
.map_err(|error| internal(error.to_string()))?,
|
||||
);
|
||||
let log_game_id = game_id.clone();
|
||||
let log_admin_user_id = admin_user_id.clone();
|
||||
let game = state
|
||||
.spacetime_client()
|
||||
.restore_game_distribution_game(GameDistributionRestoreRecordInput {
|
||||
game_id,
|
||||
admin_user_id,
|
||||
expected_publication_revision: payload.expected_publication_revision,
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
now_micros: now_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
info!(
|
||||
request_id = ctx.request_id(),
|
||||
operation = "game_restored",
|
||||
game_id = %log_game_id,
|
||||
admin_user_id = %log_admin_user_id,
|
||||
publication_revision = game.0.publication_revision,
|
||||
visibility = %game.0.visibility,
|
||||
replayed = game.1,
|
||||
elapsed_ms = ctx.elapsed(),
|
||||
"管理员恢复已下架游戏"
|
||||
);
|
||||
Ok(json_success_body(
|
||||
Some(&ctx),
|
||||
json!({ "game": game_payload(&game.0), "replayed": game.1 }),
|
||||
))
|
||||
}
|
||||
|
||||
async fn record_upload_failure(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -1766,6 +1852,51 @@ fn public_game_payload(game: GameDistributionPublicGameRecord) -> Value {
|
||||
payload
|
||||
}
|
||||
|
||||
/// 后台游戏管理页的游戏行:作者名/头像由 spacetime 事务内读时联账号表得到。
|
||||
fn admin_game_payload(game: &GameDistributionAdminGameRecord) -> Value {
|
||||
json!({
|
||||
"gameId": game.game_id,
|
||||
"title": game.title,
|
||||
"author": {
|
||||
"id": game.owner_user_id,
|
||||
"name": game.author_name.as_deref().unwrap_or("未知作者"),
|
||||
"avatarUrl": game.author_avatar_url,
|
||||
},
|
||||
"status": game.visibility,
|
||||
"versionCount": game.version_count,
|
||||
"playCount": game.play_count,
|
||||
"activeVersionId": game.active_version_id,
|
||||
"publicationRevision": game.publication_revision,
|
||||
"createdAt": game.created_at,
|
||||
"updatedAt": game.updated_at,
|
||||
"versions": game
|
||||
.versions
|
||||
.iter()
|
||||
.map(|version| admin_game_version_payload(&game.game_id, version))
|
||||
.collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_game_version_payload(
|
||||
game_id: &str,
|
||||
version: &GameDistributionAdminVersionRecord,
|
||||
) -> Value {
|
||||
json!({
|
||||
"versionId": version.version_id,
|
||||
"gameId": game_id,
|
||||
"versionNumber": version.version_number,
|
||||
"status": version.status,
|
||||
"reviewReason": version.review_reason,
|
||||
"packageBytes": version.package_bytes,
|
||||
"packageSha256": version.package_sha256,
|
||||
"entryUrl": version.entry_url,
|
||||
"createdAt": version.created_at,
|
||||
"updatedAt": version.updated_at,
|
||||
"reviewedAt": version.reviewed_at,
|
||||
"publishedAt": version.published_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn game_payload(game: &GameDistributionGameRecord) -> Value {
|
||||
let tags = serde_json::from_str::<Vec<String>>(&game.tags_json).unwrap_or_default();
|
||||
let screenshots = game
|
||||
@@ -2713,6 +2844,51 @@ mod tests {
|
||||
assert_eq!(recovery_action_for_status("unknown_status"), "none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_game_management_routes_are_mounted() {
|
||||
use axum::{body::Body, http::Request};
|
||||
use tower::ServiceExt;
|
||||
|
||||
let app = crate::app::build_router(
|
||||
crate::state::AppState::new(crate::config::AppConfig::default())
|
||||
.expect("测试状态应可构建"),
|
||||
);
|
||||
|
||||
// 全量列表与恢复都必须先过管理员鉴权,未带 token 时在进入业务前被拒。
|
||||
let unauthenticated_list = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/admin/api/game-distribution/games")
|
||||
.body(Body::empty())
|
||||
.expect("请求"),
|
||||
)
|
||||
.await
|
||||
.expect("路由响应");
|
||||
// 测试态没有启用后台运行时,鉴权中间件会在 503 处失败关闭;关键是不能 404。
|
||||
assert!(matches!(
|
||||
unauthenticated_list.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::SERVICE_UNAVAILABLE
|
||||
));
|
||||
|
||||
let unauthenticated_restore = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/admin/api/game-distribution/games/game_1/restore")
|
||||
.header("content-type", "application/json")
|
||||
.header("Idempotency-Key", "restore-1")
|
||||
.body(Body::from(r#"{"expectedPublicationRevision":1}"#))
|
||||
.expect("请求"),
|
||||
)
|
||||
.await
|
||||
.expect("路由响应");
|
||||
assert!(matches!(
|
||||
unauthenticated_restore.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::SERVICE_UNAVAILABLE
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn version_readback_and_cancel_routes_are_mounted() {
|
||||
use axum::{body::Body, http::Request};
|
||||
@@ -2803,51 +2979,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approve_requires_credential_free_https_entry_url() {
|
||||
validate_release_entry_url(
|
||||
"https://games.example.test/releases/game_1/index.html",
|
||||
false,
|
||||
)
|
||||
.expect("发行入口");
|
||||
for invalid in [
|
||||
"/releases/game_1/index.html",
|
||||
"http://games.example.test/releases/game_1/index.html",
|
||||
"http://127.0.0.1:10001/releases/game_1/index.html",
|
||||
"https://user:pass@games.example.test/index.html",
|
||||
"https://games.example.test/index.html?token=1",
|
||||
"https://games.example.test/index.html#x",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_release_entry_url(invalid, false)
|
||||
.expect_err("生产环境非法发行入口应被拒绝")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"未拒绝的发行入口:{invalid}"
|
||||
);
|
||||
}
|
||||
fn release_entry_url_is_same_origin_path_with_game_id() {
|
||||
assert_eq!(
|
||||
build_release_entry_url("game_1").expect("派生发行入口"),
|
||||
"/games/game_1/"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_production_release_entry_allows_loopback_http_only() {
|
||||
for allowed in [
|
||||
"http://127.0.0.1:10001/api/game-distribution/releases/game_1/index.html",
|
||||
"http://localhost:10001/api/game-distribution/releases/game_1/index.html",
|
||||
"https://games.example.test/releases/game_1/index.html",
|
||||
] {
|
||||
validate_release_entry_url(allowed, true).expect("非生产环境应接受回环 http");
|
||||
}
|
||||
for invalid in [
|
||||
"http://games.example.test/releases/game_1/index.html",
|
||||
"http://192.168.1.10:10001/index.html",
|
||||
"http://127.0.0.1:10001/index.html?token=1",
|
||||
"http://user:pass@127.0.0.1:10001/index.html",
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_release_entry_url(invalid, true)
|
||||
.expect_err("非生产环境也不能放宽回环之外的地址")
|
||||
.status_code(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"未拒绝的发行入口:{invalid}"
|
||||
fn release_entry_rejects_game_id_that_is_not_path_safe() {
|
||||
for invalid in ["", "../escape", "game/1", "game 1"] {
|
||||
assert!(
|
||||
build_release_entry_url(invalid).is_err(),
|
||||
"未拒绝的游戏标识:{invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// 目录 revision 乐观锁冲突。
|
||||
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
|
||||
/// 目录尚未初始化:SpacetimeDB 缺行,或存量内容与当前定义不符。
|
||||
pub const AGC_MODEL_CATALOG_NOT_INITIALIZED: &str = "AGC_MODEL_CATALOG_NOT_INITIALIZED";
|
||||
/// 客户端未显式选择模型时使用的占位标识。
|
||||
pub const AGC_MODEL_PLATFORM_DEFAULT: &str = "platform-default";
|
||||
/// 模型标识的长度上限,与客户端 `select_game_creator_model` 的校验保持一致。
|
||||
pub const AGC_MODEL_ID_MAX_BYTES: usize = 64;
|
||||
/// 目录项数上限,与后台「AGC 模型」页的新增上限保持一致。
|
||||
pub const AGC_MODEL_CATALOG_MAX_MODELS: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AgcModel {
|
||||
pub id: String,
|
||||
@@ -12,7 +21,7 @@ pub struct AgcModel {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct AgcModelCatalog {
|
||||
pub revision: u64,
|
||||
@@ -20,40 +29,62 @@ pub struct AgcModelCatalog {
|
||||
pub models: Vec<AgcModel>,
|
||||
}
|
||||
|
||||
impl Default for AgcModelCatalog {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
revision: 0,
|
||||
default_model_id: "quality".into(),
|
||||
models: vec![
|
||||
AgcModel {
|
||||
id: "quality".into(),
|
||||
alias: "高质量".into(),
|
||||
model_id: "gpt-6-astra".into(),
|
||||
enabled: true,
|
||||
},
|
||||
AgcModel {
|
||||
id: "fast".into(),
|
||||
alias: "快速".into(),
|
||||
model_id: "gpt-5.6-luna".into(),
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AgcModelCatalog {
|
||||
/// 按上游模型列表生成目录:`modelId` 是上游原始模型名,`alias` 也直接用原名
|
||||
/// (不再填「高质量/快速」这类人工别名),`id` 是模型名的稳定 slug。
|
||||
///
|
||||
/// 上游返回顺序不稳定,所以先按原始模型名排序再生成,重复同步得到一致的目录与默认项。
|
||||
pub fn from_upstream_models(
|
||||
models: impl IntoIterator<Item = String>,
|
||||
revision: u64,
|
||||
) -> Result<Self, String> {
|
||||
let mut model_names = models
|
||||
.into_iter()
|
||||
.map(|model| model.trim().to_string())
|
||||
.filter(|model| !model.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
model_names.sort();
|
||||
model_names.dedup();
|
||||
if model_names.is_empty() {
|
||||
return Err("上游模型列表为空".into());
|
||||
}
|
||||
|
||||
let mut used_ids = HashSet::new();
|
||||
let mut entries = Vec::with_capacity(model_names.len());
|
||||
for model_id in model_names {
|
||||
let id = unique_model_id(&model_id, &mut used_ids);
|
||||
entries.push(AgcModel {
|
||||
id,
|
||||
alias: model_id.clone(),
|
||||
model_id,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
let default_model_id = entries
|
||||
.first()
|
||||
.map(|entry| entry.id.clone())
|
||||
.ok_or_else(|| "上游模型列表为空".to_string())?;
|
||||
let catalog = Self {
|
||||
revision,
|
||||
default_model_id,
|
||||
models: entries,
|
||||
};
|
||||
catalog.validate()?;
|
||||
Ok(catalog)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.models.is_empty() || self.models.len() > 32 {
|
||||
return Err("模型列表必须包含 1 至 32 项".into());
|
||||
if self.models.is_empty() || self.models.len() > AGC_MODEL_CATALOG_MAX_MODELS {
|
||||
return Err(format!(
|
||||
"模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项"
|
||||
));
|
||||
}
|
||||
let mut ids = HashSet::new();
|
||||
let mut aliases = HashSet::new();
|
||||
for model in &self.models {
|
||||
if model.id.is_empty()
|
||||
|| model.id == "platform-default"
|
||||
|| model.id.len() > 64
|
||||
|| model.id == AGC_MODEL_PLATFORM_DEFAULT
|
||||
|| model.id.len() > AGC_MODEL_ID_MAX_BYTES
|
||||
|| !model
|
||||
.id
|
||||
.bytes()
|
||||
@@ -88,31 +119,202 @@ impl AgcModelCatalog {
|
||||
.map(|m| m.model_id.as_str())
|
||||
.ok_or_else(|| "所选模型不可用,请刷新模型列表".into())
|
||||
}
|
||||
|
||||
/// 请求侧解析:显式选择的标识按目录校验,未选择或占位标识使用默认项。
|
||||
pub fn resolve_requested(&self, requested: Option<&str>) -> Result<&str, String> {
|
||||
let requested = requested
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty() && *id != AGC_MODEL_PLATFORM_DEFAULT);
|
||||
match requested {
|
||||
Some(id) => self.resolve(id),
|
||||
None => self.resolve(&self.default_model_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 由上游模型名生成稳定标识:只保留小写字母、数字、连字符与下划线,其余字符折叠成 `-`。
|
||||
fn agc_model_id_from_name(model_name: &str) -> String {
|
||||
let mut id = String::new();
|
||||
let mut separator_pending = false;
|
||||
for value in model_name.chars() {
|
||||
let lowered = value.to_ascii_lowercase();
|
||||
if lowered.is_ascii_alphanumeric() || lowered == '_' {
|
||||
if separator_pending && !id.is_empty() {
|
||||
id.push('-');
|
||||
}
|
||||
separator_pending = false;
|
||||
id.push(lowered);
|
||||
} else {
|
||||
separator_pending = true;
|
||||
}
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
/// 生成在本次目录内唯一的标识:同名 slug 追加 `-2`/`-3`,并保证不超过长度上限。
|
||||
fn unique_model_id(model_name: &str, used_ids: &mut HashSet<String>) -> String {
|
||||
let slug = agc_model_id_from_name(model_name);
|
||||
let slug = if slug.is_empty() {
|
||||
"model".to_string()
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
// 预留后缀空间(`-` 加最多两位序号)后截断,保证候选标识仍在长度上限内。
|
||||
let base = slug
|
||||
.char_indices()
|
||||
.take_while(|(index, _)| *index < AGC_MODEL_ID_MAX_BYTES - 3)
|
||||
.map(|(_, value)| value)
|
||||
.collect::<String>();
|
||||
let base = base.trim_end_matches('-').to_string();
|
||||
let base = if base.is_empty() {
|
||||
"model".to_string()
|
||||
} else {
|
||||
base
|
||||
};
|
||||
|
||||
let mut candidate = base.clone();
|
||||
let mut suffix = 2;
|
||||
while !used_ids.insert(candidate.clone()) {
|
||||
candidate = format!("{base}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn upstream(models: &[&str]) -> Vec<String> {
|
||||
models.iter().map(|model| (*model).to_string()).collect()
|
||||
}
|
||||
|
||||
fn model(id: &str, alias: &str, model_id: &str) -> AgcModel {
|
||||
AgcModel {
|
||||
id: id.into(),
|
||||
alias: alias.into(),
|
||||
model_id: model_id.into(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_builds_from_upstream_models_with_stable_ids() {
|
||||
let catalog = AgcModelCatalog::from_upstream_models(
|
||||
upstream(&[
|
||||
" qwen3.8-flash ",
|
||||
"glm-5.3",
|
||||
"qwen3.8-flash",
|
||||
"deepseek-v4-pro",
|
||||
"",
|
||||
"vendor/model.v1:latest",
|
||||
]),
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(catalog.revision, 3);
|
||||
// 默认项是排序后第一项,与上游返回顺序无关。
|
||||
assert_eq!(catalog.default_model_id, "deepseek-v4-pro");
|
||||
assert_eq!(
|
||||
catalog.models,
|
||||
vec![
|
||||
model("deepseek-v4-pro", "deepseek-v4-pro", "deepseek-v4-pro"),
|
||||
model("glm-5-3", "glm-5.3", "glm-5.3"),
|
||||
model("qwen3-8-flash", "qwen3.8-flash", "qwen3.8-flash"),
|
||||
model(
|
||||
"vendor-model-v1-latest",
|
||||
"vendor/model.v1:latest",
|
||||
"vendor/model.v1:latest"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(catalog.validate().is_ok());
|
||||
// 同一模型集合重复生成结果一致。
|
||||
assert_eq!(
|
||||
AgcModelCatalog::from_upstream_models(
|
||||
upstream(&[
|
||||
"vendor/model.v1:latest",
|
||||
"deepseek-v4-pro",
|
||||
"glm-5.3",
|
||||
"qwen3.8-flash",
|
||||
]),
|
||||
3
|
||||
)
|
||||
.unwrap(),
|
||||
catalog
|
||||
);
|
||||
assert!(AgcModelCatalog::from_upstream_models(upstream(&["", " "]), 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_keeps_ids_unique_and_within_client_contract() {
|
||||
// 不同模型名折叠成同一个 slug 时按排序追加序号,且标识始终符合客户端校验。
|
||||
let catalog = AgcModelCatalog::from_upstream_models(
|
||||
upstream(&["GLM-5.3", "glm/5.3", "glm_5.3", "模型名"]),
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
let ids = catalog
|
||||
.models
|
||||
.iter()
|
||||
.map(|entry| entry.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(ids, vec!["glm-5-3", "glm-5-3-2", "glm_5-3", "model"]);
|
||||
for entry in &catalog.models {
|
||||
assert!(entry.id.len() <= AGC_MODEL_ID_MAX_BYTES);
|
||||
assert!(
|
||||
entry
|
||||
.id
|
||||
.bytes()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|
||||
);
|
||||
}
|
||||
assert!(catalog.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_maps_only_enabled_ids() {
|
||||
let mut catalog = AgcModelCatalog::default();
|
||||
let mut catalog =
|
||||
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
|
||||
assert!(catalog.validate().is_ok());
|
||||
assert_eq!(catalog.resolve("quality").unwrap(), "gpt-6-astra");
|
||||
assert!(catalog.resolve("gpt-6-astra").is_err());
|
||||
assert!(catalog.resolve("unknown").is_err());
|
||||
assert_eq!(catalog.resolve("model-a").unwrap(), "model-a");
|
||||
// 客户端不能直接指定实际模型名,只能回传目录标识。
|
||||
assert!(catalog.resolve("model-c").is_err());
|
||||
assert_eq!(catalog.resolve_requested(None).unwrap(), "model-a");
|
||||
assert_eq!(
|
||||
catalog
|
||||
.resolve_requested(Some(AGC_MODEL_PLATFORM_DEFAULT))
|
||||
.unwrap(),
|
||||
"model-a"
|
||||
);
|
||||
catalog.models[0].enabled = false;
|
||||
assert!(catalog.resolve("quality").is_err());
|
||||
assert!(catalog.resolve("model-a").is_err());
|
||||
assert!(catalog.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_rejects_duplicate_aliases_and_ids() {
|
||||
let mut catalog = AgcModelCatalog::default();
|
||||
let mut catalog =
|
||||
AgcModelCatalog::from_upstream_models(upstream(&["model-a", "model-b"]), 0).unwrap();
|
||||
catalog.models[1].alias = catalog.models[0].alias.clone();
|
||||
assert!(catalog.validate().is_err());
|
||||
catalog.models[1].alias = "快速".into();
|
||||
catalog.models[1].alias = "model-b".into();
|
||||
catalog.models[1].id = catalog.models[0].id.clone();
|
||||
assert!(catalog.validate().is_err());
|
||||
catalog.models[1].id = "model-b".into();
|
||||
catalog.models[1].id = AGC_MODEL_PLATFORM_DEFAULT.into();
|
||||
assert!(catalog.validate().is_err());
|
||||
catalog.models[1].id = "model-b".into();
|
||||
catalog.models[1].model_id = "".into();
|
||||
assert!(catalog.validate().is_err());
|
||||
|
||||
let too_many = (0..AGC_MODEL_CATALOG_MAX_MODELS + 1)
|
||||
.map(|index| format!("model-{index}"))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
AgcModelCatalog::from_upstream_models(too_many, 0).unwrap_err(),
|
||||
format!("模型列表必须包含 1 至 {AGC_MODEL_CATALOG_MAX_MODELS} 项")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::creation_entry_config::{
|
||||
};
|
||||
|
||||
/// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。
|
||||
pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [
|
||||
pub const ADMIN_TAB_PERMISSIONS: [&str; 20] = [
|
||||
"dashboard",
|
||||
"overview",
|
||||
"tables",
|
||||
@@ -26,6 +26,7 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [
|
||||
"recharge-orders",
|
||||
"editor-generation-pricing",
|
||||
"editor-showcase",
|
||||
"game-management",
|
||||
"editor-assets",
|
||||
"agc-templates",
|
||||
"error-reports",
|
||||
|
||||
@@ -89,6 +89,7 @@ pub struct GameDistributionAuthor {
|
||||
pub struct GameDistributionVersionSummary {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
/// 发行入口:平台同源路径 `/games/<gameId>/`,客户端按当前 origin 解析后再交给 iframe。
|
||||
pub entry_url: String,
|
||||
pub sha256: String,
|
||||
pub published_at: String,
|
||||
|
||||
@@ -22,11 +22,12 @@ mod error_reports;
|
||||
pub mod external_api_key;
|
||||
pub mod game_distribution;
|
||||
pub use game_distribution::{
|
||||
GameDistributionApproveRecordInput, GameDistributionCancelVersionRecordInput,
|
||||
GameDistributionConfirmPackageRecordInput, GameDistributionCreateGameRecordInput,
|
||||
GameDistributionCreateVersionRecordInput, GameDistributionFailUploadRecordInput,
|
||||
GameDistributionGetGameRecordInput, GameDistributionOwnerGameListRecordInput,
|
||||
GameDistributionPublicGameListRecordInput, GameDistributionRejectRecordInput,
|
||||
GameDistributionAdminGameListRecordInput, GameDistributionApproveRecordInput,
|
||||
GameDistributionCancelVersionRecordInput, GameDistributionConfirmPackageRecordInput,
|
||||
GameDistributionCreateGameRecordInput, GameDistributionCreateVersionRecordInput,
|
||||
GameDistributionFailUploadRecordInput, GameDistributionGetGameRecordInput,
|
||||
GameDistributionOwnerGameListRecordInput, GameDistributionPublicGameListRecordInput,
|
||||
GameDistributionRejectRecordInput, GameDistributionRestoreRecordInput,
|
||||
GameDistributionSubmitReviewRecordInput, GameDistributionSuspendRecordInput,
|
||||
GameDistributionUnpublishRecordInput,
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ pub use self::external_generation::{
|
||||
ExternalGenerationQueueStatsRecord,
|
||||
};
|
||||
pub use self::game_distribution::{
|
||||
GameDistributionAdminGameRecord, GameDistributionAdminVersionRecord,
|
||||
GameDistributionGameRecord, GameDistributionOwnerGameRecord, GameDistributionPublicGameRecord,
|
||||
GameDistributionVersionRecord,
|
||||
};
|
||||
@@ -148,9 +149,10 @@ pub(crate) use self::external_generation::{
|
||||
map_external_generation_queue_stats_result,
|
||||
};
|
||||
pub(crate) use self::game_distribution::{
|
||||
map_game_distribution_game_result, map_game_distribution_owner_game_list_result,
|
||||
map_game_distribution_public_game_list_result, map_game_distribution_public_game_result,
|
||||
map_game_distribution_review_list_result, map_game_distribution_version_result,
|
||||
map_game_distribution_admin_game_list_result, map_game_distribution_game_result,
|
||||
map_game_distribution_owner_game_list_result, map_game_distribution_public_game_list_result,
|
||||
map_game_distribution_public_game_result, map_game_distribution_review_list_result,
|
||||
map_game_distribution_version_result,
|
||||
};
|
||||
pub(crate) use self::runtime::{
|
||||
map_feature_gate_config_procedure_result, map_runtime_setting_procedure_result,
|
||||
|
||||
@@ -47,6 +47,38 @@ pub struct GameDistributionVersionRecord {
|
||||
pub metadata_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GameDistributionAdminVersionRecord {
|
||||
pub version_id: String,
|
||||
pub version_number: u64,
|
||||
pub status: String,
|
||||
pub review_reason: Option<String>,
|
||||
pub package_sha256: String,
|
||||
pub package_bytes: u64,
|
||||
pub entry_url: Option<String>,
|
||||
pub created_at: String,
|
||||
pub reviewed_at: Option<String>,
|
||||
pub published_at: Option<String>,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GameDistributionAdminGameRecord {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub title: String,
|
||||
pub author_name: Option<String>,
|
||||
pub author_avatar_url: Option<String>,
|
||||
pub visibility: String,
|
||||
pub version_count: u64,
|
||||
pub play_count: u64,
|
||||
pub active_version_id: Option<String>,
|
||||
pub publication_revision: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub versions: Vec<GameDistributionAdminVersionRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GameDistributionOwnerGameRecord {
|
||||
pub game: GameDistributionGameRecord,
|
||||
@@ -111,6 +143,57 @@ fn map_version(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_version(
|
||||
value: crate::module_bindings::GameDistributionAdminVersionSnapshot,
|
||||
) -> GameDistributionAdminVersionRecord {
|
||||
GameDistributionAdminVersionRecord {
|
||||
version_id: value.version_id,
|
||||
version_number: value.version_number,
|
||||
status: value.status,
|
||||
review_reason: value.review_reason,
|
||||
package_sha256: value.package_sha_256,
|
||||
package_bytes: value.package_bytes,
|
||||
entry_url: value.entry_url,
|
||||
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
|
||||
reviewed_at: value
|
||||
.reviewed_at_micros
|
||||
.map(shared_kernel::format_timestamp_micros),
|
||||
published_at: value
|
||||
.published_at_micros
|
||||
.map(shared_kernel::format_timestamp_micros),
|
||||
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_admin_game(
|
||||
value: crate::module_bindings::GameDistributionAdminGameSnapshot,
|
||||
) -> GameDistributionAdminGameRecord {
|
||||
GameDistributionAdminGameRecord {
|
||||
game_id: value.game_id,
|
||||
owner_user_id: value.owner_user_id,
|
||||
title: value.title,
|
||||
author_name: value.author_name,
|
||||
author_avatar_url: value.author_avatar_url,
|
||||
visibility: value.visibility,
|
||||
version_count: value.version_count,
|
||||
play_count: value.play_count,
|
||||
active_version_id: value.active_version_id,
|
||||
publication_revision: value.publication_revision,
|
||||
created_at: shared_kernel::format_timestamp_micros(value.created_at_micros),
|
||||
updated_at: shared_kernel::format_timestamp_micros(value.updated_at_micros),
|
||||
versions: value.versions.into_iter().map(map_admin_version).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_game_distribution_admin_game_list_result(
|
||||
result: crate::module_bindings::GameDistributionAdminGameListResult,
|
||||
) -> Result<Vec<GameDistributionAdminGameRecord>, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
Ok(result.games.into_iter().map(map_admin_game).collect())
|
||||
}
|
||||
|
||||
pub(crate) fn map_game_distribution_game_result(
|
||||
result: crate::module_bindings::GameDistributionProcedureResult,
|
||||
) -> Result<
|
||||
|
||||
@@ -7,6 +7,11 @@ pub struct GameDistributionPublicGameListRecordInput {
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionAdminGameListRecordInput {
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionOwnerGameListRecordInput {
|
||||
pub owner_user_id: String,
|
||||
@@ -128,6 +133,16 @@ pub struct GameDistributionRejectRecordInput {
|
||||
pub now_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionRestoreRecordInput {
|
||||
pub game_id: String,
|
||||
pub admin_user_id: String,
|
||||
pub expected_publication_revision: u64,
|
||||
pub idempotency_key: String,
|
||||
pub request_digest: String,
|
||||
pub now_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GameDistributionUnpublishRecordInput {
|
||||
pub game_id: String,
|
||||
@@ -665,6 +680,69 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 后台游戏管理页的全量游戏列表:含版本数与最近版本历史,作者名/头像由事务内读时联。
|
||||
pub async fn list_admin_game_distribution_games(
|
||||
&self,
|
||||
input: GameDistributionAdminGameListRecordInput,
|
||||
) -> Result<Vec<GameDistributionAdminGameRecord>, SpacetimeClientError> {
|
||||
let procedure_input =
|
||||
crate::module_bindings::GameDistributionAdminGameListInput { limit: input.limit };
|
||||
self.call_after_connect(
|
||||
"list_admin_game_distribution_games",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.list_admin_game_distribution_games_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_game_distribution_admin_game_list_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 管理员解除安全下架:重新激活最近一次曾公开的版本。
|
||||
pub async fn restore_game_distribution_game(
|
||||
&self,
|
||||
input: GameDistributionRestoreRecordInput,
|
||||
) -> Result<(GameDistributionGameRecord, bool), SpacetimeClientError> {
|
||||
let procedure_input = crate::module_bindings::GameDistributionRestoreInput {
|
||||
game_id: input.game_id,
|
||||
admin_user_id: input.admin_user_id,
|
||||
expected_publication_revision: input.expected_publication_revision,
|
||||
idempotency_key: input.idempotency_key,
|
||||
request_digest: input.request_digest,
|
||||
now_micros: input.now_micros,
|
||||
};
|
||||
self.call_after_connect(
|
||||
"restore_game_distribution_game",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.restore_game_distribution_game_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_game_distribution_game_result)
|
||||
.and_then(|(game, _, replayed)| {
|
||||
game.map(|game| (game, replayed)).ok_or_else(|| {
|
||||
SpacetimeClientError::missing_snapshot("游戏恢复结果")
|
||||
})
|
||||
});
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_game_distribution_reviews(
|
||||
&self,
|
||||
limit: u32,
|
||||
|
||||
@@ -398,6 +398,10 @@ pub mod feature_gate_config_snapshot_type;
|
||||
pub mod feature_gate_config_table;
|
||||
pub mod feature_gate_config_type;
|
||||
pub mod find_editor_asset_group_source_and_return_procedure;
|
||||
pub mod game_distribution_admin_game_list_input_type;
|
||||
pub mod game_distribution_admin_game_list_result_type;
|
||||
pub mod game_distribution_admin_game_snapshot_type;
|
||||
pub mod game_distribution_admin_version_snapshot_type;
|
||||
pub mod game_distribution_approve_input_type;
|
||||
pub mod game_distribution_cancel_version_input_type;
|
||||
pub mod game_distribution_confirm_package_input_type;
|
||||
@@ -421,6 +425,7 @@ pub mod game_distribution_public_game_input_type;
|
||||
pub mod game_distribution_public_game_list_input_type;
|
||||
pub mod game_distribution_public_game_snapshot_type;
|
||||
pub mod game_distribution_reject_input_type;
|
||||
pub mod game_distribution_restore_input_type;
|
||||
pub mod game_distribution_review_list_input_type;
|
||||
pub mod game_distribution_submit_review_input_type;
|
||||
pub mod game_distribution_suspend_input_type;
|
||||
@@ -468,6 +473,7 @@ pub mod import_database_migration_incremental_from_chunks_procedure;
|
||||
pub mod import_database_migration_incremental_from_file_procedure;
|
||||
pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure;
|
||||
pub mod list_admin_accounts_and_return_procedure;
|
||||
pub mod list_admin_game_distribution_games_and_return_procedure;
|
||||
pub mod list_agc_tracking_events_procedure;
|
||||
pub mod list_asset_history_and_return_procedure;
|
||||
pub mod list_editor_agent_conversations_and_return_procedure;
|
||||
@@ -591,6 +597,7 @@ pub mod repair_editor_canvas_resources_and_return_procedure;
|
||||
pub mod repair_editor_project_resource_media_and_return_procedure;
|
||||
pub mod resolve_editor_reference_and_return_procedure;
|
||||
pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure;
|
||||
pub mod restore_game_distribution_game_and_return_procedure;
|
||||
pub mod revoke_database_migration_operator_procedure;
|
||||
pub mod revoke_external_api_key_and_return_procedure;
|
||||
pub mod rollback_editor_canvas_layout_and_return_procedure;
|
||||
@@ -1168,6 +1175,10 @@ pub use feature_gate_config_snapshot_type::FeatureGateConfigSnapshot;
|
||||
pub use feature_gate_config_table::*;
|
||||
pub use feature_gate_config_type::FeatureGateConfig;
|
||||
pub use find_editor_asset_group_source_and_return_procedure::find_editor_asset_group_source_and_return;
|
||||
pub use game_distribution_admin_game_list_input_type::GameDistributionAdminGameListInput;
|
||||
pub use game_distribution_admin_game_list_result_type::GameDistributionAdminGameListResult;
|
||||
pub use game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot;
|
||||
pub use game_distribution_admin_version_snapshot_type::GameDistributionAdminVersionSnapshot;
|
||||
pub use game_distribution_approve_input_type::GameDistributionApproveInput;
|
||||
pub use game_distribution_cancel_version_input_type::GameDistributionCancelVersionInput;
|
||||
pub use game_distribution_confirm_package_input_type::GameDistributionConfirmPackageInput;
|
||||
@@ -1191,6 +1202,7 @@ pub use game_distribution_public_game_input_type::GameDistributionPublicGameInpu
|
||||
pub use game_distribution_public_game_list_input_type::GameDistributionPublicGameListInput;
|
||||
pub use game_distribution_public_game_snapshot_type::GameDistributionPublicGameSnapshot;
|
||||
pub use game_distribution_reject_input_type::GameDistributionRejectInput;
|
||||
pub use game_distribution_restore_input_type::GameDistributionRestoreInput;
|
||||
pub use game_distribution_review_list_input_type::GameDistributionReviewListInput;
|
||||
pub use game_distribution_submit_review_input_type::GameDistributionSubmitReviewInput;
|
||||
pub use game_distribution_suspend_input_type::GameDistributionSuspendInput;
|
||||
@@ -1238,6 +1250,7 @@ pub use import_database_migration_incremental_from_chunks_procedure::import_data
|
||||
pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file;
|
||||
pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return;
|
||||
pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return;
|
||||
pub use list_admin_game_distribution_games_and_return_procedure::list_admin_game_distribution_games_and_return;
|
||||
pub use list_agc_tracking_events_procedure::list_agc_tracking_events;
|
||||
pub use list_asset_history_and_return_procedure::list_asset_history_and_return;
|
||||
pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return;
|
||||
@@ -1361,6 +1374,7 @@ pub use repair_editor_canvas_resources_and_return_procedure::repair_editor_canva
|
||||
pub use repair_editor_project_resource_media_and_return_procedure::repair_editor_project_resource_media_and_return;
|
||||
pub use resolve_editor_reference_and_return_procedure::resolve_editor_reference_and_return;
|
||||
pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return;
|
||||
pub use restore_game_distribution_game_and_return_procedure::restore_game_distribution_game_and_return;
|
||||
pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator;
|
||||
pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return;
|
||||
pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return;
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionAdminGameListInput {
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionAdminGameListInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
use super::game_distribution_admin_game_snapshot_type::GameDistributionAdminGameSnapshot;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
pub struct GameDistributionAdminGameListResult {
|
||||
pub ok: bool,
|
||||
pub games: Vec<GameDistributionAdminGameSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for GameDistributionAdminGameListResult {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user