合并 origin/master:保留 3D 契约与 provider checkpoint,旧玩法表随主线退役
- server-rs/crates/shared-contracts/src/lib.rs:采用主线的模块裁剪(删掉 cfg(any()) 遗留模块、补上 agc_analytics 与 game_distribution),同时保留本分支的 editor_canvas、model3d 模块与 EDITOR_GENERATION_OPERATION_KINDS 导出 - server-rs/crates/spacetime-module/src/migration.rs:主线删除的 410 行旧玩法表 normalize 段保持删除,保留本分支的 provider_kind / provider_task_id 兼容段与对应用例 - docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:校验清单同时保留 Tripo 3D 生成任务与 editor_background_music_generation / model3d_text_to_model / model3d_image_to_model - .gitignore:补回本分支新增的 3D 模型文件忽略规则(*.glb / *.gltf 等 12 行),压测数据段随主线一并删除 - 共享记忆:本分支的 3D 决策与踩坑条目保留在主线重排后的 decision-log.md 与 pitfalls.md 中
This commit is contained in:
Generated
+15
@@ -217,6 +217,7 @@ dependencies = [
|
||||
"module-assets",
|
||||
"module-auth",
|
||||
"module-editor-agent",
|
||||
"module-game-distribution",
|
||||
"module-runtime",
|
||||
"opentelemetry",
|
||||
"platform-audio",
|
||||
@@ -2899,15 +2900,29 @@ dependencies = [
|
||||
"spacetimedb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-game-distribution"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"serde",
|
||||
"sha2",
|
||||
"shared-kernel",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "module-runtime"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared-contracts",
|
||||
"shared-kernel",
|
||||
"spacetimedb",
|
||||
"time",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -18,6 +18,7 @@ members = [
|
||||
"crates/module-assets",
|
||||
"crates/module-auth",
|
||||
"crates/module-editor-agent",
|
||||
"crates/module-game-distribution",
|
||||
"crates/module-runtime",
|
||||
"crates/platform-oss",
|
||||
"crates/platform-auth",
|
||||
@@ -53,6 +54,7 @@ module-ai = { path = "crates/module-ai", default-features = false }
|
||||
module-assets = { path = "crates/module-assets", default-features = false }
|
||||
module-auth = { path = "crates/module-auth", default-features = false }
|
||||
module-editor-agent = { path = "crates/module-editor-agent", default-features = false }
|
||||
module-game-distribution = { path = "crates/module-game-distribution", default-features = false }
|
||||
module-runtime = { path = "crates/module-runtime", default-features = false }
|
||||
platform-agent-harness = { path = "crates/platform-agent-harness", default-features = false }
|
||||
platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false }
|
||||
|
||||
@@ -23,6 +23,7 @@ module-ai = { workspace = true }
|
||||
module-assets = { workspace = true, features = ["server-service"] }
|
||||
module-auth = { workspace = true }
|
||||
module-editor-agent = { workspace = true }
|
||||
module-game-distribution = { workspace = true }
|
||||
module-runtime = { workspace = true }
|
||||
platform-editor-agent = { workspace = true }
|
||||
platform-audio = { workspace = true }
|
||||
|
||||
@@ -44,24 +44,17 @@
|
||||
22. 接入 `POST /api/auth/wechat/bind-phone` 微信待绑定账号补绑手机号链路
|
||||
23. 接入 `POST /api/assets/objects/bind` 已确认对象绑定业务实体槽位链路
|
||||
24. 接入 `POST /api/assets/sts-upload-credentials` 禁用式 STS 写权限 contract
|
||||
25. 接入 `custom-world-library`、`custom-world-gallery` 与 agent `publish_world` 首批 Axum facade
|
||||
26. 接入 custom world agent `session create / session snapshot` Axum facade
|
||||
27. 旧 `runtime story` 兼容接口已下线并保持未挂载;运行时故事写链路改为:
|
||||
- `POST /api/story/sessions/runtime`
|
||||
- `GET /api/story/sessions/{story_session_id}/runtime-projection`
|
||||
- `POST /api/story/sessions/{story_session_id}/actions/resolve`
|
||||
28. 接入 `POST /api/assets/character-visual/generate`
|
||||
29. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
30. 接入 `POST /api/assets/character-visual/publish`
|
||||
31. 接入 `GET /api/assets/character-animation/templates`
|
||||
32. 接入 `POST /api/assets/character-animation/import-video`
|
||||
33. 接入 `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
34. 接入 `POST /api/assets/character-workflow-cache`
|
||||
35. 接入 `POST /api/assets/character-animation/generate`
|
||||
36. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
37. 接入 `POST /api/assets/character-animation/publish`
|
||||
38. 下线旧 `/generated-*` 资产直读代理;生成资产读取统一走 `/api/assets/read-url` 或 asset object projection
|
||||
39. 下线旧 `/api/custom-world/*` 非 runtime 前缀和 `/api/runtime/puzzle/runs/local-next-level`,并用路由回归测试确认这些旧入口保持 `404`
|
||||
25. 接入 `POST /api/assets/character-visual/generate`
|
||||
26. 接入 `GET /api/assets/character-visual/jobs/{task_id}`
|
||||
27. 接入 `POST /api/assets/character-visual/publish`
|
||||
28. 接入 `GET /api/assets/character-animation/templates`
|
||||
29. 接入 `POST /api/assets/character-animation/import-video`
|
||||
30. 接入 `GET /api/assets/character-workflow-cache/{character_id}`
|
||||
31. 接入 `POST /api/assets/character-workflow-cache`
|
||||
32. 接入 `POST /api/assets/character-animation/generate`
|
||||
33. 接入 `GET /api/assets/character-animation/jobs/{task_id}`
|
||||
34. 接入 `POST /api/assets/character-animation/publish`
|
||||
35. 生成资产读取统一走 `/api/assets/read-url` 或 asset object projection
|
||||
|
||||
后续与本 crate 直接相关的任务包括:
|
||||
|
||||
@@ -84,15 +77,11 @@
|
||||
17. [x] 接入 `/api/auth/wechat/bind-phone`
|
||||
18. [x] 接入 `/api/assets/objects/bind`
|
||||
19. [x] 接入 `/api/assets/sts-upload-credentials`
|
||||
20. [x] 接入 `custom world library / gallery / publish_world` 首批 facade
|
||||
21. [x] 接入 `custom world agent session create / snapshot` facade
|
||||
22. [x] 下线旧 `runtime story` compat facade,并接入 story session scoped runtime story 写读入口
|
||||
23. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
24. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿兼容 facade
|
||||
25. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿兼容 facade
|
||||
26. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链兼容 facade
|
||||
27. [x] 下线旧 `/generated-*` 路径 OSS 私有读同源代理
|
||||
28. [x] 下线旧 `/api/custom-world/*` 非 runtime 前缀和 Puzzle `local-next-level` 兼容入口
|
||||
20. [x] 接入 `character-visual generate / jobs / publish` 第一批 OSS 主链
|
||||
21. [x] 接入 `character-animation templates / import-video` 第一批 OSS 草稿链路
|
||||
22. [x] 接入 `character-workflow-cache get / save` 第一批 OSS JSON 草稿链路
|
||||
23. [x] 接入 `character-animation generate / jobs / publish` 第一批 OSS 主链
|
||||
24. [x] 生成资产读取统一走 `/api/assets/read-url` 或 asset object projection
|
||||
|
||||
当前 tracing 约定:
|
||||
|
||||
@@ -161,13 +150,9 @@
|
||||
11. 当前手机号登录与微信登录都复用 `module-auth` 的进程内认证仓储,`api-server` 负责请求解析、场景判定、系统 JWT 签发与 refresh cookie 写回。
|
||||
12. 当前微信回调不会把第三方 token 直接透传给前端或 SpacetimeDB,而是统一换成系统签发的 JWT。
|
||||
13. 当前 `/api/assets/sts-upload-credentials` 按“服务器上传、Web 只下载”口径固定返回 `403`,不向浏览器下发 OSS 写权限。
|
||||
14. 当前 `/api/runtime/custom-world/agent/sessions` 与 `/api/runtime/custom-world/agent/sessions/{session_id}` 只提供 deterministic session 骨架与 snapshot 读取,不承诺 message submit、operation query、card detail 的完整能力。
|
||||
15. 当前 `/api/runtime/story/*` 不再挂载;runtime story 开局、读取和动作结算通过 `/api/story/sessions/runtime`、`/api/story/sessions/{story_session_id}/runtime-projection` 与 `/api/story/sessions/{story_session_id}/actions/resolve` 进入 BFF,并由 `module-runtime-story`、story session 和 runtime snapshot 投影共同闭合。
|
||||
16. 当前 `/api/assets/character-visual/*` 第一批只保证旧接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
17. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
18. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
19. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
20. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
21. 当前旧 `/generated-*` 直读兼容层已下线;`/generated-*` 只作为 `legacyPublicPath` / OSS object key 标识,读取必须通过 `/api/assets/read-url` 或业务投影中的签名读 URL。
|
||||
22. 当前旧 `/api/custom-world/entity`、`/api/custom-world/scene-npc`、`/api/custom-world/scene-image`、`/api/custom-world/cover-image`、`/api/custom-world/cover-upload` 不再挂载,RPG 创作资产入口统一使用 `/api/runtime/custom-world/*`。
|
||||
23. 当前旧 `/api/runtime/puzzle/runs/local-next-level` 不再挂载,正式 Puzzle 运行态只通过 `/api/runtime/puzzle/runs/{run_id}/next-level` 进入后端真相源。
|
||||
14. 当前 `/api/assets/character-visual/*` 第一批只保证现役接口 contract、OSS 草稿/正式对象、`asset_object` 与 `asset_entity_binding` 主链可用;真实图片模型、workflow cache 与本地角色覆盖写回仍在后续阶段。
|
||||
15. 当前 `/api/assets/character-animation/import-video` 第一批只接受 `data:video/*;base64,...` 并写入 OSS 草稿区,不读取旧本地 `public/` 路径,也不创建正式 `asset_object`。
|
||||
16. 当前 `/api/assets/character-workflow-cache/*` 第一批只把工作流 JSON 草稿写入 OSS,不迁移历史本地缓存,也不创建正式 `asset_object`。
|
||||
17. 当前 `/api/assets/character-animation/generate` 第一批只用 Rust 占位产物打通 `AiTaskService + OSS` 草稿链;`image-sequence` 写 SVG 帧,视频类策略优先复用参考视频或仓库内可播放占位视频,不代表真实上游视频模型已完成迁移。
|
||||
18. 当前 `/api/assets/character-animation/publish` 会把前端提交帧、动作级 manifest 与总 manifest 写入 OSS,并只把总 manifest 确认为 `asset_object` 后绑定到 `character / animation_set`。
|
||||
19. `/generated-*` 只作为 `legacyPublicPath` / OSS object key 标识,读取必须通过 `/api/assets/read-url` 或业务投影中的签名读 URL。
|
||||
|
||||
@@ -2269,6 +2269,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
|
||||
"/admin/api/external-api-keys" => AnyTab(&["tables"]),
|
||||
"/admin/api/debug/http" => AnyTab(&["debug"]),
|
||||
"/admin/api/tracking/events" => AnyTab(&["tracking"]),
|
||||
"/admin/api/agc/tracking-events" => AnyTab(&["agc-tracking"]),
|
||||
"/admin/api/tracking/event-keys" => AnyTab(&["tracking", "tasks"]),
|
||||
"/admin/api/error-reports" => AnyTab(&["error-reports"]),
|
||||
"/admin/api/project-snapshots" => AnyTab(&["project-snapshots"]),
|
||||
@@ -2279,6 +2280,7 @@ 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"]),
|
||||
"/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => {
|
||||
AnyTab(&["redeem"])
|
||||
}
|
||||
@@ -2295,6 +2297,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
|
||||
"/admin/api/profile/users/detail" => AnyTab(&[
|
||||
"tables",
|
||||
"tracking",
|
||||
"agc-tracking",
|
||||
"recharge-orders",
|
||||
"editor-showcase",
|
||||
"editor-assets",
|
||||
@@ -6956,6 +6959,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn admin_tab_permissions_cover_shared_and_sensitive_routes() {
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["agc-tracking".into()],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/profile/users/detail"
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["tracking".into()],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/agc/tracking-events"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
@@ -6986,6 +7009,11 @@ mod tests {
|
||||
("tables", Method::GET, "/admin/api/database/tables"),
|
||||
("debug", Method::POST, "/admin/api/debug/http"),
|
||||
("tracking", Method::GET, "/admin/api/tracking/events"),
|
||||
(
|
||||
"agc-tracking",
|
||||
Method::GET,
|
||||
"/admin/api/agc/tracking-events",
|
||||
),
|
||||
("error-reports", Method::GET, "/admin/api/error-reports"),
|
||||
(
|
||||
"project-snapshots",
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//! 客户端埋点只在数据库提交后确认;不使用普通路由 tracking outbox。
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{DefaultBodyLimit, Extension, Query, State},
|
||||
http::StatusCode,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::{admin::AdminAgcTrackingEventListQuery, agc_analytics::AgcAnalyticsBatch};
|
||||
use spacetime_client::SpacetimeClientError;
|
||||
|
||||
use crate::{
|
||||
admin::{AuthenticatedAdmin, require_admin_auth},
|
||||
api_response::json_success_body,
|
||||
auth::{AuthenticatedAccessToken, require_bearer_auth},
|
||||
config::AppConfig,
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/agc/analytics/batches",
|
||||
post(upload_batch)
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/agc/tracking-events",
|
||||
get(list_events).route_layer(middleware::from_fn_with_state(state, require_admin_auth)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn upload_batch(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Extension(auth): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<Value>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
let Json(raw) = payload.map_err(|error| {
|
||||
let status = if error.status() == StatusCode::PAYLOAD_TOO_LARGE {
|
||||
StatusCode::PAYLOAD_TOO_LARGE
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
AppError::from_status(status).with_message("客户端埋点请求格式无效")
|
||||
})?;
|
||||
// serde 也能将位置数组解成 struct;上传合同只接受命名字段的 JSON 对象。
|
||||
if !raw.is_object()
|
||||
|| !raw
|
||||
.get("events")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|events| events.iter().all(Value::is_object))
|
||||
{
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
|
||||
.with_message("客户端埋点必须使用事件对象"));
|
||||
}
|
||||
let batch: AgcAnalyticsBatch = serde_json::from_value(raw).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点字段不符合合同")
|
||||
})?;
|
||||
validate_subject_and_origin(&batch, auth.claims().user_id(), &state.config)?;
|
||||
module_runtime::agc_analytics::validate_agc_analytics_batch(&batch).map_err(|error| {
|
||||
let status = if error == "events_too_large" {
|
||||
StatusCode::PAYLOAD_TOO_LARGE
|
||||
} else {
|
||||
StatusCode::BAD_REQUEST
|
||||
};
|
||||
AppError::from_status(status).with_message("客户端埋点批次格式不符合合同")
|
||||
})?;
|
||||
let acknowledgement = state
|
||||
.spacetime_client()
|
||||
.upload_agc_analytics_batch(batch)
|
||||
.await
|
||||
.map_err(map_database_error)?;
|
||||
Ok(json_success_body(Some(&context), acknowledgement))
|
||||
}
|
||||
|
||||
async fn list_events(
|
||||
State(state): State<AppState>,
|
||||
Extension(context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Query(query): Query<AdminAgcTrackingEventListQuery>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
module_runtime::agc_analytics::validate_agc_tracking_query(&query).map_err(|_| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点查询参数无效")
|
||||
})?;
|
||||
let payload = state
|
||||
.spacetime_client()
|
||||
.list_agc_tracking_events(query)
|
||||
.await
|
||||
.map_err(map_database_error)?;
|
||||
Ok(json_success_body(Some(&context), payload))
|
||||
}
|
||||
|
||||
fn validate_subject_and_origin(
|
||||
batch: &AgcAnalyticsBatch,
|
||||
user_id: &str,
|
||||
config: &AppConfig,
|
||||
) -> Result<(), AppError> {
|
||||
// 复用部署渠道,不使用客户端 body 或 Host / Forwarded 头推断服务器所属平台。
|
||||
let expected_origin = match config.client_download_channel.as_str() {
|
||||
"dev" => "https://dev.genarrative.world",
|
||||
"release" => "https://www.genarrative.world",
|
||||
_ => {
|
||||
return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("客户端埋点接收渠道无效"));
|
||||
}
|
||||
};
|
||||
// 本地开发允许回环地址及可变端口,兼容端口漂移和容器映射;线上部署不启用此例外。
|
||||
let local_origin = config.client_download_channel == "dev"
|
||||
&& matches!(
|
||||
config.environment.as_str(),
|
||||
"development" | "test" | "container"
|
||||
)
|
||||
&& url::Url::parse(&batch.destination_origin).is_ok_and(|url| {
|
||||
matches!(url.scheme(), "http" | "https")
|
||||
&& url.host_str().is_some_and(|host| {
|
||||
host == "localhost"
|
||||
|| host
|
||||
.trim_matches(['[', ']'])
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
})
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.origin().ascii_serialization() == batch.destination_origin
|
||||
});
|
||||
if batch.user_id != user_id
|
||||
|| (batch.destination_origin != expected_origin && !local_origin)
|
||||
|| batch
|
||||
.events
|
||||
.iter()
|
||||
.any(|event| event.user_id.as_deref() != Some(user_id))
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_message("客户端埋点身份或平台不匹配")
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_database_error(error: SpacetimeClientError) -> AppError {
|
||||
match error {
|
||||
SpacetimeClientError::Procedure(message) if message == "agc_event_conflict" => {
|
||||
AppError::from_status(StatusCode::CONFLICT).with_message("客户端埋点事件 ID 内容冲突")
|
||||
}
|
||||
SpacetimeClientError::Procedure(message)
|
||||
if matches!(message.as_str(), "invalid_agc_query" | "invalid_agc_cursor") =>
|
||||
{
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点查询参数无效")
|
||||
}
|
||||
// 不回显数据库或上传内容,超时/未知提交结果不产生确认。
|
||||
_ => AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
|
||||
.with_message("客户端埋点数据服务暂不可用"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn batch() -> AgcAnalyticsBatch {
|
||||
AgcAnalyticsBatch {
|
||||
schema_version: 1,
|
||||
batch_id: uuid::Uuid::new_v4().to_string(),
|
||||
destination_origin: "https://dev.genarrative.world".into(),
|
||||
user_id: "user-a".into(),
|
||||
events: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_analytics_subject_and_deployment_are_both_required() {
|
||||
for (channel, origin, other_origin) in [
|
||||
(
|
||||
"dev",
|
||||
"https://dev.genarrative.world",
|
||||
"https://www.genarrative.world",
|
||||
),
|
||||
(
|
||||
"release",
|
||||
"https://www.genarrative.world",
|
||||
"https://dev.genarrative.world",
|
||||
),
|
||||
] {
|
||||
let config = AppConfig {
|
||||
environment: "production".into(),
|
||||
client_download_channel: channel.into(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let mut batch = batch();
|
||||
batch.destination_origin = origin.into();
|
||||
assert!(validate_subject_and_origin(&batch, "user-a", &config).is_ok());
|
||||
assert_eq!(
|
||||
validate_subject_and_origin(&batch, "user-b", &config)
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
for rejected in [
|
||||
other_origin,
|
||||
"http://127.0.0.1:8082",
|
||||
"https://custom.example",
|
||||
] {
|
||||
batch.destination_origin = rejected.into();
|
||||
assert_eq!(
|
||||
validate_subject_and_origin(&batch, "user-a", &config)
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"{channel}: {rejected}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for channel in ["", "qa-2026", "dev-win"] {
|
||||
let config = AppConfig {
|
||||
client_download_channel: channel.into(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
validate_subject_and_origin(&batch(), "user-a", &config)
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_analytics_loopback_supports_local_ports_only_in_dev_environments() {
|
||||
let mut batch = batch();
|
||||
for environment in ["development", "test", "container", "production", "staging"] {
|
||||
for channel in ["dev", "release"] {
|
||||
let config = AppConfig {
|
||||
environment: environment.into(),
|
||||
client_download_channel: channel.into(),
|
||||
..AppConfig::default()
|
||||
};
|
||||
let allowed =
|
||||
channel == "dev" && matches!(environment, "development" | "test" | "container");
|
||||
for origin in [
|
||||
"http://127.0.0.1:8082",
|
||||
"http://localhost:18080",
|
||||
"http://[::1]:19001",
|
||||
"https://localhost:8443",
|
||||
] {
|
||||
batch.destination_origin = origin.into();
|
||||
assert_eq!(
|
||||
validate_subject_and_origin(&batch, "user-a", &config).is_ok(),
|
||||
allowed,
|
||||
"{environment}/{channel}: {origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_analytics_local_exception_rejects_noncanonical_and_nonloopback_origins() {
|
||||
let config = AppConfig::default();
|
||||
let mut batch = batch();
|
||||
for origin in [
|
||||
"http://192.168.1.2:8082",
|
||||
"http://localhost.example:8082",
|
||||
"http://0.0.0.0:8082",
|
||||
"http://localhost:8082/",
|
||||
"http://localhost:8082/api",
|
||||
"http://localhost:8082?x=1",
|
||||
"http://localhost:8082#fragment",
|
||||
"http://user:password@localhost:8082",
|
||||
"ftp://localhost:8082",
|
||||
"https://dev.genarrative.world/",
|
||||
"http://dev.genarrative.world",
|
||||
"",
|
||||
] {
|
||||
batch.destination_origin = origin.into();
|
||||
assert_eq!(
|
||||
validate_subject_and_origin(&batch, "user-a", &config)
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"{origin}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agc_analytics_database_failure_never_becomes_acknowledgement() {
|
||||
for (message, status) in [
|
||||
("agc_event_conflict", StatusCode::CONFLICT),
|
||||
("invalid_agc_cursor", StatusCode::BAD_REQUEST),
|
||||
("unknown", StatusCode::SERVICE_UNAVAILABLE),
|
||||
] {
|
||||
assert_eq!(
|
||||
map_database_error(SpacetimeClientError::Procedure(message.into())).status_code(),
|
||||
status
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,4 @@
|
||||
use std::convert::Infallible;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
http::{HeaderValue, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use axum::Json;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
#[cfg(test)]
|
||||
@@ -41,30 +32,6 @@ where
|
||||
Json(serde_json::to_value(data).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
pub fn json_success_data_bytes_response(
|
||||
request_context: Option<&RequestContext>,
|
||||
data_json: Bytes,
|
||||
) -> Response {
|
||||
if let Some(context) = request_context
|
||||
&& context.wants_envelope()
|
||||
{
|
||||
let meta = serde_json::to_vec(&build_api_response_meta(Some(context)))
|
||||
.map(Bytes::from)
|
||||
.unwrap_or_else(|_| Bytes::from_static(b"null"));
|
||||
let chunks = [
|
||||
Bytes::from_static(b"{\"ok\":true,\"data\":"),
|
||||
data_json,
|
||||
Bytes::from_static(b",\"error\":null,\"meta\":"),
|
||||
meta,
|
||||
Bytes::from_static(b"}"),
|
||||
];
|
||||
let stream = stream::iter(chunks.into_iter().map(Ok::<Bytes, Infallible>));
|
||||
return json_body_response(Body::from_stream(stream));
|
||||
}
|
||||
|
||||
json_bytes_response(data_json)
|
||||
}
|
||||
|
||||
pub fn json_error_body(
|
||||
request_context: Option<&RequestContext>,
|
||||
error: &ApiErrorPayload,
|
||||
@@ -98,19 +65,6 @@ fn build_api_response_meta(request_context: Option<&RequestContext>) -> ApiRespo
|
||||
)
|
||||
}
|
||||
|
||||
fn json_bytes_response(bytes: Bytes) -> Response {
|
||||
json_body_response(Body::from(bytes))
|
||||
}
|
||||
|
||||
fn json_body_response(body: Body) -> Response {
|
||||
let mut response = body.into_response();
|
||||
response.headers_mut().insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,31 +106,6 @@ mod tests {
|
||||
assert!(body.get("meta").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn success_response_streams_cached_data_inside_standard_envelope() {
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
let request_context = build_request_context(true);
|
||||
let response = json_success_data_bytes_response(
|
||||
Some(&request_context),
|
||||
Bytes::from_static(br#"{"items":[]}"#),
|
||||
);
|
||||
let body = response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.expect("response body should collect")
|
||||
.to_bytes();
|
||||
let payload: Value = serde_json::from_slice(&body).expect("body should be json");
|
||||
|
||||
assert_eq!(payload["ok"], Value::Bool(true));
|
||||
assert_eq!(payload["data"]["items"], Value::Array(Vec::new()));
|
||||
assert_eq!(
|
||||
payload["meta"]["requestId"],
|
||||
Value::String("req-test".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_body_returns_legacy_shape_without_envelope_header() {
|
||||
let request_context = build_request_context(false);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,6 @@ use crate::{
|
||||
wallet_refund_outbox::{WalletRefundOutboxEnqueueOutcome, WalletRefundOutboxRecord},
|
||||
};
|
||||
|
||||
pub(crate) const ASSET_OPERATION_POINTS_COST: u64 = 1;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ExternalGenerationBillingContext {
|
||||
job_id: String,
|
||||
@@ -145,29 +143,7 @@ where
|
||||
.await
|
||||
}
|
||||
|
||||
/// 资产操作统一执行入口:业务层只声明操作类型与资源 ID,钱包扣退费由服务层收口。
|
||||
pub(crate) async fn execute_billable_asset_operation<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
operation: Fut,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
execute_billable_asset_operation_with_cost(
|
||||
state,
|
||||
owner_user_id,
|
||||
asset_kind,
|
||||
asset_id,
|
||||
ASSET_OPERATION_POINTS_COST,
|
||||
operation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 生图等特殊操作可声明独立泥点成本,避免修改全局资产操作默认价格。
|
||||
/// 资产操作统一执行入口:业务层声明后端计算的泥点成本,钱包扣退费由服务层收口。
|
||||
pub(crate) async fn execute_billable_asset_operation_with_cost<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -706,25 +682,6 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn should_skip_asset_operation_billing_for_connectivity(
|
||||
error: &SpacetimeClientError,
|
||||
) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
|
||||
SpacetimeClientError::Build(message)
|
||||
| SpacetimeClientError::Procedure(message)
|
||||
| SpacetimeClientError::Runtime(message) => {
|
||||
message.contains("503")
|
||||
|| message.contains("Service Unavailable")
|
||||
|| message.contains("Failed to connect")
|
||||
|| message.contains("WebSocket")
|
||||
|| message.contains("No such procedure")
|
||||
|| message.contains("连接已断开")
|
||||
|| message.contains("连接在返回结果前已断开")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_use_wallet_refund_emergency_spool(error: &SpacetimeClientError) -> bool {
|
||||
match error {
|
||||
SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout(_) => true,
|
||||
@@ -875,27 +832,6 @@ mod tests {
|
||||
assert_eq!(wallet.balance_delta, -37);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_operation_connectivity_errors_are_classified_for_non_billing_fallbacks() {
|
||||
assert_eq!(ASSET_OPERATION_POINTS_COST, 1);
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::ConnectDropped
|
||||
));
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Runtime(
|
||||
"Failed to connect: HTTP error: 503 Service Unavailable".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure(
|
||||
"No such procedure: consume_profile_wallet_points_and_return".to_string(),
|
||||
),
|
||||
));
|
||||
assert!(!should_skip_asset_operation_billing_for_connectivity(
|
||||
&SpacetimeClientError::Procedure("泥点余额不足".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_refund_emergency_spool_requires_database_unavailability() {
|
||||
assert!(should_use_wallet_refund_emergency_spool(
|
||||
|
||||
@@ -304,13 +304,6 @@ pub enum ExternalGenerationMode {
|
||||
}
|
||||
|
||||
impl ExternalGenerationMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inline => "inline",
|
||||
Self::Queue => "queue",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_inline(self) -> bool {
|
||||
matches!(self, Self::Inline)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,3 @@ pub use api::{
|
||||
create_editor_agent_conversation, delete_editor_agent_conversation,
|
||||
get_editor_agent_conversation, list_editor_agent_conversations,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use reconcile::reconcile_completed_editor_agent_tool_call_for_test;
|
||||
|
||||
@@ -226,15 +226,6 @@ fn reconcile_completed_editor_agent_tool_call(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reconcile_completed_editor_agent_tool_call_for_test(
|
||||
message: &mut EditorAgentMessage,
|
||||
result_payload_json: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
reconcile_completed_editor_agent_tool_call(message, result_payload_json)
|
||||
.map_err(|error| format!("{error:?}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -146,17 +146,6 @@ fn editor_screen_background_color_prompt(color: EditorScreenBackgroundColor) ->
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_asset_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
format!(
|
||||
"背景必须是{},且{},方便扣除背景;{}",
|
||||
editor_screen_background_color_prompt(color),
|
||||
EDITOR_GREEN_SCREEN_BACKGROUND_GUARDRAILS,
|
||||
EDITOR_GREEN_SCREEN_ASSET_GUARDRAILS
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_green_screen_character_prompt_clause(
|
||||
color: EditorScreenBackgroundColor,
|
||||
) -> String {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1249,7 +1249,6 @@ pub(crate) async fn prepare_editor_spritesheet_slices_for_generation(
|
||||
Some(upload.asset_object),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: input.project_id.clone(),
|
||||
owner_user_id: caller.owner_user_id.clone(),
|
||||
folder_id: input.asset_folder_id.clone(),
|
||||
label: upload.name.clone(),
|
||||
image_src: upload.image_src.clone(),
|
||||
@@ -1830,7 +1829,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
Some(source_persisted.asset_object),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: caller.owner_user_id.clone(),
|
||||
folder_id: asset_folder_id.clone(),
|
||||
label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"),
|
||||
image_src: source_image_src.clone(),
|
||||
@@ -2066,7 +2064,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
|
||||
Some(spritesheet_prepared_image.asset_object.clone()),
|
||||
PersistEditorGeneratedAssetInput {
|
||||
project_id: payload.project_id.clone(),
|
||||
owner_user_id: owner_user_id.clone(),
|
||||
folder_id: asset_folder_id.clone(),
|
||||
label: spritesheet_label,
|
||||
image_src: spritesheet_image_src.clone(),
|
||||
|
||||
@@ -1897,10 +1897,6 @@ fn extract_router_token_info(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_router_token_id(payload: &Value, token_name: &str) -> Result<Option<String>, String> {
|
||||
extract_router_token_info(payload, token_name).map(|token| token.map(|token| token.id))
|
||||
}
|
||||
|
||||
fn provider_payload_data(value: &Value) -> &Value {
|
||||
value.get("data").unwrap_or(value)
|
||||
}
|
||||
@@ -2377,32 +2373,6 @@ mod tests {
|
||||
assert_eq!(payload["group"], "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_fixed_router_tokens_require_reconciliation() {
|
||||
let payload = json!({
|
||||
"data": [
|
||||
{"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER},
|
||||
{"id": 88, "name": LLM_ROUTER_TOKEN_IDENTIFIER}
|
||||
]
|
||||
});
|
||||
|
||||
let error = extract_router_token_id(&payload, LLM_ROUTER_TOKEN_IDENTIFIER)
|
||||
.expect_err("multiple fixed-name tokens must be rejected");
|
||||
assert!(error.contains("多个不同 token id"));
|
||||
|
||||
let duplicate_same_id = json!({
|
||||
"data": [
|
||||
{"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER},
|
||||
{"id": 77, "name": LLM_ROUTER_TOKEN_IDENTIFIER}
|
||||
]
|
||||
});
|
||||
assert_eq!(
|
||||
extract_router_token_id(&duplicate_same_id, LLM_ROUTER_TOKEN_IDENTIFIER)
|
||||
.expect("same token repeated in an envelope is not ambiguous"),
|
||||
Some("77".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_api_user_update_uses_numeric_id_and_fixed_group() {
|
||||
let payload = router_user_update_request(42, "router_abcd", "user_full-id");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,9 @@ use crate::{
|
||||
pub struct FrontendRuntimeConfigResponse {
|
||||
pub image_editor_agent_sidebar_enabled: bool,
|
||||
pub agc_template_library_enabled: bool,
|
||||
/// 游戏发布灰度:运营未配置 `game-distribution:publish` 时对已登录作者默认开放,
|
||||
/// 显式收紧后只有白名单/灰度命中的作者拿到 `true`;匿名一律 `false`(发布必须先登录)。
|
||||
pub game_distribution_publish_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn get_frontend_runtime_config(
|
||||
@@ -57,6 +60,22 @@ pub async fn get_frontend_runtime_config(
|
||||
}))
|
||||
})?;
|
||||
|
||||
let game_distribution_publish_enabled = match user_id {
|
||||
Some(user_id) => state
|
||||
.is_game_distribution_publish_enabled_for_user(Some(user_id))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取前端运行时配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?,
|
||||
// 未登录时发布入口不开放:灰度只面向已登录作者,写入口也要求 Bearer 会话。
|
||||
None => false,
|
||||
};
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
@@ -67,6 +86,7 @@ pub async fn get_frontend_runtime_config(
|
||||
FrontendRuntimeConfigResponse {
|
||||
image_editor_agent_sidebar_enabled,
|
||||
agc_template_library_enabled,
|
||||
game_distribution_publish_enabled,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -839,36 +839,6 @@ fn is_responses_terminal_sse_event(event: &str) -> bool {
|
||||
.is_some_and(|kind| matches!(kind.as_str(), "response.completed" | "response.incomplete"))
|
||||
}
|
||||
|
||||
const SSE_DONE_MARKER: &[u8] = b"data: [DONE]";
|
||||
|
||||
fn find_sse_done_marker(bytes: &[u8]) -> Option<usize> {
|
||||
bytes
|
||||
.windows(SSE_DONE_MARKER.len())
|
||||
.enumerate()
|
||||
.find_map(|(position, window)| {
|
||||
(window == SSE_DONE_MARKER && (position == 0 || bytes[position - 1] == b'\n'))
|
||||
.then_some(position)
|
||||
})
|
||||
}
|
||||
|
||||
fn find_sse_event_end(bytes: &[u8], event_start: usize) -> Option<usize> {
|
||||
let event = &bytes[event_start..];
|
||||
if let Some(offset) = event.windows(2).position(|window| window == b"\n\n") {
|
||||
return Some(event_start + offset + 2);
|
||||
}
|
||||
event
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|offset| event_start + offset + 4)
|
||||
}
|
||||
|
||||
fn sse_done_marker_suffix_len(bytes: &[u8]) -> usize {
|
||||
(1..SSE_DONE_MARKER.len())
|
||||
.rev()
|
||||
.find(|&length| bytes.ends_with(&SSE_DONE_MARKER[..length]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
async fn resolve_llm_router_client(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -1588,18 +1558,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_sse_done_marker_is_found_only_at_event_line_start() {
|
||||
let bytes = b"data: {\"text\":\"data: [DONE]\"}\n\ndata: [DONE]\n\n";
|
||||
let done_start = find_sse_done_marker(bytes).expect("done event should be found");
|
||||
assert_eq!(
|
||||
&bytes[done_start..done_start + SSE_DONE_MARKER.len()],
|
||||
SSE_DONE_MARKER
|
||||
);
|
||||
assert_eq!(find_sse_event_end(bytes, done_start), Some(bytes.len()));
|
||||
assert_eq!(sse_done_marker_suffix_len(b"data: [DON"), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_utf8_chunk_preserves_multibyte_characters_split_across_chunks() {
|
||||
let mut text = String::new();
|
||||
|
||||
@@ -5,6 +5,7 @@ mod admin_accounts;
|
||||
mod admin_project_snapshots;
|
||||
mod admin_recharge;
|
||||
mod admin_templates;
|
||||
mod agc_analytics;
|
||||
mod agc_models;
|
||||
mod ai_tasks;
|
||||
mod aliyun_matting;
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod editor_project;
|
||||
pub mod external_api;
|
||||
pub mod external_generation;
|
||||
pub mod frontend_runtime_config;
|
||||
pub mod game_distribution;
|
||||
pub mod health;
|
||||
pub mod internal;
|
||||
pub mod platform;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,154 +0,0 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
creative_agent::{
|
||||
cancel_creative_agent_session, confirm_creative_puzzle_template,
|
||||
create_creative_agent_session, get_creative_agent_session, stream_creative_agent_message,
|
||||
stream_creative_draft_edit,
|
||||
},
|
||||
state::AppState,
|
||||
story_battles::{
|
||||
create_story_battle, create_story_npc_battle, get_story_battle_state, resolve_story_battle,
|
||||
},
|
||||
story_sessions::{
|
||||
begin_story_runtime_session, begin_story_session, continue_story,
|
||||
get_story_runtime_projection, get_story_session_state, resolve_story_runtime_action,
|
||||
},
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/story/sessions",
|
||||
post(begin_story_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/runtime",
|
||||
post(begin_story_runtime_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/state",
|
||||
get(get_story_session_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/runtime-projection",
|
||||
get(get_story_runtime_projection).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/{story_session_id}/actions/resolve",
|
||||
post(resolve_story_runtime_action).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/sessions/continue",
|
||||
post(continue_story).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles",
|
||||
post(create_story_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles/{battle_state_id}",
|
||||
get(get_story_battle_state).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/npc/battle",
|
||||
post(create_story_npc_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/story/battles/resolve",
|
||||
post(resolve_story_battle).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions",
|
||||
post(create_creative_agent_session)
|
||||
// 中文注释:创意 Agent 首轮允许携带参考图 URL/Data URL,沿用拼图参考图入口上限。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}",
|
||||
get(get_creative_agent_session).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/messages/stream",
|
||||
post(stream_creative_agent_message)
|
||||
// 中文注释:message stream 同样可能带图片素材,避免默认 JSON limit 过早拒绝。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/confirm-template",
|
||||
post(confirm_creative_puzzle_template).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/draft-edits/stream",
|
||||
post(stream_creative_draft_edit)
|
||||
// 中文注释:草稿编辑会携带当前 puzzle draft JSON,保持和拼图草稿入口一致的 body 上限。
|
||||
.layer(DefaultBodyLimit::max(
|
||||
PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES,
|
||||
))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/creative-agent/sessions/{session_id}/cancel",
|
||||
post(cancel_creative_agent_session)
|
||||
.route_layer(middleware::from_fn_with_state(state, require_bearer_auth)),
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use axum::http::StatusCode;
|
||||
use platform_image::{
|
||||
DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage,
|
||||
VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client,
|
||||
create_vector_engine_image_edit, create_vector_engine_image_edit_with_references,
|
||||
create_vector_engine_image_edit_with_references_and_model,
|
||||
create_vector_engine_image_generation, create_vector_engine_image_generation_with_model,
|
||||
create_vector_engine_nanobanana_generate_content,
|
||||
@@ -18,8 +17,7 @@ use time::OffsetDateTime;
|
||||
|
||||
use crate::{
|
||||
external_api_audit::{
|
||||
ExternalApiFailureDraft, build_external_api_failure_draft_from_platform_image_audit,
|
||||
record_external_api_failure,
|
||||
build_external_api_failure_draft_from_platform_image_audit, record_external_api_failure,
|
||||
},
|
||||
http_error::AppError,
|
||||
request_context::RequestContext,
|
||||
@@ -248,83 +246,6 @@ pub(crate) async fn create_openai_nanobanana_generate_content(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
reference_image: &OpenAiReferenceImage,
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": 1,
|
||||
});
|
||||
let result = create_vector_engine_image_edit(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
reference_image,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn create_openai_image_edit_with_references(
|
||||
http_client: &reqwest::Client,
|
||||
settings: &OpenAiImageSettings,
|
||||
prompt: &str,
|
||||
negative_prompt: Option<&str>,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_images: &[OpenAiReferenceImage],
|
||||
failure_context: &str,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
let started_at_micros = current_utc_micros();
|
||||
let request_payload = json!({
|
||||
"size": size,
|
||||
"candidateCount": candidate_count,
|
||||
"promptChars": prompt.chars().count(),
|
||||
"negativePromptChars": negative_prompt.map(str::chars).map(Iterator::count),
|
||||
"referenceImageCount": reference_images.len(),
|
||||
});
|
||||
let result = create_vector_engine_image_edit_with_references(
|
||||
http_client,
|
||||
&settings.provider_settings(),
|
||||
prompt,
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
failure_context,
|
||||
)
|
||||
.await;
|
||||
map_platform_image_result(
|
||||
settings,
|
||||
result,
|
||||
"image_edit_with_references",
|
||||
failure_context,
|
||||
request_payload,
|
||||
started_at_micros,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn create_openai_image_edit_with_references_and_model(
|
||||
http_client: &reqwest::Client,
|
||||
@@ -386,16 +307,6 @@ pub(crate) fn build_openai_image_request_body(
|
||||
}
|
||||
|
||||
impl OpenAiImageSettings {
|
||||
pub(crate) fn with_external_api_audit_actor(
|
||||
mut self,
|
||||
user_id: Option<String>,
|
||||
profile_id: Option<String>,
|
||||
) -> Self {
|
||||
self.external_api_audit_user_id = user_id;
|
||||
self.external_api_audit_profile_id = profile_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn with_external_api_audit_context(
|
||||
mut self,
|
||||
request_context: &RequestContext,
|
||||
@@ -501,14 +412,6 @@ async fn record_openai_image_failure_audit_if_configured(
|
||||
record_external_api_failure(state, draft).await;
|
||||
}
|
||||
|
||||
pub(crate) fn build_openai_image_failure_audit_draft(
|
||||
error: &PlatformImageError,
|
||||
) -> Option<ExternalApiFailureDraft> {
|
||||
error
|
||||
.audit()
|
||||
.map(build_external_api_failure_draft_from_platform_image_audit)
|
||||
}
|
||||
|
||||
pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError {
|
||||
let error = error.into_final_error();
|
||||
let status = match error.status_hint() {
|
||||
@@ -705,37 +608,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vector_engine_multi_reference_edit_rejects_empty_references() {
|
||||
let settings = OpenAiImageSettings {
|
||||
base_url: "https://vector.example".to_string(),
|
||||
api_key: "test-key".to_string(),
|
||||
request_timeout_ms: 1_000_000,
|
||||
request_deadline: None,
|
||||
external_api_audit_state: None,
|
||||
external_api_audit_user_id: None,
|
||||
external_api_audit_profile_id: None,
|
||||
external_api_audit_request_id: None,
|
||||
};
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
let result = create_openai_image_edit_with_references(
|
||||
&http_client,
|
||||
&settings,
|
||||
"提示词",
|
||||
None,
|
||||
"1:1",
|
||||
1,
|
||||
&[],
|
||||
"测试图片编辑失败",
|
||||
)
|
||||
.await;
|
||||
|
||||
let error = result.expect_err("empty references should be rejected locally");
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert!(error.body_text().contains("缺少参考图"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_data_url_stays_provider_owned() {
|
||||
let source = format!(
|
||||
|
||||
@@ -1257,6 +1257,46 @@ impl AppState {
|
||||
Ok(module_runtime::is_feature_gate_allowed(gate, &user_context))
|
||||
}
|
||||
|
||||
/// 游戏分发发布开关:灰度未配置时**不开放**(运营在后台配置后才对白名单/灰度命中
|
||||
/// 的作者开放),未登录、gate 行缺失或 `enabled=false` 都返回 false。
|
||||
/// 读取、目录、详情、发行网关与安全下架不经过这里,已公开游戏始终可玩。
|
||||
pub async fn is_game_distribution_publish_enabled_for_user(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
) -> Result<bool, SpacetimeClientError> {
|
||||
let Some(user_id) = user_id.map(str::trim).filter(|id| !id.is_empty()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let gates = self.get_feature_gate_config().await?;
|
||||
let Some(gate) = gates
|
||||
.iter()
|
||||
.find(|item| item.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY)
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !gate.enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
let user_context = self
|
||||
.feature_gate_user_context(Some(user_id), feature_gate_requires_user_tags(gate))
|
||||
.await;
|
||||
Ok(module_runtime::is_feature_gate_allowed(
|
||||
Some(gate),
|
||||
&user_context,
|
||||
))
|
||||
}
|
||||
|
||||
/// 游戏分发发布总开关(与具体作者无关):gate 行存在且 `enabled=true` 即为开放。
|
||||
///
|
||||
/// 管理员审核通过(激活新版本)没有作者身份,只能按总开关判定;作者写入仍走
|
||||
/// `is_game_distribution_publish_enabled_for_user` 的白名单/灰度判定。
|
||||
pub async fn is_game_distribution_publish_open(&self) -> Result<bool, SpacetimeClientError> {
|
||||
let gates = self.get_feature_gate_config().await?;
|
||||
Ok(gates.iter().any(|gate| {
|
||||
gate.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY && gate.enabled
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn is_agc_template_library_enabled_for_user(
|
||||
&self,
|
||||
user_id: Option<&str>,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user