合并 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:
2026-09-23 19:39:30 +08:00
997 changed files with 56264 additions and 80302 deletions
+15
View File
@@ -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]]
+2
View File
@@ -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 }
+1
View File
@@ -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 }
+22 -37
View File
@@ -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。
+28
View File
@@ -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();
+1
View File
@@ -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!(
+40
View File
@@ -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