From cc84656a1fb3d495f226777571aa280a5a1c6c10 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 20:54:35 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=9A=E7=AB=AF?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E4=BA=92=E7=9B=B8=E9=A1=B6=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单设备退出只撤销当前 refresh session,不再提升账号级 token_version 认证中间件和 refresh 接口在本进程未命中会话时按需刷新 SpacetimeDB 认证工作集 补充多端登录与跨进程会话补水回归测试 同步项目文档和 Hermes 共享决策记录 --- .hermes/shared-memory/decision-log.md | 10 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 +- ...项目基线】当前产品与工程约束-2026-05-15.md | 1 + server-rs/crates/api-server/src/app.rs | 105 ++++++++++++ server-rs/crates/api-server/src/auth.rs | 152 ++++++++++++++---- .../crates/api-server/src/refresh_session.rs | 53 ++++-- server-rs/crates/api-server/src/state.rs | 43 ++++- .../crates/module-auth/src/application.rs | 6 + server-rs/crates/module-auth/src/lib.rs | 145 +++++++++++++++-- 9 files changed, 463 insertions(+), 55 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 87868a02..7b88c272 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -32,6 +32,14 @@ - 验证方式:`npm run test -- src/components/auth/AuthGate.test.tsx`。 - 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 +## 2026-06-07 多端登录以 refresh session 为粒度互不顶号 + +- 背景:同一账号在多端登录后,若单设备退出或请求被打到尚未见过该 session 的 api-server 进程,旧设备会被误判为登录态失效。 +- 决策:普通登录只新增当前设备 refresh session,不撤销其它 active session;`POST /api/auth/logout` 只撤销当前 refresh session,不再提升账号级 `token_version`;`POST /api/auth/logout-all`、改密和重置密码继续吊销全端 session 并提升 `token_version`。api-server 鉴权和 refresh cookie 轮换在本进程工作集未命中 session 时,先从 SpacetimeDB 正式认证表按需刷新一次工作集再复查,支持多实例和滚动重启下的新会话被所有进程识别。 +- 影响范围:`module-auth` refresh session 语义、`api-server` Bearer 鉴权和 `/api/auth/refresh`、账号安全页多端会话。 +- 验证方式:`cargo test -p module-auth logout_current_session --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth refresh_from_snapshot_json_merges_session_created_by_another_process --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server logout_current_device_keeps_other_device_session_alive --manifest-path server-rs/Cargo.toml`。 +- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-06-07 跳一跳排行榜展示名禁止泄露内部身份键 - 背景:跳一跳排行榜曾在结果页和运行态失败弹窗里直接展示 `playerId` / `user_id`,用户可见内容暴露了内部身份键。 @@ -723,7 +731,7 @@ ## 2026-05-13 refresh_session 会话组后端聚合与远端踢下线 - 背景:账号安全页中同设备同 IP 的多条 active `refresh_session` 会重复展示;退出登录没有稳定撤销当前 refresh session;前端“踢下线”只做本地状态变化,未真正让远端设备失效。 -- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session,并继续递增 `token_version`。 +- 决策:`GET /api/auth/sessions` 由后端按“同设备 + 同 IP”聚合 active refresh sessions,响应保留代表 `sessionId` 并新增 `sessionIds/sessionCount`;组内包含当前 refresh hash 或 Bearer `sid` 时整组视为当前设备组,前端不展示踢下线。新增 `POST /api/auth/sessions/{session_id}/revoke`,只允许撤销当前用户自己的非当前会话,不递增 `token_version`,但认证中间件会校验 access token `sid` 对应 active refresh session,使被踢设备立即失效。`/api/auth/logout` 在 refresh cookie 缺失时回退用 Bearer `sid` 撤销当前 session;自 2026-06-07 起单设备退出也不再递增 `token_version`,避免误伤其它设备,只有退出全部设备和改密类安全动作提升账号级版本。 - 影响范围:`module-auth` refresh session service、`api-server` auth middleware/logout/sessions route、`shared-contracts`/TS auth contract、`AuthGate`、`AccountModal`、认证会话技术文档和路由/埋点索引。 - 验证方式:执行 `cargo test -p module-auth --manifest-path server-rs/Cargo.toml refresh_session`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml auth_sessions -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml revoke_auth_session -- --nocapture`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid -- --nocapture`、`npm run test -- AuthGate.test.tsx AccountModal.test.tsx authService.test.ts`、`npm run check:encoding`、`git diff --check`,并用 `npm run dev:api-server` 检查 `/healthz`。 - 关联文档:`docs/technical/AUTH_SESSIONS_QUERY_DESIGN_2026-04-21.md`、`docs/technical/SPACETIMEDB_REFRESH_SESSION_TABLE_DESIGN_2026-04-21.md`、`docs/technical/SERVER_RS_DDD_G1_CONTRACT_AND_ROUTE_MATRIX_2026-04-29.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 2b996168..e031ab73 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -78,6 +78,7 @@ npm run check:server-rs-ddd - `AuthUserPayload` / `AuthUser` 只保留前端当前会用到的身份与绑定展示字段:`id`、`publicUserCode`、`displayName`、`avatarUrl`、`phoneNumber`、`phoneNumberMasked`、`loginMethod`、`bindingStatus`、`wechatBound`、`wechatDisplayName`、`wechatAccount`。账号信息面板展示微信绑定时优先使用 `wechatDisplayName`;该字段只能来自微信平台 profile、历史已保存的微信身份资料,或小程序原生 `input type="nickname"` 提交的 `displayName`,不得用系统账号显示名或“微信旅人”这类假昵称兜底。小程序 `/api/auth/wechat/miniprogram-login` 与 `/api/auth/wechat/bind-phone` 可接收 `displayName`;`/api/auth/wechat/miniprogram-login` 额外返回 `created`,供小程序壳在快捷登录后判断是否需要补采集微信昵称。`jscode2session` 无法直接返回微信昵称或个人微信号,只能稳定拿到小程序维度 `openid`,后端以 `wechatAccount` 下发可区分的绑定账号标识,前端在缺少真实昵称时展示账号尾号。 - `AuthSessionSummaryPayload` / `AuthSessionSummary` 只保留设备卡片与撤销需要的摘要字段:`sessionId`、`sessionIds`、`sessionCount`、`clientLabel`、`ipMasked`、`isCurrent`、`createdAt`、`lastSeenAt`、`expiresAt`。 - 设备诊断信息(例如原始 `clientType` / `clientRuntime` / `clientPlatform` / `userAgent` / `miniProgramAppId` / `miniProgramEnv` / `deviceDisplayName`)不再默认下发到前端;若未来确需展示,优先单独加窄 DTO,而不是把账号 / 会话快照恢复为全量对象。 +- 多端登录语义以 `refresh_session` 为粒度:同一账号可保留多个 active session,普通登录不会撤销旧设备;`POST /api/auth/logout` 只撤销当前 refresh session,不提升 `token_version`;`POST /api/auth/logout-all`、改密、重置密码才吊销全端 session 并提升 `token_version`。鉴权中间件仍校验 Bearer `sid` 对应的 refresh session 是否 active,单独踢下线或当前设备退出可以让目标设备立即失效而不误伤其它设备。 ## api-server 模块化演进规则 @@ -244,7 +245,7 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - 源码:`server-rs/crates/spacetime-module/src/auth/tables.rs` -认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 +认证恢复策略:`api-server` 启动时只从 SpacetimeDB 正式认证表(`user_account` / `auth_identity` / `refresh_session`)投影恢复进程内认证工作集;运行中若 Bearer `sid` 或 refresh cookie 在本进程工作集内未命中,会先从 SpacetimeDB 正式认证表按需刷新一次认证工作集再复查,避免多实例或滚动重启时新登录设备只被签发它的进程认识。`auth_store_snapshot` 只保留行级快照备查,不再作为启动兜底来源。`module-auth` 只保留内存工作集和 JSON 导入 / 导出能力,不再写本地持久化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 不再是兼容恢复源,也不得在启动时回写覆盖 `auth_identity` / `user_account`。认证创建、登录会话、刷新、退出、改密、重置密码、绑定和资料变更等写操作必须在返回客户端前成功同步 SpacetimeDB 正式认证表;同步失败时接口返回错误,不允许把只存在于当前进程内存的账号或会话当成成功结果。新用户注册奖励、邀请码绑定和登录埋点必须排在认证同步成功之后,避免认证没落库时先写出钱包或邀请关系。若启动恢复阶段 SpacetimeDB 不可连接或超时,`api-server` 进入依赖不可用模式并对请求返回 `503 SERVICE_UNAVAILABLE`,直到运维恢复 SpacetimeDB 并重启服务。 `auth_store_snapshot` 禁止再写单行 `snapshot_id = "default"` 聚合 JSON。认证同步入口收到 `module-auth` 整份快照后必须拆成行级记录写入同一张表,当前行键前缀包括:`meta/next_user_id`、`user/`、`phone/`、`session/`、`session_hash/`、`wechat/`、`union/`。SpacetimeDB 模块只保留 `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认证快照过程;旧 `get_auth_store_snapshot`、`upsert_auth_store_snapshot`、`import_auth_store_snapshot` 兼容入口已删除。导入正式表时只按主键 upsert 本次快照包含的用户、身份和会话,避免过期快照把其他用户整表删除。 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 88304754..884383dd 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -51,6 +51,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当 8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。 9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信优先展示微信平台实际返回并由后端保存的 `wechatDisplayName`。小程序 `jscode2session` 不能直接返回微信昵称或个人微信号,只能稳定拿到当前小程序维度的 `openid`,并在满足微信开放平台条件时拿到 `unionid`;小程序昵称来自快捷登录后按需展示的原生 `input type="nickname"` 提交的 `displayName`。后端下发 `wechatAccount` 作为绑定账号标识,前端在没有真实昵称时展示微信账号尾号,不展示裸“已绑定”。换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。 10. H5 登录态从未登录变为已登录,或从已登录变为未登录后,必须刷新当前页面一次,确保推荐运行态、作品架、个人缓存和私有 query 都按新身份重新初始化;普通 access token 续期、账号资料更新和同一登录态内的设置变化不得触发整页刷新。 +11. 同一账号允许多端同时在线。新增登录和单设备退出只影响对应 refresh session,不得提升账号级 `tokenVersion` 让其它设备的 access token 失效;只有“退出全部设备”、修改密码、重置密码等明确安全动作才吊销全端 refresh session 并提升 `tokenVersion`。 ## 账户与充值 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 339abb1a..05f4f6d1 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -3844,6 +3844,111 @@ mod tests { assert_eq!(me_response.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn logout_current_device_keeps_other_device_session_alive() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + seed_phone_user_with_password(&state, "13800138031", TEST_PASSWORD).await; + let app = build_router(state); + + let first_login_response = password_login_request_with_client( + app.clone(), + "13800138031", + TEST_PASSWORD, + "logout-current-device", + "203.0.113.41", + ) + .await; + let first_refresh_cookie = first_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("first refresh cookie should exist") + .to_string(); + let first_login_body = first_login_response + .into_body() + .collect() + .await + .expect("first login body should collect") + .to_bytes(); + let first_access_token = read_access_token(&first_login_body); + + let second_login_response = password_login_request_with_client( + app.clone(), + "13800138031", + TEST_PASSWORD, + "logout-other-device", + "203.0.113.42", + ) + .await; + let second_refresh_cookie = second_login_response + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .expect("second refresh cookie should exist") + .to_string(); + let second_login_body = second_login_response + .into_body() + .collect() + .await + .expect("second login body should collect") + .to_bytes(); + let second_access_token = read_access_token(&second_login_body); + + let logout_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/logout") + .header("authorization", format!("Bearer {first_access_token}")) + .header("cookie", first_refresh_cookie) + .body(Body::empty()) + .expect("logout request should build"), + ) + .await + .expect("logout request should succeed"); + assert_eq!(logout_response.status(), StatusCode::OK); + + let first_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {first_access_token}")) + .body(Body::empty()) + .expect("first me request should build"), + ) + .await + .expect("first me request should succeed"); + assert_eq!(first_me_response.status(), StatusCode::UNAUTHORIZED); + + let second_me_response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/me") + .header("authorization", format!("Bearer {second_access_token}")) + .body(Body::empty()) + .expect("second me request should build"), + ) + .await + .expect("second me request should succeed"); + assert_eq!(second_me_response.status(), StatusCode::OK); + + let second_refresh_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/refresh") + .header("cookie", second_refresh_cookie) + .body(Body::empty()) + .expect("second refresh request should build"), + ) + .await + .expect("second refresh request should succeed"); + assert_eq!(second_refresh_response.status(), StatusCode::OK); + } + #[tokio::test] async fn logout_succeeds_without_refresh_cookie_when_bearer_token_is_valid() { let state = AppState::new(AppConfig::default()).expect("state should build"); diff --git a/server-rs/crates/api-server/src/auth.rs b/server-rs/crates/api-server/src/auth.rs index 75626047..93380d4d 100644 --- a/server-rs/crates/api-server/src/auth.rs +++ b/server-rs/crates/api-server/src/auth.rs @@ -135,7 +135,10 @@ pub async fn require_bearer_auth( mut request: Request, next: Next, ) -> Result { - let Some(authenticated) = authenticate_request(&state, &request)? else { + let path = request.uri().path().to_string(); + let headers = request.headers().clone(); + let request_id = request_id_from_request(&request); + let Some(authenticated) = authenticate_request(&state, path, headers, request_id).await? else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; request.extensions_mut().insert(authenticated.clone()); @@ -151,7 +154,11 @@ pub async fn require_runtime_principal_auth( mut request: Request, next: Next, ) -> Result { - let Some(principal) = authenticate_runtime_principal(&state, &request)? else { + let path = request.uri().path().to_string(); + let headers = request.headers().clone(); + let request_id = request_id_from_request(&request); + let Some(principal) = authenticate_runtime_principal(&state, path, headers, request_id).await? + else { return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); }; request.extensions_mut().insert(principal.clone()); @@ -162,24 +169,21 @@ pub async fn require_runtime_principal_auth( Ok(response) } -fn authenticate_runtime_principal( +async fn authenticate_runtime_principal( state: &AppState, - request: &Request, + path: String, + headers: HeaderMap, + request_id: String, ) -> Result, AppError> { - if !request.headers().contains_key(AUTHORIZATION) { + if !headers.contains_key(AUTHORIZATION) { return Ok(None); } - match authenticate_request(state, request) { + match authenticate_request(state, path, headers.clone(), request_id.clone()).await { Ok(Some(authenticated)) => Ok(Some(RuntimePrincipal::User(authenticated))), Ok(None) => Ok(None), Err(_) => { - let bearer_token = extract_bearer_token(request.headers())?; - let request_id = request - .extensions() - .get::() - .map(|context| context.request_id().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + let bearer_token = extract_bearer_token(&headers)?; let claims = verify_runtime_guest_token(&bearer_token, state.auth_jwt_config()) .map_err(|error| { warn!( @@ -202,26 +206,23 @@ fn authenticate_runtime_principal( } } -fn authenticate_request( +async fn authenticate_request( state: &AppState, - request: &Request, + path: String, + headers: HeaderMap, + request_id: String, ) -> Result, AppError> { - if allows_internal_forwarded_auth(request.uri().path()) { - if let Some(claims) = try_build_internal_forwarded_claims(&state, request.headers()) { + if allows_internal_forwarded_auth(&path) { + if let Some(claims) = try_build_internal_forwarded_claims(state, &headers) { return Ok(Some(AuthenticatedAccessToken::new(claims))); } } - if !request.headers().contains_key(AUTHORIZATION) { + if !headers.contains_key(AUTHORIZATION) { return Ok(None); } - let bearer_token = extract_bearer_token(request.headers())?; - let request_id = request - .extensions() - .get::() - .map(|context| context.request_id().to_string()) - .unwrap_or_else(|| "unknown".to_string()); + let bearer_token = extract_bearer_token(&headers)?; let claims = verify_access_token(&bearer_token, state.auth_jwt_config()).map_err(|error| { warn!( %request_id, @@ -230,7 +231,7 @@ fn authenticate_request( ); AppError::from_status(StatusCode::UNAUTHORIZED) })?; - let current_user = state + let mut current_user = state .auth_user_service() .get_user_by_id(claims.user_id()) .map_err(|error| { @@ -240,15 +241,52 @@ fn authenticate_request( "Bearer JWT 用户快照读取失败" ); AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - })? - .ok_or_else(|| { - warn!( - %request_id, - user_id = %claims.user_id(), - "Bearer JWT 对应用户不存在" - ); - AppError::from_status(StatusCode::UNAUTHORIZED) })?; + if current_user.is_none() { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在,准备刷新认证工作集后复查" + ); + if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await { + current_user = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户快照刷新后读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + } + } + let Some(mut current_user) = current_user else { + warn!( + %request_id, + user_id = %claims.user_id(), + "Bearer JWT 对应用户不存在" + ); + return Err(AppError::from_status(StatusCode::UNAUTHORIZED)); + }; + if current_user.token_version != claims.token_version() { + if refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await + && let Some(refreshed_user) = state + .auth_user_service() + .get_user_by_id(claims.user_id()) + .map_err(|error| { + warn!( + %request_id, + error = %error, + "Bearer JWT 用户版本刷新后读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })? + { + current_user = refreshed_user; + } + } if current_user.token_version != claims.token_version() { warn!( %request_id, @@ -261,7 +299,7 @@ fn authenticate_request( .with_message("当前登录态已失效,请重新登录")); } - let session_is_active = state + let mut session_is_active = state .refresh_session_service() .is_session_active_for_user( claims.user_id(), @@ -278,6 +316,27 @@ fn authenticate_request( ); AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) })?; + if !session_is_active + && refresh_auth_store_for_stale_bearer(state, &request_id, claims.user_id()).await + { + session_is_active = state + .refresh_session_service() + .is_session_active_for_user( + claims.user_id(), + claims.session_id(), + OffsetDateTime::now_utc(), + ) + .map_err(|error| { + warn!( + %request_id, + user_id = %claims.user_id(), + session_id = %claims.session_id(), + error = %error, + "Bearer JWT refresh session 刷新后状态读取失败" + ); + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + })?; + } if !session_is_active { warn!( %request_id, @@ -292,6 +351,33 @@ fn authenticate_request( Ok(Some(AuthenticatedAccessToken::new(claims))) } +fn request_id_from_request(request: &Request) -> String { + request + .extensions() + .get::() + .map(|context| context.request_id().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +async fn refresh_auth_store_for_stale_bearer( + state: &AppState, + request_id: &str, + user_id: &str, +) -> bool { + match state.refresh_auth_store_from_spacetime().await { + Ok(refreshed) => refreshed, + Err(error) => { + warn!( + %request_id, + user_id = %user_id, + error = %error, + "刷新认证工作集失败,继续按本进程现有状态处理" + ); + false + } + } +} + pub async fn inspect_auth_claims( Extension(request_context): Extension, Extension(authenticated): Extension, diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 2c45a948..331f2ea7 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -7,6 +7,7 @@ use module_auth::{RefreshSessionError, RotateRefreshSessionInput}; use platform_auth::hash_refresh_session_token; use shared_contracts::auth::RefreshSessionResponse; use time::OffsetDateTime; +use tracing::warn; use crate::{ api_response::json_success_body, @@ -39,16 +40,48 @@ pub async fn refresh_session( let next_refresh_token = platform_auth::create_refresh_session_token(); let next_refresh_token_hash = hash_refresh_session_token(&next_refresh_token); - let rotated = state - .refresh_session_service() - .rotate_session( - RotateRefreshSessionInput { - refresh_token_hash, - next_refresh_token_hash, - }, - OffsetDateTime::now_utc(), - ) - .map_err(|error| map_refresh_error_with_clear_cookie(&state, error))?; + let rotated = match state.refresh_session_service().rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: refresh_token_hash.clone(), + next_refresh_token_hash: next_refresh_token_hash.clone(), + }, + OffsetDateTime::now_utc(), + ) { + Ok(rotated) => rotated, + Err(RefreshSessionError::SessionNotFound) => { + match state.refresh_auth_store_from_spacetime().await { + Ok(true) => {} + Ok(false) => { + return Err(map_refresh_error_with_clear_cookie( + &state, + RefreshSessionError::SessionNotFound, + )); + } + Err(error) => { + warn!( + request_id = request_context.request_id(), + error = %error, + "refresh session 本地未命中后刷新认证工作集失败" + ); + return Err(map_refresh_error_with_clear_cookie( + &state, + RefreshSessionError::SessionNotFound, + )); + } + } + state + .refresh_session_service() + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash, + next_refresh_token_hash, + }, + OffsetDateTime::now_utc(), + ) + .map_err(|error| map_refresh_error_with_clear_cookie(&state, error))? + } + Err(error) => return Err(map_refresh_error_with_clear_cookie(&state, error)), + }; let access_token = sign_access_token_for_user( &state, &rotated.user, diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d4bbb445..51cab967 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -12,7 +12,8 @@ use axum::extract::FromRef; use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, - RefreshSessionService, WechatAuthService, WechatAuthStateService, + RefreshAuthStoreSnapshotResult, RefreshSessionService, WechatAuthService, + WechatAuthStateService, }; use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] @@ -660,6 +661,46 @@ impl AppState { Ok(()) } + pub fn refresh_auth_store_from_snapshot_json( + &self, + snapshot_json: &str, + ) -> Result { + self.auth_store + .refresh_from_snapshot_json(snapshot_json) + .map_err(SpacetimeClientError::Runtime) + } + + pub async fn refresh_auth_store_from_spacetime(&self) -> Result { + #[cfg(test)] + { + return Ok(false); + } + + #[cfg(not(test))] + { + let snapshot = self + .spacetime_client + .export_auth_store_snapshot_from_tables() + .await?; + let Some(snapshot_json) = snapshot + .snapshot_json + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(false); + }; + let result = self.refresh_auth_store_from_snapshot_json(snapshot_json)?; + info!( + user_count = result.user_count, + session_count = result.session_count, + updated_at_micros = snapshot.updated_at_micros, + "已按需刷新本进程认证工作集" + ); + Ok(true) + } + } + pub async fn try_restore_auth_store_from_spacetime( config: AppConfig, ) -> Result { diff --git a/server-rs/crates/module-auth/src/application.rs b/server-rs/crates/module-auth/src/application.rs index 132b9995..c5910211 100644 --- a/server-rs/crates/module-auth/src/application.rs +++ b/server-rs/crates/module-auth/src/application.rs @@ -111,6 +111,12 @@ pub struct LogoutCurrentSessionResult { pub user: AuthUser, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RefreshAuthStoreSnapshotResult { + pub user_count: usize, + pub session_count: usize, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct LogoutAllSessionsResult { pub user: AuthUser, diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index c5195388..b3f4db23 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -863,6 +863,12 @@ impl AuthUserService { input: LogoutCurrentSessionInput, now: OffsetDateTime, ) -> Result { + let user = self + .store + .find_by_user_id(&input.user_id) + .map_err(map_password_error_to_logout_error)? + .ok_or(LogoutError::UserNotFound)? + .user; let revoked_by_hash = if let Some(refresh_token_hash) = input .refresh_token_hash .as_ref() @@ -889,12 +895,6 @@ impl AuthUserService { .map_err(map_refresh_error_to_logout_error)?; } - let user = self - .store - .increment_user_token_version(&input.user_id) - .map_err(map_password_error_to_logout_error)? - .ok_or(LogoutError::UserNotFound)?; - Ok(LogoutCurrentSessionResult { user }) } @@ -989,6 +989,16 @@ impl InMemoryAuthStoreState { } } + fn apply_persistent_state(&mut self, next_state: Self) { + self.next_user_id = next_state.next_user_id; + self.users_by_username = next_state.users_by_username; + self.phone_to_user_id = next_state.phone_to_user_id; + self.sessions_by_id = next_state.sessions_by_id; + self.session_id_by_refresh_token_hash = next_state.session_id_by_refresh_token_hash; + self.wechat_identity_by_provider_uid = next_state.wechat_identity_by_provider_uid; + self.user_id_by_provider_union_id = next_state.user_id_by_provider_union_id; + } + fn to_persistent_snapshot(&self) -> PersistentAuthStoreSnapshot { PersistentAuthStoreSnapshot { next_user_id: self.next_user_id, @@ -1013,6 +1023,26 @@ impl InMemoryAuthStore { }) } + pub fn refresh_from_snapshot_json( + &self, + snapshot_json: &str, + ) -> Result { + let snapshot = serde_json::from_str::(snapshot_json) + .map_err(|error| format!("解析认证快照失败:{error}"))?; + let next_state = InMemoryAuthStoreState::from_persistent_snapshot(snapshot); + let result = RefreshAuthStoreSnapshotResult { + user_count: next_state.users_by_username.len(), + session_count: next_state.sessions_by_id.len(), + }; + let mut state = self + .inner + .lock() + .map_err(|_| "认证仓储锁已中毒".to_string())?; + state.apply_persistent_state(next_state); + + Ok(result) + } + pub fn export_snapshot_json(&self) -> Result { let state = self .inner @@ -2857,6 +2887,68 @@ mod tests { assert_eq!(rotated.user.id, user.id); } + #[tokio::test] + async fn refresh_from_snapshot_json_merges_session_created_by_another_process() { + let source_store = InMemoryAuthStore::default(); + let user = create_phone_login_user(source_store.clone(), "13800138033").await; + let source_refresh_service = build_refresh_service(source_store.clone()); + let source_session = source_refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: hash_refresh_session_token("remote-process-token"), + issued_by_provider: AuthLoginMethod::Password, + client_info: build_client_info(), + }, + OffsetDateTime::now_utc(), + ) + .expect("source session should create"); + let snapshot_json = source_store + .export_snapshot_json() + .expect("source snapshot should export"); + + let local_store = InMemoryAuthStore::default(); + let local_phone_service = build_phone_service(local_store.clone()); + let local_now = OffsetDateTime::now_utc(); + local_phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now, + ) + .await + .expect("local transient phone code should send"); + let refreshed = local_store + .refresh_from_snapshot_json(&snapshot_json) + .expect("local store should refresh"); + + assert_eq!(refreshed.user_count, 1); + assert_eq!(refreshed.session_count, 1); + assert!( + build_refresh_service(local_store) + .is_session_active_for_user( + &user.id, + &source_session.session.session_id, + OffsetDateTime::now_utc() + Duration::minutes(1) + ) + .expect("refreshed session active check should succeed") + ); + assert!(matches!( + local_phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138034".to_string(), + scene: PhoneAuthScene::Login, + }, + local_now + Duration::seconds(5), + ) + .await, + Err(PhoneAuthError::SendCoolingDown { .. }) + )); + } + #[tokio::test] async fn snapshot_json_drops_orphan_phone_index_before_phone_login() { let snapshot = PersistentAuthStoreSnapshot { @@ -3124,12 +3216,13 @@ mod tests { } #[tokio::test] - async fn logout_current_session_revokes_session_and_increments_token_version() { + async fn logout_current_session_revokes_only_current_session_without_token_version_bump() { let store = build_store(); let user = create_phone_login_user(store.clone(), "13800138005").await; let refresh_service = build_refresh_service(store.clone()); let user_service = build_user_service(store); let refresh_token_hash = hash_refresh_session_token("logout-token"); + let other_refresh_token_hash = hash_refresh_session_token("logout-token-other"); refresh_service .create_session( CreateRefreshSessionInput { @@ -3141,6 +3234,21 @@ mod tests { OffsetDateTime::now_utc(), ) .expect("session should create"); + let other_session = refresh_service + .create_session( + CreateRefreshSessionInput { + user_id: user.id.clone(), + refresh_token_hash: other_refresh_token_hash.clone(), + issued_by_provider: AuthLoginMethod::Password, + client_info: RefreshSessionClientInfo { + client_runtime: "firefox".to_string(), + device_display_name: "Windows / Firefox".to_string(), + ..build_client_info() + }, + }, + OffsetDateTime::now_utc() + Duration::seconds(1), + ) + .expect("other session should create"); let result = user_service .logout_current_session( @@ -3153,7 +3261,7 @@ mod tests { ) .expect("logout should succeed"); - assert_eq!(result.user.token_version, 2); + assert_eq!(result.user.token_version, user.token_version); let refresh_error = refresh_service .rotate_session( @@ -3165,6 +3273,25 @@ mod tests { ) .expect_err("revoked session should fail"); assert_eq!(refresh_error, RefreshSessionError::SessionNotFound); + assert!( + refresh_service + .is_session_active_for_user( + &user.id, + &other_session.session.session_id, + OffsetDateTime::now_utc() + Duration::minutes(2) + ) + .expect("other session active check should succeed") + ); + let rotated_other = refresh_service + .rotate_session( + RotateRefreshSessionInput { + refresh_token_hash: other_refresh_token_hash, + next_refresh_token_hash: hash_refresh_session_token("logout-token-other-next"), + }, + OffsetDateTime::now_utc() + Duration::minutes(2), + ) + .expect("other session should still rotate"); + assert_eq!(rotated_other.user.id, user.id); } #[tokio::test] @@ -3286,7 +3413,7 @@ mod tests { ) .expect("logout should succeed"); - assert_eq!(result.user.token_version, user.token_version + 1); + assert_eq!(result.user.token_version, user.token_version); assert!( !refresh_service .is_session_active_for_user( From decded991ed90d04e39e00b5730c2b82e66db41f Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 22:20:58 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除后端未使用的历史 helper、mapper、handler 和 re-export 将仅测试使用的导入、常量和辅助函数收口到 cfg(test) 补齐 Jump Hop 测试构造体字段并对齐 Match3D 当前素材表测试契约 验证后端 workspace cargo check 与 Match3D、Puzzle 相关测试 --- server-rs/crates/api-server/src/admin.rs | 1 + .../crates/api-server/src/bark_battle.rs | 1 + .../crates/api-server/src/custom_world.rs | 2 +- .../api-server/src/custom_world/mappers.rs | 35 -- .../crates/api-server/src/custom_world_ai.rs | 6 +- .../api-server/src/external_api_audit.rs | 3 + .../api-server/src/generated_asset_sheets.rs | 20 +- .../api-server/src/generated_image_assets.rs | 11 +- server-rs/crates/api-server/src/match3d.rs | 22 +- .../api-server/src/match3d/item_assets.rs | 4 +- .../crates/api-server/src/match3d/tests.rs | 84 ++-- .../src/match3d/vector_engine_gemini.rs | 361 ------------------ .../crates/api-server/src/match3d/works.rs | 49 +-- .../api-server/src/openai_image_generation.rs | 21 +- .../api-server/src/prompt/rpg/runtime_chat.rs | 72 ---- server-rs/crates/api-server/src/puzzle.rs | 4 +- .../crates/api-server/src/puzzle/draft.rs | 23 +- .../crates/api-server/src/puzzle/mappers.rs | 43 --- .../crates/api-server/src/puzzle/tests.rs | 8 +- .../api-server/src/puzzle/vector_engine.rs | 75 +--- .../api-server/src/puzzle_gallery_cache.rs | 6 +- server-rs/crates/api-server/src/state.rs | 7 +- server-rs/crates/api-server/src/telemetry.rs | 27 -- server-rs/crates/api-server/src/tracking.rs | 2 + .../src/vector_engine_audio_generation.rs | 2 - .../generation.rs | 88 +---- .../vector_engine_audio_generation/tasks.rs | 34 +- server-rs/crates/api-server/src/wechat_pay.rs | 2 +- .../crates/api-server/src/wooden_fish.rs | 27 -- .../crates/api-server/src/work_author.rs | 1 + .../crates/spacetime-client/src/jump_hop.rs | 2 + server-rs/crates/spacetime-client/src/lib.rs | 18 - .../src/mapper/custom_world.rs | 14 - .../spacetime-module/src/public_work.rs | 12 - .../spacetime-module/src/runtime/profile.rs | 168 -------- 35 files changed, 109 insertions(+), 1146 deletions(-) diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 69c523f3..f46342a7 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -884,6 +884,7 @@ fn extract_sql_statement_columns(statement: &Value) -> Vec { .unwrap_or_default() } +#[cfg(test)] fn build_admin_database_table_row(row: &Value, columns: &[String]) -> AdminDatabaseTableRowPayload { build_admin_database_table_row_for_table("", row, columns) } diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 56cc47e7..392e894d 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -1052,6 +1052,7 @@ fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str resolve_work_author_by_user_id(state, owner_user_id, None, None).display_name } +#[cfg(test)] fn normalize_author_display_name(display_name: Option) -> String { display_name .map(|value| value.trim().to_string()) diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index cb8d50d0..cd6d3240 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -37,7 +37,7 @@ use spacetime_client::{ CustomWorldAgentOperationRecord, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, CustomWorldDraftCardDetailRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord, - CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, + CustomWorldLibraryEntryRecord, CustomWorldProfileLikeReportRecordInput, CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord, CustomWorldResultPreviewBlockerRecord, diff --git a/server-rs/crates/api-server/src/custom_world/mappers.rs b/server-rs/crates/api-server/src/custom_world/mappers.rs index ee10422e..d078003f 100644 --- a/server-rs/crates/api-server/src/custom_world/mappers.rs +++ b/server-rs/crates/api-server/src/custom_world/mappers.rs @@ -114,41 +114,6 @@ pub(super) fn build_custom_world_library_list_profile_payload( }) } -pub(super) fn map_custom_world_gallery_card_response( - state: &AppState, - entry: CustomWorldGalleryEntryRecord, -) -> CustomWorldGalleryCardResponse { - let author = resolve_work_author_by_user_id( - state, - &entry.owner_user_id, - Some(&entry.author_display_name), - Some(&entry.author_public_user_code), - ); - CustomWorldGalleryCardResponse { - owner_user_id: entry.owner_user_id, - profile_id: entry.profile_id, - public_work_code: entry.public_work_code, - author_public_user_code: author - .public_user_code - .unwrap_or(entry.author_public_user_code), - visibility: entry.visibility, - published_at: entry.published_at, - updated_at: entry.updated_at, - author_display_name: author.display_name, - world_name: entry.world_name, - subtitle: entry.subtitle, - summary_text: entry.summary_text, - cover_image_src: entry.cover_image_src, - theme_mode: entry.theme_mode, - playable_npc_count: entry.playable_npc_count, - landmark_count: entry.landmark_count, - play_count: entry.play_count, - remix_count: entry.remix_count, - like_count: entry.like_count, - recent_play_count_7d: entry.recent_play_count_7d, - } -} - pub(super) fn map_public_work_custom_world_gallery_card_response( state: &AppState, entry: spacetime_client::PublicWorkGalleryEntryRecord, diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 932f5099..74b93c70 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -10,9 +10,9 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use image::{ - DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType, -}; +use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType}; +#[cfg(test)] +use image::ImageFormat; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, diff --git a/server-rs/crates/api-server/src/external_api_audit.rs b/server-rs/crates/api-server/src/external_api_audit.rs index 78c0a40b..11a104d5 100644 --- a/server-rs/crates/api-server/src/external_api_audit.rs +++ b/server-rs/crates/api-server/src/external_api_audit.rs @@ -1,3 +1,4 @@ +#[cfg(test)] use axum::http::StatusCode; use module_runtime::RuntimeTrackingScopeKind; use platform_image::PlatformImageFailureAudit; @@ -157,6 +158,7 @@ pub(crate) fn build_external_api_failure_draft_from_platform_image_audit( } /// 中文注释:下载图片、OSS 读写等非标准 HTTP 状态统一显式归类,避免 OTLP 低基数 label 误落到 `transport`。 +#[cfg(test)] pub(crate) fn app_error_status_class(status_code: StatusCode) -> &'static str { status_class(Some(status_code.as_u16())) } @@ -304,6 +306,7 @@ fn build_external_api_failure_metadata(failure: &ExternalApiFailureDraft) -> Val metadata } +#[cfg(test)] pub(crate) fn is_retryable_external_api_failure( status_code: Option, timeout: bool, diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index b5df860e..5c800414 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -9,29 +9,13 @@ use crate::{ #[allow(unused_imports)] pub(crate) use generated_asset_sheets_impl::{ GeneratedAssetSheetAlphaOptions, GeneratedAssetSheetError, GeneratedAssetSheetKeyColor, - GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, - GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, GeneratedAssetSheetUpload, + GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetSliceImage, + GeneratedAssetSheetUpload, apply_generated_asset_sheet_alpha_with_options, apply_generated_asset_sheet_green_screen_alpha, crop_generated_asset_sheet_view_edge_matte, crop_generated_asset_sheet_view_edge_matte_with_options, }; -pub(crate) fn build_generated_asset_sheet_prompt( - input: &GeneratedAssetSheetPromptInput<'_>, -) -> Result { - generated_asset_sheets_impl::build_generated_asset_sheet_prompt(input) - .map_err(map_generated_asset_sheet_error) -} - -pub(crate) fn slice_generated_asset_sheet( - image: &DownloadedOpenAiImage, - item_names: &[String], - grid_size: usize, -) -> Result>, AppError> { - generated_asset_sheets_impl::slice_generated_asset_sheet(image, item_names, grid_size) - .map_err(map_generated_asset_sheet_error) -} - pub(crate) fn slice_generated_asset_sheet_two_items_per_row( image: &DownloadedOpenAiImage, item_names: &[String], diff --git a/server-rs/crates/api-server/src/generated_image_assets.rs b/server-rs/crates/api-server/src/generated_image_assets.rs index 5f4da592..0da9476a 100644 --- a/server-rs/crates/api-server/src/generated_image_assets.rs +++ b/server-rs/crates/api-server/src/generated_image_assets.rs @@ -6,15 +6,8 @@ pub mod helpers { pub use platform_image::generated_assets::helpers::*; } -pub(crate) use adapter::{ - GeneratedImageAssetAdapter, GeneratedImageAssetAdapterBoundary, - GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput, - GeneratedImageAssetPreparedPut, -}; +pub(crate) use adapter::GeneratedImageAssetAdapter; pub(crate) use helpers::{ - GeneratedImageAssetDataUrl, GeneratedImageAssetHelperError, GeneratedImageAssetImageFormat, - GeneratedImageAssetMetadataInput, GeneratedImageAssetStoragePaths, - build_generated_image_asset_metadata, build_generated_image_asset_storage_paths, - decode_generated_image_asset_data_url, merge_generated_image_asset_metadata, + GeneratedImageAssetDataUrl, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 62d73cf7..5f4f2e22 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -74,10 +74,9 @@ use crate::{ generated_asset_sheets::apply_generated_asset_sheet_green_screen_alpha, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, OpenAiGeneratedImages, OpenAiReferenceImage, - build_openai_image_http_client, create_openai_image_edit, - create_openai_image_edit_with_references, create_openai_image_generation, - require_openai_image_settings, + DownloadedOpenAiImage, OpenAiReferenceImage, build_openai_image_http_client, + create_openai_image_edit, create_openai_image_edit_with_references, + create_openai_image_generation, require_openai_image_settings, }, platform_errors::map_oss_error, request_context::RequestContext, @@ -87,7 +86,6 @@ use crate::{ }, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, }; - const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; const MATCH3D_WORKS_PROVIDER: &str = "match3d-works"; const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime"; @@ -101,7 +99,9 @@ const MATCH3D_MATERIAL_ITEM_BATCH_SIZE: usize = 20; const MATCH3D_ITEM_VIEW_COUNT: usize = 5; const MATCH3D_MATERIAL_GRID_SIZE: u32 = 10; const MATCH3D_MAX_GENERATED_ITEM_COUNT: usize = 20; +#[cfg(test)] const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL: &str = "gemini-3-pro-image-preview"; +#[cfg(test)] const MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO: &str = "1:1"; const MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS: u64 = 3 * 60_000; const MATCH3D_OSS_PUT_TIMEOUT_MS: u64 = 3 * 60_000; @@ -509,7 +509,9 @@ use self::runtime::*; mod item_assets; use self::item_assets::*; +#[cfg(test)] mod vector_engine_gemini; +#[cfg(test)] use self::vector_engine_gemini::*; fn ensure_non_empty( @@ -528,6 +530,16 @@ fn ensure_non_empty( Ok(()) } +fn match3d_mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + "image/jpeg" | "image/jpg" => "jpg", + _ => "png", + } +} + fn match3d_json( payload: Result, JsonRejection>, request_context: &RequestContext, diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index f21ecbbf..670d3ca8 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -735,10 +735,9 @@ pub(super) struct Match3DMaterialSheet { pub(super) image: DownloadedOpenAiImage, } +#[cfg(test)] pub(super) struct Match3DVectorEngineGeminiImageSettings { pub(super) base_url: String, - pub(super) api_key: String, - pub(super) request_timeout_ms: u64, } #[cfg(test)] @@ -1482,6 +1481,7 @@ pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgrou .is_some()) } +#[cfg(test)] pub(super) fn build_match3d_material_sheet_prompt( config: &Match3DConfigJson, item_names: &[String], diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 226be1f1..3f753d5a 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1,7 +1,5 @@ use super::*; -use super::*; - fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), @@ -149,17 +147,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { } #[test] -fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { - let width = 500; - let height = 500; +fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() { + let width = 1000; + let height = 1000; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let mut sheet = image::RgbaImage::new(width, height); - for row in 0..5 { - for col in 0..5 { + for row in 0..10 { + for col in 0..10 { let color = image::Rgba([ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, + 24 + row as u8 * 16, + 30 + col as u8 * 14, + 210 - row as u8 * 10, 255, ]); for y in row * 100..(row + 1) * 100 { @@ -182,22 +180,24 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); assert_eq!(slices.len(), 3); - for (row, views) in slices.iter().enumerate() { + for (item_index, views) in slices.iter().enumerate() { assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); - for (col, view) in views.iter().enumerate() { + for (view_index, view) in views.iter().enumerate() { let decoded = image::load_from_memory(view.bytes.as_slice()) .expect("view should decode") .to_rgba8(); let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); + let source_row = item_index / 2; + let source_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT + view_index; assert_eq!( pixel.0, [ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, + 24 + source_row as u8 * 16, + 30 + source_col as u8 * 14, + 210 - source_row as u8 * 10, 255, ], - "row {row} col {col} should be cut from the fixed 5*5 grid row" + "item {item_index} view {view_index} should be cut from the fixed 10*10 grid" ); } } @@ -205,8 +205,8 @@ fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { #[test] fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { - let width = 500; - let height = 500; + let width = 1000; + let height = 1000; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); for y in 1..5 { @@ -689,35 +689,35 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() { } #[test] -fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() { +fn match3d_draft_item_plan_rounds_up_to_full_spritesheet_batch() { let plan = parse_match3d_draft_plan( r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#, &config("水果", 12, 4), ) .expect("draft plan should parse"); - assert_eq!(plan.items.len(), 10); + assert_eq!(plan.items.len(), MATCH3D_MATERIAL_ITEM_BATCH_SIZE); assert_eq!(plan.items[8].name, "蓝莓"); assert_ne!(plan.items[9].name, "蓝莓"); } #[test] -fn match3d_generated_item_count_rounds_up_to_five_multiples() { +fn match3d_generated_item_count_uses_full_spritesheet_batch() { assert_eq!( resolve_match3d_generated_item_count(&config("水果", 8, 2)), - 5 + MATCH3D_MAX_GENERATED_ITEM_COUNT ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 12, 4)), - 10 + MATCH3D_MAX_GENERATED_ITEM_COUNT ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 16, 6)), - 15 + MATCH3D_MAX_GENERATED_ITEM_COUNT ); assert_eq!( resolve_match3d_generated_item_count(&config("水果", 21, 8)), - 25 + MATCH3D_MAX_GENERATED_ITEM_COUNT ); } @@ -733,12 +733,12 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { } #[test] -fn match3d_item_asset_points_cost_counts_five_item_batches() { +fn match3d_item_asset_points_cost_counts_spritesheet_batches() { assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); assert_eq!(calculate_match3d_item_assets_points_cost(1), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(5), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); - assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); + assert_eq!(calculate_match3d_item_assets_points_cost(20), 2); + assert_eq!(calculate_match3d_item_assets_points_cost(21), 4); + assert_eq!(calculate_match3d_item_assets_points_cost(40), 4); } #[test] @@ -777,7 +777,10 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { ); assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); - assert_eq!(plan.padded_item_names.len(), 5); + assert_eq!( + plan.padded_item_names.len(), + MATCH3D_MATERIAL_ITEM_BATCH_SIZE + ); assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); assert_eq!( calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), @@ -900,28 +903,27 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { } #[test] -fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() { +fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_layout() { let prompt = build_match3d_material_sheet_prompt( &config("水果", 12, 4), &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], ); - assert!(prompt.contains("5行*5列")); - assert!(prompt.contains("严格5*5均匀排布")); + assert!(prompt.contains("10行*10列")); + assert!(prompt.contains("素材间距严格均匀分布")); + assert!(prompt.contains("每一行包含两种物品")); + assert!(prompt.contains("每种物品的五个不同形态")); assert!(prompt.contains("绿幕背景")); assert!(prompt.contains("#00FF00")); - assert!(prompt.contains("单个素材格宽度的1/4空白间距")); - assert!(prompt.contains("约25%单格宽度")); - assert!(prompt.contains("禁止主体跨格")); - assert!(prompt.contains("贴边或越界")); + assert!(prompt.contains("严禁出现两种高相似度的物品")); } #[test] -fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { +fn match3d_pixel_retro_style_prompt_hardens_asset_style_and_negative_prompt() { let mut config = config("水果", 12, 4); config.asset_style_id = Some("pixel-retro".to_string()); config.asset_style_label = Some("像素复古".to_string()); - let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); + let prompt = resolve_match3d_asset_style_prompt(&config).expect("style prompt should exist"); let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); assert!(prompt.contains("64x64")); @@ -1004,13 +1006,9 @@ fn match3d_extracts_vector_engine_gemini_inline_image_data() { fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() { let root_settings = Match3DVectorEngineGeminiImageSettings { base_url: "https://api.vectorengine.cn".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, }; let v1_settings = Match3DVectorEngineGeminiImageSettings { base_url: "https://api.vectorengine.cn/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, }; assert_eq!( diff --git a/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs index c3a078a6..e79792e5 100644 --- a/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs +++ b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs @@ -1,165 +1,5 @@ use super::*; -pub(super) async fn generate_match3d_material_sheet( - state: &AppState, - config: &Match3DConfigJson, - item_names: &[String], -) -> Result { - let settings = require_match3d_vector_engine_gemini_image_settings(state)?; - let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?; - let prompt = build_match3d_material_sheet_prompt(config, item_names); - let negative_prompt = build_match3d_material_sheet_negative_prompt(config); - let generated = create_match3d_vector_engine_gemini_image_generation( - &http_client, - &settings, - prompt.as_str(), - negative_prompt.as_str(), - "抓大鹅素材图生成失败", - ) - .await?; - let image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "message": "抓大鹅素材图生成失败:未返回图片", - })) - })?; - - Ok(Match3DMaterialSheet { - task_id: generated.task_id, - prompt, - image_src: None, - image_object_key: None, - image, - }) -} - -fn require_match3d_vector_engine_gemini_image_settings( - state: &AppState, -) -> Result { - let base_url = state - .config - .vector_engine_base_url - .trim() - .trim_end_matches('/'); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "vector-engine-gemini", - "reason": "VECTOR_ENGINE_BASE_URL 未配置", - })), - ); - } - - let api_key = state - .config - .vector_engine_api_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "vector-engine-gemini", - "reason": "VECTOR_ENGINE_API_KEY 未配置", - })) - })?; - - Ok(Match3DVectorEngineGeminiImageSettings { - base_url: base_url.to_string(), - api_key: api_key.to_string(), - request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), - }) -} - -fn build_match3d_vector_engine_gemini_image_http_client( - settings: &Match3DVectorEngineGeminiImageSettings, -) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "vector-engine-gemini", - "message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"), - })) - }) -} - -async fn create_match3d_vector_engine_gemini_image_generation( - http_client: &reqwest::Client, - settings: &Match3DVectorEngineGeminiImageSettings, - prompt: &str, - negative_prompt: &str, - failure_context: &str, -) -> Result { - let request_body = build_match3d_vector_engine_gemini_image_request_body( - prompt, - negative_prompt, - MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, - ); - let response = http_client - .post(build_match3d_vector_engine_gemini_generate_content_url( - settings, - )) - .query(&[("key", settings.api_key.as_str())]) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - .map_err(|error| { - map_match3d_vector_engine_gemini_image_request_error(format!( - "{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}" - )) - })?; - let status = response.status(); - let response_text = response.text().await.map_err(|error| { - map_match3d_vector_engine_gemini_image_request_error(format!( - "{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}" - )) - })?; - if !status.is_success() { - return Err(map_match3d_vector_engine_gemini_image_upstream_error( - status, - response_text.as_str(), - failure_context, - )); - } - - let payload = parse_match3d_json_payload( - response_text.as_str(), - "解析抓大鹅 VectorEngine Gemini 图片生成响应失败", - "vector-engine-gemini", - )?; - let image_urls = extract_match3d_image_urls(&payload); - if !image_urls.is_empty() { - return download_match3d_images_from_urls( - http_client, - format!("vector-engine-gemini-{}", current_utc_micros()), - image_urls, - 1, - "vector-engine-gemini", - ) - .await; - } - - let b64_images = extract_match3d_b64_images(&payload); - if !b64_images.is_empty() { - return Ok(match3d_images_from_base64( - format!("vector-engine-gemini-{}", current_utc_micros()), - b64_images, - 1, - )); - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片", - "rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800), - })), - ) -} - pub(super) fn build_match3d_vector_engine_gemini_image_request_body( prompt: &str, negative_prompt: &str, @@ -201,125 +41,6 @@ fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str format!("{prompt}\n避免:{negative_prompt}") } -async fn download_match3d_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, - provider: &str, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - { - images - .push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?); - } - Ok(OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - }) -} - -async fn download_match3d_remote_image( - http_client: &reqwest::Client, - image_url: &str, - provider: &str, -) -> Result { - let response = http_client.get(image_url).send().await.map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("下载抓大鹅生成图片失败:{error}"), - })) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/png") - .to_string(); - let body = response.bytes().await.map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("读取抓大鹅生成图片内容失败:{error}"), - })) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": "下载抓大鹅生成图片失败", - "status": status.as_u16(), - })), - ); - } - - let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str()); - Ok(DownloadedOpenAiImage { - extension: match3d_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes: body.to_vec(), - }) -} - -fn match3d_images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> OpenAiGeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - .filter_map(|raw| decode_match3d_base64_image(raw.as_str())) - .collect(); - OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - } -} - -fn decode_match3d_base64_image(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string(); - Some(DownloadedOpenAiImage { - extension: match3d_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - -fn parse_match3d_json_payload( - raw_text: &str, - failure_context: &str, - provider: &str, -) -> Result { - serde_json::from_str::(raw_text).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("{failure_context}:{error}"), - "rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800), - })) - }) -} - -fn extract_match3d_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_match3d_strings_by_key(payload, "url", &mut urls); - collect_match3d_strings_by_key(payload, "image", &mut urls); - collect_match3d_strings_by_key(payload, "image_url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec { let mut values = Vec::new(); collect_match3d_strings_by_key(payload, "b64_json", &mut values); @@ -365,12 +86,6 @@ fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec) } } -fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_match3d_strings_by_key(payload, target_key, &mut results); - results.into_iter().next() -} - fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec) { match payload { Value::Array(entries) => { @@ -408,79 +123,3 @@ fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &m _ => {} } } - -fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "message": message, - })) -} - -fn map_match3d_vector_engine_gemini_image_upstream_error( - upstream_status: reqwest::StatusCode, - raw_text: &str, - fallback_message: &str, -) -> AppError { - let message = parse_match3d_api_error_message(raw_text, fallback_message); - let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800); - tracing::warn!( - provider = "vector-engine-gemini", - upstream_status = upstream_status.as_u16(), - message = %message, - raw_excerpt = %raw_excerpt, - "抓大鹅 VectorEngine Gemini 图片生成上游请求失败" - ); - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "upstreamStatus": upstream_status.as_u16(), - "message": message, - "rawExcerpt": raw_excerpt, - })) -} - -fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String { - let trimmed = raw_text.trim(); - if trimmed.is_empty() { - return fallback_message.to_string(); - } - if let Ok(payload) = serde_json::from_str::(trimmed) { - for key in ["message", "code"] { - if let Some(value) = find_first_match3d_string_by_key(&payload, key) { - return if key == "message" { - value - } else { - format!("{fallback_message}({value})") - }; - } - } - } - trimmed.to_string() -} - -fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { - raw_text.chars().take(max_chars).collect() -} - -fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/png"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() - } - _ => "image/png".to_string(), - } -} - -pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - "image/jpeg" | "image/jpg" => "jpg", - _ => "png", - } -} diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index bcdea311..05e5f35a 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -189,54 +189,6 @@ pub(super) fn resolve_author_display_name( .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "玩家".to_string()) } -pub(super) async fn ensure_match3d_background_asset( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - background_prompt: &str, - mut assets: Vec, -) -> Result, Response> { - let normalized_prompt = normalize_match3d_background_prompt(background_prompt); - let resolved_prompt = if normalized_prompt.is_empty() { - build_fallback_match3d_background_prompt(config) - } else { - normalized_prompt - }; - if let Some(existing_background) = find_match3d_generated_background_asset(&assets) { - if is_match3d_background_asset_ready(&existing_background) { - return Ok(assets); - } - } - - let generated_background = generate_match3d_level_asset_bundle( - state, - request_context, - owner_user_id, - session_id, - profile_id, - config, - &resolved_prompt, - ) - .await - .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - attach_match3d_background_asset_to_assets(&mut assets, generated_background); - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - Ok(assets) -} - pub(super) async fn resolve_or_generate_match3d_level_asset_bundle( state: &AppState, request_context: &RequestContext, @@ -769,6 +721,7 @@ pub(super) fn build_match3d_background_from_scene_prompt() -> String { "移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容".to_string() } +#[cfg(test)] pub(super) fn build_match3d_background_generation_prompt( config: &Match3DConfigJson, prompt: &str, diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 406d4ef3..ea026cf6 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -2,9 +2,12 @@ use axum::http::StatusCode; use platform_image::{ DownloadedImage, GeneratedImages, PlatformImageError, PlatformImageStatusHint, ReferenceImage, VECTOR_ENGINE_PROVIDER, VectorEngineImageSettings, build_vector_engine_image_http_client, - build_vector_engine_image_request_body, create_vector_engine_image_edit, - create_vector_engine_image_edit_with_references, create_vector_engine_image_generation, - download_remote_image as download_platform_image_remote_image, vector_engine_images_edit_url, + create_vector_engine_image_edit, create_vector_engine_image_edit_with_references, + create_vector_engine_image_generation, +}; +#[cfg(test)] +use platform_image::{ + build_vector_engine_image_request_body, vector_engine_images_edit_url, vector_engine_images_generation_url, }; use serde_json::{Value, json}; @@ -233,15 +236,7 @@ pub(crate) async fn create_openai_image_edit_with_references( .await } -pub(crate) async fn download_remote_image( - http_client: &reqwest::Client, - image_url: &str, -) -> Result { - download_platform_image_remote_image(http_client, image_url) - .await - .map_err(map_platform_image_error) -} - +#[cfg(test)] pub(crate) fn build_openai_image_request_body( prompt: &str, negative_prompt: Option<&str>, @@ -430,10 +425,12 @@ pub(crate) fn map_platform_image_error(error: PlatformImageError) -> AppError { AppError::from_status(status).with_details(details) } +#[cfg(test)] fn vector_engine_images_generation_url_for_test(settings: &OpenAiImageSettings) -> String { vector_engine_images_generation_url(&settings.provider_settings()) } +#[cfg(test)] fn vector_engine_images_edit_url_for_test(settings: &OpenAiImageSettings) -> String { vector_engine_images_edit_url(&settings.provider_settings()) } diff --git a/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs index f02d87eb..3470122c 100644 --- a/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/runtime_chat.rs @@ -1,16 +1,5 @@ use serde_json::{Value, json}; -#[derive(Clone, Debug)] -pub(crate) struct RuntimeStoryTextPromptParams<'a> { - pub world_type: &'a str, - pub character: Value, - pub monsters: Value, - pub history: Value, - pub choice: Value, - pub context: Value, - pub available_options: Value, -} - #[derive(Clone, Debug)] pub(crate) struct RuntimeNpcDialoguePromptParams<'a> { pub world_type: &'a str, @@ -25,42 +14,6 @@ pub(crate) struct RuntimeNpcDialoguePromptParams<'a> { pub available_options: Vec, } -#[derive(Clone, Debug)] -pub(crate) struct RuntimeReasonedStoryPromptParams<'a> { - pub world_type: &'a str, - pub character: &'a Value, - pub monsters: Vec, - pub history: Vec, - pub context: Value, - pub choice: &'a str, - pub result_summary: &'a str, - pub requested_option: Value, - pub available_options: Vec, -} - -pub(crate) fn runtime_story_director_system_prompt(initial: bool) -> &'static str { - if initial { - "你是游戏运行时剧情导演。请用中文输出一段可直接展示给玩家的开局剧情,不要输出 JSON。" - } else { - "你是游戏运行时剧情导演。请用中文根据玩家选择续写一段剧情,不要输出 JSON。" - } -} - -pub(crate) fn build_runtime_story_director_user_prompt( - params: RuntimeStoryTextPromptParams<'_>, -) -> String { - json!({ - "worldType": params.world_type, - "character": params.character, - "monsters": params.monsters, - "history": params.history, - "choice": params.choice, - "context": params.context, - "availableOptions": params.available_options, - }) - .to_string() -} - pub(crate) fn runtime_npc_dialogue_system_prompt() -> &'static str { "你是游戏运行时 NPC 对话导演。只输出中文正文,不要输出 JSON、Markdown 或规则说明;不要新增系统尚未结算的奖励、任务结果或战斗结果。" } @@ -200,31 +153,6 @@ pub(crate) fn build_npc_recruit_dialogue_user_prompt( ) } -pub(crate) fn runtime_reasoned_story_system_prompt() -> &'static str { - "你是游戏运行时剧情导演。只输出中文剧情正文,不要输出 JSON、Markdown 或规则说明;必须尊重已结算的战斗 outcome、伤害和状态,不要发明额外奖励。" -} - -pub(crate) fn build_runtime_reasoned_story_user_prompt( - params: RuntimeReasonedStoryPromptParams<'_>, -) -> String { - let state_prompt = json!({ - "worldType": params.world_type, - "character": params.character, - "monsters": params.monsters, - "history": params.history, - "context": params.context, - "choice": params.choice, - "resultSummary": params.result_summary, - "requestedOption": params.requested_option, - "availableOptions": params.available_options, - }) - .to_string(); - - format!( - "请基于以下运行时状态,为这一轮战斗结算生成一段 120 字以内的结果叙事,并自然引出下一组选项。\n{state_prompt}" - ) -} - pub(crate) const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT: &str = r#"你是角色扮演 RPG 里的当前 NPC。 你只输出这名 NPC 此刻会对玩家说的一轮回复。 只输出纯中文口语回复正文,不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。 diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index be4b8cb0..dc1be22a 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -23,7 +23,7 @@ use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus}; use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest}; use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use shared_contracts::{ creation_audio::CreationAudioAsset, puzzle_agent::{ @@ -58,7 +58,7 @@ use spacetime_client::{ PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleCreatorIntentRecord, PuzzleDraftCompileFailureRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, - PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, + PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 43bc146d..b7a66dae 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -977,6 +977,7 @@ pub(crate) fn attach_selected_puzzle_candidate_to_levels( } } +#[cfg(test)] pub(crate) fn resolve_puzzle_initial_ui_background_prompt( draft: &PuzzleResultDraftRecord, target_level: &PuzzleDraftLevelRecord, @@ -1042,6 +1043,7 @@ pub(crate) fn build_puzzle_ui_background_generation_prompt( ) } +#[cfg(test)] pub(crate) fn attach_puzzle_level_ui_background( levels: &mut [PuzzleDraftLevelRecord], level_id: &str, @@ -1083,27 +1085,6 @@ pub(crate) fn attach_puzzle_level_asset_bundle( level.ui_background_image_object_key = Some(generated.level_background.object_key); } -pub(crate) async fn generate_puzzle_initial_ui_background_required( - state: &PuzzleApiState, - request_context: &RequestContext, - owner_user_id: &str, - session_id: &str, - draft: &PuzzleResultDraftRecord, - target_level: &PuzzleDraftLevelRecord, -) -> Result<(String, GeneratedPuzzleUiBackgroundResponse), AppError> { - let prompt = resolve_puzzle_initial_ui_background_prompt(draft, target_level); - let generated = generate_puzzle_ui_background_image( - state, - request_context, - owner_user_id, - session_id, - target_level.level_name.as_str(), - prompt.as_str(), - ) - .await?; - Ok((prompt, generated)) -} - pub(crate) async fn generate_puzzle_level_asset_bundle_required( state: &PuzzleApiState, request_context: &RequestContext, diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index 89ae4291..acb6f50d 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -396,49 +396,6 @@ pub(super) fn map_puzzle_work_summary_response( } } -pub(super) fn map_puzzle_gallery_card_response( - state: &PuzzleApiState, - item: PuzzleGalleryCardRecord, -) -> PuzzleWorkSummaryResponse { - let author = resolve_puzzle_work_author_by_user_id( - state, - &item.owner_user_id, - Some(&item.author_display_name), - None, - ); - PuzzleWorkSummaryResponse { - work_id: item.work_id, - profile_id: item.profile_id, - owner_user_id: item.owner_user_id, - source_session_id: item.source_session_id, - author_display_name: author.display_name, - work_title: item.work_title, - work_description: item.work_description, - level_name: item.level_name, - summary: item.summary, - theme_tags: item.theme_tags, - cover_image_src: item.cover_image_src, - cover_asset_id: item.cover_asset_id, - publication_status: item.publication_status, - updated_at: item.updated_at, - published_at: item.published_at, - play_count: item.play_count, - remix_count: item.remix_count, - like_count: item.like_count, - recent_play_count_7d: item.recent_play_count_7d, - point_incentive_total_half_points: item.point_incentive_total_half_points, - point_incentive_claimed_points: item.point_incentive_claimed_points, - point_incentive_total_points: item.point_incentive_total_half_points as f64 / 2.0, - point_incentive_claimable_points: item - .point_incentive_total_half_points - .saturating_div(2) - .saturating_sub(item.point_incentive_claimed_points), - publish_ready: item.publish_ready, - generation_status: item.generation_status, - levels: Vec::new(), - } -} - pub(super) fn map_public_work_puzzle_gallery_card_response( state: &PuzzleApiState, item: spacetime_client::PublicWorkGalleryEntryRecord, diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index b5b902b9..bec54d44 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -44,7 +44,6 @@ fn puzzle_vector_engine_create_request_never_embeds_reference_image() { mime_type: "image/png".to_string(), bytes_len: cursor.get_ref().len(), bytes: cursor.into_inner(), - signed_read_url: None, }; let body = build_puzzle_vector_engine_image_request_body( @@ -197,15 +196,11 @@ fn puzzle_ui_spritesheet_postprocess_turns_green_screen_transparent() { } #[test] -fn puzzle_vector_engine_create_request_never_embeds_signed_reference_url() { +fn puzzle_vector_engine_create_request_never_embeds_reference_payload() { let reference_image = PuzzleResolvedReferenceImage { mime_type: "image/png".to_string(), bytes_len: 4, bytes: b"test".to_vec(), - signed_read_url: Some( - "https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc" - .to_string(), - ), }; let body = build_puzzle_vector_engine_image_request_body( @@ -583,7 +578,6 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() { mime_type: "image/png".to_string(), bytes_len: 8, bytes: b"pngbytes".to_vec(), - signed_read_url: None, }; let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved); diff --git a/server-rs/crates/api-server/src/puzzle/vector_engine.rs b/server-rs/crates/api-server/src/puzzle/vector_engine.rs index 4338966b..285e3105 100644 --- a/server-rs/crates/api-server/src/puzzle/vector_engine.rs +++ b/server-rs/crates/api-server/src/puzzle/vector_engine.rs @@ -45,7 +45,6 @@ pub(crate) struct PuzzleResolvedReferenceImage { pub(crate) mime_type: String, pub(crate) bytes_len: usize, pub(crate) bytes: Vec, - pub(crate) signed_read_url: Option, } pub(crate) struct GeneratedPuzzleImageCandidate { @@ -318,10 +317,10 @@ pub(crate) fn build_puzzle_downloaded_image_reference( mime_type: image.mime_type.clone(), bytes_len: image.bytes.len(), bytes: image.bytes.clone(), - signed_read_url: None, } } +#[cfg(test)] pub(crate) fn build_puzzle_vector_engine_image_request_body( image_model: PuzzleImageModel, prompt: &str, @@ -330,7 +329,7 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body( candidate_count: u32, reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Value { - let body = Map::from_iter([ + let body = serde_json::Map::from_iter([ ( "model".to_string(), Value::String(image_model.request_model_name().to_string()), @@ -415,32 +414,6 @@ pub(crate) fn collect_puzzle_reference_image_sources( sources } -pub(crate) fn collect_legacy_puzzle_reference_image_sources( - legacy_reference_image_src: Option<&str>, - reference_image_srcs: &[String], -) -> Vec { - let mut sources = Vec::new(); - for source in legacy_reference_image_src - .into_iter() - .chain(reference_image_srcs.iter().map(String::as_str)) - { - let normalized = source.trim(); - if normalized.is_empty() { - continue; - } - if !sources - .iter() - .any(|existing: &String| existing == normalized) - { - sources.push(normalized.to_string()); - } - if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT { - break; - } - } - sources -} - pub(crate) fn has_puzzle_reference_images( legacy_reference_image_src: Option<&str>, reference_image_srcs: &[String], @@ -463,6 +436,7 @@ pub(crate) fn should_use_puzzle_reference_image_generation( use_reference_image_generation && has_puzzle_reference_image(reference_image_src) } +#[cfg(test)] pub(crate) fn build_puzzle_vector_engine_prompt(prompt: &str, negative_prompt: &str) -> String { let prompt = prompt.trim(); let negative_prompt = negative_prompt.trim(); @@ -525,7 +499,6 @@ pub(crate) async fn resolve_puzzle_reference_image( mime_type: parsed.mime_type, bytes_len, bytes: parsed.bytes, - signed_read_url: None, }); } @@ -758,7 +731,6 @@ async fn download_signed_puzzle_reference_image( mime_type, bytes_len, bytes: body.to_vec(), - signed_read_url: Some(signed_read_url), }) } @@ -1075,47 +1047,6 @@ pub(crate) fn decode_puzzle_base64(value: &str) -> Option> { Some(output) } -pub(crate) fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_puzzle_strings_by_key(payload, target_key, &mut results); - results.into_iter().next() -} - -pub(crate) fn collect_puzzle_strings_by_key( - payload: &Value, - target_key: &str, - results: &mut Vec, -) { - match payload { - Value::Array(entries) => { - for entry in entries { - collect_puzzle_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, value) in object { - if key == target_key { - collect_puzzle_string_values(value, results); - } - collect_puzzle_strings_by_key(value, target_key, results); - } - } - _ => {} - } -} - -pub(crate) fn collect_puzzle_string_values(payload: &Value, results: &mut Vec) { - match payload { - Value::String(text) => results.push(text.to_string()), - Value::Array(items) => { - for item in items { - collect_puzzle_string_values(item, results); - } - } - _ => {} - } -} - pub(crate) fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') diff --git a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs index adb24caf..4c66badb 100644 --- a/server-rs/crates/api-server/src/puzzle_gallery_cache.rs +++ b/server-rs/crates/api-server/src/puzzle_gallery_cache.rs @@ -10,9 +10,11 @@ use shared_contracts::{ puzzle_works::PuzzleWorkSummaryResponse, }; use tokio::{ - sync::{Mutex, MutexGuard, OwnedMutexGuard, RwLock}, + sync::{Mutex, MutexGuard, RwLock}, time, }; +#[cfg(test)] +use tokio::sync::OwnedMutexGuard; use crate::{api_response::json_success_data_bytes_response, request_context::RequestContext}; @@ -69,6 +71,7 @@ impl PuzzleGalleryCache { }) } + #[cfg(test)] pub async fn read_stale_response(&self) -> Option { let guard = self.inner.read().await; let entry = guard.as_ref()?; @@ -77,6 +80,7 @@ impl PuzzleGalleryCache { }) } + #[cfg(test)] pub fn try_acquire_owned_rebuild_guard(&self) -> Option> { self.rebuild_lock.clone().try_lock_owned().ok() } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 51cab967..e19693a6 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -12,9 +12,10 @@ use axum::extract::FromRef; use module_ai::{AiTaskService, InMemoryAiTaskStore}; use module_auth::{ AuthUserService, InMemoryAuthStore, PasswordEntryService, PhoneAuthService, - RefreshAuthStoreSnapshotResult, RefreshSessionService, WechatAuthService, - WechatAuthStateService, + RefreshSessionService, WechatAuthService, WechatAuthStateService, }; +#[cfg(not(test))] +use module_auth::RefreshAuthStoreSnapshotResult; use module_runtime::RuntimeSnapshotRecord; #[cfg(test)] use module_runtime::{SAVE_SNAPSHOT_VERSION, format_utc_micros}; @@ -316,6 +317,7 @@ pub enum AppStateInitError { } impl AppState { + #[cfg(test)] pub fn new(config: AppConfig) -> Result { Self::new_with_empty_auth_store(config) } @@ -661,6 +663,7 @@ impl AppState { Ok(()) } + #[cfg(not(test))] pub fn refresh_auth_store_from_snapshot_json( &self, snapshot_json: &str, diff --git a/server-rs/crates/api-server/src/telemetry.rs b/server-rs/crates/api-server/src/telemetry.rs index d4a34db4..b9d04b94 100644 --- a/server-rs/crates/api-server/src/telemetry.rs +++ b/server-rs/crates/api-server/src/telemetry.rs @@ -97,22 +97,10 @@ pub(crate) fn record_puzzle_gallery_cache_hit() { puzzle_gallery_cache_metrics().hits.add(1, &[]); } -pub(crate) fn record_puzzle_gallery_cache_stale_hit() { - puzzle_gallery_cache_metrics().stale_hits.add(1, &[]); -} - pub(crate) fn record_puzzle_gallery_cache_miss() { puzzle_gallery_cache_metrics().misses.add(1, &[]); } -pub(crate) fn record_puzzle_gallery_cache_refresh_started() { - puzzle_gallery_cache_metrics().refreshes_started.add(1, &[]); -} - -pub(crate) fn record_puzzle_gallery_cache_refresh_failed() { - puzzle_gallery_cache_metrics().refreshes_failed.add(1, &[]); -} - pub(crate) fn record_puzzle_gallery_cache_rebuild( duration: std::time::Duration, data_bytes: usize, @@ -208,10 +196,7 @@ struct HttpMetrics { struct PuzzleGalleryCacheMetrics { hits: Counter, - stale_hits: Counter, misses: Counter, - refreshes_started: Counter, - refreshes_failed: Counter, rebuilds: Counter, rebuild_duration: opentelemetry::metrics::Histogram, data_json_bytes: opentelemetry::metrics::Histogram, @@ -301,22 +286,10 @@ fn puzzle_gallery_cache_metrics() -> &'static PuzzleGalleryCacheMetrics { .u64_counter("genarrative.puzzle_gallery.cache.hits") .with_description("Puzzle gallery response cache hits") .build(), - stale_hits: meter - .u64_counter("genarrative.puzzle_gallery.cache.stale_hits") - .with_description("Puzzle gallery stale response cache hits") - .build(), misses: meter .u64_counter("genarrative.puzzle_gallery.cache.misses") .with_description("Puzzle gallery response cache misses") .build(), - refreshes_started: meter - .u64_counter("genarrative.puzzle_gallery.cache.refreshes_started") - .with_description("Puzzle gallery background refresh start count") - .build(), - refreshes_failed: meter - .u64_counter("genarrative.puzzle_gallery.cache.refreshes_failed") - .with_description("Puzzle gallery background refresh failure count") - .build(), rebuilds: meter .u64_counter("genarrative.puzzle_gallery.cache.rebuilds") .with_description("Puzzle gallery response cache rebuild count") diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index f878902a..d1f3e7e8 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -1,4 +1,5 @@ use axum::http::{Method, StatusCode}; +#[cfg(not(test))] use module_auth::AuthLoginMethod; use module_runtime::RuntimeTrackingScopeKind; use serde_json::{Value, json}; @@ -553,6 +554,7 @@ fn is_dynamic_path_segment(segment: &str) -> bool { || lower.starts_with("session") } +#[cfg(not(test))] pub async fn record_daily_login_tracking_event_after_success( state: &AppState, request_context: &RequestContext, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs index c0072aef..000f7498 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation.rs @@ -18,7 +18,5 @@ pub use handlers::{ publish_visual_novel_background_music_asset, publish_visual_novel_sound_effect_asset, }; -#[allow(unused_imports)] -pub(crate) use generation::generate_background_music_asset_for_creation; pub(crate) use generation::generate_sound_effect_asset_for_creation; pub(crate) use types::GeneratedCreationAudioTarget; diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs index 7aca5791..63fdd53e 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs @@ -9,7 +9,7 @@ use super::{ clock::{current_utc_iso_text, current_utc_micros}, errors::{map_platform_audio_error, vector_engine_bad_gateway}, publish::wait_for_generated_audio_asset, - tasks::{create_background_music_task_response, create_sound_effect_task_response}, + tasks::create_sound_effect_task_response, types::{AudioAssetBindingTarget, AudioAssetSlot, GeneratedCreationAudioTarget}, }; @@ -86,92 +86,6 @@ pub(crate) async fn generate_sound_effect_asset_for_creation( outcome } -pub(crate) async fn generate_background_music_asset_for_creation( - state: &AppState, - owner_user_id: &str, - prompt: String, - title: String, - tags: Option, - model: Option, - target: GeneratedCreationAudioTarget, -) -> Result { - let started_at_micros = current_utc_micros(); - let normalized_prompt = platform_audio::normalize_limited_text_allow_empty( - &prompt, - "prompt", - platform_audio::SUNO_PROMPT_MAX_CHARS, - ) - .map_err(map_platform_audio_error)?; - let normalized_title = platform_audio::normalize_limited_text( - &title, - "title", - platform_audio::SUNO_TITLE_MAX_CHARS, - ) - .map_err(map_platform_audio_error)?; - let request_payload = json!({ - "kind": "background_music", - "promptChars": normalized_prompt.chars().count(), - "titleChars": normalized_title.chars().count(), - "hasTags": tags.as_ref().is_some_and(|value| !value.trim().is_empty()), - "model": model, - "targetEntityKind": target.entity_kind, - "targetEntityId": target.entity_id, - "targetSlot": target.slot, - "targetAssetKind": target.asset_kind, - }); - let outcome = async { - let task = create_background_music_task_response( - state, - normalized_prompt.clone(), - normalized_title.clone(), - tags, - model, - ) - .await?; - let target = AudioAssetBindingTarget { - storage_scope: target.entity_kind.clone(), - entity_kind: target.entity_kind, - entity_id: target.entity_id, - slot: target.slot, - asset_kind: target.asset_kind, - profile_id: target.profile_id, - storage_prefix: target.storage_prefix, - }; - let generated = wait_for_generated_audio_asset( - state, - owner_user_id, - task.task_id.clone(), - AudioAssetSlot::BackgroundMusic, - target, - ) - .await?; - let audio_src = generated - .audio_src - .ok_or_else(|| vector_engine_bad_gateway("背景音乐生成完成但缺少播放地址"))?; - - Ok::<_, AppError>(creation_audio::CreationAudioAsset { - task_id: generated.task_id, - provider: generated.provider, - asset_object_id: generated.asset_object_id, - asset_kind: generated.asset_kind, - audio_src, - prompt: Some(normalized_prompt), - title: Some(normalized_title), - updated_at: Some(current_utc_iso_text()), - }) - } - .await; - record_creation_audio_generation_run( - state, - "background_music", - request_payload, - started_at_micros, - &outcome, - ) - .await; - outcome -} - async fn record_creation_audio_generation_run( state: &AppState, operation: &'static str, diff --git a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs index 0d52dccd..fec797c0 100644 --- a/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs +++ b/server-rs/crates/api-server/src/vector_engine_audio_generation/tasks.rs @@ -1,42 +1,10 @@ -use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest}; +use platform_audio::SoundEffectTaskRequest; use shared_contracts::creation_audio; use crate::{http_error::AppError, state::AppState}; use super::{errors::map_platform_audio_error, settings::require_vector_engine_audio_settings}; -pub(super) async fn create_background_music_task_response( - state: &AppState, - prompt: String, - title: String, - tags: Option, - model: Option, -) -> Result { - let settings = require_vector_engine_audio_settings(state)?; - let http_client = platform_audio::build_vector_engine_audio_http_client(&settings) - .map_err(map_platform_audio_error)?; - let task = platform_audio::submit_background_music_task( - &http_client, - &settings, - BackgroundMusicTaskRequest { - prompt, - title, - tags, - model, - instrumental: true, - }, - ) - .await - .map_err(map_platform_audio_error)?; - - Ok(creation_audio::AudioGenerationTaskResponse { - kind: creation_audio::CreationAudioGenerationKind::BackgroundMusic, - task_id: task.task_id, - provider: task.provider, - status: task.status, - }) -} - pub(super) async fn create_sound_effect_task_response( state: &AppState, prompt: String, diff --git a/server-rs/crates/api-server/src/wechat_pay.rs b/server-rs/crates/api-server/src/wechat_pay.rs index eba24beb..2b6cffd3 100644 --- a/server-rs/crates/api-server/src/wechat_pay.rs +++ b/server-rs/crates/api-server/src/wechat_pay.rs @@ -287,7 +287,7 @@ pub(crate) struct WechatMiniProgramMessagePushQuery { #[derive(Debug, Deserialize)] struct WechatMiniProgramEncryptedMessage { #[serde(rename = "ToUserName", alias = "to_user_name", default)] - to_user_name: Option, + _to_user_name: Option, #[serde(rename = "Encrypt", alias = "encrypt")] encrypt: String, } diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index 1a30d2cb..a0e60220 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -229,33 +229,6 @@ pub async fn list_wooden_fish_works( )) } -pub async fn delete_wooden_fish_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty(&request_context, &profile_id, "profileId")?; - let works = state - .spacetime_client() - .delete_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - wooden_fish_error_response( - &request_context, - WOODEN_FISH_CREATION_PROVIDER, - map_wooden_fish_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - WoodenFishWorksResponse { - items: works.into_iter().map(|work| work.summary).collect(), - }, - )) -} - pub async fn get_wooden_fish_runtime_work( State(state): State, Path(profile_id): Path, diff --git a/server-rs/crates/api-server/src/work_author.rs b/server-rs/crates/api-server/src/work_author.rs index c758e3ca..572fa555 100644 --- a/server-rs/crates/api-server/src/work_author.rs +++ b/server-rs/crates/api-server/src/work_author.rs @@ -87,6 +87,7 @@ fn orphan_work_author_summary() -> WorkAuthorSummary { } /// 中文注释:运维回填只处理空作者或认证仓储不可再解析的历史 owner_user_id,避免把有效作品误转给占位账号。 +#[cfg(test)] pub fn should_rebind_orphan_work_owner( auth_user_service: &module_auth::AuthUserService, owner_user_id: &str, diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index df224a68..9f1aeef1 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -1177,6 +1177,7 @@ mod tests { tile_atlas_asset: None, tile_assets: None, cover_composite: None, + back_button_asset: None, } } @@ -1273,6 +1274,7 @@ mod tests { tile_assets: Vec::new(), path: None, cover_composite: None, + back_button_asset: None, generation_status: JumpHopGenerationStatus::Draft, } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index bb0c0a13..20361ba8 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -174,24 +174,6 @@ use module_npc::{ NpcStanceProfile as DomainNpcStanceProfile, NpcStateSnapshot as DomainNpcStateSnapshot, ResolveNpcInteractionInput as DomainResolveNpcInteractionInput, }; -use module_puzzle::{ - PuzzleAgentMessageSnapshot as DomainPuzzleAgentMessageSnapshot, - PuzzleAgentSessionSnapshot as DomainPuzzleAgentSessionSnapshot, - PuzzleAgentSuggestedAction as DomainPuzzleAgentSuggestedAction, - PuzzleAnchorItem as DomainPuzzleAnchorItem, PuzzleAnchorPack as DomainPuzzleAnchorPack, - PuzzleBoardSnapshot as DomainPuzzleBoardSnapshot, - PuzzleCellPosition as DomainPuzzleCellPosition, - PuzzleCreatorIntent as DomainPuzzleCreatorIntent, PuzzleDraftLevel as DomainPuzzleDraftLevel, - PuzzleGeneratedImageCandidate as DomainPuzzleGeneratedImageCandidate, - PuzzleMergedGroupState as DomainPuzzleMergedGroupState, - PuzzlePieceState as DomainPuzzlePieceState, PuzzleResultDraft as DomainPuzzleResultDraft, - PuzzleResultPreviewBlocker as DomainPuzzleResultPreviewBlocker, - PuzzleResultPreviewEnvelope as DomainPuzzleResultPreviewEnvelope, - PuzzleResultPreviewFinding as DomainPuzzleResultPreviewFinding, - PuzzleRunSnapshot as DomainPuzzleRunSnapshot, - PuzzleRuntimeLevelSnapshot as DomainPuzzleRuntimeLevelSnapshot, - PuzzleWorkProfile as DomainPuzzleWorkProfile, -}; use module_runtime::{ AnalyticsMetricQueryResponse as DomainAnalyticsMetricQueryResponse, RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeProfileDashboardRecord, diff --git a/server-rs/crates/spacetime-client/src/mapper/custom_world.rs b/server-rs/crates/spacetime-client/src/mapper/custom_world.rs index 6b084df0..6ffc112f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/mapper/custom_world.rs @@ -58,20 +58,6 @@ pub(crate) fn map_custom_world_library_detail_result( }) } -pub(crate) fn map_custom_world_gallery_list_result( - result: CustomWorldGalleryListResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(map_custom_world_gallery_entry_snapshot) - .collect::, _>>()?) -} - pub(crate) fn map_custom_world_library_mutation_result( result: CustomWorldLibraryMutationResult, ) -> Result { diff --git a/server-rs/crates/spacetime-module/src/public_work.rs b/server-rs/crates/spacetime-module/src/public_work.rs index 21dea02e..2ab5a708 100644 --- a/server-rs/crates/spacetime-module/src/public_work.rs +++ b/server-rs/crates/spacetime-module/src/public_work.rs @@ -553,18 +553,6 @@ fn map_match3d_gallery_entry(row: Match3DGalleryViewRow) -> PublicWorkGalleryEnt } } -fn map_match3d_detail_entry(row: Match3DGalleryViewRow) -> PublicWorkDetailEntry { - let detail_payload_json = json_string(json!({ - "sourceType": "match3d", - "themeText": row.theme_text, - "referenceImageSrc": row.reference_image_src, - "clearCount": row.clear_count, - "difficulty": row.difficulty, - "generatedItemAssetsReady": row.generated_item_assets_json.as_ref().is_some_and(|value| !value.trim().is_empty()), - })); - gallery_to_detail(map_match3d_gallery_entry(row), detail_payload_json) -} - fn map_square_hole_gallery_entry(row: SquareHoleGalleryViewRow) -> PublicWorkGalleryEntry { let sort_time_micros = row.published_at_micros.unwrap_or(row.updated_at_micros); diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index d1bbb3c3..73595d0d 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -1912,174 +1912,6 @@ pub(crate) fn build_profile_save_archive_snapshot_from_row( } } -fn read_string_from_json(value: Option<&JsonValue>) -> Option { - value - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn resolve_profile_world_snapshot_meta( - game_state: Option<&serde_json::Map>, -) -> Option { - let game_state = game_state?; - let custom_world_profile = game_state - .get("customWorldProfile") - .and_then(JsonValue::as_object); - - if let Some(custom_world_profile) = custom_world_profile { - let profile_id = read_string_from_json(custom_world_profile.get("id")); - let world_title = read_string_from_json(custom_world_profile.get("name")) - .or_else(|| read_string_from_json(custom_world_profile.get("title"))); - if profile_id.is_some() || world_title.is_some() { - let world_title = world_title.unwrap_or_else(|| "自定义世界".to_string()); - return Some(RuntimeProfileWorldSnapshotMeta { - world_key: profile_id - .as_ref() - .map(|profile_id| format!("custom:{profile_id}")) - .unwrap_or_else(|| format!("custom:{world_title}")), - owner_user_id: None, - profile_id, - world_type: Some("CUSTOM".to_string()), - world_title, - world_subtitle: read_string_from_json(custom_world_profile.get("summary")) - .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) - .unwrap_or_default(), - }); - } - } - - let world_type = read_string_from_json(game_state.get("worldType"))?; - let current_scene_preset = game_state - .get("currentScenePreset") - .and_then(JsonValue::as_object); - - Some(RuntimeProfileWorldSnapshotMeta { - world_key: format!("builtin:{world_type}"), - owner_user_id: None, - profile_id: None, - world_type: Some(world_type.clone()), - world_title: current_scene_preset - .and_then(|preset| read_string_from_json(preset.get("name"))) - .unwrap_or_else(|| build_builtin_world_title(&world_type)), - world_subtitle: current_scene_preset - .and_then(|preset| { - read_string_from_json(preset.get("summary")) - .or_else(|| read_string_from_json(preset.get("description"))) - }) - .unwrap_or_default(), - }) -} - -fn resolve_profile_save_archive_meta( - game_state: &JsonValue, - current_story_json: Option<&str>, -) -> Option { - if is_non_persistent_runtime_snapshot(game_state) { - return None; - } - - let game_state_object = game_state.as_object(); - let world_meta = resolve_profile_world_snapshot_meta(game_state_object)?; - let story_engine_memory = game_state_object - .and_then(|state| state.get("storyEngineMemory")) - .and_then(JsonValue::as_object); - let continue_game_digest = story_engine_memory - .and_then(|memory| read_string_from_json(memory.get("continueGameDigest"))); - let current_story_text = parse_optional_json_str(current_story_json) - .ok() - .flatten() - .and_then(|story| story.as_object().cloned()) - .and_then(|story| read_string_from_json(story.get("text"))); - let custom_world_profile = game_state_object - .and_then(|state| state.get("customWorldProfile")) - .and_then(JsonValue::as_object); - - if let Some(custom_world_profile) = custom_world_profile { - let world_name = read_string_from_json(custom_world_profile.get("name")) - .or_else(|| read_string_from_json(custom_world_profile.get("title"))) - .unwrap_or_else(|| world_meta.world_title.clone()); - let subtitle = read_string_from_json(custom_world_profile.get("summary")) - .or_else(|| read_string_from_json(custom_world_profile.get("settingText"))) - .unwrap_or_else(|| world_meta.world_subtitle.clone()); - let summary_text = continue_game_digest - .or(current_story_text) - .or_else(|| { - if subtitle.is_empty() { - None - } else { - Some(subtitle.clone()) - } - }) - .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); - - return Some(RuntimeProfileSaveArchiveMeta { - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_name, - subtitle, - summary_text, - cover_image_src: read_string_from_json(custom_world_profile.get("coverImageSrc")), - }); - } - - let summary_text = continue_game_digest - .or(current_story_text) - .or_else(|| { - if world_meta.world_subtitle.is_empty() { - None - } else { - Some(world_meta.world_subtitle.clone()) - } - }) - .unwrap_or_else(|| DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT.to_string()); - let current_scene_preset = game_state_object - .and_then(|state| state.get("currentScenePreset")) - .and_then(JsonValue::as_object); - - Some(RuntimeProfileSaveArchiveMeta { - world_key: world_meta.world_key, - owner_user_id: world_meta.owner_user_id, - profile_id: world_meta.profile_id, - world_type: world_meta.world_type, - world_name: world_meta.world_title, - subtitle: world_meta.world_subtitle.clone(), - summary_text, - cover_image_src: current_scene_preset - .and_then(|preset| read_string_from_json(preset.get("imageSrc"))), - }) -} - -fn is_non_persistent_runtime_snapshot(game_state: &JsonValue) -> bool { - let Some(game_state) = game_state.as_object() else { - return false; - }; - - if game_state - .get("runtimePersistenceDisabled") - .and_then(JsonValue::as_bool) - .unwrap_or(false) - { - return true; - } - - matches!( - read_string_from_json(game_state.get("runtimeMode")).as_deref(), - Some("preview") | Some("test") - ) -} - -fn build_builtin_world_title(world_type: &str) -> String { - match world_type { - "WUXIA" => "武侠世界".to_string(), - "XIANXIA" => "仙侠世界".to_string(), - _ => "叙事世界".to_string(), - } -} - fn get_profile_dashboard_snapshot( ctx: &ReducerContext, input: RuntimeProfileDashboardGetInput, From d3a3238028f7d50652cf223993a53fcc68685b43 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 7 Jun 2026 17:19:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90?= =?UTF-8?q?=E5=9B=BE=E7=89=87OSS=E7=AD=BE=E5=90=8D=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端将完整阿里云OSS generated 地址归一为 legacy path 后走 read-url 换签。 platform-oss 为 generated 私有对象 PostObject 和 PutObject 写入 immutable Cache-Control。 补齐 shared-contracts 与 api-server 直传票据字段映射。 更新后端、运维文档和 Hermes 团队记忆,明确不使用服务端磁盘缓存兜底。 --- .hermes/shared-memory/decision-log.md | 8 ++ .hermes/shared-memory/pitfalls.md | 8 ++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- server-rs/crates/api-server/src/assets.rs | 1 + server-rs/crates/platform-oss/README.md | 3 + server-rs/crates/platform-oss/src/lib.rs | 95 ++++++++++++++++++- .../crates/shared-contracts/src/assets.rs | 3 + src/services/assetReadUrlService.test.ts | 42 ++++++++ src/services/assetReadUrlService.ts | 35 +++++++ 10 files changed, 193 insertions(+), 8 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7b88c272..5a12f609 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -48,6 +48,14 @@ - 验证方式:`npm run test -- src/components/jump-hop-runtime/JumpHopRuntimeShell.test.tsx -t "排行榜"`、`npm run test -- src/components/jump-hop-result/JumpHopResultView.test.tsx -t "排行榜"`、`cargo test -p api-server jump_hop_leaderboard_display_name_never_falls_back_to_player_id --manifest-path server-rs/Cargo.toml`。 - 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +## 2026-06-07 generated 图片读取坚持 OSS 源站与签名缓存链路 + +- 背景:生成图片如果以完整 OSS 私有 bucket URL 进入前端,浏览器会裸连 OSS 并遇到 403 或绕过现有 `/api/assets/read-url` 签名缓存;同时旧对象缺少 `Cache-Control` 时只能走 `ETag` / `Last-Modified` 协商缓存,容易被误解为需要 api-server 本地磁盘缓存。 +- 决策:OSS 继续作为 generated 私有资产源站,api-server 只签发短期读 URL,不做本地磁盘静态资源兜底。前端收到同 bucket 的 `https://*.oss-*.aliyuncs.com/generated-*` 地址时,必须先归一为 legacy public path,再复用 `/api/assets/read-url` 和本地 signed URL 缓存。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`,缓存职责交给 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN。 +- 影响范围:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss`、`shared-contracts` direct upload form fields、`api-server` assets DTO 映射、后端契约文档和开发运维排障口径。 +- 验证方式:完整 OSS generated URL 应触发 `/api/assets/read-url?legacyPublicPath=...`,同一路径在签名有效期内复用本地 signed URL;`platform-oss` 的 `PostObject` policy / form fields 和 `PutObject` 请求头都应包含 immutable `Cache-Control`,且 `PutObject` V4 签名的 `AdditionalHeaders` 包含该普通请求头。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`server-rs/crates/platform-oss/README.md`。 + ## 2026-06-06 小程序微信绑定展示使用原生昵称组件 - 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 93b02f2c..9b66a037 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -15,6 +15,14 @@ - 关联:相关文件、文档、提交或 Issue ``` +## generated 图片重复下载不要改成服务端本地磁盘缓存 + +- 现象:同一张 OSS generated 图片每次展示都重新从 OSS 拉取,或者完整 OSS 私有 URL 裸请求返回 403。 +- 原因:前端输入如果是 `https://*.oss-*.aliyuncs.com/generated-*`,会被当普通绝对 URL 直连,绕过 `/api/assets/read-url` 和 signed URL 本地缓存;旧 OSS 对象如果缺少 `Cache-Control`,浏览器只能依赖 `ETag` / `Last-Modified` 做 304 协商缓存,不会长期强缓存。 +- 处理:完整 OSS generated URL 先归一成 `/generated-*` legacy public path,再走 `/api/assets/read-url` 换签;新上传 generated 私有对象由 `platform-oss` 在 `PostObject` form fields / policy 和服务端 `PutObject` 请求头中写入 `Cache-Control: public, max-age=31536000, immutable`。不要把 api-server 变成图片静态代理,也不要把 OSS 内容 fallback 到服务器磁盘。 +- 验证:前端测试应看到完整 OSS generated URL 调用 `/api/assets/read-url?legacyPublicPath=...`;`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml` 应覆盖 `Cache-Control` policy、form field、PutObject headers 和 V4 `AdditionalHeaders`;线上旧对象可用 `curl -I` 观察是否只有 `ETag` / `Last-Modified` 或已经补齐 `Cache-Control`。 +- 关联:`src/services/assetReadUrlService.ts`、`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/platform-oss/README.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 小程序 H5 导航不能清掉宿主 query - 现象:微信小程序首次进入 H5 后,点击需要登录的入口没有返回小程序原生授权页,而是弹出 Web 端登录窗口;充值渠道也可能被误判为普通网页环境。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index e031ab73..7fb30be8 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -130,7 +130,7 @@ npm run check:server-rs-ddd 3. Adapter 输出应保留 legacy public path、object key、asset object id、MIME、extension、task id 和实际 prompt。 4. Adapter 不负责扣费、退款或钱包读取;计费仍由调用方显式包裹。 5. 图片 provider 协议不再放在玩法模块里实现。VectorEngine `gpt-image-2` 创建 / 编辑协议、URL / base64 图片解析、远端图片下载、请求超时 / 上游状态 / 响应解析 / 缺图 / 下载失败的结构化日志统一在 `server-rs/crates/platform-image/src/vector_engine/`;其中 `client.rs` 只保留 provider 调用编排,`transport.rs` 负责 HTTP client 与 reqwest 错误归一,`request.rs` 负责请求体和路径,`payload.rs` 负责响应 JSON 字段提取,`response.rs` 负责响应状态分流和图片结果归一。`api-server` 只负责配置校验、玩法 prompt 编排、OSS / asset object / binding 持久化、计费和外部 API 失败审计落库。 -6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 +6. OSS 平台适配日志统一在 `server-rs/crates/platform-oss` 输出,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。日志字段固定使用 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms`,只记录对象定位和排障信息;不得输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。generated 私有对象上传时必须由 OSS 对象头承载浏览器 / CDN 缓存策略,默认写入 `Cache-Control: public, max-age=31536000, immutable`,不得改成 api-server 本地磁盘静态资源兜底。 7. Puzzle、Match3D、音频、GLB、视频等复杂媒体可以复用 OSS + asset object + binding 的底层持久化能力,但玩法专属处理规则留在各自编排层,不塞进公共接口。 8. 拼图入口页与结果页新增关卡的本地参考图不走浏览器直传 OSS,前端读取为 Data URL 后随创作 action 提交,并在读取前限制 6MB、显示“图片≤6MB”。`api-server` 必须对 Data URL 实际字节数再次校验;历史图片才提交 `referenceImageAssetObjectId(s)`,后端校验 `asset_object` 的 bucket、kind、图片 MIME、大小和 owner 后签发只读 URL 给 VectorEngine 读取。 9. 系列素材图集实现真相源在 `server-rs/crates/platform-image/src/generated_asset_sheets/`:调用方必须传入 `grid_size` 作为 `n*n` 的 `n`,可选传入物品名称 prompt 模板和特殊设定 prompt;模块负责 sheet prompt 组装、按 `n*n` 切片、透明化、PNG 输出、OSS private upload 请求构造和 sheet / item / special prompt 元数据持久化。`server-rs/crates/api-server/src/generated_asset_sheets.rs` 只保留 `AppState` / `AppError` 适配和兼容导出。玩法只负责规划 slot、调用具体生图 provider、计费、失败回写,以及把通用切片结果映射回自己的 DTO / 草稿 / runtime 字段。 @@ -177,7 +177,7 @@ npm run check:server-rs-ddd - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 -- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。 +- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c0ab1715..47be1e71 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -302,7 +302,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日 - api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。 - HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。 - 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。 -- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。 +- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。 - SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。 - 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。 - Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。 diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 1c709b96..9a759b6b 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -208,6 +208,7 @@ fn direct_upload_ticket_form_fields_from_oss( signature: value.signature, success_action_status: value.success_action_status, content_type: value.content_type, + cache_control: value.cache_control, metadata: value.metadata, } } diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index 84d38def..17dd5256 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -23,6 +23,7 @@ 6. `x-oss-meta-*` 元数据归一化与大小限制校验 7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成 8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志 +9. generated 私有对象上传默认写入 `Cache-Control: public, max-age=31536000, immutable` 当前仍未落地的内容: @@ -38,6 +39,8 @@ 4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。 5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。 6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 +7. 完整 OSS URL 不能当作 object key 传入签名接口;前端收到 `https://*.oss-*.aliyuncs.com/generated-*` 时应先归一为 legacy public path,再通过 `/api/assets/read-url` 换取短期 signed URL。 +8. generated 资源缓存的主路径是 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN,不允许改成 api-server 本地磁盘静态资源兜底。 ## 3. 边界约束 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 31d48a1b..fe91fe45 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -16,6 +16,7 @@ pub const DEFAULT_READ_EXPIRE_SECONDS: u64 = 10 * 60; pub const DEFAULT_POST_MAX_SIZE_BYTES: u64 = 20 * 1024 * 1024; pub const DEFAULT_SUCCESS_ACTION_STATUS: u16 = 200; pub const DEFAULT_METADATA_TOTAL_BYTES_LIMIT: usize = 8 * 1024; +pub const DEFAULT_IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable"; const OSS_V4_ALGORITHM: &str = "OSS4-HMAC-SHA256"; const OSS_V4_REQUEST: &str = "aliyun_v4_request"; const OSS_V4_SERVICE: &str = "oss"; @@ -199,6 +200,8 @@ pub struct OssPostObjectFormFields { pub success_action_status: String, #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")] pub content_type: Option, + #[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")] + pub cache_control: Option, #[serde(flatten)] pub metadata: BTreeMap, } @@ -425,6 +428,7 @@ impl OssClient { let legacy_public_path = format!("/{}", object_key); let content_type = normalize_optional_value(request.content_type); let metadata = normalize_metadata(request.metadata)?; + let cache_control = Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string()); let expires_at = OffsetDateTime::now_utc() .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( @@ -448,6 +452,7 @@ impl OssClient { max_size_bytes, success_action_status, content_type.as_deref(), + cache_control.as_deref(), &metadata, &credential, &signature_date, @@ -485,6 +490,7 @@ impl OssClient { signature, success_action_status: success_action_status.to_string(), content_type, + cache_control, metadata, }, }) @@ -788,7 +794,7 @@ impl OssClient { let file_name = sanitize_file_name(&request.file_name)?; let object_key = build_object_key(request.prefix, &sanitized_segments, &file_name); let content_type = normalize_optional_value(request.content_type); - let metadata = normalize_metadata(request.metadata)?; + let headers = build_put_object_headers(request.metadata)?; let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key).map_err( |error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")), @@ -802,7 +808,7 @@ impl OssClient { Some(&object_key), target_url, content_type.as_deref(), - &metadata, + &headers, )? .header(reqwest::header::CONTENT_LENGTH, content_length) .body(request.body); @@ -957,6 +963,7 @@ fn build_policy_json( max_size_bytes: u64, success_action_status: u16, content_type: Option<&str>, + cache_control: Option<&str>, metadata: &BTreeMap, credential: &str, signature_date: &str, @@ -979,6 +986,10 @@ fn build_policy_json( conditions.push(json!(["eq", "$content-type", content_type])); } + if let Some(cache_control) = cache_control { + conditions.push(json!(["eq", "$Cache-Control", cache_control])); + } + for (key, value) in metadata { conditions.push(json!(["eq", format!("${key}"), value])); } @@ -1089,6 +1100,18 @@ fn normalize_metadata( Ok(normalized) } +fn build_put_object_headers( + metadata: BTreeMap, +) -> Result, OssError> { + // 中文注释:生成资产 object key 含会话与 asset id,内容不可变,适合交给浏览器/CDN 长缓存。 + let mut headers = BTreeMap::from([( + "Cache-Control".to_string(), + DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(), + )]); + headers.extend(normalize_metadata(metadata)?); + Ok(headers) +} + fn normalize_metadata_key(raw: &str) -> String { let stripped = raw .trim() @@ -1283,13 +1306,13 @@ fn signed_request_builder( } let canonical_headers = build_v4_canonical_headers(&signed_headers); - let additional_headers = "host"; + let additional_headers = build_v4_additional_headers(&signed_headers); let canonical_request = build_v4_canonical_request( method.as_str(), &canonical_uri, "", &canonical_headers, - additional_headers, + &additional_headers, &body_sha256, ); let string_to_sign = @@ -1468,6 +1491,16 @@ fn build_v4_canonical_headers(headers: &BTreeMap) -> String { .collect::() } +fn build_v4_additional_headers(headers: &BTreeMap) -> String { + let mut additional_headers = headers + .keys() + .map(|key| key.to_ascii_lowercase()) + .filter(|key| key != "content-type" && key != "content-md5" && !key.starts_with("x-oss-")) + .collect::>(); + additional_headers.sort(); + additional_headers.join(";") +} + fn build_canonical_query_string(params: &BTreeMap) -> String { params .iter() @@ -1713,6 +1746,14 @@ mod tests { policy["conditions"][7], json!(["eq", "$content-type", "image/png"]) ); + assert_eq!( + policy["conditions"][8], + json!(["eq", "$Cache-Control", DEFAULT_IMMUTABLE_CACHE_CONTROL]) + ); + assert_eq!( + response.form_fields.cache_control, + Some(DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string()) + ); assert_eq!(response.bucket, "genarrative-assets".to_string()); } @@ -1870,6 +1911,10 @@ mod tests { #[test] fn canonicalized_oss_headers_matches_sorted_v4_header_shape() { let headers = BTreeMap::from([ + ( + "Cache-Control".to_string(), + DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(), + ), ( "x-oss-meta-source-job-id".to_string(), " job_001 ".to_string(), @@ -1882,7 +1927,47 @@ mod tests { assert_eq!( build_v4_canonical_headers(&headers), - "x-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n" + "cache-control:public, max-age=31536000, immutable\nx-oss-meta-asset-kind:character_visual\nx-oss-meta-source-job-id:job_001\n" + ); + } + + #[test] + fn additional_headers_include_plain_headers_and_skip_oss_managed_headers() { + let headers = BTreeMap::from([ + ( + "host".to_string(), + "genarrative-assets.oss-cn-beijing.aliyuncs.com".to_string(), + ), + ("content-type".to_string(), "image/png".to_string()), + ( + "Cache-Control".to_string(), + DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string(), + ), + ("x-oss-date".to_string(), "20260507T120000Z".to_string()), + ( + "x-oss-meta-asset-kind".to_string(), + "puzzle-cover".to_string(), + ), + ]); + + assert_eq!(build_v4_additional_headers(&headers), "cache-control;host"); + } + + #[test] + fn put_object_headers_include_immutable_cache_control_for_generated_assets() { + let headers = build_put_object_headers(BTreeMap::from([( + "asset-kind".to_string(), + "puzzle-cover".to_string(), + )])) + .expect("headers should build"); + + assert_eq!( + headers.get("Cache-Control"), + Some(&DEFAULT_IMMUTABLE_CACHE_CONTROL.to_string()) + ); + assert_eq!( + headers.get("x-oss-meta-asset-kind"), + Some(&"puzzle-cover".to_string()) ); } diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index 3f9eb35f..e7c1e31b 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -541,6 +541,8 @@ pub struct DirectUploadTicketFormFields { pub success_action_status: String, #[serde(rename = "Content-Type", skip_serializing_if = "Option::is_none")] pub content_type: Option, + #[serde(rename = "Cache-Control", skip_serializing_if = "Option::is_none")] + pub cache_control: Option, #[serde(flatten)] pub metadata: BTreeMap, } @@ -684,6 +686,7 @@ mod tests { signature: "sig".to_string(), success_action_status: "200".to_string(), content_type: Some("image/png".to_string()), + cache_control: Some("public, max-age=31536000, immutable".to_string()), metadata: BTreeMap::from([( "x-oss-meta-asset-kind".to_string(), "character_visual".to_string(), diff --git a/src/services/assetReadUrlService.test.ts b/src/services/assetReadUrlService.test.ts index 8569a1d5..437df4c0 100644 --- a/src/services/assetReadUrlService.test.ts +++ b/src/services/assetReadUrlService.test.ts @@ -140,6 +140,48 @@ describe('assetReadUrlService', () => { ); }); + test('resolveAssetReadUrl exchanges generated Aliyun OSS url for signed url', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + ok: true, + data: { + read: { + objectKey: + 'generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', + signedUrl: 'https://signed.example.com/puzzle.png', + expiresAt: '2099-01-01T00:10:00Z', + }, + }, + error: null, + meta: { + apiVersion: '2026-04-08', + routeVersion: '2026-04-08', + latencyMs: 1, + timestamp: '2099-01-01T00:00:00Z', + }, + }), + { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }, + ), + ); + + await expect( + resolveAssetReadUrl( + 'https://genarrative-release.oss-cn-beijing.aliyuncs.com/generated-puzzle-assets/puzzle-session-1/candidate-1/asset-1/image.png', + ), + ).resolves.toBe('https://signed.example.com/puzzle.png'); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(String(vi.mocked(globalThis.fetch).mock.calls[0]?.[0])).toContain( + 'legacyPublicPath=%2Fgenerated-puzzle-assets%2Fpuzzle-session-1%2Fcandidate-1%2Fasset-1%2Fimage.png', + ); + }); + test('resolveAssetReadUrl does not append cache busting query to OSS signed url', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response( diff --git a/src/services/assetReadUrlService.ts b/src/services/assetReadUrlService.ts index 49812b89..5c0c7e06 100644 --- a/src/services/assetReadUrlService.ts +++ b/src/services/assetReadUrlService.ts @@ -67,6 +67,26 @@ export function isGeneratedLegacyPath(value: string) { return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); } +function isAliyunOssHost(hostname: string) { + return /^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(hostname.trim()); +} + +function resolveGeneratedLegacyPathFromUrl(value: string) { + try { + const parsedUrl = new URL( + value, + globalThis.location?.origin ?? 'http://localhost', + ); + if (!isAliyunOssHost(parsedUrl.hostname)) { + return ''; + } + const legacyPath = decodeURIComponent(parsedUrl.pathname); + return isGeneratedLegacyPath(legacyPath) ? legacyPath : ''; + } catch { + return ''; + } +} + function normalizeLegacyPublicPath(value: string) { return `/${value.trim().replace(/^\/+/u, '')}`; } @@ -284,6 +304,21 @@ export async function resolveAssetReadUrl( value.startsWith('data:') || value.startsWith('blob:') ) { + const legacyPath = resolveGeneratedLegacyPathFromUrl(value); + if (legacyPath) { + const signedUrl = await getSignedAssetReadUrl( + { + legacyPublicPath: legacyPath, + expireSeconds: options.expireSeconds, + }, + options.signal, + { + bypassCache: + options.refreshKey !== null && options.refreshKey !== undefined, + }, + ); + return signedUrl; + } return appendCacheBustParam(value, options.refreshKey); }