diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md b/apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md new file mode 100644 index 000000000..b7369c0f5 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/prompts/automatic-project-name.md @@ -0,0 +1 @@ +你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 248cb1348..03597027a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -13,6 +13,70 @@ const UI_EDITOR_IMAGE_MAX_COUNT: usize = 100; // imageSrc 与签名地址只在本文件的客户端下载阶段存在,绝不进入 Agent observation。 const AGENT_EDITOR_ASSET_LIBRARY_MAX_ITEMS: usize = 500; const AGENT_EDITOR_ASSET_ID_MAX_CHARS: usize = 512; +const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000; +const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64; +const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str = + include_str!("../prompts/automatic-project-name.md"); + +fn is_chinese_project_name_character(value: char) -> bool { + matches!( + value, + '\u{3400}'..='\u{4DBF}' + | '\u{4E00}'..='\u{9FFF}' + | '\u{F900}'..='\u{FAFF}' + | '\u{20000}'..='\u{2FA1F}' + | '\u{30000}'..='\u{323AF}' + ) +} + +pub(crate) fn normalize_suggested_project_name(value: &str) -> Option { + let value = value.trim(); + let character_count = value.chars().count(); + if !(2..=16).contains(&character_count) || !value.chars().all(is_chinese_project_name_character) + { + return None; + } + normalize_game_creation_project_name(value).ok() +} + +pub(crate) fn build_automatic_project_name_prompt(prompt: &str) -> Result { + let prompt = prompt.trim(); + if prompt.is_empty() { + return Err("首页创作需求为空,不能提炼项目名称".to_string()); + } + Ok(prompt + .chars() + .take(AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS) + .collect()) +} + +async fn request_automatic_project_name(prompt: &str) -> Result, String> { + let user_prompt = build_automatic_project_name_prompt(prompt)?; + let app_config = load_game_creator_app_config()?; + if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER { + let reply = crate::agent::direct_game_creator_home_codex_chat( + AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim().to_string(), + user_prompt, + ) + .await?; + return Ok(normalize_suggested_project_name(reply.trim())); + } + let mut llm = app_config.llm.clone(); + llm.max_retries = 0; + let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?; + let request = + LlmRunRequest::single_turn(AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT.trim(), user_prompt) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_model(llm.model.clone()) + .with_request_timeout_ms(llm.request_timeout_ms) + .with_max_output_tokens(AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS) + .with_web_search(false); + let response = client + .run(request) + .await + .map_err(|error| format!("自动项目命名失败:{error}"))?; + Ok(normalize_suggested_project_name(response.text.trim())) +} #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -284,7 +348,11 @@ fn automatic_local_game_projects_root(app: &tauri::AppHandle) -> Result, ) -> Result { + let requested_name = requested_name + .map(normalize_game_creation_project_name) + .transpose()?; if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() { return Err("自动工作区根目录必须是绝对路径".to_string()); } @@ -303,7 +371,9 @@ pub(crate) fn create_automatic_local_game_project_at( for _ in 0..16 { let workspace_id = uuid::Uuid::new_v4().simple().to_string(); let short_id = &workspace_id[..8]; - let project_name = format!("GameAgent 项目 {short_id}"); + let project_name = requested_name + .clone() + .unwrap_or_else(|| format!("GameAgent 项目 {short_id}")); let project_root = projects_root.join(format!("gameagent-{short_id}")); match fs::create_dir(&project_root) { Ok(()) => { @@ -341,8 +411,12 @@ pub(crate) fn create_automatic_local_game_project_at( #[tauri::command] pub(crate) fn create_automatic_local_game_project( app: tauri::AppHandle, + name: Option, ) -> Result { - create_automatic_local_game_project_at(&automatic_local_game_projects_root(&app)?) + create_automatic_local_game_project_at( + &automatic_local_game_projects_root(&app)?, + name.as_deref(), + ) } #[tauri::command] @@ -357,6 +431,40 @@ pub(crate) fn init_local_game_project( init_local_game_project_at(root, project_id.trim(), name.trim()) } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RenameLocalProjectResult { + pub(crate) manifest: GameCreationAppManifest, + pub(crate) revision: u64, +} + +#[tauri::command] +pub(crate) fn rename_local_game_project( + project_path: String, + name: String, +) -> Result { + let next_name = normalize_game_creation_project_name(name.trim())?; + let root = validated_local_project_directory_path(project_path.trim())?; + enforce_project_permission_policy(&root, "project.rename")?; + let _lock = acquire_project_write_lock(&root, "project.rename")?; + let current = read_existing_manifest_for_project(&root)?; + if current.name == next_name { + return Ok(RenameLocalProjectResult { + manifest: current, + revision: read_game_creator_agent_runtime_project_revision(&root)?.revision, + }); + } + + let manifest = mutate_manifest_at(&root, |manifest| { + manifest.name = next_name; + Ok(manifest.clone()) + })?; + Ok(RenameLocalProjectResult { + manifest, + revision: read_game_creator_agent_runtime_project_revision(&root)?.revision, + }) +} + #[tauri::command] pub(crate) fn import_local_godot_project( project_path: String, @@ -589,6 +697,9 @@ pub(crate) fn validated_local_project_directory_path( if !path.is_absolute() { return Err("项目目录必须是绝对路径".to_string()); } + if project_path_has_control_chars(path) { + return Err("项目目录不能包含控制字符".to_string()); + } if !path.exists() { return Err("项目目录不存在".to_string()); } @@ -1842,6 +1953,15 @@ pub(crate) async fn check_game_creator_llm_config() -> Result Result, String> { + request_automatic_project_name(prompt.trim()) + .await + .map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320)) +} + #[tauri::command] pub(crate) fn read_platform_account_session_generation() -> u64 { current_platform_session_generation() diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 12a7e65a7..838542574 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2487,6 +2487,8 @@ fn main() { is_local_project_directory_non_empty, inspect_local_project_directory, pick_local_project_directory, + rename_local_game_project, + suggest_automatic_project_name, pick_local_file, pick_client_extension_file, pick_client_extension_directory, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 8b2efaf66..6059702a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -7,6 +7,24 @@ const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10; static MANIFEST_LOCK_OPEN_GUARD: OnceLock> = OnceLock::new(); +pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80; + +pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result { + let name = value.trim(); + if name.is_empty() { + return Err("项目名称不能为空".to_string()); + } + if name.chars().any(char::is_control) { + return Err("项目名称不能包含控制字符".to_string()); + } + if name.chars().count() > GAME_CREATION_PROJECT_NAME_MAX_CHARS { + return Err(format!( + "项目名称最多支持 {GAME_CREATION_PROJECT_NAME_MAX_CHARS} 个字符" + )); + } + Ok(name.to_string()) +} + #[cfg(windows)] fn godot_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { use std::os::windows::fs::MetadataExt; @@ -424,9 +442,7 @@ pub(crate) fn init_local_game_project_at( if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } - if name.is_empty() { - return Err("项目名称不能为空".to_string()); - } + let name = normalize_game_creation_project_name(name)?; prepare_game_creator_project_root_for_read(root, true, "本地项目目录")?; for relative in ["game", "assets", "memory", "memory/agents", "exports"] { @@ -506,9 +522,7 @@ pub(crate) fn import_local_godot_project_at( if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } - if name.is_empty() { - return Err("项目名称不能为空".to_string()); - } + let name = normalize_game_creation_project_name(name)?; let manifest_path = root.join(".agent/manifest.json"); if manifest_storage_exists(&manifest_path)? { diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index 33759f02d..54f8eae14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1499,12 +1499,14 @@ fn project_picker_uses_the_closest_existing_parent_for_a_suggested_new_path() { fn automatic_local_game_project_allocates_unique_initialized_workspaces() { let projects_root = unique_project_path(); - let first = create_automatic_local_game_project_at(&projects_root) + let first = create_automatic_local_game_project_at(&projects_root, None) .expect("create first automatic workspace"); - let second = create_automatic_local_game_project_at(&projects_root) + let second = create_automatic_local_game_project_at(&projects_root, Some("自定义项目名")) .expect("create second automatic workspace"); assert_ne!(first.project_path, second.project_path); + assert!(first.manifest.name.starts_with("GameAgent 项目 ")); + assert_eq!(second.manifest.name, "自定义项目名"); for result in [first, second] { let root = PathBuf::from(&result.project_path); assert_eq!(root.parent(), Some(projects_root.as_path())); @@ -1514,12 +1516,156 @@ fn automatic_local_game_project_allocates_unique_initialized_workspaces() { .is_some_and(|name| name.starts_with("gameagent-"))); assert!(root.join(".agent/manifest.json").is_file()); assert!(root.join("game/index.html").is_file()); - assert!(result.manifest.name.starts_with("GameAgent 项目 ")); } fs::remove_dir_all(projects_root).ok(); } +#[test] +fn automatic_local_game_project_accepts_only_a_safe_custom_name() { + let projects_root = unique_project_path(); + + let result = create_automatic_local_game_project_at(&projects_root, Some(" 星轨夜航 ")) + .expect("create named automatic workspace"); + assert_eq!(result.manifest.name, "星轨夜航"); + + for name in [ + " \n\t", + "名称\n包含控制字符", + &"长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1), + ] { + assert!( + create_automatic_local_game_project_at(&projects_root, Some(name)).is_err(), + "unsafe name must fail: {name:?}" + ); + } + + fs::remove_dir_all(projects_root).ok(); +} + +#[test] +fn rename_local_game_project_updates_only_the_manifest_name() { + let root = unique_project_path(); + let before = + init_local_game_project_at(&root, "rename-project", "旧项目名").expect("project init"); + let revision = read_game_creator_agent_runtime_project_revision(&root).expect("read revision"); + let task_count = before.manifest.tasks.len(); + let asset_count = before.manifest.assets.len(); + + let renamed = rename_local_game_project( + root.to_string_lossy().into_owned(), + " 新项目名 ".to_string(), + ) + .expect("rename project"); + + assert_eq!(renamed.manifest.name, "新项目名"); + assert_eq!(renamed.manifest.project_id, before.manifest.project_id); + assert_eq!(renamed.manifest.tasks.len(), task_count); + assert_eq!(renamed.manifest.assets.len(), asset_count); + assert_eq!(renamed.revision, revision.revision); + assert_eq!( + renamed.manifest, + read_manifest_for_project(&root).expect("reread manifest") + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn rename_local_game_project_rejects_unsafe_names_and_uninitialized_projects() { + let root = unique_project_path(); + init_local_game_project_at(&root, "rename-invalid", "原项目名").expect("project init"); + + for name in [ + " \n\t".to_string(), + "名称\n包含控制字符".to_string(), + "长".repeat(GAME_CREATION_PROJECT_NAME_MAX_CHARS + 1), + ] { + let error = rename_local_game_project(root.to_string_lossy().into_owned(), name) + .expect_err("unsafe rename must fail"); + assert!(!error.is_empty()); + } + assert_eq!( + read_manifest_for_project(&root) + .expect("reread manifest") + .name, + "原项目名" + ); + + let empty_root = unique_project_path(); + fs::create_dir_all(&empty_root).expect("create empty root"); + assert!(rename_local_game_project( + empty_root.to_string_lossy().into_owned(), + "未初始化".to_string(), + ) + .is_err()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(empty_root).ok(); +} + +#[test] +fn automatic_project_name_suggestions_are_normalized_fail_closed() { + assert_eq!( + normalize_suggested_project_name(" 星轨夜航 "), + Some("星轨夜航".to_string()) + ); + assert_eq!( + normalize_suggested_project_name("星河"), + Some("星河".to_string()) + ); + assert_eq!( + normalize_suggested_project_name("一二三四五六七八九十甲乙丙丁戊己"), + Some("一二三四五六七八九十甲乙丙丁戊己".to_string()) + ); + assert_eq!( + normalize_suggested_project_name("星轨夜航\n解释"), + None, + "multi-line suggestions must fail closed" + ); + for value in [ + "", + " \t\r\n", + "名", + "项目名:名", + "名称\n控制字符", + "**星轨夜航**", + "https://example.com", + "C:\\projects\\game", + "Bearer sk-secret", + "“星轨夜航”", + "《星轨夜航》", + "项目名称:星轨夜航", + "星轨夜航2", + "星轨Game", + &"长".repeat(17), + &"长".repeat(81), + ] { + assert!( + normalize_suggested_project_name(value).is_none(), + "invalid suggestion: {value:?}" + ); + } +} + +#[test] +fn automatic_project_name_prompt_is_bounded_and_uses_user_requirement_only() { + let system_prompt = include_str!("../../prompts/automatic-project-name.md"); + assert!(system_prompt.contains("长度为 2 到 16 个字符")); + assert!(system_prompt.contains("不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释")); + + let prompt = build_automatic_project_name_prompt("做一个在月球邮局送信的解谜游戏") + .expect("build prompt"); + + assert_eq!(prompt, "做一个在月球邮局送信的解谜游戏"); + assert_eq!( + build_automatic_project_name_prompt(&"长".repeat(8_001)).expect("bound prompt"), + "长".repeat(8_000) + ); + + assert!(build_automatic_project_name_prompt("").is_err()); +} + #[cfg(unix)] #[test] fn automatic_local_game_project_rejects_symlinked_projects_root() { @@ -1531,7 +1677,7 @@ fn automatic_local_game_project_rejects_symlinked_projects_root() { fs::create_dir_all(&target).expect("create automatic workspace target"); symlink(&target, &projects_root).expect("symlink automatic workspace root"); - let error = create_automatic_local_game_project_at(&projects_root) + let error = create_automatic_local_game_project_at(&projects_root, None) .expect_err("symlinked automatic workspace root must fail"); assert!(error.contains("不能包含符号链接")); diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 7b2bae213..58d549b60 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -114,6 +114,11 @@ export interface InitLocalProjectResult { manifest: GameCreationAppManifest; } +export interface RenameLocalProjectResult { + manifest: GameCreationAppManifest; + revision: number; +} + export interface LocalProjectDirectoryStatus { projectPath: string; exists: boolean; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index f783c2693..2aad6e5c3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -1,14 +1,22 @@ import { + Check, CircleAlert, Ellipsis, FolderKanban, FolderOpen, FolderPlus, Gamepad2, + PenLine, Search, X, } from 'lucide-react'; -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { + type FormEvent, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; import { closeDialogOnEscape } from '../../app/dialogs'; import type { RecentProjectRow } from './model'; @@ -40,9 +48,11 @@ function projectStatusTone(project: RecentProjectRow) { function ProjectMoreMenu({ project, recentProjects, + onRename, }: { project: RecentProjectRow; recentProjects: RecentProjectsController; + onRename: (project: RecentProjectRow) => void; }) { const [open, setOpen] = useState(false); const [dropUp, setDropUp] = useState(false); @@ -118,6 +128,18 @@ function ProjectMoreMenu({ className={`launcher-project-more-menu${dropUp ? ' launcher-project-more-menu-drop-up' : ''}`} role="menu" > + + )} + + {projectKindLabel(project)} - - {project.name} - {project.path} + + {projectStatusTone(project) === 'warning' ? ( + - - - {projectKindLabel(project)} - - - {projectStatusTone(project) === 'warning' ? ( - - - - )) + {renaming ? ( +
+ + +
+ ) : ( + + )} + + ); + }) ) : hasStoredProjects ? (
diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 95e3cfb61..0d0fe0ea5 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1434,6 +1434,9 @@ export function registerHomeProjectCreationTests() { ([command]) => command === 'create_automatic_local_game_project', ), ).toHaveLength(1); + expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { + name: null, + }); expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, prompt: '你好,今天多少号', @@ -1484,6 +1487,12 @@ export function registerHomeProjectCreationTests() { manifestPath: `${automaticProjectPath}\\.agent\\manifest.json`, }; } + if (command === 'suggest_automatic_project_name') { + expect(args).toEqual({ + prompt: '按这个角色做游戏', + }); + return '角色参考游戏'; + } if (command === 'chat_with_game_creator_direct_codex') { return '附件已经进入当前项目。'; } @@ -1518,6 +1527,9 @@ export function registerHomeProjectCreationTests() { fireEvent.click(screen.getByRole('button', { name: '开启创作' })); expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('create_automatic_local_game_project', { + name: '角色参考游戏', + }); expect(invoke).toHaveBeenCalledWith('upload_local_asset', { projectPath: automaticProjectPath, fileName: '角色参考.png', @@ -2751,6 +2763,111 @@ export function registerRecentProjectsTests() { expect(window.localStorage.length).toBe(0); }); + it('renames a recent project and refreshes the inspected manifest name', async () => { + const projectPath = '/tmp/rename-game'; + const renamedManifest = createGameCreationAppManifest( + 'rename-game', + '星轨夜航', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + const renamed = + invoke.mock.calls.filter( + ([candidate]) => candidate === 'rename_local_game_project', + ).length > 0; + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: renamed ? '星轨夜航' : '旧项目名', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'rename_local_game_project') { + expect(args).toEqual({ + projectPath, + name: '星轨夜航', + }); + return { manifest: renamedManifest, revision: 3 }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify([projectPath]), + ); + renderLauncherProjectsAt('/?launcher'); + + expect(await screen.findByText('旧项目名')).not.toBeNull(); + fireEvent.click( + within(openProjectMoreMenu('旧项目名')).getByRole('menuitem', { + name: '重命名', + }), + ); + const nameInput = screen.getByLabelText('项目名称'); + fireEvent.change(nameInput, { target: { value: '星轨夜航' } }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + expect(await screen.findByText('星轨夜航')).not.toBeNull(); + expect(screen.queryByText('旧项目名')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('rename_local_game_project', { + projectPath, + name: '星轨夜航', + }); + }); + + it('keeps rename editing open when the native command fails', async () => { + const projectPath = '/tmp/rename-failed-game'; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '原项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'rename_local_game_project') { + expect(args?.name).toBe('失败后的名称'); + throw new Error('manifest 写入失败'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify([projectPath]), + ); + renderLauncherProjectsAt('/?launcher'); + + expect(await screen.findByText('原项目')).not.toBeNull(); + fireEvent.click( + within(openProjectMoreMenu('原项目')).getByRole('menuitem', { + name: '重命名', + }), + ); + fireEvent.change(screen.getByLabelText('项目名称'), { + target: { value: '失败后的名称' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + expect(await screen.findByRole('alert')).not.toBeNull(); + expect(screen.getByRole('alert').textContent).toBe('manifest 写入失败'); + expect((screen.getByLabelText('项目名称') as HTMLInputElement).value).toBe( + '失败后的名称', + ); + }); + it('opens a recent launcher project directory in the system file manager', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { diff --git a/docs/README.md b/docs/README.md index 8492cccb9..0d4c24053 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,6 +47,7 @@ ## 后端、运维与测试 - [BgFilter 受限资源调度方案](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) +- [Issue225 登录成功 AGC 用户归属修复](./technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md):登录 route tracking 的真实用户归属、`daily_login` 幂等边界和实施验收。 - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) - [Jenkins 容器预览部署控制面](./technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md) - [浏览器内 AI Web 工程沙箱预览](./technical/【技术方案】浏览器内AIWeb工程沙箱预览方案-2026-06-13.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 69ab04190..9138f5f4d 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -15,6 +15,34 @@ - 关联文档:相关 PRD、技术文档、提交或 Issue ``` +## 2026-09-03 AGC 登录 route event 使用 handler 已验证主体归属 + +- 背景:登录请求进入时尚未拥有 `AuthenticatedAccessToken`,通用 tracking middleware 无法从响应 extensions 归属登录成功用户;将 AGC marker 直接写入按用户/业务日幂等的 `daily_login` 又会受到不同来源登录顺序影响。 +- 决策:密码登录和手机号登录 handler 在认证及 session 创建成功后,仅对合法 AGC marker 请求向响应 extensions 附加一次性 `TrackingLoginSubject`。tracking middleware 在现有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 之后使用该主体生成登录 route event 的 `user_id`、`owner_user_id` 和 User scope。`daily_login` 保持原有 event key、幂等键、业务日和 metadata 语义,不承担 AGC 来源归因。 +- 安全边界:主体只来自后端认证服务返回的用户 ID;Header 仅决定是否进行 AGC 来源归因,不参与身份计算;不解析、不记录 access token、refresh token 或 Cookie。 +- 影响范围:`api-server` 登录 handler、资产读取 handler、tracking middleware、Issue225 技术方案和定向集成测试;不修改 SpacetimeDB schema、migration、bindings、OpenAPI、后台页面或认证响应协议。 +- 资产读取边界:`/api/assets/read-url` 和 `/api/assets/read-bytes` 继续支持匿名公开读取;有效 Bearer 复用同一次可选鉴权结果,在成功响应 extensions 中传递 `AuthenticatedAccessToken`,供 AGC route tracking 归属用户。External API Key/Admin 路由继续使用各自主体和审计链路。 +- 验证方式:密码/手机号登录真实 `build_router` 链路分别验证 AGC route event 的 marker、真实用户归属和 outbox 落盘;资产读取主体保留、响应 extension 与匿名不附加单测;tracking identity 单测、`cargo check --locked -p api-server`、相关 `cargo test --locked -p api-server`、格式、编码和 diff 检查通过。 +- 关联文档:`docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md`、Issue #225、`server-rs/crates/api-server/src/tracking.rs`、`server-rs/crates/api-server/src/app.rs`、`server-rs/crates/api-server/src/assets.rs`。 + +## 2026-09-03 server-rs workspace 保留独立 platform-agent 排除边界 + +- 背景:主站 #251 合并清理旧玩法表后,`server-rs/Cargo.toml` 的旧 crate 排除清单被收窄;`platform-agent` 仍位于 `server-rs/crates/` 下,但实际由 AGC 独立 Cargo workspace 通过路径依赖使用。若不显式排除,主 workspace 的 `cargo fmt --all` 会把它识别为“位于 workspace 内但不是 member”的非法包并直接失败。 +- 决策:继续将 `crates/platform-agent` 放在 `server-rs` workspace 的 `exclude` 中。它不加入主 workspace,也不在其 manifest 中新增平行 `[workspace]`;AGC 的独立 Cargo manifest 继续负责该 crate 的构建边界。 +- 影响范围:`server-rs/Cargo.toml` 与仓库 Rust 格式检查;不改变 `platform-agent` 源码、AGC 依赖关系或主站运行时。 +- 验证方式:`npm run check:rustfmt` 通过;`cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check` 不再报告 `platform-agent` workspace 错误。 +- 关联材料:Repository checks #5755、主站合并提交 `025f62729`、`server-rs/Cargo.toml`、AGC `apps/ai-game-creator-shell/src-tauri/Cargo.toml`。 + +## 2026-09-03 Native shell 检查清单只维护现役 HostBridge 文件 + +- 背景:#251 退役并删除了 H5 个人中心 QR 扫码弹层及其测试,同时移除了不再有源码消费者的生命周期 / 网络 wrapper;`scripts/check-native-shells.mjs` 和根 Vitest include 仍保留旧路径,导致 Native shell tests 在最后的 H5 HostBridge 调用链扫描阶段失败。 +- 决策:Native shell 静态检查、H5 HostBridge 定向测试和根 Vitest include 只列出现役文件;已删除的 QR 扫码组件/测试及生命周期、网络 wrapper 从清单移除,不恢复已退役实现,也不为历史路径增加兼容占位文件。 +- 影响范围:`scripts/check-native-shells.mjs`、`vitest.config.ts` 和 Native shell 检查门禁;不改变移动壳现役 `QrScannerOverlay` 或 HostBridge 公共契约。 +- 验证方式:运行 `npm run check:native-shells`,确认 H5 HostBridge 调用链静态扫描不再引用已删除文件;同时运行编码、格式和 diff 检查。 +- 关联材料:Native shell tests #5758、主站合并提交 `025f62729`、已删除的 `PlatformProfileQrScannerModal` 文件。 + +--- + ## 2026-09-02 Direct 过程卡按回合阶段状态驱动 - 背景:DirectProject 结果卡把工具活动词、中间文本和真实回复增量都当成“实时回复”,标题随最近一次事件跳动;上游常整包返回正文时还叠加合成打字机,用户看到的是行为名而非当前阶段。 @@ -24,7 +52,6 @@ - 关联文档:`docs/technical/【技术方案】Direct回合行为审计账本-2026-08-31.md`、分支 `feat/agc-llm-router-official-chain`。 --- - ## 2026-09-02 GDD 审批卡的后台 hydrate 不抢占已加载决定 - 背景:项目页首次加载和运行态刷新可能并发 hydrate。卡片已经显示后,短暂的 `hydrateBusy` 会让已打开的评论弹层提交按钮瞬时变灰,用户无法提交已输入的修改意见。 diff --git a/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md b/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md new file mode 100644 index 000000000..2b40d2606 --- /dev/null +++ b/docs/technical/【后端架构】Issue225登录成功AGC用户归属修复方案-2026-09-03.md @@ -0,0 +1,328 @@ +# Issue225 登录成功 AGC 用户归属修复方案 + +状态:已实现(2026-09-03) + +## 1. 一句话交付目标 + +对带有合法 `X-Genarrative-Client: agc` 的登录成功请求,将现有登录 route tracking event 归属到登录成功后的真实用户;保持 `daily_login` 原有的“用户 + 北京时间业务日”幂等语义,不解析 access token,不新增数据库表、事件体系或状态机。 + +## 2. Issue 边界 + +### 2.1 本次只做 #225 + +本方案只修改主站 `api-server` 的登录成功埋点归属链路。 + +`#226` 已冻结并合入的客户端 Header 注入、主站 client factory、同源重定向和第三方请求边界作为本方案输入,不在本次修改。 + +### 2.2 本次解决 + +- AGC 密码登录成功 route event 的真实用户归属。 +- AGC 手机号登录成功 route event 的真实用户归属。 +- tracking middleware 与登录 handler 之间的可信主体传递。 +- 方案文档、测试和验收口径与实际登录阶段保持一致。 + +### 2.3 明确不做 + +- 不修改 access token、refresh token、Cookie、登录响应体或认证协议。 +- 不从响应体、Cookie 或 Header 解析用户身份。 +- 不把 `X-Genarrative-Client` 当作认证或授权边界。 +- 不修改 `daily_login` 的 event key、幂等键、业务日算法或 daily stat 语义。 +- 不新增 `agc_login_success` 平行事件。 +- 不新增 SpacetimeDB 表、字段、migration、binding 或后台页面。 +- 不改变未标记请求的既有 route tracking 统计口径。 +- 不在 `RequestContext` 中增加一次性登录主体状态。 + +## 3. 修复前行为与根因 + +当前登录链路大致如下: + +```text +请求进入 + → tracking middleware 读取 AGC marker + → 登录 handler 校验密码/验证码 + → 创建 session 和 access token + → handler 手工记录 daily_login + → 返回 access token / Cookie + → tracking middleware 读取 response extensions + → 写入登录 route event +``` + +当前 `tracking` middleware 只从 response extensions 读取: + +- `AuthenticatedAccessToken`; +- `ExternalApiPrincipal`。 + +登录请求进入时还没有 `AuthenticatedAccessToken`。虽然 +`phone_auth.rs` 和 `password_entry.rs` 的 handler 在认证成功后已经拥有可信的 +`result.user.id`,但目前没有把这个主体传给 tracking middleware。 + +因此会出现: + +- `auth_password_login_success` / `auth_phone_login_success` route event 带有 `client=agc`,但 `user_id`、`owner_user_id` 为空,`scope_id` 为 `anonymous`; +- `daily_login` 有真实用户 ID,但没有 AGC 来源信息。 + +这不是认证失败或用户身份校验错误,而是“认证成功发生在 handler 内部,通用 middleware 无法看到 handler 内部认证结果”的传递缺口。 + +## 4. 设计决策 + +### 4.1 route event 作为 AGC 登录来源的权威记录 + +本次不把 `daily_login` 改造成 AGC 来源事件,而是让现有登录 route event 同时承载: + +- 本次成功登录请求的 route、method、status; +- `client=agc` 来源标记; +- 认证成功后的真实用户归属。 + +这样可以直接回答“哪些用户通过 AGC 成功登录”,并且保留现有 route event 的每请求事件语义。 + +### 4.2 `daily_login` 保持原有语义 + +`daily_login` 使用按用户和业务日生成的稳定 event id: + +```text +daily-login:{user_id}:{beijing_day_key} +``` + +同一用户一天内的第二次登录会被幂等去重。如果把 `client=agc` 写进该事件,会出现来源丢失或误标记: + +- 先网页登录、后 AGC 登录:AGC 来源可能被第二次幂等跳过; +- 先 AGC 登录、后网页登录:当天事件会永久看起来像 AGC 登录; +- 同一天不同来源无法表达多次登录事实。 + +因此 `daily_login` 继续只表示“该用户当天已完成登录”,不承担 AGC 来源归因。需要按用户查询 AGC 登录时,查询带 `metadata.client = "agc"` 的登录 route event。 + +### 4.3 使用一次性 response extension,不引入状态机 + +增加一个仅在本次 HTTP 请求内存在的内部主体类型,例如: + +```rust +TrackingLoginSubject { + user_id: String, +} +``` + +它只保存认证服务已经确认的用户 ID,不保存 token,不落库,不跨请求复用,也不引入新的生命周期状态。 + +## 5. 目标链路 + +```text +AGC marker + → tracking middleware 解析并放入 request extensions + → 登录 handler 完成密码/验证码校验 + → 从 result.user.id 构造 TrackingLoginSubject + → 将主体放入 response extensions + → tracking middleware 读取主体 + → 现有 identity resolver 生成 user/owner/scope + → 现有 route tracking metadata/outbox/SpacetimeDB 链路 +``` + +## 6. 具体实现约定 + +### 6.1 登录 handler + +涉及: + +- `server-rs/crates/api-server/src/phone_auth.rs` +- `server-rs/crates/api-server/src/password_entry.rs` + +处理顺序: + +1. 保持现有密码/验证码校验、session 创建、Cookie 设置和响应 body 不变。 +2. 只有当前请求的 marker 精确解析为 `TrackingClientMarker::Agc` 时,才附加 `TrackingLoginSubject`。 +3. `user_id` 只能来自认证服务返回的 `result.user.id`。 +4. 仅在认证和 session 创建成功后附加主体;任何失败路径都不附加。 +5. 通过响应转换 helper 写入 response extensions,不能改变 HTTP status、headers 或 JSON body。 + +Header 只参与“是否进行 AGC 来源归因”的判断,不能提供用户身份。即使调用方伪造 Header,最终写入的用户 ID 仍来自后端已验证的登录结果。 + +### 6.2 tracking middleware + +涉及: + +- `server-rs/crates/api-server/src/app.rs` +- `server-rs/crates/api-server/src/tracking.rs` + +在现有主体来源之外增加登录主体兜底: + +```text +ExternalApiPrincipal + → AuthenticatedAccessToken + → TrackingLoginSubject +``` + +其中: + +- `ExternalApiPrincipal` 和 `AuthenticatedAccessToken` 的现有优先级和行为不变; +- `TrackingLoginSubject` 只用于合法 AGC marker 的登录 route; +- `TrackingLoginSubject` 的 `user_id` 和 `owner_user_id` 由同一个已验证用户 ID 填充; +- User scope 的 `scope_id` 解析为真实用户,不再回退到 `anonymous`。 + +### 6.3 route 策略边界 + +当前分支的 route policy 必须保持: + +- `/api/auth/entry` 使用 AGC-only route spec;没有合法 marker 时不产生 route event,但登录业务本身不被拒绝; +- `/api/auth/phone/login` 继续使用既有全客户端 route tracking 语义;带合法 marker 时新增真实用户归属,未标记时保持既有匿名 route event 语义。 + +本方案不把手机号登录改造成 AGC-only,也不扩大 #225 的 route coverage。 + +### 6.4 可选鉴权的资产读取路由 + +`/api/assets/read-url` 和 `/api/assets/read-bytes` 同时支持匿名公开素材读取与已登录用户读取, +因此不能直接挂接 `require_bearer_auth`。这两个 handler 会继续使用 +`optional_access_token_from_headers` 完成 Bearer 校验和素材权限判断;如果校验成功, +则把同一个已验证的 `AuthenticatedAccessToken` 放入成功响应 extensions,供外层 tracking +middleware 读取真实用户主体。 + +该传递只发生在当前响应内: + +- 匿名公开读取不附加认证主体,仍按 `anonymous` 记录; +- 无效 Bearer 仍返回 `401`,不产生成功 route event; +- External API Key 和管理员资产读取继续使用各自的 `ExternalApiPrincipal` / 管理员审计链路; +- 不把 Authorization Header、access token、refresh token 或 Cookie 写入埋点。 + +## 7. 事件结果示例 + +### 7.1 AGC 密码登录成功 + +```text +event_key = auth_password_login_success +metadata.client = agc +user_id = user-xxx +owner_user_id = user-xxx +scope_kind = user +scope_id = user-xxx +``` + +### 7.2 AGC 手机号登录成功 + +```text +event_key = auth_phone_login_success +metadata.client = agc +user_id = user-xxx +owner_user_id = user-xxx +scope_kind = user +scope_id = user-xxx +``` + +### 7.3 `daily_login` + +```text +event_key = daily_login +user_id = user-xxx +event_id = daily-login:user-xxx:{beijing_day_key} +metadata = 保持现有 operation/loginMethod 等字段,不承担 AGC 来源归因 +``` + +## 8. 不采用的方案 + +### 8.1 只给 `daily_login` 追加 `client` + +不采用。它会受到用户当天首次登录来源和幂等去重顺序影响,不能准确表达 AGC 登录事实。 + +### 8.2 从响应体或 Cookie 解析 access token + +不采用。会让埋点 middleware 依赖认证响应格式,扩大敏感凭据处理边界,且没有必要。 + +### 8.3 新增 `agc_login_success` 事件 + +不采用。现有登录 route event 已经有 route、method、status、event key 和 metadata 承载能力,再增加事件会产生第二套统计口径和后台查询约定。 + +### 8.4 让 Header 直接决定 user_id + +不采用。Header 是可伪造的来源标签,不能成为身份事实。 + +### 8.5 修改已有 `daily_login` 事件的重复记录行为 + +不采用。这样会改变每日任务、daily stat 和事件幂等语义,超出本次登录来源归因问题。 + +## 9. 测试与验收 + +### 9.1 middleware 成功链路 + +通过现有 `build_router` 和隔离 tracking outbox,验证: + +```text +HTTP request + → marker middleware + → 登录 handler + → response extension + → tracking middleware + → outbox +``` + +断言: + +- 登录响应 status、headers、body 与原行为一致; +- AGC 密码登录 route event 有 `metadata.client = "agc"`; +- AGC 手机登录 route event 有 `metadata.client = "agc"`; +- `user_id`、`owner_user_id`、`scope_id` 为真实用户; +- outbox metadata 不包含 access token、refresh token、Cookie、密码或验证码。 + +资产读取的成功链路还必须满足: + +- AGC + 有效 Bearer 的 `/api/assets/read-url` 和 `/api/assets/read-bytes`,route event 的 + `user_id`、`owner_user_id`、`scope_id` 均为 Bearer 对应的真实用户; +- AGC + 无 Bearer 的公开素材读取仍保持匿名归属; +- 资产读取权限判断和 tracking 主体传递复用同一次 Bearer 校验结果,不重复解析或自行构造身份。 + +### 9.2 未标记回归 + +- 未标记手机号登录继续保持既有 route tracking 语义; +- 未标记密码登录不产生 AGC route event; +- `/api/auth/entry` 缺少 marker 时仍不放行 route tracking,但不改变登录业务响应; +- Header 伪造不会改变最终 user_id。 + +### 9.3 `daily_login` 回归 + +- `daily_login` 仍使用真实用户; +- event id 仍按用户 + 北京时间业务日生成; +- 同一用户同一天重复登录仍幂等; +- 本次修复不要求从 `daily_login` 查询 AGC 来源。 + +### 9.4 常规验证 + +按修改范围运行: + +- api-server 相关定向 Rust 测试; +- `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server`; +- `cargo fmt --check`; +- `npm run check:encoding`; +- `git diff --check`。 + +CI 已覆盖的全量测试不要求本地重复运行。 + +## 10. 数据和发布边界 + +- 不做历史数据回填。旧登录 route event 没有足够可信的信息安全补写用户归属。 +- 不修改 tracking schema、migration、SpacetimeDB bindings 或后台 readback API。 +- 新代码上线后产生的 AGC 登录 route event 才保证用户归属完整。 +- 认证主链路、登录响应和埋点失败不阻断业务的既有语义全部保留。 + +## 11. 实施顺序 + +1. 在 `tracking.rs` 增加内部 `TrackingLoginSubject` 和身份解析兜底。 +2. 在密码/手机号登录成功响应中附加主体 extension。 +3. 在 `app.rs` tracking middleware 读取主体并传入 route tracking。 +4. 增加真实 middleware 链路及未标记回归测试。 +5. 更新 Issue225 现有方案和阶段验收文档中的登录归属表述。 +6. 完成定向验证后再提交代码。 + +## 12. 最终判断 + +推荐使用“登录 handler 传递一次性可信主体,route event 负责 AGC 登录归因,`daily_login` 保持原语义”的方案。 + +该方案只增加一个单字段、请求级的内部传递对象,不引入新的持久化状态或状态机;同时避免了把每日幂等事件错误地当作来源审计事件,最终代码边界和后台查询口径都更容易维护。 + +## 13. 实现记录 + +本方案已在当前分支落地: + +- `server-rs/crates/api-server/src/tracking.rs` 增加 `TrackingLoginSubject`,并将其作为登录主体的最后兜底来源;既有 `ExternalApiPrincipal`、`AuthenticatedAccessToken` 优先级保持不变。 +- `server-rs/crates/api-server/src/password_entry.rs` 和 `server-rs/crates/api-server/src/phone_auth.rs` 在认证与 session 创建成功后,仅对合法 AGC marker 请求向 response extensions 附加真实用户主体。 +- `server-rs/crates/api-server/src/app.rs` 只在合法 AGC marker 存在时读取该主体并交给 route tracking;未标记请求不使用该新主体来源。 +- `server-rs/crates/api-server/src/assets.rs` 保留两个资产读取路由的可选鉴权语义,并在有效 Bearer 成功读取时把已验证主体放入响应 extensions,补齐 AGC 资产读取事件的用户归属;匿名读取和 External API Key/Admin 路由边界未改变。 +- `daily_login` helper、event id 幂等、认证响应和 Cookie 语义未修改。 +- 已增加密码登录、手机号登录、资产读取主体传递和主体解析测试;登录链路与资产读取主体传递均验证通过。 + +本次实现没有修改 SpacetimeDB schema、migration、bindings、OpenAPI 或后台页面,也没有新增事件 key。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index ca6b22f1c..f29907f33 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -1,5 +1,12 @@ # AI 游戏创作智能体 App 实施计划 +## 2026-09-02 项目名称显示与自动提炼 + +- `.agent/manifest.json` 的 `name` 仍是本地项目显示名唯一事实源;项目组页行尾更多菜单提供行内重命名,保存必须走 Tauri 受控命令、项目写锁、manifest 写锁与既有 ACL/权限校验。重命名只更新 manifest,不改变项目目录、`projectId`、项目类型、任务、资源、版本或远端同步状态;保存成功后当前项目上下文、窗口标题和最近项目检查结果必须回读新 manifest 并保持一致。 +- 项目名称统一 trim 后非空、最多 80 个字符且不得包含控制字符。空名称、超长、控制字符或未初始化项目必须失败关闭,原 manifest 保持可用;失败提示只展示安全错误,不泄露宿主路径之外的新内部信息。 +- 首页自动创建工作区前允许一次受限 LLM 名称提炼:输入只包含用户文本需求和附件元数据摘要,输出只允许一个简短中文项目名。Rust 侧统一规范化并在空值、控制字符、超长或多行格式时返回失败;前端把有效名称传给自动建项命令,失败时继续使用现有 `GameAgent 项目 {短ID}` 默认名并照常进入创作,不重试、不阻断、不额外消耗 Provider 请求。 +- 手动“新建项目”继续默认使用所选文件夹名,不额外调用 LLM;项目创建后用户可通过项目组页重命名修正显示名。自动建项命令的自定义名称必须走同一校验,未提供名称时保持现有默认名,保证旧调用与失败回退路径不变。 + ## 2026-09-02 客户端会话恢复可观测性与超时兜底 - AGC 客户端启动恢复按“读取本地凭据 → 刷新会话(无 token 或失效时)→ 读取当前用户 → Tauri 本地运行时会话安装”阶段执行。界面必须展示当前阶段和已等待时间;不能以无期限的单一 loading 文案隐藏网络或 Runner 故障。 diff --git a/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md b/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md new file mode 100644 index 000000000..45311511f --- /dev/null +++ b/local-docs/【实施方案】Issue225-AGC主站请求标记埋点统计-2026-09-02.md @@ -0,0 +1,342 @@ +# Issue #225:AGC 主站请求标记埋点统计实施方案 + +更新时间:2026-09-02 +关联 Issue: + +- `#225 添加客户端埋点统计`:本文全部实施范围 +- `#226 添加客户端特殊标识`:客户端侧已完成;本文只接收其固定交接契约,不回改客户端 + +当前状态:阶段 0~6 已完成。主站生产代码和定向测试已按阶段提交;阶段 6 仅补充最终门禁、交接与验收文档。未修改 SpacetimeDB schema、OpenAPI 或后台页面。 + +## 1. 一句话交付结果 + +主站 `api-server` 能识别 AGC 发来的 `X-Genarrative-Client: agc`,并在本期新增的 AGC 专用成功路由埋点写入 `tracking_event.metadata_json` 的 `client: "agc"`,同时按真实登录用户或 External API Key 的 `owner_user_id` 归属,后台可以通过现有 tracking 事件查询看到这类请求;既有 route tracking 和手工资产事件保持原有全客户端统计口径,不改变鉴权、计费、幂等和响应语义。 + +## 2. Issue 边界 + +### 2.1 本次只做 #225 + +本次主改动限定在主站后端: + +- `server-rs/crates/api-server/src/app.rs` 的 tracking middleware 入口。 +- `server-rs/crates/api-server/src/tracking.rs` 的标记识别、主体归属、metadata 合并和 route tracking 覆盖。 +- 必要的 `api-server` 定向测试、External v1 路由测试和后台 tracking 读取验证。 +- 方案、验收、交接文档。 + +### 2.2 本次不做 #226 的回改 + +不修改: + +- AGC TS 的 `fetchClientHttp` 标记注入。 +- AGC Rust 主站 Client factory、请求终结器和 redirect policy。 +- AGC 主站/第三方请求边界、timeout、认证、幂等和请求体语义。 + +`#226` 已冻结并合入的客户端契约直接作为本 Issue 的输入。 + +### 2.3 明确不做项 + +- 不新增 `tracking_event` 列、索引、migration 或生成 bindings;第一阶段复用已有 `metadata_json`。 +- 不新增平行的 `agc_client_request` 事件、平行表或平行统计口径。 +- 不改变 `/api/external/v1` 的路由、HTTP 方法、DTO、状态码、鉴权或异步语义,因此不改 OpenAPI 契约。 +- 不把 `generationInputs.source`、User-Agent、请求体中的 owner 字段当作客户端来源或主体。 +- 不记录 access token、External API Key 明文、Cookie、签名 URL、项目绝对路径或其他秘密。 +- 不把 OSS 上传、签名 URL 实际下载、LLM/Codex Provider、受控搜索、loopback、更新下载和任意外部网页请求当成主站业务 tracking。 +- 不默认把失败响应改造成新的 tracking 事实;先保持现有“成功响应才写 route tracking”语义。 +- 不默认新增后台筛选控件;后台先复用现有 tracking 原始事件查询展示 `metadata_json`。 + +## 3. 当前实现基线与缺口 + +### 3.1 现有 tracking 流程 + +当前全局链路位于: + +- `server-rs/crates/api-server/src/app.rs` +- `server-rs/crates/api-server/src/tracking.rs` + +流程为: + +```text +请求进入 + → tracking middleware 保存 method/path + → next.run(request) + → 从 response extensions 读取认证主体 + → 仅对成功响应解析 RouteTrackingSpec + → 生成 TrackingEventDraft.metadata + → 本机 tracking outbox + → SpacetimeDB tracking_event / tracking_daily_stat +``` + +现有 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 都会在认证 middleware 成功后写入 response extensions;但 tracking middleware 目前只消费前者。 + +### 3.2 当前缺口 + +- tracking middleware 尚未读取 `X-Genarrative-Client`。 +- route metadata 尚未写入 `client`。 +- External API Key 的 `ExternalApiPrincipal.owner_user_id` 尚未接入 route tracking 归属。 +- `/api/external/v1/*` 没有完整的 route tracking spec。 +- 账号态实际被 AGC 使用的 `/api/editor`、`/api/assets`、`/api/runtime` 路径也有未覆盖项。 + +## 4. 冻结的 #226 交接契约 + +### 4.1 Header 识别 + +```http +X-Genarrative-Client: agc +``` + +规则: + +- Header 名按 HTTP 规则大小写不敏感。 +- 值去除首尾空白后,精确等于小写 `agc` 才认定为 AGC。 +- 缺失、空值、`AGC`、其他未知值均按“未标记”处理。 +- 未标记请求不被拒绝,也不改变业务行为。 +- Header 只用于来源审计和统计,不参与鉴权、权限、计费、幂等或账号归属。 + +### 4.2 metadata 形态 + +第一阶段复用已有 `tracking_event.metadata_json`,在原对象上追加固定键: + +```json +{ + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc" +} +``` + +约定: + +- JSON key 固定为 `client`,值固定为 `agc`。 +- 有效标记时追加 `client`;未标记时不写 `client`,不写空字符串或 `null`。 +- 已有 `route`、`method`、`status`、`operation` 以及资产类嵌套 metadata 必须保留。 +- `route` 使用主站实际收到的 method/path;External v1 不得只记录客户端账本中的内部映射路径。 + +### 4.3 认证主体归属 + +| 请求类型 | `user_id` | `owner_user_id` | `scope_kind/scope_id` | 说明 | +|---|---|---|---|---| +| 登录账号态 | 沿用 `AuthenticatedAccessToken.claims().user_id()` | 沿用现有行为,通常与 user_id 相同 | 沿用 route spec;User scope 使用真实用户 | Header 不能覆盖真实认证主体 | +| External API Key 态 | 不伪造登录用户,可为空 | `ExternalApiPrincipal.owner_user_id()` | User scope 使用 owner_user_id | 归属 API Key 所属账号 | +| 无认证的公开/站点请求 | 为空 | 为空 | 沿用 Site/公开 route spec | 只在已有公开 route spec 时记录 | + +第一阶段不把 API Key 明文写入 metadata。`key_id` 是安全的内部标识,但只有在后续明确需要按单个 Key 统计或审计时才增加 `externalApiKeyId`,不作为本期客户端交接前提。 + +### 4.4 成功与失败语义 + +本期先保留当前 route tracking 的成功响应语义: + +- 2xx 成功响应按 route spec 写入 tracking。 +- 3xx、4xx、5xx 和 transport failure 不因为 AGC 标记而自动新增 route 事件。 +- 生成提交、异步轮询、重试请求使用同一标记;是否落库仍由现有 route tracking 规则决定。 + +如果产品后续要求统计失败调用,应另立失败事件的 event key、幂等键、容量和报表口径,不在本期隐式扩展。 + +## 5. 推荐实现:扩展现有 route tracking + +本期采用方案一:在现有 route tracking 上追加来源和主体信息,不创建通用 AGC 请求事件。 + +```text +请求 Header + → tracking middleware 在 next.run 前白名单解析 marker + → next.run(request) + → 从 response extensions 读取 AuthenticatedAccessToken / ExternalApiPrincipal + → 按实际 method + path 解析 RouteTrackingSpec + → 计算 user/owner/scope + → 在现有 metadata 对象追加 client=agc + → 复用现有 outbox、tracking_event、tracking_daily_stat + → 现有后台 tracking 查询读取 metadata_json +``` + +### 5.1 为什么不新增通用 AGC 事件 + +- 不会与现有 route event 重复计数。 +- 不会改变 `tracking_daily_stat` 的 event key 和历史统计口径。 +- 仍能看到实际 path、method、status、operation 和 client 的组合。 +- 继续复用现有 outbox、SpacetimeDB procedure 和后台原始事件查询。 +- 新增或确认 AGC 路由时只需补齐 route spec 和测试,不需要引入第二套事件系统。 + +代价是 route coverage 必须显式审计;因此阶段 3 将以 AGC 客户端调用清单和 External v1 router/OpenAPI 交叉核对,禁止“标记已经发送但主站没有 route spec”的漏项。 + +### 5.2 实现边界 + +推荐保持最小抽象: + +- `app.rs` 在消费 request 之前解析 marker,并从 response extensions 取两类主体。 +- `tracking.rs` 提供小型白名单解析函数和主体归属逻辑。 +- `RouteTrackingSpec` 明确区分既有全客户端 route 与本期新增的 AGC-only route;新增 AGC route 只有在 marker 有效时才允许落库。 +- `build_route_tracking_metadata` 只在有效 marker 时追加 `client`,不重写既有字段。 +- `record_route_tracking_event_after_success` 继续负责 route spec、outbox 和失败日志策略。 +- 不把 marker 放进 `RequestContext`,除非阶段 1 证明同一请求的其他统一 tracking 入口确实需要它;避免扩大公共上下文结构。 + +既有 route spec 默认保持全客户端语义;本期新增、仅为 AGC 调用清单补齐的 route spec 使用 AGC-only 策略。已有 `handled_by_existing_event` 的详细资产事件不改记录范围,只在有效 marker 时追加 `client`。 + +## 6. Route 覆盖范围 + +### 6.1 必须覆盖的账号态路径 + +按 AGC 当前扫描清单,至少核对并覆盖: + +- `/api/auth/*`:当前登录用户查询、refresh、登录入口、发送验证码、手机号登录、logout 等实际调用。 +- `/api/profile/*`:dashboard、recharge center、recharge order/confirm、wallet ledger、API Key 管理。 +- `/api/assets/*`:direct-upload-tickets、objects/confirm、read-url、read-bytes 以及 AGC 实际使用的资产操作。 +- `/api/editor/*`:projects、assets/library、assets/folders、项目 resources、图片/编辑/去背景/图集/角色动画/视频/音效/BGM 生成和轮询相关内部路径。 +- `/api/runtime/external-generation/jobs/{operationId}`:账号态生成任务轮询。 + +已有 route spec 的 event key 和 scope 语义保持不变;新增项使用稳定、可读、与路径/operation 对应的 event key。 + +### 6.2 必须覆盖的 External API Key 业务路径 + +按 `server-rs/crates/api-server/src/modules/external_api.rs` 和实际 AGC 使用情况核对: + +- `/api/external/v1/assets/direct-upload-tickets` +- `/api/external/v1/assets/objects/confirm` +- `/api/external/v1/assets/read-url` +- `/api/external/v1/editor/projects` 及项目读取/资源登记相关路径 +- `/api/external/v1/editor/assets/library`、folders、asset CRUD 中实际被 AGC 使用的路径 +- `/api/external/v1/editor/images/generations` +- `/api/external/v1/editor/images/edits` +- `/api/external/v1/editor/images/background-removals` +- `/api/external/v1/editor/icon-spritesheets/generations` +- `/api/external/v1/editor/character-animations/generations` +- `/api/external/v1/editor/videos/generations` +- `/api/external/v1/editor/audios/sound-effects/generations` +- `/api/external/v1/editor/audios/background-music/generations` +- `/api/external/v1/generations/{operationId}` + +External v1 路由的 tracking 必须记录外部实际 path,不把它改写成 `/api/editor`、`/api/assets` 或 `/api/runtime`。 + +### 6.3 明确排除的 External v1 路径 + +以下是公开发现或集成协议入口,不属于普通 AGC 主站业务调用,本期不新增普通 route tracking spec: + +- `GET /api/external/v1/openapi.json` +- `GET /api/external/v1/agent-integration.json` +- `GET /api/external/v1/skill/SKILL.md` +- `GET /api/external/v1/skill.zip` +- `POST /api/external/v1/mcp` + +如果未来 AGC 明确接入远程 MCP/Skill,再单独定义集成流量的事件语义,不把它们隐式混入普通业务统计。 + +## 7. 数据库与后台承接 + +### 7.1 数据库 + +沿用现有链路: + +```text +TrackingEventDraft.metadata + → RuntimeTrackingEventInput.metadata_json + → tracking outbox + → record_tracking_event procedure + → tracking_event.metadata_json +``` + +不增加字段、不改 migration、不重新生成 bindings。现有 JSON object 校验足以容纳 `client` 键;`tracking_daily_stat` 继续按原 event key/scope/day 聚合,不按 client 另建统计表。 + +### 7.2 后台 + +现有后台 tracking 查询已经返回 `metadata_json`,第一阶段只做数据可见性验证: + +- 管理员能在原始事件详情中看到 `client: "agc"`。 +- 原有 event key、scope、user/owner、日期查询不回归。 +- 不新增客户端筛选器、导出列或新的后台路由。 + +如果后续需要高频按 client 筛选,再评估新增结构化列和索引;那将是独立 schema 变更,不应在本期偷偷引入。 + +## 8. 安全与兼容性约束 + +- 来源 Header 是可伪造的审计标签,不能当作安全边界。 +- 主体必须来自已验证的 `AuthenticatedAccessToken` 或 `ExternalApiPrincipal`,不能信任请求体 owner、客户端账本或 Header 中的身份。 +- 不记录 Bearer token、API Key 原文、Cookie、签名 URL 或请求体中的敏感字段。 +- Header 缺失/未知时继续走原有业务和 tracking 逻辑,只是不追加 `client`。 +- metadata 合并必须保留既有资产嵌套字段,不得用新对象覆盖旧 metadata。 +- outbox 入队、SpacetimeDB 写入失败继续只记录 warning,不阻断主业务响应。 +- 不修改 route normalization、dynamic id 归一化、event id 幂等或 daily stat 聚合规则。 + +## 9. 定向测试方案 + +### 9.1 Marker 解析 + +表驱动测试覆盖: + +- `X-Genarrative-Client: agc` → `Some("agc")`。 +- Header 名大小写变化 → 仍识别。 +- 缺失、空值、首尾空白、`AGC`、未知值 → 未标记。 + +### 9.2 Metadata 与主体归属 + +- 有效 marker 的账号态成功路由:metadata 含 `client: "agc"`,user_id/owner_user_id 保持真实用户。 +- 有效 marker 的 External v1 成功路由:metadata 含 `client: "agc"`,owner_user_id 等于 `ExternalApiPrincipal.owner_user_id()`,scope_id 不落到 `anonymous`。 +- 未标记请求:metadata 不含 `client`。 +- 请求体伪造 owner 或 marker 不改变主体归属。 +- metadata 原有 route/method/status/operation 和资产嵌套字段仍存在。 + +### 9.3 AGC-only 记录门禁 + +- 本期新增的账号态和 External v1 AGC route:带有效 marker 的 2xx 响应才记录。 +- 同一批新增 route:缺失、非法或未知 marker 时不记录,也不拒绝业务请求。 +- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。 +- 4xx/5xx 即使带有效 marker 也不新增成功 route event。 +- `build_router` middleware 集成测试验证 marker、账号认证主体、成功 route tracking 和隔离 outbox 可以串联,且 metadata 保留 `client`、实际 route 和 user/owner。 + +### 9.4 Route 覆盖 + +- 当前 AGC 调用清单中的账号态路径全部能解析到 route spec。 +- 当前 AGC 使用的 External v1 业务路径全部能解析到 route spec。 +- `/api/external/v1` 发现、Skill 和 MCP 路径不被普通业务 spec 误收录。 +- dynamic project/asset/operation ID 仍按现有 normalize 规则归一化。 + +### 9.5 持久化与后台读取 + +- 构造 tracking event input 后,`metadata_json` 是合法 JSON object 且含 `client`。 +- outbox 入队/回退直写路径不丢失 `client`。 +- tracking_event 读回及后台 tracking API 解析不丢失 metadata。 +- 现有 daily stat、event id 幂等和失败不阻断语义保持不变。 + +### 9.6 状态码语义 + +至少保留一组回归: + +- 2xx 成功响应写入 route tracking。 +- 4xx/5xx 不因为 marker 自动新增成功 route event。 +- 认证失败不伪造 ExternalApiPrincipal 或用户归属。 +- 轻量 middleware 成功链路测试通过;不依赖真实 SpacetimeDB,不纳入完整环境型 E2E。 + +## 10. 实施顺序 + +1. 阶段 0:确认基线、交接契约、路径边界和不做项。 +2. 阶段 1:加入 marker 白名单解析,并将解析结果传入现有 route tracking。 +3. 阶段 2:接入 `AuthenticatedAccessToken` / `ExternalApiPrincipal`,完成 owner/user/scope 归属和 metadata 合并。 +4. 阶段 3:按 AGC 调用清单补齐账号态与 External v1 业务 route spec,明确排除发现/MCP。 +5. 阶段 4:验证 outbox、SpacetimeDB `tracking_event` 和现有后台原始查询,无 schema 变更。 +6. 阶段 5:完成 marker、主体、route 覆盖、状态码、安全和持久化定向测试。 +7. 阶段 6:执行最终门禁,更新交接材料并把结果回传 Issue #225;不要求 #226 回改。 + +## 11. 完成判据 + +只有同时满足以下条件,#225 才算完成: + +- 主站精确识别 `X-Genarrative-Client: agc`,未知/缺失值不拒绝请求。 +- 有效标记进入现有 route tracking metadata,写入 `client: "agc"`。 +- 已有 route、method、status、operation、资产 metadata 和 event key 语义不丢失。 +- 登录账号态按真实用户归属,External API Key 态按 `owner_user_id` 归属。 +- 当前 AGC 实际使用的账号态和 External v1 业务路径均有 tracking spec。 +- 本期新增的 AGC route spec 未携带有效 marker 时不产生 route tracking event;既有 route spec 和手工资产事件保持原有全客户端统计语义。 +- External v1 discovery/Skill/MCP、OSS、签名下载、Provider、搜索、loopback、更新下载不被误纳入普通 AGC 业务统计。 +- outbox、SpacetimeDB 写入、daily stat、幂等和失败不阻断语义无回归。 +- 后台现有 tracking 查询可以看到 `metadata_json.client`,无需新增 schema 或页面。 +- 定向测试和必要的格式/编码/空白检查通过。 + +## 12. 评审与提交节奏建议 + +建议按阶段分组提交或至少在一个 PR 中保持以下逻辑顺序: + +1. 阶段 0 文档与契约冻结。 +2. marker 解析、metadata 合并和账号态主体接入。 +3. External API Key 主体接入与 External v1 route spec。 +4. 持久化/后台读取验证和定向测试。 +5. 最终文档、交接评论和门禁记录。 + +每个阶段先通过自身验收,再进入下一阶段;不需要等待 #226 再次修改,也不需要先新增数据库字段。 diff --git a/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md b/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md new file mode 100644 index 000000000..2acdebf32 --- /dev/null +++ b/local-docs/【实施计划】Issue225-AGC主站请求标记埋点分阶段验收-2026-09-02.md @@ -0,0 +1,398 @@ +# Issue #225:AGC 主站请求标记埋点分阶段实施与验收计划 + +更新时间:2026-09-02 +关联 Issue: + +- `#225 添加客户端埋点统计`:本计划全部实施范围 +- `#226 添加客户端特殊标识`:客户端已完成;仅作为固定交接输入 + +当前状态:阶段 0、阶段 1、阶段 2、阶段 3、阶段 4、阶段 5、阶段 6 已完成。阶段 1~5 的实现已提交:`2296f79fd`、`2a23ba657`、`01159a269`、`c75c61521`、`c5170669e`;日登录埋点构造器回归修复为 `0a06b4988`。阶段 6 仅补充最终验收、交接和门禁文档,不新增生产代码。 + +## 1. 交付目标 + +主站识别有效的: + +```http +X-Genarrative-Client: agc +``` + +并在已有 route tracking 的 `tracking_event.metadata_json` 中记录: + +```json +{ + "client": "agc" +} +``` + +同时按真实认证主体归属:登录账号态使用 `AuthenticatedAccessToken` 的用户,External API Key 态使用 `ExternalApiPrincipal.owner_user_id()`;不新增平行事件体系,不改鉴权、计费、幂等和 API 契约。 + +## 2. 固定不做项 + +本计划不修改: + +- AGC 客户端 Header 注入和 Rust Client factory。 +- `tracking_event` schema、migration、bindings 或索引。 +- `/api/external/v1` OpenAPI、DTO、HTTP 方法、状态码和鉴权。 +- 失败请求的全新 tracking 事实。 +- 公开 discovery、Skill、MCP、OSS、签名下载、Provider、搜索、loopback 和更新下载的普通 AGC 业务统计。 +- 后台新筛选器或报表页面;第一阶段只复用原始 metadata 查询。 + +## 3. 阶段总览 + +```text +阶段 0 现状基线与交接契约冻结(已完成) + ↓ +阶段 1 Marker 解析与 tracking 入口接入 + ↓ +阶段 2 认证主体归属与 metadata 合并 + ↓ +阶段 3 账号态 / External v1 route coverage + ↓ +阶段 4 tracking_event、outbox 与后台读取验证 + ↓ +阶段 5 定向测试、安全边界与语义回归 + ↓ +阶段 6 最终门禁与 #226 交接收口 +``` + +每阶段先通过本阶段验收,再进入下一阶段。阶段 1~6 均只改主站或相关测试/文档,不要求 #226 回改。 + +## 4. 阶段 0:现状基线与交接契约冻结 + +### 4.1 工作内容 + +- 确认当前分支、HEAD、工作树和 `origin/master` 关系。 +- 核对 `app.rs` tracking middleware、`tracking.rs` route spec、outbox 和 `tracking_event` 写入链路。 +- 核对 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal` 的 response extension 传播。 +- 读取 AGC 调用清单和 External v1 router,冻结正向/排除路径。 +- 固定 Header、metadata、未知值行为、主体归属和成功响应语义。 + +### 4.2 阶段边界 + +- 不修改生产代码。 +- 不新增 schema 或后台代码。 +- 不把 Issue226 的客户端实现重新打开。 + +### 4.3 验收标准 + +- [x] 当前基线和代码缺口已记录。 +- [x] `X-Genarrative-Client: agc` 的识别规则已固定。 +- [x] `metadata_json.client = "agc"` 的落库形态已固定。 +- [x] 账号态与 External API Key 态的主体归属已固定。 +- [x] External v1 discovery/Skill/MCP 与第三方边界已列明。 +- [x] 未修改主站生产代码、schema、OpenAPI 或后台。 + +### 4.4 阶段产物 + +- [【阶段验收】Issue225阶段0现状基线与边界冻结-2026-09-02.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue225阶段0现状基线与边界冻结-2026-09-02.md) +- 本实施方案。 +- 本分阶段验收计划。 + +## 5. 阶段 1:Marker 解析与 tracking 入口接入 + +### 5.1 工作内容 + +修改范围限定在 `api-server` tracking 入口及定向测试: + +- 在 `record_api_tracking_after_success` 的 `next.run(request)` 之前读取请求 Header。 +- 增加白名单解析:首尾空白可清理,值必须精确为小写 `agc`。 +- 将解析结果传入现有 `record_route_tracking_event_after_success`。 +- 不把原始未知值、完整 Header、token 或请求体写入日志/metadata。 + +### 5.2 阶段验收 + +定向单元测试必须证明: + +- 有效 `agc` 能被识别。 +- Header 名大小写变化仍能识别。 +- 缺失、空值、`AGC`、未知值均按未标记处理。 +- 未标记请求仍走原有 route tracking 逻辑,不被拒绝。 +- 认证、响应状态和现有 request context 行为不变。 + +### 5.3 阶段完成判据 + +- [x] marker 只在统一 tracking 入口解析一次。 +- [x] 业务 handler 无需逐个读取 Header。 +- [x] 尚未接入主体归属和 External v1 route 扩展之外,不发生无关改动。 + +### 5.4 阶段 1 实现与验证记录 + +- `app.rs` 在 `next.run(request)` 前调用统一 marker 解析函数,并把解析结果传入 route tracking。 +- `tracking.rs` 只接受 Header 名 `X-Genarrative-Client`(HTTP 名称大小写不敏感)和值 `trim` 后精确等于小写 `agc` 的请求。 +- 解析结果以内部 `TrackingClientMarker::Agc` 保存到 `TrackingEventDraft`,本阶段不改变 metadata 内容;metadata 合并由阶段 2 完成。 +- 未标记、未知值和无效 Header 值均得到 `None`,不会写入日志,也不会拒绝请求。 +- 定向测试:`tracking::tests::tracking_client_marker_accepts_only_trimmed_lowercase_agc` 通过。 +- `cargo fmt --manifest-path server-rs/crates/api-server/Cargo.toml`、`git diff --check` 通过。 +- 当前未运行完整 `api-server` 测试套件;现有编译过程已通过,完整测试按阶段 5/CI 统一执行。 + +## 6. 阶段 2:认证主体归属与 metadata 合并 + +### 6.1 工作内容 + +- 从最终 response extensions 读取 `AuthenticatedAccessToken`。 +- 同时读取 `ExternalApiPrincipal`。 +- 账号态沿用现有 user_id/owner_user_id 语义。 +- External API Key 态设置 `owner_user_id = principal.owner_user_id()`,User scope 的 scope_id 使用 owner,避免回退到 `anonymous`。 +- 在 `build_route_tracking_metadata` 原有 JSON object 上追加 `client: "agc"`,保留原字段和资产嵌套 metadata。 +- 第一阶段不写 API Key 明文;不新增 `externalApiKeyId`,除非评审明确要求单 Key 统计。 + +### 6.2 阶段验收 + +- 账号态成功请求的 user_id 与现有 access token 用户一致。 +- External v1 成功请求的 owner_user_id 与 `ExternalApiPrincipal.owner_user_id()` 一致。 +- Header 不能覆盖、伪造或替换认证主体。 +- 有效 marker 时 metadata 含 `client: "agc"`;无效/缺失时不含该键。 +- `route`、`method`、`status`、`operation` 和已有嵌套字段均保留。 +- token、API Key 明文和签名 URL 不进入 metadata 或日志。 + +### 6.3 阶段完成判据 + +- [x] route tracking draft 在两类主体下都能生成正确的结构化归属。 +- [x] metadata 合并逻辑有独立测试,不能只依赖端到端偶然覆盖。 +- [x] 不改变 event id、outbox 和 daily stat 逻辑。 + +### 6.4 阶段 2 实现与验证记录 + +- `app.rs` 从最终 response extensions 同时读取 `AuthenticatedAccessToken` 和 `ExternalApiPrincipal`。 +- `tracking.rs` 优先使用已验证的 External API Key owner;External API Key 请求不伪造 `user_id`,User scope 的 `scope_id` 使用 `owner_user_id`。 +- 账号态继续使用 access token claims 的 user id,同时写入 `user_id` 和 `owner_user_id`,保持原有语义。 +- `build_route_tracking_metadata` 只在有效 marker 时追加 `client: "agc"`,保留 route、method、status、operation 和 asset 嵌套 metadata;无 marker 不写 client。 +- 定向测试覆盖账号态主体、External API Key owner 优先级、User scope owner 和 metadata 字段保留。 +- 当前未修改 External v1 route spec、schema、OpenAPI、outbox 或后台页面。 + +## 7. 阶段 3:账号态与 External v1 Route Coverage + +### 7.1 工作内容 + +以 `local-docs/【扫描清单】AGC客户端主站调用与可标记点-2026-09-01.md` 和 `modules/external_api.rs` 为输入,补齐显式 route spec: + +- 账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/external-generation/jobs/{id}` 的实际 AGC 路径。 +- External API Key 态 `/api/external/v1/assets/*`、`/editor/*`、`/generations/{id}` 的实际业务路径。 +- 生成提交、轮询、项目读取/资源登记、素材库、上传凭证、对象确认、换签读取等当前已确认调用。 +- 每个新增 spec 固定 event key、module key、scope kind 和动态路径归一化方式。 + +本阶段新增的 route spec 统一使用 AGC-only 记录策略:只有带有效 marker 的 2xx 请求才写入;既有 route spec 保持原有全客户端语义,上传票据/对象确认等 `handled_by_existing_event` 路径继续由手工资产事件负责。 + +同时明确不加入: + +- `/api/external/v1/openapi.json`。 +- `/api/external/v1/agent-integration.json`。 +- `/api/external/v1/skill/SKILL.md`、`skill.zip`。 +- `/api/external/v1/mcp`。 + +### 7.2 阶段验收 + +- AGC 当前实际调用清单中的每个 method + path 都能解析到 spec。 +- External v1 记录实际外部 path,不被映射为内部 `/api/editor` 等路径。 +- 发现/MCP 路径不会误进入普通业务 route tracking。 +- project/asset/operation 动态 ID 仍按现有规则归一化。 +- 原有 route spec 的 event key、scope 和统计口径不变。 +- 新增 AGC route 在未携带有效 marker 时不产生 route tracking event,且不影响业务响应。 + +### 7.3 阶段完成判据 + +- [x] 有一张可审计的“AGC 调用清单 → route spec → event key”矩阵。 +- [x] 新增路径均有 resolver 测试;未确认的 OpenAPI 潜在路径单独记录,不混入已实现范围。 +- [x] 没有用 catch-all `agc_client_request` 取代显式 route tracking。 + +### 7.4 阶段 3 实现与验证记录 + +- `tracking.rs` 补齐当前 AGC 实际使用的账号态 `/api/auth`、`/api/profile`、`/api/assets`、`/api/editor`、`/api/runtime/external-generation/jobs/{id}` 路径。 +- `tracking.rs` 补齐当前 AGC 实际使用的 External v1 资产、项目、素材库、生成提交和任务轮询路径;External v1 使用独立的实际外部 path 进入 metadata,不映射回账号态内部路径。 +- 上传票据和对象确认已有详细资产事件,route spec 以 `handled_by_existing_event` 标记为复用现有事件,统一 route tracking 不再重复写入;tracking middleware 将有效 marker 放入 request extensions,由共享资产 handler 将 `client: "agc"` 合并到既有资产 metadata,并补齐实际 route/method/status/operation。 +- 共享资产事件沿用真实主体:账号态写入 user/owner;External API Key 态只写 `owner_user_id`,不伪造登录 `user_id`,User scope 使用 owner。 +- 扩展动态路径归一化的静态段白名单,使 project、generation、asset 和 external v1 路径按既有 `{id}` 规则正确匹配;未引入 catch-all AGC 事件。 +- discovery、agent-integration、Skill、skill.zip 和 MCP 路径没有业务 route spec。 + +已执行定向验证: + +```text +cargo fmt --manifest-path server-rs/crates/api-server/Cargo.toml -- --check +cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::asset_tracking_metadata_receives_only_valid_agc_marker -- --nocapture +git diff --check +``` + +结果:tracking 定向测试 13 个通过,资产 marker/route metadata 与 External owner 归属测试 2 个通过,api-server 编译检查通过。 + +## 8. 阶段 4:tracking_event、outbox 与后台读取验证 + +### 8.1 工作内容 + +- 验证 `TrackingEventDraft.metadata` 经 `build_tracking_event_input` 后仍是合法 JSON object。 +- 验证本机 tracking outbox 入队和 SpacetimeDB 回退直写都保留 `client`。 +- 验证既有 `tracking_event` 行和 `tracking_daily_stat` 聚合未改变。 +- 验证现有后台 tracking API 返回 `metadata_json` 中的 `client`。 +- 不新增 schema/migration/bindings,不修改后台页面。 + +### 8.2 阶段验收 + +- AGC 成功事件可在 tracking_event 读回 `client: "agc"`。 +- unmarked 事件不会被补写 `client`。 +- owner_user_id、user_id、scope_id、event_key 和 occurred_at 不丢失。 +- 后台原始事件查询和已有 event key 查询不回归。 +- `npm run check:spacetime-schema` 不因本阶段产生 schema 变更;若未改 schema,可记录为无需运行。 + +### 8.3 阶段完成判据 + +- 至少有一条账号态和一条 External Key 态从 tracking draft 到后台读取的完整证据。 +- 已确认第一阶段不需要新增数据库字段。 + +### 8.4 阶段 4 实现与验证记录 + +- `api-server` tracking 测试分别构造账号态和 External API Key 态输入,验证 `TrackingEventDraft → build_tracking_event_input` 后 metadata 仍是合法 JSON object,`client: "agc"`、route、status、event key、module、scope 和 user/owner 字段均保留;未标记输入不补写 `client`。 +- tracking outbox 测试使用 External v1 AGC 事件完成 NDJSON 入队和读取 round-trip,验证 `metadata_json.client`、实际 External v1 route、`owner_user_id` 和“不伪造 `user_id`”均保留。 +- `spacetime-client` active mapper 测试验证送往生成绑定的 `RuntimeTrackingEventInput` 不丢失 `client`、External owner、scope、event key 和 module;module-runtime 的统一输入校验测试验证 metadata 必须是 JSON object,标记对象可正常通过。由于本地未启动 SpacetimeDB,未把确定性 input/mapper 验证扩大解释为真实远端库 E2E。 +- 后台 tracking SQL response parser 测试分别覆盖账号态与 External API Key 态,并增加 draft/input → SQL row → parser 组合回归,验证管理员现有原始事件读取能保留 `metadata_json.client`、实际 route 和 user/owner 归属。 +- 未修改 tracking schema、migration、bindings、outbox 失败回退策略、daily stat 聚合逻辑或后台页面;SpacetimeDB 的实际入库仍复用既有 `record_tracking_event` / `record_tracking_events` procedure,未新增字段。 + +已执行定向验证: + +```text +cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check +cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking_outbox::tests:: -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::parse_admin_tracking_events_sql_response_ -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture +``` + +结果:module-runtime 2 个、api-server tracking 15 个、tracking outbox 8 个、后台 readback parser 5 个、spacetime-client mapper 1 个定向测试全部通过;格式检查通过。未运行完整工作区测试和 `npm run check:spacetime-schema`,因为本阶段未修改 schema。 + +## 9. 阶段 5:定向测试、安全边界与语义回归 + +### 9.1 工作内容 + +补齐并运行与改动直接相关的测试: + +- marker 解析表驱动测试。 +- route metadata 合并测试。 +- 账号态主体归属测试。 +- ExternalApiPrincipal owner 归属测试。 +- External v1 route resolver 覆盖测试。 +- discovery/MCP 排除测试。 +- 2xx 记录、4xx/5xx 不新增成功 route 事件测试。 +- `build_router` 轻量 middleware 成功链路测试:真实 HTTP request 经认证、tracking middleware 后写入隔离 outbox,并断言 `client`、route 和主体归属。 +- outbox/SpacetimeDB input 和后台 tracking readback 测试。 + +CI 已覆盖且与本改动无直接关系的全量测试可交给 CI;本阶段仍必须运行能直接证明本 Issue 契约的 targeted tests。 + +### 9.2 阶段验收 + +- 有效 marker 正向场景全部通过。 +- 缺失/空值/未知 marker 负向场景全部通过。 +- 登录账号态和 External Key 态主体归属均通过。 +- 未发生鉴权、状态码、幂等、请求体或异步轮询回归。 +- 测试输出不包含 token、API Key 明文、Cookie、签名 URL 或本地私密路径。 + +### 9.3 阶段完成判据 + +- 测试矩阵覆盖“标记/未标记 × 账号态/External Key 态 × 已覆盖/排除路由”。 +- 所有失败都能定位到 marker、主体、route、持久化或后台读取中的具体层。 + +### 9.4 阶段 5 实现与验证记录 + +- `tracking.rs` 抽出 `should_record_route_tracking` 判定,明确新增 AGC-only route 只有在带有效 marker 的 2xx 响应下才进入统一成功 route tracking;既有全客户端 route 保持原语义,4xx/5xx 和上传确认等复用事件均不新增重复成功事件。 +- 新增 2xx、4xx/5xx 状态矩阵测试,覆盖 `OK / CREATED / ACCEPTED / NO_CONTENT` 与认证、权限、客户端、服务端失败状态。 +- 新增 AGC-only route 正反向门禁测试:有效 marker 记录,缺失或无效 marker 不记录;既有 route 和手工资产事件策略保持不变。 +- 新增 `app::tests::agc_marker_flows_through_auth_tracking_middleware_to_outbox`,不启动真实 SpacetimeDB,仅验证 middleware 到 outbox 的最小闭环。 +- 新增 `app.rs` 路由回归:带有效 `X-Genarrative-Client: agc` 的未认证业务请求仍返回 `401`,marker 不绕过鉴权,也不改变失败状态。 +- 新增 route metadata 安全边界测试,确认统一 route metadata 只包含既有 route/method/status/operation 和可选 `client`/资产字段,不出现 authorization、token、API Key、Cookie、签名 URL 或 request body 字段。 +- 阶段 4 的账号态、External API Key、outbox、后台 readback 和 mapper 测试全部复跑;SpacetimeDB 既有 event-id 幂等回归也通过。未修改鉴权、请求体、异步轮询、event id、daily stat 或失败回退实现。 + +已执行定向验证: + +```text +cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check +cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking::tests:: -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::asset_tracking_metadata_receives_only_valid_agc_marker -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server assets::tests::external_asset_tracking_keeps_owner_without_forging_user -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking_outbox::tests:: -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::parse_admin_tracking_events_sql_response_ -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server admin::tests::tracking_inputs_round_trip_to_admin_readback_for_both_subjects -- --nocapture +cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture +``` + +结果:module-runtime 2 个、spacetime-client 1 个、api-server tracking 18 个、app 鉴权回归 1 个、资产 marker/owner 2 个、tracking outbox 8 个、后台 readback 5 个、spacetime-module 幂等 1 个定向测试全部通过;格式检查通过。未运行与本 Issue 无直接关系的完整工作区测试,按约定交给 CI。 + +## 10. 阶段 6:最终门禁与 #226 交接收口 + +### 10.1 工作内容 + +- 汇总阶段 1~5 的测试和 readback 证据。 +- 更新本方案、验收记录和 Issue #225 评论草案。 +- 复核 #226 交接契约不需要客户端回改。 +- 检查 diff、编码、格式、敏感信息和无关文件。 + +### 10.2 最终门禁 + +按实际改动范围运行: + +- `cargo fmt --manifest-path server-rs/Cargo.toml -- --check`。 +- `cargo test --locked -p api-server` 的 tracking/External v1 定向测试。 +- 必要时 `cargo check --locked -p api-server`。 +- `npm run check:encoding`。 +- `git diff --check`。 +- `git status --short`,确认没有构建产物、日志、凭据或无关文件。 + +未修改 schema 时不运行 schema 生成;若实现阶段意外需要 schema,必须停在阶段 4 重新确认迁移范围,不能顺手修改。 + +### 10.3 阶段完成判据 + +- 主站可以在现有 tracking_event metadata 中看到 `client: "agc"`。 +- 两类认证主体归属正确。 +- 当前 AGC 实际业务路径无漏记,发现/MCP/第三方边界无误记。 +- 后台现有查询可读,无需新 schema 或新页面。 +- 所有必要 targeted tests、格式/编码/空白检查通过。 +- 向 #225 交付固定 Header、metadata、主体归属、路由覆盖和验证证据;不要求 #226 回改。 + +### 10.4 阶段 6 实际执行与结果 + +阶段 6 以当前分支 `feat/agc_call_rec`、`HEAD=5aa38d9f3` 为基线;该提交已合并最新 `origin/master`(`8932f0b27`)。本阶段未修改 `server-rs`、SpacetimeDB schema、OpenAPI 或后台页面,只更新验收和交接文档。 + +最终门禁结果: + +| 验收项 | 命令/证据 | 结果 | +|---|---|---| +| api-server 编译 | `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server` | 通过;仅有仓库既有 dead-code warning | +| api-server tracking/资产/后台/outbox 定向测试 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking -- --nocapture` | 通过,43 tests passed | +| marker 不绕过鉴权回归 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture` | 通过,1 test passed | +| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 通过,2 tests passed | +| spacetime-client mapper | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture` | 通过,1 test passed | +| SpacetimeDB event-id 幂等 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture` | 通过,1 test passed | +| Rust 格式 | `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | 通过 | +| 中文编码 | `npm run check:encoding` | 通过,5662 个文件 | +| Diff 空白与分支基线 | `git diff --check`、`git merge-base --is-ancestor origin/master HEAD` | 通过 | + +上述测试合计 48 个与本 Issue 直接相关的测试通过。完整工作区测试按用户约定交由 CI;本阶段未启动真实 SpacetimeDB,也未执行发布环境线上写入,因此最终证据是本地确定性 input/mapper、outbox、后台 parser 和 route tracking 回归,不把本地测试表述为线上 E2E。 + +### 10.5 阶段 6 交接结论 + +- 主站只对白名单值 `X-Genarrative-Client: agc` 追加 `metadata_json.client = "agc"`;缺失、空值、`AGC` 和未知值按未标记处理,不拒绝请求。 +- 本期新增的 AGC route spec 只有在带有效 marker 的 2xx 响应下才记录;既有 route spec 与手工资产事件保持原有全客户端统计语义。 +- 账号态沿用真实 access token 用户;External API Key 态使用 `ExternalApiPrincipal.owner_user_id()`,不伪造 `user_id`。 +- 当前 AGC 实际账号态和 External v1 业务路径均有显式 route spec;动态 ID 继续归一化,External v1 保留实际外部 path。 +- OSS、签名下载、Provider、受控搜索、loopback、更新下载、公开 discovery/Skill/MCP 不进入普通 AGC 业务 route tracking。 +- 最终记录仍落在现有 SpacetimeDB `tracking_event.metadata_json`,经过本机 `server-rs/.data/tracking-outbox/` 临时缓冲后由既有 tracking procedure 写入;不新增 schema、索引、后台页面或独立事件体系。 +- `#226` 的客户端标记注入和 origin-safe redirect 契约已满足本 Issue 输入要求,不需要回改 #226 设计。 +- 另有一个独立于 #225 的客户端后续项:自定义 origin 使用显式默认端口或大写主机名时,`clientHttp.ts` 的字符串与 `URL.origin` 比较可能误判为跨 origin;它只会拒绝合法请求,不会泄漏 marker,应单独在 #226 跟踪。 + +阶段 6 详细验收记录与可直接粘贴到 Issue #225 的交付评论见: + +- [【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md](C:/projects/narrative/Genarrative/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md) + +## 11. 建议提交分组 + +建议按以下逻辑分组提交,便于逐阶段验收: + +1. 阶段 1:marker 解析与 tracking 入口。 +2. 阶段 2:主体归属与 metadata 合并。 +3. 阶段 3:route spec 与 External v1 覆盖。 +4. 阶段 4~5:持久化/后台验证、定向测试和安全回归。 +5. 阶段 6:验收记录、Issue 交接评论和最终门禁。 + +如仓库要求单提交,也应在 PR 描述中按上述五组列出,确保每组都有独立验收标准。 diff --git a/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md b/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md new file mode 100644 index 000000000..a2e7d1003 --- /dev/null +++ b/local-docs/【阶段验收】Issue225阶段5定向测试与安全边界-2026-09-02.md @@ -0,0 +1,105 @@ +# Issue225 阶段 5:定向测试与安全边界 + +更新时间:2026-09-02 +关联分支:`feat/agc_call_rec` + +## 1. 阶段交付结果 + +本阶段补齐 Issue #225 直接相关的状态码、鉴权、安全 metadata 和幂等回归,确认 `X-Genarrative-Client: agc` 只是来源审计标签,不改变认证、权限、计费、请求体、异步轮询或成功事件语义。 + +## 2. 新增回归 + +### 2.1 成功状态语义 + +`tracking.rs` 新增 `should_record_route_tracking` 判定测试: + +- `200 / 201 / 202 / 204` 的显式业务 route 可以进入成功 tracking; +- `400 / 401 / 403 / 404 / 500 / 502` 即使带 AGC marker,也不会新增成功 route event; +- 已由详细资产事件处理的上传票据和对象确认不重复生成统一 route event。 + +### 2.2 AGC-only 路由门禁 + +本期新增、仅为 AGC 调用清单补齐的账号态和 External v1 route spec 均要求有效 `X-Genarrative-Client: agc`: + +- 带有效 marker 的 2xx 响应才写入 route tracking; +- 缺失、非法、未知或大小写不符合契约的 marker 不写入这些新增 route event,也不拒绝业务请求; +- 既有 route spec 和已有详细资产事件保持原有全客户端记录语义。 + +### 2.3 鉴权边界 + +`app.rs` 通过真实 router 发起未认证的: + +```text +GET /api/editor/projects +X-Genarrative-Client: agc +``` + +响应仍为 `401 Unauthorized`。marker 不会获得权限,也不会改变失败状态。 + +### 2.4 metadata 安全边界 + +route metadata 测试确认不会写入: + +```text +authorization +accessToken +token +apiKey +cookie +signature +signedUrl +requestBody +``` + +有效 marker 只产生固定的 `client: "agc"`,并保留既有 route/method/status/operation 与资产嵌套字段。 + +### 2.5 既有链路复回归 + +阶段 4 的以下路径在阶段 5 再次顺序执行: + +- `TrackingEventDraft → RuntimeTrackingEventInput`; +- tracking outbox NDJSON round-trip; +- `spacetime-client` mapper; +- module-runtime metadata object 校验; +- 后台 tracking SQL response parser; +- 账号态 / External API Key owner 归属; +- SpacetimeDB tracking event-id 幂等回归。 + +## 3. 验收结果 + +- [x] marker 有效值、缺失值、空值、大小写和未知值测试通过。 +- [x] 账号态 `user_id = owner_user_id` 归属测试通过。 +- [x] External API Key 态只写真实 `owner_user_id`,不伪造 `user_id`,scope 使用 owner。 +- [x] 当前账号态和 External v1 业务 route coverage 测试通过。 +- [x] discovery、Skill、MCP 排除测试通过。 +- [x] 2xx 成功记录和 4xx/5xx 不新增成功 route event 测试通过。 +- [x] AGC marker 不绕过鉴权、不改变失败状态。 +- [x] route metadata 不包含 token、API Key、Cookie、签名 URL 或请求体字段。 +- [x] outbox、mapper、runtime input 和后台 readback 回归通过。 +- [x] event-id 幂等回归通过。 + +## 4. 定向验证结果 + +| 测试范围 | 通过数 | +|---|---:| +| `module-runtime` tracking input | 2 | +| `spacetime-client` tracking mapper | 1 | +| `api-server` tracking | 18 | +| `api-server` app 鉴权回归 | 1 | +| `api-server` 资产 marker / owner | 2 | +| `api-server` tracking outbox | 8 | +| `api-server` 后台 readback | 5 | +| `spacetime-module` event-id 幂等 | 1 | +| 合计 | 38 | + +另外通过: + +- `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` +- `git diff --check` +- `npm run check:encoding` + +未运行与本 Issue 无直接关系的完整工作区测试,按约定交给 CI。没有启动真实 SpacetimeDB,因此没有把确定性 procedure input/mapper 和 admin parser 回归扩大解释为远端数据库 E2E。 + +## 5. 后续阶段 + +阶段 6 汇总阶段 1~5 的证据,复核 #226 交接契约、执行最终门禁并准备 Issue #225 交付说明。阶段 5 代码已提交为 `c5170669e`;日登录埋点构造器回归修复补充提交为 `0a06b4988`。 diff --git a/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md b/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md new file mode 100644 index 000000000..a36a13acd --- /dev/null +++ b/local-docs/【阶段验收】Issue225阶段6最终门禁与交接收口-2026-09-02.md @@ -0,0 +1,148 @@ +# Issue225 阶段6:最终门禁与 #226 交接收口 + +更新时间:`2026-09-02` +实施范围:`#225 添加客户端埋点统计` +交接输入:`#226 添加客户端特殊标识` + +执行结论:阶段 6 通过,Issue #225 的主站接收、tracking metadata、主体归属、路由覆盖、持久化/后台读取验证和安全语义回归已收口。本阶段没有新增生产功能,没有修改 #226 客户端实现,不需要 #226 回改设计。 + +## 1. 阶段边界 + +本阶段只完成: + +1. 汇总阶段 1~5 的实现提交、定向测试和 readback 证据。 +2. 复核 `#226` 交接的 Header、origin、认证和第三方边界。 +3. 执行与本 Issue 直接相关的最终编译、测试、格式、编码和空白门禁。 +4. 更新实施方案、分阶段计划和 Issue #225 交付评论草案。 + +本阶段明确不做: + +- 不修改 `AGC` 客户端的 `fetchClientHttp`、Rust Client factory、请求终结器或 redirect policy。 +- 不新增 `tracking_event` 列、索引、migration、生成 bindings 或新的统计表。 +- 不修改 `/api/external/v1` 的 OpenAPI、DTO、HTTP 方法、状态码、鉴权或异步语义。 +- 不新增后台筛选器、报表页面或真实发布环境线上写入。 + +## 2. 当前仓库基线 + +阶段 6 开始时仓库状态: + +| 项目 | 结果 | +|---|---| +| 分支 | `feat/agc_call_rec` | +| HEAD | `5aa38d9f3`(已合并最新 `origin/master`) | +| `origin/master` | `8932f0b27` | +| 工作树 | 开始阶段 6 时干净;本记录及计划/方案更新属于本阶段待提交文档变更 | + +阶段 1~5 的实现提交: + +| 阶段 | 提交 | +|---|---| +| 阶段 1:marker 解析与 tracking 入口 | `2296f79fd` | +| 阶段 2:主体归属与 metadata | `2a23ba657` | +| 阶段 3:路由覆盖 | `01159a269` | +| 阶段 4:持久化与后台读取 | `c75c61521` | +| 阶段 5:安全与语义回归 | `c5170669e` | +| 日登录埋点构造器回归修复 | `0a06b4988` | + +## 3. 最终交付行为 + +### 3.1 Marker 与 metadata + +主站识别: + +```http +X-Genarrative-Client: agc +``` + +规则: + +- Header 名按 HTTP 规则大小写不敏感。 +- Header 值去除首尾空白后,必须精确等于小写 `agc`。 +- 缺失、空值、`AGC` 或未知值按未标记处理,不拒绝请求。 +- 有效标记只追加到已有成功 route tracking 的 `tracking_event.metadata_json`: + +```json +{ + "route": "/api/editor/projects", + "method": "GET", + "status": 200, + "operation": "listEditorProjects", + "client": "agc" +} +``` + +不写入 Header 原文、token、API Key、Cookie、签名 URL、请求体或项目绝对路径。 + +本期新增的 AGC route spec 只有在请求带有效 marker 时才记录;既有 route spec 与手工资产事件沿用原有全客户端记录策略。 + +阶段 6 后续补充了一条轻量 middleware 成功链路回归:真实 `build_router` 请求经过账号认证和 tracking middleware 后写入隔离 outbox,并验证 `client`、实际 route、user/owner 归属;该测试不启动真实 SpacetimeDB,不改变完整环境型 E2E 仍作为发布前 smoke 的边界。 + +### 3.2 主体归属 + +- 登录账号态:使用已验证 access token 的真实用户,保留既有 `user_id`、`owner_user_id` 和 scope 语义。 +- External API Key 态:使用 `ExternalApiPrincipal.owner_user_id()`;不伪造登录 `user_id`,User scope 的 `scope_id` 使用 owner。 +- Header 只是来源审计标签,不能绕过认证、权限、计费或替换主体。 + +### 3.3 路由覆盖与排除 + +已覆盖的实际业务路径: + +- 账号态:`/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*`。 +- External API Key 态:`/api/external/v1/*` 的当前资产、项目、素材库、生成提交和任务轮询业务路径。 +- 动态 project/generation/asset ID 继续按显式静态段规则归一化;External v1 metadata 保留实际外部 path。 +- 上传票据和对象确认沿用已有详细资产事件,不重复生成普通 route event,但会保留有效 AGC metadata。 + +明确排除: + +- OSS multipart 上传、签名 URL/OSS 媒体下载。 +- LLM/Codex Provider、AGC 受控搜索、loopback 工具桥。 +- 更新清单、更新包下载和任意外部网页请求。 +- `/api/external/v1/openapi.json`、`agent-integration.json`、Skill 文档/压缩包和 MCP 入口。 + +### 3.4 落库与读取位置 + +AGC 调用记录最终落在主站 SpacetimeDB 的 `tracking_event` 表,标识位于 `tracking_event.metadata_json.client`。普通 route tracking 默认先进入 api-server 本机: + +```text +server-rs/.data/tracking-outbox/active.ndjson +server-rs/.data/tracking-outbox/sealed-*.ndjson +``` + +worker 使用既有 `record_tracking_events_and_return` 批量 procedure 写入 SpacetimeDB;outbox 不可用时沿用同步 `record_tracking_event_and_return` 回退。后台 `GET /admin/api/tracking/events` 读取同一 `tracking_event` 的 `metadata_json`,当前没有单独的 `client=agc` 查询参数,需要从返回 JSON 中识别 `"client": "agc"`。 + +## 4. 最终门禁证据 + +| 验收项 | 命令 | 结果 | +|---|---|---| +| api-server 编译 | `cargo check --locked --manifest-path server-rs/Cargo.toml -p api-server` | 通过;仅仓库既有 warning | +| api-server tracking/资产/后台/outbox 定向测试 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server tracking -- --nocapture` | 43 passed,0 failed | +| marker 不绕过鉴权回归 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p api-server app::tests::agc_marker_does_not_bypass_authentication_or_change_failure_status -- --nocapture` | 1 passed,0 failed | +| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 2 passed,0 failed | +| spacetime-client mapper | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-client tracking_input_mapper_preserves_agc_metadata_and_external_owner -- --nocapture` | 1 passed,0 failed | +| SpacetimeDB event-id 幂等 | `cargo test --locked --manifest-path server-rs/Cargo.toml -p spacetime-module duplicate_tracking_event_ids_are_treated_as_idempotent_replays -- --nocapture` | 1 passed,0 failed | +| Rust 格式 | `cargo fmt --manifest-path server-rs/Cargo.toml --all -- --check` | 通过 | +| 中文编码 | `npm run check:encoding` | 通过,5662 个文件 | +| Diff 空白/分支基线 | `git diff --check`、`git merge-base --is-ancestor origin/master HEAD` | 通过 | + +与本 Issue 直接相关的本轮定向测试合计 48 个通过。完整工作区测试不在本地重复运行,按约定交给 CI;本地未启动真实 SpacetimeDB,因此没有把 input/mapper/readback parser 测试表述为线上 E2E。 + +## 5. #226 交接复核 + +`#226` 已提供并冻结以下输入: + +- 发往当前 Genarrative 主站 origin 的业务请求带 `X-Genarrative-Client: agc`。 +- OSS、签名下载、Provider、受控搜索、loopback、更新下载和外部网页请求不带该标记。 +- 同源重定向继续允许;跨 origin 重定向被阻断,避免标记泄漏到第三方 origin。 +- 请求级同名 Header 不能伪造最终值,主站业务请求最终仍为 `agc`。 + +主站 #225 已按上述契约消费 Header;不存在要求 #226 重新设计或回改的接口缺口。 + +补充记录一个不属于 #225 的客户端后续项:`clientHttp.ts` 当前把自定义服务器地址的规范化字符串直接与 `URL.origin` 比较;显式默认端口(例如 `https://example.com:443`)或主机名大小写可能导致合法自定义 origin 被误判为跨 origin。该问题表现为请求被客户端拒绝,不会造成 AGC 标记泄漏,也不影响当前 release/dev 默认地址;应作为 #226 的独立客户端修复跟踪,不能在 #225 中偷偷回改客户端设计。 + +## 6. 可直接粘贴到 Issue #225 的交付评论 + +> `#225` 主站侧已完成并收口:统一识别 `X-Genarrative-Client: agc`,本期新增的 AGC-only 成功 route 只有在带有效 marker 时才写入 `tracking_event.metadata_json.client = "agc"`;既有 route tracking 和手工资产事件保持原有全客户端统计语义。账号态按已验证 access token 的真实用户归属,External API Key 态按 `ExternalApiPrincipal.owner_user_id()` 归属,不伪造 `user_id`,不记录 token/API Key/Cookie/签名 URL。当前 AGC 实际使用的账号态 `/api/auth/*`、`/api/profile/*`、`/api/assets/*`、`/api/editor/*`、`/api/runtime/*` 与 External v1 业务路径均有显式 route spec;动态 ID 继续归一化,External v1 保留实际外部 path。OSS/签名下载、Provider、受控搜索、loopback、更新下载、公开 discovery/Skill/MCP 不进入普通 AGC 业务统计。记录最终复用既有 outbox、SpacetimeDB procedure、daily stat、event-id 幂等和后台 `GET /admin/api/tracking/events` 读取,不新增 schema、页面或独立事件体系。阶段 1~5 的定向实现和回归已提交,阶段 6 最终门禁通过:api-server tracking/资产/后台/outbox 43 个、鉴权回归 1 个、module-runtime 2 个、spacetime-client 1 个、spacetime-module 1 个定向测试全部通过,Rust 编译/格式、编码和 diff 检查通过;完整工作区测试按 CI 执行。`#226` 客户端标记、origin-safe redirect 和第三方边界契约无需回改。` + +## 7. 后续发布前事项 + +本 Issue 代码和本地确定性验证已完成;发布或联调时由主站环境补做一次真实链路 smoke:使用已部署的 AGC 客户端请求主站业务接口,确认 Header 被接收、`tracking_event.metadata_json.client` 可由后台原始查询读回。该 smoke 是环境验证,不改变本 PR 的设计或代码范围。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 9085d8188..1742f5956 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -19,7 +19,7 @@ describe('AI 游戏创作 App 共享契约', () => { it('keeps command permissions explicit', () => { const commandIds = GAME_CREATION_APP_COMMANDS.map((command) => command.id); - expect(GAME_CREATION_APP_COMMANDS).toHaveLength(63); + expect(GAME_CREATION_APP_COMMANDS).toHaveLength(64); expect(commandIds).toContain('project.git_inspect'); expect(commandIds).toContain('project.git_commit'); expect(commandIds).toContain('project.patchset'); @@ -60,6 +60,11 @@ describe('AI 游戏创作 App 共享契约', () => { (command) => command.id === 'command.run_limited', )?.permission, ).toBe('confirm'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'project.rename', + )?.permission, + ).toBe('confirm'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'command.exec', diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 09c8a109d..1f39ad546 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -15,6 +15,7 @@ export interface GameCreationAppCommandDescriptor { export const GAME_CREATION_APP_COMMANDS = [ { id: 'help.show', permission: 'auto' }, { id: 'project.create', permission: 'confirm' }, + { id: 'project.rename', permission: 'confirm' }, { id: 'project.status', permission: 'auto' }, { id: 'project.index', permission: 'auto' }, { id: 'project.checkpoint', permission: 'confirm' }, diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index e99d87784..9fd7a7416 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -211,23 +211,17 @@ function assertNativeShellDependencyVersionGuardrails() { assertNativeShellDependencyVersionGuardrails(); const h5HostBridgeCallChainWrapperFiles = [ - 'src/hooks/useHostLifecycleActive.ts', 'src/hooks/useHostNavigationCanGoBack.ts', - 'src/hooks/useHostNetworkOnline.ts', 'src/components/platform-entry/platformProfileHostClipboard.ts', - 'src/components/platform-entry/platformHostBridgeSync.ts', ]; const h5HostBridgeRequiredCallChainFiles = [ 'src/ActiveApp.tsx', 'src/components/auth/AuthGate.tsx', 'src/components/platform-entry/PlatformFeedbackView.tsx', - 'src/components/platform-entry/PlatformProfileQrScannerModal.tsx', 'src/components/platform-entry/PlatformProfileReferralModal.tsx', 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx', 'src/components/platform-entry/usePlatformProfileCenterController.ts', - 'src/hooks/useHostLifecycleActive.ts', 'src/hooks/useHostNavigationCanGoBack.ts', - 'src/hooks/useHostNetworkOnline.ts', 'src/active-main.tsx', 'src/services/activeAppTitle.ts', 'src/services/authService.ts', @@ -2155,7 +2149,6 @@ const h5HostBridgeTests = [ 'src/services/host-bridge/nativeAppHostBridge.test.ts', 'src/components/auth/AuthGate.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx', - 'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx', 'src/routing/activeAppRoutes.test.ts', 'src/services/clipboard.test.ts', 'src/services/activeAppTitle.test.ts', diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 568d80a89..83e472426 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -9,6 +9,7 @@ default-members = [ exclude = [ "crates/agent-runtime-core", "crates/agent-runtime-orchestration", + "crates/platform-agent", ] members = [ "crates/api-server", diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index 1e0e708f2..a94d2fd47 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -1310,6 +1310,8 @@ pub async fn admin_create_editor_showcase_campaign_image_upload_ticket( &state, &request_context, admin.session().subject.as_str(), + Some(admin.session().subject.as_str()), + None, CreateDirectUploadTicketRequest { legacy_prefix: "generated-character-drafts".to_string(), path_segments: vec![ @@ -1365,6 +1367,8 @@ pub async fn admin_confirm_editor_showcase_campaign_image_upload( &state, &request_context, admin.session().subject.as_str(), + Some(admin.session().subject.as_str()), + None, confirm_request, ) .await @@ -4820,7 +4824,7 @@ mod tests { http::{Method, StatusCode}, response::IntoResponse, }; - use serde_json::json; + use serde_json::{Value, json}; use shared_contracts::admin::{ AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminDashboardQuery, AdminDatabaseTableRowsQuery, AdminExternalApiKeyListQuery, AdminTrackingEventListQuery, @@ -4833,7 +4837,11 @@ mod tests { SpacetimeClientStage, }; - use crate::{config::AppConfig, state::AppState}; + use crate::{ + config::AppConfig, + state::AppState, + tracking::{TrackingEventDraft, build_tracking_event_input}, + }; fn test_admin_editor_showcase_asset_record(owner_user_id: &str) -> EditorShowcaseAssetRecord { EditorShowcaseAssetRecord { @@ -6586,6 +6594,161 @@ mod tests { assert_eq!(entries[0].module_key.as_deref(), Some("profile")); } + #[test] + fn parse_admin_tracking_events_sql_response_preserves_agc_metadata_and_owner() { + let payload = json!([ + { + "rows": [ + [ + "account-event-225", + "editor_projects_view", + "user", + "user-225", + 20600, + {"some": "user-225"}, + {"some": "user-225"}, + null, + "editor", + "{\"route\":\"/api/editor/projects\",\"method\":\"GET\",\"status\":200,\"operation\":\"listEditorProjects\",\"client\":\"agc\"}", + "2026-09-02T00:00:00Z" + ], + [ + "external-event-225", + "editor_image_generation_submit", + "user", + "owner-225", + 20600, + null, + {"some": "owner-225"}, + null, + "editor", + "{\"route\":\"/api/external/v1/editor/images/generations\",\"method\":\"POST\",\"status\":202,\"operation\":\"generateExternalEditorImage\",\"client\":\"agc\"}", + "2026-09-02T00:00:01Z" + ] + ] + } + ]); + + let entries = parse_admin_tracking_events_sql_response(payload) + .expect("AGC tracking rows should be readable by the admin parser"); + + assert_eq!(entries.len(), 2); + let account = entries + .iter() + .find(|entry| entry.event_id == "account-event-225") + .expect("account event should be present"); + let account_metadata = serde_json::from_str::(&account.metadata_json) + .expect("account metadata should remain valid JSON"); + assert_eq!(account_metadata["client"], "agc"); + assert_eq!(account_metadata["route"], "/api/editor/projects"); + assert_eq!(account.user_id.as_deref(), Some("user-225")); + assert_eq!(account.owner_user_id.as_deref(), Some("user-225")); + + let external = entries + .iter() + .find(|entry| entry.event_id == "external-event-225") + .expect("External API Key event should be present"); + let external_metadata = serde_json::from_str::(&external.metadata_json) + .expect("External API Key metadata should remain valid JSON"); + assert_eq!(external_metadata["client"], "agc"); + assert_eq!( + external_metadata["route"], + "/api/external/v1/editor/images/generations" + ); + assert_eq!(external.user_id, None); + assert_eq!(external.owner_user_id.as_deref(), Some("owner-225")); + } + + #[test] + fn tracking_inputs_round_trip_to_admin_readback_for_both_subjects() { + let mut account_draft = TrackingEventDraft::new("editor_projects_view", "editor"); + account_draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + account_draft.scope_id = "user-225".to_string(); + account_draft.user_id = Some("user-225".to_string()); + account_draft.owner_user_id = Some("user-225".to_string()); + account_draft.metadata = json!({ + "route": "/api/editor/projects", + "method": "GET", + "status": 200, + "operation": "listEditorProjects", + "client": "agc", + }); + + let mut external_draft = + TrackingEventDraft::new("editor_image_generation_submit", "editor"); + external_draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + external_draft.scope_id = "owner-225".to_string(); + external_draft.owner_user_id = Some("owner-225".to_string()); + external_draft.metadata = json!({ + "route": "/api/external/v1/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc", + }); + + let inputs = [ + build_tracking_event_input(account_draft), + build_tracking_event_input(external_draft), + ]; + let row_for_input = |input: &module_runtime::RuntimeTrackingEventInput, + occurred_at: &str| { + let optional_column = |value: &Option| { + value + .as_ref() + .map(|value| json!({"some": value})) + .unwrap_or(Value::Null) + }; + json!([ + input.event_id.clone(), + input.event_key.clone(), + "user", + input.scope_id.clone(), + 20600, + optional_column(&input.user_id), + optional_column(&input.owner_user_id), + optional_column(&input.profile_id), + optional_column(&input.module_key), + input.metadata_json.clone(), + occurred_at, + ]) + }; + let payload = json!([{ + "rows": [ + row_for_input(&inputs[0], "2026-09-02T00:00:00Z"), + row_for_input(&inputs[1], "2026-09-02T00:00:01Z"), + ] + }]); + + let entries = parse_admin_tracking_events_sql_response(payload) + .expect("tracking inputs should remain readable by admin readback"); + assert_eq!(entries.len(), 2); + + let account = entries + .iter() + .find(|entry| entry.event_key == "editor_projects_view") + .expect("account input should be readable"); + let account_metadata = serde_json::from_str::(&account.metadata_json) + .expect("account metadata should remain valid JSON"); + assert_eq!(account_metadata["client"], "agc"); + assert_eq!(account.user_id.as_deref(), Some("user-225")); + assert_eq!(account.owner_user_id.as_deref(), Some("user-225")); + + let external = entries + .iter() + .find(|entry| entry.event_key == "editor_image_generation_submit") + .expect("External API Key input should be readable"); + let external_metadata = serde_json::from_str::(&external.metadata_json) + .expect("External API Key metadata should remain valid JSON"); + assert_eq!(external_metadata["client"], "agc"); + assert_eq!( + external_metadata["route"], + "/api/external/v1/editor/images/generations" + ); + assert_eq!(external.user_id, None); + assert_eq!(external.owner_user_id.as_deref(), Some("owner-225")); + } + #[test] fn parse_admin_tracking_events_sql_response_normalizes_sats_values() { let payload = json!([ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 11e9ca980..d898bdbf9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -18,13 +18,17 @@ use crate::{ auth::AuthenticatedAccessToken, backpressure::limit_concurrent_requests, error_middleware::normalize_error_response, + external_api_auth::ExternalApiPrincipal, http_error::AppError, modules, request_context::{RequestContext, attach_request_context, resolve_request_id}, response_headers::propagate_request_id_header, state::{AppState, BackpressureState}, telemetry::record_http_observability, - tracking::record_route_tracking_event_after_success, + tracking::{ + TrackingClientMarker, TrackingLoginSubject, record_route_tracking_event_after_success, + resolve_tracking_client_marker, + }, wechat::pay::{ handle_wechat_pay_notify, handle_wechat_pay_refund_notify, handle_wechat_virtual_payment_message_push_verify, handle_wechat_virtual_payment_notify, @@ -237,16 +241,26 @@ async fn spacetime_unavailable_handler( async fn record_api_tracking_after_success( axum::extract::State(state): axum::extract::State, Extension(request_context): Extension, - request: Request, + mut request: Request, next: middleware::Next, ) -> Response { let method = request.method().clone(); let path = request.uri().path().to_string(); + let client_marker = resolve_tracking_client_marker(request.headers()); + if let Some(client_marker) = client_marker { + request.extensions_mut().insert(client_marker); + } let response = next.run(request).await; let authenticated = response .extensions() .get::() .cloned(); + let external_principal = response.extensions().get::().cloned(); + let login_subject = if matches!(client_marker, Some(TrackingClientMarker::Agc)) { + response.extensions().get::().cloned() + } else { + None + }; record_route_tracking_event_after_success( &state, &request_context, @@ -254,6 +268,9 @@ async fn record_api_tracking_after_success( &path, response.status(), authenticated.as_ref(), + external_principal.as_ref(), + login_subject.as_ref(), + client_marker, ) .await; response @@ -261,6 +278,8 @@ async fn record_api_tracking_after_success( #[cfg(test)] mod tests { + use std::path::Path; + use axum::{ Router, body::Body, @@ -420,6 +439,23 @@ mod tests { .expect("password login request should succeed") } + async fn read_tracking_outbox_events(outbox_dir: &Path) -> Vec { + tokio::fs::read_to_string(outbox_dir.join("active.ndjson")) + .await + .expect("tracking middleware should enqueue outbox events") + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("outbox line should be JSON")) + .collect() + } + + fn find_tracking_event<'a>(records: &'a [Value], event_key: &str) -> &'a Value { + records + .iter() + .find(|record| record["event"]["event_key"] == event_key) + .unwrap_or_else(|| panic!("tracking event should exist: {event_key}")) + } + #[tokio::test] async fn showcase_invalid_bearer_is_not_downgraded_and_is_not_cacheable() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); @@ -452,6 +488,263 @@ mod tests { ); } + #[tokio::test] + async fn agc_marker_does_not_bypass_authentication_or_change_failure_status() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/editor/projects") + .header("x-genarrative-client", "agc") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn agc_marker_flows_through_auth_tracking_middleware_to_outbox() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-tracking-middleware-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let user = seed_phone_user_with_password(&state, "13800138001", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &user, "sess_agc_tracking_middleware"); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/api/auth/me") + .header("authorization", format!("Bearer {token}")) + .header("x-genarrative-client", "agc") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let outbox = tokio::fs::read_to_string(outbox_dir.join("active.ndjson")) + .await + .expect("tracking middleware should enqueue an outbox event"); + let records = outbox + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("outbox line should be JSON")) + .collect::>(); + assert_eq!(records.len(), 1); + + let event = &records[0]["event"]; + assert_eq!(event["event_key"], "auth_me_view"); + assert_eq!(event["user_id"], user.id); + assert_eq!(event["owner_user_id"], user.id); + let metadata = serde_json::from_str::( + event["metadata_json"] + .as_str() + .expect("tracking metadata should be serialized as a string"), + ) + .expect("tracking metadata should be valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!(metadata["route"], "/api/auth/me"); + assert_eq!(metadata["status"], 200); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + + #[tokio::test] + async fn agc_password_login_tracking_uses_verified_login_user_subject() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-agc-password-login-tracking-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let user = seed_phone_user_with_password(&state, "13800138002", TEST_PASSWORD).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .header("x-genarrative-client", "agc") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138002", + "password": TEST_PASSWORD + }) + .to_string(), + )) + .expect("password login request should build"), + ) + .await + .expect("password login request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + let records = read_tracking_outbox_events(&outbox_dir).await; + let event = find_tracking_event(&records, "auth_password_login_success"); + assert_eq!(event["event"]["user_id"], user.id); + assert_eq!(event["event"]["owner_user_id"], user.id); + assert_eq!(event["event"]["scope_id"], user.id); + let metadata = serde_json::from_str::( + event["event"]["metadata_json"] + .as_str() + .expect("tracking metadata should be serialized as a string"), + ) + .expect("tracking metadata should be valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!(metadata["route"], "/api/auth/entry"); + assert_eq!(metadata["status"], 200); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + + #[tokio::test] + async fn agc_password_login_tracking_does_not_record_without_marker() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-unmarked-password-login-tracking-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let _user = seed_phone_user_with_password(&state, "13800138003", TEST_PASSWORD).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/entry") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138003", + "password": TEST_PASSWORD + }) + .to_string(), + )) + .expect("password login request should build"), + ) + .await + .expect("password login request should succeed"); + + assert_eq!(response.status(), StatusCode::OK); + assert!(!outbox_dir.join("active.ndjson").exists()); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + + #[tokio::test] + async fn agc_phone_login_tracking_uses_verified_login_user_subject() { + let outbox_dir = std::env::temp_dir().join(format!( + "genarrative-api-agc-phone-login-tracking-{}", + OffsetDateTime::now_utc().unix_timestamp_nanos() + )); + let _ = std::fs::remove_dir_all(&outbox_dir); + let config = AppConfig { + sms_auth_enabled: true, + tracking_outbox_dir: outbox_dir.clone(), + tracking_outbox_batch_size: 100, + ..AppConfig::default() + }; + let state = AppState::new(config).expect("state should build"); + let app = build_router(state); + + let send_code_response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/send-code") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138002", + "scene": "login" + }) + .to_string(), + )) + .expect("send code request should build"), + ) + .await + .expect("send code request should succeed"); + assert_eq!(send_code_response.status(), StatusCode::OK); + + let login_response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/phone/login") + .header("content-type", "application/json") + .header("x-genarrative-client", "agc") + .body(Body::from( + serde_json::json!({ + "purePhoneNumber": "13800138002", + "code": "123456" + }) + .to_string(), + )) + .expect("phone login request should build"), + ) + .await + .expect("phone login request should succeed"); + + assert_eq!(login_response.status(), StatusCode::OK); + let body = login_response + .into_body() + .collect() + .await + .expect("phone login response body should collect") + .to_bytes(); + let payload: Value = + serde_json::from_slice(&body).expect("phone login response should be valid JSON"); + let user_id = payload["user"]["id"] + .as_str() + .expect("phone login response should include user id"); + + let records = read_tracking_outbox_events(&outbox_dir).await; + let event = find_tracking_event(&records, "auth_phone_login_success"); + assert_eq!(event["event"]["user_id"], user_id); + assert_eq!(event["event"]["owner_user_id"], user_id); + assert_eq!(event["event"]["scope_id"], user_id); + let metadata = serde_json::from_str::( + event["event"]["metadata_json"] + .as_str() + .expect("tracking metadata should be serialized as a string"), + ) + .expect("tracking metadata should be valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!(metadata["route"], "/api/auth/phone/login"); + assert_eq!(metadata["status"], 200); + + let _ = std::fs::remove_dir_all(outbox_dir); + } + #[cfg(any())] fn build_internal_creative_agent_app() -> Router { let mut config = AppConfig::default(); diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index d1d3280e4..93449d2bd 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -3,7 +3,7 @@ use axum::{ body::Body, extract::{Extension, Query, State}, http::{HeaderMap, StatusCode, header}, - response::Response, + response::{IntoResponse, Response}, }; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, INITIAL_ASSET_OBJECT_VERSION, @@ -32,7 +32,7 @@ use crate::{ platform_errors::map_oss_error, request_context::RequestContext, state::AppState, - tracking::{TrackingEventDraft, record_tracking_event_after_success}, + tracking::{TrackingClientMarker, TrackingEventDraft, record_tracking_event_after_success}, }; // 历史素材类型需要与 SpacetimeDB 侧白名单保持同一口径,避免新增素材类型时 HTTP 门面漏同步。 @@ -71,16 +71,25 @@ struct AssetReadTarget { is_legacy_public_path: bool, } +#[derive(Debug)] +struct AssetReadAuthorizationContext { + authorization: AssetReadAuthorization, + authenticated: Option, +} + pub async fn create_direct_upload_ticket( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { create_direct_upload_ticket_for_owner( &state, &request_context, authenticated.claims().user_id(), + Some(authenticated.claims().user_id()), + client_marker.map(|Extension(marker)| marker), payload, ) .await @@ -90,6 +99,8 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( state: &AppState, request_context: &RequestContext, owner_user_id: &str, + tracking_user_id: Option<&str>, + client_marker: Option, payload: CreateDirectUploadTicketRequest, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { @@ -143,8 +154,10 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( record_asset_tracking_event( state, request_context, + tracking_user_id, owner_user_id, "asset_upload_ticket_create", + client_marker, json!({ "asset": { "operation": "asset_upload_ticket_create", @@ -173,15 +186,27 @@ pub async fn get_asset_read_url( Extension(request_context): Extension, headers: HeaderMap, Query(query): Query, -) -> Result, AppError> { - let authorization = resolve_public_asset_read_authorization( +) -> Result { + let authorization_context = resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-url", ) .await?; - get_asset_read_url_with_query(&state, &request_context, query, authorization).await + let response = get_asset_read_url_with_query( + &state, + &request_context, + query, + authorization_context.authorization, + ) + .await? + .into_response(); + + Ok(attach_asset_read_authentication( + response, + authorization_context.authenticated, + )) } pub(crate) async fn get_asset_read_url_with_query( @@ -306,16 +331,20 @@ pub async fn get_asset_read_bytes( })?; let target = resolve_asset_read_target(&query)?; - let authorization = resolve_public_asset_read_authorization( + let authorization_context = resolve_public_asset_read_authorization( &state, &request_context, headers, "/api/assets/read-bytes", ) .await?; - let access_scope = - authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization) - .await?; + let access_scope = authorize_asset_read_target( + &state, + oss_client.config_bucket(), + &target, + &authorization_context.authorization, + ) + .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { @@ -376,7 +405,7 @@ pub async fn get_asset_read_bytes( return Err(map_asset_read_bytes_too_large()); } - Response::builder() + let response = Response::builder() .status(StatusCode::OK) .header(header::CONTENT_TYPE, content_type) .header(header::CACHE_CONTROL, "private, max-age=60") @@ -386,7 +415,12 @@ pub async fn get_asset_read_bytes( "provider": "asset-read-bytes", "message": format!("构造资源内容响应失败:{error}"), })) - }) + })?; + + Ok(attach_asset_read_authentication( + response, + authorization_context.authenticated, + )) } pub async fn get_asset_history( @@ -457,12 +491,15 @@ pub async fn confirm_asset_object( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { confirm_asset_object_for_owner( &state, &request_context, authenticated.claims().user_id(), + Some(authenticated.claims().user_id()), + client_marker.map(|Extension(marker)| marker), payload, ) .await @@ -472,6 +509,8 @@ pub(crate) async fn confirm_asset_object_for_owner( state: &AppState, request_context: &RequestContext, owner_user_id: &str, + tracking_user_id: Option<&str>, + client_marker: Option, payload: ConfirmAssetObjectRequest, ) -> Result, AppError> { let oss_client = state.oss_client().ok_or_else(|| { @@ -512,8 +551,10 @@ pub(crate) async fn confirm_asset_object_for_owner( record_asset_tracking_event( state, request_context, + tracking_user_id, owner_user_id, "asset_upload_confirm", + client_marker, json!({ "asset": { "operation": "asset_upload_confirm", @@ -545,6 +586,7 @@ pub async fn bind_asset_object_to_entity( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { let now_micros = current_utc_micros(); @@ -583,8 +625,10 @@ pub async fn bind_asset_object_to_entity( record_asset_tracking_event( &state, &request_context, + Some(authenticated.claims().user_id()), authenticated.claims().user_id(), "asset_bind", + client_marker.map(|Extension(marker)| marker), json!({ "asset": { "operation": "asset_bind", @@ -611,15 +655,83 @@ pub async fn bind_asset_object_to_entity( async fn record_asset_tracking_event( state: &AppState, request_context: &RequestContext, - user_id: &str, + tracking_user_id: Option<&str>, + owner_user_id: &str, event_key: &'static str, + client_marker: Option, metadata: Value, ) { - let mut draft = TrackingEventDraft::user(event_key, "asset", user_id); - draft.metadata = metadata; + let draft = build_asset_tracking_event_draft( + request_context, + tracking_user_id, + owner_user_id, + event_key, + client_marker, + metadata, + ); record_tracking_event_after_success(state, request_context, draft).await; } +fn build_asset_tracking_event_draft( + request_context: &RequestContext, + tracking_user_id: Option<&str>, + owner_user_id: &str, + event_key: &'static str, + client_marker: Option, + metadata: Value, +) -> TrackingEventDraft { + let mut draft = TrackingEventDraft::new(event_key, "asset"); + draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + draft.scope_id = owner_user_id.trim().to_string(); + draft.user_id = tracking_user_id.map(|user_id| user_id.trim().to_string()); + draft.owner_user_id = Some(owner_user_id.trim().to_string()); + let metadata = apply_asset_tracking_client_marker(metadata, client_marker); + draft.metadata = if client_marker.is_some() { + apply_asset_tracking_route_metadata(metadata, request_context) + } else { + metadata + }; + draft +} + +fn apply_asset_tracking_client_marker( + mut metadata: Value, + client_marker: Option, +) -> Value { + if matches!(client_marker, Some(TrackingClientMarker::Agc)) + && let Some(object) = metadata.as_object_mut() + { + object.insert("client".to_string(), json!("agc")); + } + metadata +} + +fn apply_asset_tracking_route_metadata( + mut metadata: Value, + request_context: &RequestContext, +) -> Value { + let Some(object) = metadata.as_object_mut() else { + return metadata; + }; + let Some((method, request_uri)) = request_context.operation().split_once(' ') else { + return metadata; + }; + let route = request_uri.split('?').next().unwrap_or(request_uri); + object + .entry("route".to_string()) + .or_insert_with(|| json!(route)); + object + .entry("method".to_string()) + .or_insert_with(|| json!(method)); + object + .entry("status".to_string()) + .or_insert_with(|| json!(200)); + object + .entry("operation".to_string()) + .or_insert_with(|| json!(request_context.operation())); + metadata +} + fn resolve_asset_read_target(query: &GetReadUrlQuery) -> Result { if let Some(object_key) = query .object_key @@ -656,7 +768,7 @@ async fn resolve_public_asset_read_authorization( request_context: &RequestContext, headers: HeaderMap, path: &str, -) -> Result { +) -> Result { let authenticated = optional_access_token_from_headers( state, path.to_string(), @@ -664,11 +776,27 @@ async fn resolve_public_asset_read_authorization( request_context.request_id().to_string(), ) .await?; - Ok(authenticated + let authorization = authenticated + .as_ref() .map(|authenticated| { AssetReadAuthorization::Owner(authenticated.claims().user_id().to_string()) }) - .unwrap_or(AssetReadAuthorization::Anonymous)) + .unwrap_or(AssetReadAuthorization::Anonymous); + + Ok(AssetReadAuthorizationContext { + authorization, + authenticated, + }) +} + +fn attach_asset_read_authentication( + mut response: Response, + authenticated: Option, +) -> Response { + if let Some(authenticated) = authenticated { + response.extensions_mut().insert(authenticated); + } + response } async fn authorize_asset_read_target( @@ -1012,12 +1140,14 @@ mod tests { use axum::{ body::Body, - http::{Request, StatusCode}, + http::{HeaderMap, HeaderValue, Request, StatusCode, header::AUTHORIZATION}, + response::IntoResponse, }; use hmac::{Hmac, Mac}; use http_body_util::BodyExt; use platform_auth::{ AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, + verify_access_token, }; use reqwest::{Method, multipart}; use serde_json::{Value, json}; @@ -1026,10 +1156,137 @@ mod tests { use time::OffsetDateTime; use tower::ServiceExt; - use crate::{app::build_router, config::AppConfig, state::AppState}; + use crate::tracking::TrackingClientMarker; + use crate::{ + app::build_router, auth::AuthenticatedAccessToken, config::AppConfig, + request_context::RequestContext, state::AppState, + }; type HmacSha256 = Hmac; + #[test] + fn asset_tracking_metadata_receives_only_valid_agc_marker() { + let request_context = crate::request_context::RequestContext::new( + "request-225-asset".to_string(), + "POST /api/external/v1/assets/objects/confirm?x=1".to_string(), + std::time::Duration::ZERO, + false, + ); + let marked = super::apply_asset_tracking_client_marker( + json!({"asset": {"operation": "asset_upload_confirm"}}), + Some(TrackingClientMarker::Agc), + ); + assert_eq!(marked["client"], "agc"); + assert_eq!(marked["asset"]["operation"], "asset_upload_confirm"); + + let marked = super::apply_asset_tracking_route_metadata(marked, &request_context); + assert_eq!(marked["route"], "/api/external/v1/assets/objects/confirm"); + assert_eq!(marked["method"], "POST"); + assert_eq!(marked["status"], 200); + assert_eq!( + marked["operation"], + "POST /api/external/v1/assets/objects/confirm?x=1" + ); + + let unmarked = super::apply_asset_tracking_client_marker( + json!({"asset": {"operation": "asset_upload_confirm"}}), + None, + ); + assert!(unmarked.get("client").is_none()); + } + + #[test] + fn external_asset_tracking_keeps_owner_without_forging_user() { + let request_context = crate::request_context::RequestContext::new( + "request-225-owner".to_string(), + "POST /api/external/v1/assets/objects/confirm".to_string(), + std::time::Duration::ZERO, + false, + ); + let draft = super::build_asset_tracking_event_draft( + &request_context, + None, + "owner-225", + "asset_upload_confirm", + Some(TrackingClientMarker::Agc), + json!({"asset": {"operation": "asset_upload_confirm"}}), + ); + + assert_eq!(draft.user_id, None); + assert_eq!(draft.owner_user_id.as_deref(), Some("owner-225")); + assert_eq!( + draft.scope_kind, + module_runtime::RuntimeTrackingScopeKind::User + ); + assert_eq!(draft.scope_id, "owner-225"); + assert_eq!(draft.metadata["client"], "agc"); + } + + #[tokio::test] + async fn public_asset_read_response_carries_verified_bearer_for_tracking() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let token = + seed_authenticated_token(&state, "13800138126", "sess_asset_read_tracking").await; + let request_context = RequestContext::new( + "request-225-asset-read".to_string(), + "GET /api/assets/read-url".to_string(), + std::time::Duration::ZERO, + false, + ); + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")) + .expect("authorization header should build"), + ); + + let authorization_context = super::resolve_public_asset_read_authorization( + &state, + &request_context, + headers, + "/api/assets/read-url", + ) + .await + .expect("valid bearer should authenticate"); + let expected_user_id = verify_access_token(&token, state.auth_jwt_config()) + .expect("test bearer should verify") + .user_id() + .to_string(); + assert!(matches!( + authorization_context.authorization, + super::AssetReadAuthorization::Owner(ref user_id) if user_id == &expected_user_id + )); + + let authenticated = authorization_context + .authenticated + .as_ref() + .expect("verified bearer should be retained for tracking"); + assert_eq!(authenticated.claims().user_id(), expected_user_id); + + let response = super::attach_asset_read_authentication( + StatusCode::OK.into_response(), + authorization_context.authenticated, + ); + let response_authenticated = response + .extensions() + .get::() + .expect("asset read response should expose verified bearer to tracking"); + assert_eq!(response_authenticated.claims().user_id(), expected_user_id); + } + + #[test] + fn anonymous_asset_read_response_does_not_add_authentication_extension() { + let response = + super::attach_asset_read_authentication(StatusCode::OK.into_response(), None); + + assert!( + response + .extensions() + .get::() + .is_none() + ); + } + fn asset_object_record( access_policy: module_assets::AssetObjectAccessPolicy, owner_user_id: Option<&str>, diff --git a/server-rs/crates/api-server/src/external_assets_api.rs b/server-rs/crates/api-server/src/external_assets_api.rs index 6c23f1ea8..68490dac0 100644 --- a/server-rs/crates/api-server/src/external_assets_api.rs +++ b/server-rs/crates/api-server/src/external_assets_api.rs @@ -17,6 +17,7 @@ use crate::{ http_error::AppError, request_context::RequestContext, state::AppState, + tracking::TrackingClientMarker, }; const SCOPE_EDITOR_ASSET: &str = "editor:asset"; @@ -26,6 +27,7 @@ pub async fn create_external_direct_upload_ticket( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + client_marker: Option>, Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; @@ -33,6 +35,8 @@ pub async fn create_external_direct_upload_ticket( &state, &request_context, principal.owner_user_id(), + None, + client_marker.map(|Extension(marker)| marker), payload, ) .await @@ -42,12 +46,20 @@ pub async fn confirm_external_asset_object( State(state): State, Extension(request_context): Extension, Extension(principal): Extension, + client_marker: Option>, Json(mut payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_ASSET)?; payload.owner_user_id = Some(principal.owner_user_id().to_string()); - confirm_asset_object_for_owner(&state, &request_context, principal.owner_user_id(), payload) - .await + confirm_asset_object_for_owner( + &state, + &request_context, + principal.owner_user_id(), + None, + client_marker.map(|Extension(marker)| marker), + payload, + ) + .await } pub async fn get_external_asset_read_url( diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index f76b83079..406046b1e 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -2,7 +2,7 @@ use axum::{ Json, extract::{Extension, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{IntoResponse, Response}, }; use module_auth::{AuthLoginMethod, PasswordEntryError, PasswordEntryInput}; use serde_json::json; @@ -19,14 +19,16 @@ use crate::{ request_context::RequestContext, session_client::resolve_session_client_context, state::AppState, + tracking::{TrackingClientMarker, TrackingLoginSubject}, }; pub async fn password_entry( State(state): State, Extension(request_context): Extension, headers: HeaderMap, + client_marker: Option>, Json(payload): Json, -) -> Result { +) -> Result { state .refresh_auth_store_from_spacetime() .await @@ -95,13 +97,16 @@ pub async fn password_entry( ) .await; + let tracking_login_subject = + client_marker.map(|Extension(_)| TrackingLoginSubject::new(&result.user.id)); + let mut headers = HeaderMap::new(); attach_set_cookie_header( &mut headers, build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, ); - Ok(( + let mut response = ( headers, json_success_body( Some(&request_context), @@ -110,7 +115,12 @@ pub async fn password_entry( user: map_auth_user_payload(result.user), }, ), - )) + ) + .into_response(); + if let Some(subject) = tracking_login_subject { + response.extensions_mut().insert(subject); + } + Ok(response) } fn map_password_entry_error(error: PasswordEntryError) -> AppError { diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 227870794..5f382c320 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -2,7 +2,7 @@ use axum::{ Json, extract::{Extension, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, + response::{IntoResponse, Response}, }; use module_auth::{ AuthLoginMethod, PhoneAuthError, PhoneAuthScene, PhoneLoginInput, SendPhoneCodeInput, @@ -27,6 +27,7 @@ use crate::{ request_context::RequestContext, session_client::resolve_session_client_context, state::AppState, + tracking::{TrackingClientMarker, TrackingLoginSubject}, }; pub async fn send_phone_code( @@ -131,8 +132,9 @@ pub async fn phone_login( State(state): State, Extension(request_context): Extension, headers: HeaderMap, + client_marker: Option>, Json(payload): Json, -) -> Result { +) -> Result { // 手机号验证码校验通过后,沿用统一会话签发逻辑,确保 refresh cookie 与 JWT 行为一致。 if !state.config.sms_auth_enabled { return Err( @@ -271,13 +273,16 @@ pub async fn phone_login( ) .await; + let tracking_login_subject = + client_marker.map(|Extension(_)| TrackingLoginSubject::new(&result.user.id)); + let mut headers = HeaderMap::new(); attach_set_cookie_header( &mut headers, build_refresh_session_cookie_header(&state, &signed_session.refresh_token)?, ); - Ok(( + let mut response = ( headers, json_success_body( Some(&request_context), @@ -288,7 +293,12 @@ pub async fn phone_login( referral, }, ), - )) + ) + .into_response(); + if let Some(subject) = tracking_login_subject { + response.extensions_mut().insert(subject); + } + Ok(response) } async fn bind_referral_invite_code_on_registration( diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index ffcde2ddc..0632f992f 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -1,4 +1,4 @@ -use axum::http::{Method, StatusCode}; +use axum::http::{HeaderMap, Method, StatusCode}; #[cfg(not(test))] use module_auth::AuthLoginMethod; use module_runtime::RuntimeTrackingScopeKind; @@ -6,7 +6,46 @@ use serde_json::{Value, json}; use time::OffsetDateTime; use uuid::Uuid; -use crate::{auth::AuthenticatedAccessToken, request_context::RequestContext, state::AppState}; +use crate::{ + auth::AuthenticatedAccessToken, external_api_auth::ExternalApiPrincipal, + request_context::RequestContext, state::AppState, +}; + +const AGC_CLIENT_MARKER_HEADER: &str = "x-genarrative-client"; +const AGC_CLIENT_MARKER_VALUE: &str = "agc"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TrackingClientMarker { + Agc, +} + +/// 登录 handler 在认证成功后向外层 tracking middleware 传递的可信用户主体。 +/// +/// 该类型只存在于当前 HTTP 响应的 extensions 中,不保存 token,也不跨请求复用。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct TrackingLoginSubject { + user_id: String, +} + +impl TrackingLoginSubject { + pub(crate) fn new(user_id: &str) -> Self { + Self { + user_id: user_id.trim().to_string(), + } + } + + fn user_id(&self) -> &str { + &self.user_id + } +} + +pub(crate) fn resolve_tracking_client_marker(headers: &HeaderMap) -> Option { + headers + .get(AGC_CLIENT_MARKER_HEADER) + .and_then(|value| value.to_str().ok()) + .filter(|value| value.trim() == AGC_CLIENT_MARKER_VALUE) + .map(|_| TrackingClientMarker::Agc) +} /// 后端用户行为埋点入口统一走这里:写入失败只记录日志,不反向阻断主业务。 #[derive(Clone, Debug)] @@ -21,6 +60,12 @@ pub struct TrackingEventDraft { pub metadata: Value, } +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct TrackingIdentity { + user_id: Option, + owner_user_id: Option, +} + impl TrackingEventDraft { pub fn new(event_key: &'static str, module_key: &'static str) -> Self { Self { @@ -52,6 +97,8 @@ struct RouteTrackingSpec { module_key: &'static str, scope_kind: RuntimeTrackingScopeKind, scope_id: &'static str, + handled_by_existing_event: bool, + requires_agc_marker: bool, } pub async fn record_external_generation_run_after_success( @@ -110,34 +157,85 @@ pub async fn record_route_tracking_event_after_success( path: &str, status: StatusCode, authenticated: Option<&AuthenticatedAccessToken>, + external_principal: Option<&ExternalApiPrincipal>, + login_subject: Option<&TrackingLoginSubject>, + client_marker: Option, ) { - if !status.is_success() { - return; - } let Some(spec) = resolve_route_tracking_spec(method, path) else { return; }; + if !should_record_route_tracking(status, &spec, client_marker) { + return; + } - let user_id = authenticated.map(|auth| auth.claims().user_id().to_string()); - let scope_id = match spec.scope_kind { - RuntimeTrackingScopeKind::User => { - user_id.clone().unwrap_or_else(|| spec.scope_id.to_string()) - } - RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(), - _ => spec.scope_id.to_string(), - }; + let identity = resolve_tracking_identity(authenticated, external_principal, login_subject); + let scope_id = resolve_tracking_scope_id(&spec, &identity); let mut draft = TrackingEventDraft::new(spec.event_key, spec.module_key); draft.scope_kind = spec.scope_kind; draft.scope_id = scope_id; - draft.user_id = user_id; - draft.metadata = build_route_tracking_metadata(&spec, request_context, method, path, status); - if draft.user_id.is_some() { - draft.owner_user_id = draft.user_id.clone(); - } + draft.user_id = identity.user_id; + draft.owner_user_id = identity.owner_user_id; + draft.metadata = + build_route_tracking_metadata(&spec, request_context, method, path, status, client_marker); record_route_tracking_event_via_outbox_after_success(state, request_context, draft).await; } +fn should_record_route_tracking( + status: StatusCode, + spec: &RouteTrackingSpec, + client_marker: Option, +) -> bool { + status.is_success() + && !spec.handled_by_existing_event + && (!spec.requires_agc_marker || matches!(client_marker, Some(TrackingClientMarker::Agc))) +} + +fn resolve_tracking_identity( + authenticated: Option<&AuthenticatedAccessToken>, + external_principal: Option<&ExternalApiPrincipal>, + login_subject: Option<&TrackingLoginSubject>, +) -> TrackingIdentity { + if let Some(principal) = external_principal { + return TrackingIdentity { + owner_user_id: Some(principal.owner_user_id().to_string()), + ..TrackingIdentity::default() + }; + } + + if let Some(authenticated) = authenticated { + let user_id = authenticated.claims().user_id().to_string(); + return TrackingIdentity { + user_id: Some(user_id.clone()), + owner_user_id: Some(user_id), + }; + } + + if let Some(subject) = login_subject { + let user_id = subject.user_id().trim(); + if !user_id.is_empty() { + return TrackingIdentity { + user_id: Some(user_id.to_string()), + owner_user_id: Some(user_id.to_string()), + }; + } + } + + TrackingIdentity::default() +} + +fn resolve_tracking_scope_id(spec: &RouteTrackingSpec, identity: &TrackingIdentity) -> String { + match spec.scope_kind { + RuntimeTrackingScopeKind::User => identity + .owner_user_id + .clone() + .or_else(|| identity.user_id.clone()) + .unwrap_or_else(|| spec.scope_id.to_string()), + RuntimeTrackingScopeKind::Site => spec.scope_id.to_string(), + _ => spec.scope_id.to_string(), + } +} + fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { use RuntimeTrackingScopeKind::{Site, User}; @@ -159,6 +257,12 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(agc_route_spec( + "auth_password_login_success", + "auth", + User, + "anonymous", + )), ("GET", "/api/auth/me") => Some(route_spec("auth_me_view", "auth", User, "anonymous")), ("GET", "/api/auth/sessions") => { Some(route_spec("auth_sessions_view", "auth", User, "anonymous")) @@ -191,6 +295,12 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(agc_route_spec( + "profile_api_key_create", + "profile", + User, + "anonymous", + )), ("GET", "/api/profile/wallet-ledger") => Some(route_spec( "wallet_ledger_view", "profile", @@ -209,6 +319,12 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(agc_route_spec( + "recharge_order_wechat_confirm", + "profile", + User, + "anonymous", + )), ("POST", "/api/profile/feedback") => { Some(route_spec("feedback_submit", "profile", User, "anonymous")) } @@ -321,6 +437,24 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option { + Some(manual_asset_route_spec("asset_upload_ticket_create")) + } + ("POST", "/api/assets/objects/confirm") => { + Some(manual_asset_route_spec("asset_upload_confirm")) + } + ("GET", "/api/assets/read-url") => Some(agc_route_spec( + "asset_read_url_view", + "asset", + User, + "anonymous", + )), + ("GET", "/api/assets/read-bytes") => Some(agc_route_spec( + "asset_read_bytes_view", + "asset", + User, + "anonymous", + )), ("POST", "/api/assets/character-visual/generate") => Some(route_spec( "asset_character_visual_generate", "asset", @@ -380,6 +514,90 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(agc_route_spec( + "editor_projects_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/projects") => Some(agc_route_spec( + "editor_project_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/editor/projects/{id}") => Some(agc_route_spec( + "editor_project_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/projects/{id}/resources") => Some(agc_route_spec( + "editor_project_resource_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/editor/assets/library") => Some(agc_route_spec( + "editor_asset_library_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/assets/folders") => Some(agc_route_spec( + "editor_asset_folder_create", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/images/generations") => Some(agc_route_spec( + "editor_image_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/images/edits") => Some(agc_route_spec( + "editor_image_edit_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/images/background-removals") => Some(agc_route_spec( + "editor_image_background_removal_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/icon-spritesheets/generations") => Some(agc_route_spec( + "editor_icon_spritesheet_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/character-animations/generations") => Some(agc_route_spec( + "editor_character_animation_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/videos/generations") => Some(agc_route_spec( + "editor_video_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/audios/sound-effects/generations") => Some(agc_route_spec( + "editor_sound_effect_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/editor/audios/background-music/generations") => Some(agc_route_spec( + "editor_background_music_generation_submit", + "editor", + User, + "anonymous", + )), ("POST", "/api/llm/chat/completions") => { Some(route_spec("llm_request", "llm", User, "anonymous")) } @@ -414,6 +632,120 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option Some(agc_route_spec( + "external_generation_job_view", + "runtime", + User, + "anonymous", + )), + ("POST", "/api/external/v1/assets/direct-upload-tickets") => { + Some(manual_asset_route_spec("asset_upload_ticket_create")) + } + ("POST", "/api/external/v1/assets/objects/confirm") => { + Some(manual_asset_route_spec("asset_upload_confirm")) + } + ("GET", "/api/external/v1/assets/read-url") => Some(agc_route_spec( + "asset_read_url_view", + "asset", + User, + "anonymous", + )), + ("GET", "/api/external/v1/editor/projects") => Some(agc_route_spec( + "editor_projects_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/projects") => Some(agc_route_spec( + "editor_project_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/external/v1/editor/projects/{id}") => Some(agc_route_spec( + "editor_project_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/projects/{id}/resources") => Some(agc_route_spec( + "editor_project_resource_create", + "editor", + User, + "anonymous", + )), + ("GET", "/api/external/v1/editor/assets/library") => Some(agc_route_spec( + "editor_asset_library_view", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/assets/folders") => Some(agc_route_spec( + "editor_asset_folder_create", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/images/generations") => Some(agc_route_spec( + "editor_image_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/images/edits") => Some(agc_route_spec( + "editor_image_edit_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/images/background-removals") => Some(agc_route_spec( + "editor_image_background_removal_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/icon-spritesheets/generations") => Some(agc_route_spec( + "editor_icon_spritesheet_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/character-animations/generations") => { + Some(agc_route_spec( + "editor_character_animation_generation_submit", + "editor", + User, + "anonymous", + )) + } + ("POST", "/api/external/v1/editor/videos/generations") => Some(agc_route_spec( + "editor_video_generation_submit", + "editor", + User, + "anonymous", + )), + ("POST", "/api/external/v1/editor/audios/sound-effects/generations") => { + Some(agc_route_spec( + "editor_sound_effect_generation_submit", + "editor", + User, + "anonymous", + )) + } + ("POST", "/api/external/v1/editor/audios/background-music/generations") => { + Some(agc_route_spec( + "editor_background_music_generation_submit", + "editor", + User, + "anonymous", + )) + } + ("GET", "/api/external/v1/generations/{id}") => Some(agc_route_spec( + "external_generation_job_view", + "runtime", + User, + "anonymous", + )), #[cfg(any())] ("GET", "/api/runtime/save/snapshot") => Some(route_spec( "runtime_snapshot_view", @@ -486,6 +818,32 @@ fn route_spec( module_key, scope_kind, scope_id, + handled_by_existing_event: false, + requires_agc_marker: false, + } +} + +fn agc_route_spec( + event_key: &'static str, + module_key: &'static str, + scope_kind: RuntimeTrackingScopeKind, + scope_id: &'static str, +) -> RouteTrackingSpec { + RouteTrackingSpec { + requires_agc_marker: true, + ..route_spec(event_key, module_key, scope_kind, scope_id) + } +} + +fn manual_asset_route_spec(event_key: &'static str) -> RouteTrackingSpec { + RouteTrackingSpec { + handled_by_existing_event: true, + ..route_spec( + event_key, + "asset", + RuntimeTrackingScopeKind::User, + "anonymous", + ) } } @@ -504,6 +862,7 @@ fn build_route_tracking_metadata( method: &Method, path: &str, status: StatusCode, + client_marker: Option, ) -> Value { let mut metadata = json!({ "route": path, @@ -517,6 +876,10 @@ fn build_route_tracking_metadata( metadata["assetOperation"] = json!(spec.event_key); } + if matches!(client_marker, Some(TrackingClientMarker::Agc)) { + metadata["client"] = json!(AGC_CLIENT_MARKER_VALUE); + } + metadata } @@ -584,15 +947,19 @@ fn is_known_static_route_segment(segment: &str) -> bool { segment, "ai" | "analytics" | "api" + | "api-keys" | "asr" | "assets" | "auth" + | "audios" | "background-music" | "bidirection" | "bind-phone" + | "background-removals" | "browse-history" | "cancel" | "character-animation" + | "character-animations" | "character-visual" | "character-workflow-cache" | "chat" @@ -602,12 +969,23 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "completions" | "config" | "dashboard" + | "direct-upload-tickets" + | "edits" + | "editor" + | "external" + | "external-generation" | "fail" | "feedback" + | "folders" | "generate" + | "generations" | "history" | "import-video" + | "icon-spritesheets" + | "images" | "invite-center" + | "jobs" + | "library" | "llm" | "login" | "optimizations" @@ -616,20 +994,25 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "logout-all" | "me" | "metric" + | "objects" | "orders" | "phone" | "play-stats" | "profile" + | "projects" | "publish" | "recharge" | "recharge-center" | "redeem" | "redeem-code" | "redeem-codes" + | "read-bytes" + | "read-url" | "references" | "referrals" | "refresh" | "revoke" + | "resources" | "runtime" | "save" | "save-archives" @@ -648,6 +1031,8 @@ fn is_known_static_route_segment(segment: &str) -> bool { | "tasks" | "tts" | "volcengine" + | "v1" + | "videos" | "wallet-ledger" | "wechat" ) @@ -837,9 +1222,383 @@ fn runtime_profile_beijing_day_key(occurred_at_micros: i64) -> i64 { #[cfg(test)] mod tests { - use axum::http::Method; + use std::time::Duration; - use super::{is_route_tracking_excluded, normalize_route_path, resolve_route_tracking_spec}; + use axum::http::{HeaderMap, HeaderValue, Method}; + use platform_auth::{ + AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, JwtConfig, + }; + use time::OffsetDateTime; + + use crate::{ + auth::AuthenticatedAccessToken, external_api_auth::ExternalApiPrincipal, + request_context::RequestContext, + }; + + use super::{ + TrackingClientMarker, TrackingEventDraft, TrackingLoginSubject, + build_route_tracking_metadata, build_tracking_event_input, is_route_tracking_excluded, + normalize_route_path, resolve_route_tracking_spec, resolve_tracking_client_marker, + resolve_tracking_identity, resolve_tracking_scope_id, route_spec, + should_record_route_tracking, + }; + + fn build_test_authenticated(user_id: &str) -> AuthenticatedAccessToken { + let config = JwtConfig::new("test-issuer".to_string(), "test-secret".to_string(), 3600) + .expect("test JWT config should build"); + let claims = AccessTokenClaims::from_input( + AccessTokenClaimsInput { + user_id: user_id.to_string(), + session_id: "test-session".to_string(), + provider: AuthProvider::Password, + roles: vec!["user".to_string()], + token_version: 1, + phone_verified: false, + binding_status: BindingStatus::Active, + display_name: Some("测试用户".to_string()), + }, + &config, + OffsetDateTime::now_utc(), + ) + .expect("test claims should build"); + + AuthenticatedAccessToken::new(claims) + } + + fn assert_route_spec( + method: Method, + path: &str, + event_key: &str, + module_key: &str, + scope_kind: module_runtime::RuntimeTrackingScopeKind, + handled_by_existing_event: bool, + ) { + let spec = resolve_route_tracking_spec(&method, path) + .unwrap_or_else(|| panic!("missing route tracking spec: {method} {path}")); + assert_eq!(spec.event_key, event_key, "{method} {path}"); + assert_eq!(spec.module_key, module_key, "{method} {path}"); + assert_eq!(spec.scope_kind, scope_kind, "{method} {path}"); + assert_eq!( + spec.handled_by_existing_event, handled_by_existing_event, + "{method} {path}" + ); + } + + #[test] + fn tracking_client_marker_accepts_only_trimmed_lowercase_agc() { + let cases = [ + (None, None), + (Some(""), None), + (Some("AGC"), None), + (Some("other"), None), + (Some(" agc "), Some(TrackingClientMarker::Agc)), + (Some("agc"), Some(TrackingClientMarker::Agc)), + ]; + + for (value, expected) in cases { + let mut headers = HeaderMap::new(); + if let Some(value) = value { + headers.insert( + "X-GENARRATIVE-CLIENT", + HeaderValue::from_str(value).expect("marker fixture should be valid"), + ); + } + assert_eq!(resolve_tracking_client_marker(&headers), expected); + } + + let mut invalid_headers = HeaderMap::new(); + invalid_headers.insert( + "x-genarrative-client", + HeaderValue::from_bytes(&[0xff]).expect("invalid text fixture should be accepted"), + ); + assert_eq!(resolve_tracking_client_marker(&invalid_headers), None); + } + + #[test] + fn tracking_identity_keeps_authenticated_user_as_owner() { + let authenticated = build_test_authenticated("user-225"); + let identity = resolve_tracking_identity(Some(&authenticated), None, None); + + assert_eq!(identity.user_id.as_deref(), Some("user-225")); + assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); + + let spec = route_spec( + "profile_dashboard_view", + "profile", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + assert_eq!(resolve_tracking_scope_id(&spec, &identity), "user-225"); + } + + #[test] + fn user_tracking_draft_sets_user_scope_and_subject_fields() { + let draft = TrackingEventDraft::user("daily_login", "profile", " user-225 "); + + assert_eq!( + draft.scope_kind, + module_runtime::RuntimeTrackingScopeKind::User + ); + assert_eq!(draft.scope_id, "user-225"); + assert_eq!(draft.user_id.as_deref(), Some("user-225")); + assert_eq!(draft.owner_user_id.as_deref(), Some("user-225")); + assert_eq!(draft.module_key, Some("profile")); + assert_eq!(draft.event_key, "daily_login"); + } + + #[test] + fn tracking_identity_uses_external_api_owner_and_does_not_forge_user() { + let authenticated = build_test_authenticated("login-user"); + let principal = ExternalApiPrincipal::for_test("owner-user", &[]); + let identity = resolve_tracking_identity(Some(&authenticated), Some(&principal), None); + + assert_eq!(identity.user_id, None); + assert_eq!(identity.owner_user_id.as_deref(), Some("owner-user")); + + let spec = route_spec( + "external_editor_view", + "editor", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + assert_eq!(resolve_tracking_scope_id(&spec, &identity), "owner-user"); + } + + #[test] + fn tracking_identity_uses_verified_login_subject_when_no_session_identity_exists() { + let subject = TrackingLoginSubject::new(" user-225 "); + let identity = resolve_tracking_identity(None, None, Some(&subject)); + + assert_eq!(identity.user_id.as_deref(), Some("user-225")); + assert_eq!(identity.owner_user_id.as_deref(), Some("user-225")); + } + + #[test] + fn route_tracking_metadata_adds_agc_marker_without_dropping_existing_fields() { + let spec = route_spec( + "asset_upload_ticket_create", + "asset", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + let request_context = RequestContext::new( + "request-225".to_string(), + "POST /api/assets/upload".to_string(), + Duration::ZERO, + false, + ); + + let marked = build_route_tracking_metadata( + &spec, + &request_context, + &Method::POST, + "/api/assets/upload", + axum::http::StatusCode::ACCEPTED, + Some(TrackingClientMarker::Agc), + ); + assert_eq!(marked["client"], "agc"); + assert_eq!(marked["route"], "/api/assets/upload"); + assert_eq!(marked["method"], "POST"); + assert_eq!(marked["status"], 202); + assert_eq!(marked["operation"], "POST /api/assets/upload"); + assert_eq!(marked["asset"]["operation"], "asset_upload_ticket_create"); + assert_eq!(marked["assetOperation"], "asset_upload_ticket_create"); + + let unmarked = build_route_tracking_metadata( + &spec, + &request_context, + &Method::POST, + "/api/assets/upload", + axum::http::StatusCode::ACCEPTED, + None, + ); + assert!(unmarked.get("client").is_none()); + } + + #[test] + fn tracking_event_input_preserves_agc_metadata_and_subject_fields() { + let cases = [ + (Some("user-225"), Some("user-225"), "user-225", "account"), + (None, Some("owner-225"), "owner-225", "external-key"), + ]; + + for (user_id, owner_user_id, scope_id, label) in cases { + let mut draft = TrackingEventDraft::new("editor_image_generation_submit", "editor"); + draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + draft.scope_id = scope_id.to_string(); + draft.user_id = user_id.map(str::to_string); + draft.owner_user_id = owner_user_id.map(str::to_string); + draft.metadata = serde_json::json!({ + "route": "/api/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc", + }); + + let input = build_tracking_event_input(draft); + let metadata = serde_json::from_str::(&input.metadata_json) + .unwrap_or_else(|error| panic!("{label} metadata should remain JSON: {error}")); + + assert!( + metadata.is_object(), + "{label} metadata must remain an object" + ); + assert_eq!(metadata["client"], "agc", "{label}"); + assert_eq!( + metadata["route"], "/api/editor/images/generations", + "{label}" + ); + assert_eq!(metadata["status"], 202, "{label}"); + assert_eq!(input.user_id.as_deref(), user_id); + assert_eq!(input.owner_user_id.as_deref(), owner_user_id); + assert_eq!(input.scope_id, scope_id); + assert_eq!(input.event_key, "editor_image_generation_submit"); + assert_eq!(input.module_key.as_deref(), Some("editor")); + } + } + + #[test] + fn tracking_event_input_without_marker_keeps_metadata_object_without_client() { + let mut draft = TrackingEventDraft::new("editor_projects_view", "editor"); + draft.scope_kind = module_runtime::RuntimeTrackingScopeKind::User; + draft.scope_id = "user-225".to_string(); + draft.user_id = Some("user-225".to_string()); + draft.owner_user_id = Some("user-225".to_string()); + draft.metadata = serde_json::json!({ + "route": "/api/editor/projects", + "method": "GET", + "status": 200, + "operation": "listEditorProjects", + }); + + let input = build_tracking_event_input(draft); + let metadata = serde_json::from_str::(&input.metadata_json) + .expect("unmarked metadata should remain valid JSON"); + + assert!(metadata.is_object()); + assert!(metadata.get("client").is_none()); + assert_eq!(metadata["route"], "/api/editor/projects"); + assert_eq!(metadata["status"], 200); + } + + #[test] + fn successful_statuses_record_only_explicit_business_routes() { + let business_spec = route_spec( + "editor_projects_view", + "editor", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + let handled_spec = super::manual_asset_route_spec("asset_upload_confirm"); + + for status in [ + axum::http::StatusCode::OK, + axum::http::StatusCode::CREATED, + axum::http::StatusCode::ACCEPTED, + axum::http::StatusCode::NO_CONTENT, + ] { + assert!( + should_record_route_tracking(status, &business_spec, None), + "{status} should record a successful business route" + ); + } + assert!(!should_record_route_tracking( + axum::http::StatusCode::OK, + &handled_spec, + Some(TrackingClientMarker::Agc), + )); + } + + #[test] + fn client_marker_does_not_change_failure_status_or_excluded_route_semantics() { + let business_spec = resolve_route_tracking_spec( + &Method::POST, + "/api/external/v1/editor/images/generations", + ) + .expect("External v1 business route should resolve"); + assert!( + !should_record_route_tracking(axum::http::StatusCode::ACCEPTED, &business_spec, None,), + "an AGC-only route must not record an unmarked request" + ); + assert!(should_record_route_tracking( + axum::http::StatusCode::ACCEPTED, + &business_spec, + Some(TrackingClientMarker::Agc), + )); + let existing_spec = resolve_route_tracking_spec(&Method::GET, "/api/auth/me") + .expect("existing auth route should resolve"); + assert!(should_record_route_tracking( + axum::http::StatusCode::OK, + &existing_spec, + None, + )); + for status in [ + axum::http::StatusCode::BAD_REQUEST, + axum::http::StatusCode::UNAUTHORIZED, + axum::http::StatusCode::FORBIDDEN, + axum::http::StatusCode::NOT_FOUND, + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::http::StatusCode::BAD_GATEWAY, + ] { + assert!( + !should_record_route_tracking( + status, + &business_spec, + Some(TrackingClientMarker::Agc), + ), + "{status} must not create a successful route event" + ); + } + assert!( + resolve_route_tracking_spec(&Method::POST, "/api/external/v1/mcp").is_none(), + "AGC marker must not turn MCP into a business route" + ); + } + + #[test] + fn route_metadata_contains_no_authentication_or_secret_fields() { + let spec = route_spec( + "editor_projects_view", + "editor", + module_runtime::RuntimeTrackingScopeKind::User, + "anonymous", + ); + let request_context = RequestContext::new( + "request-225-safe-metadata".to_string(), + "GET /api/editor/projects".to_string(), + Duration::ZERO, + false, + ); + let metadata = build_route_tracking_metadata( + &spec, + &request_context, + &Method::GET, + "/api/editor/projects", + axum::http::StatusCode::OK, + Some(TrackingClientMarker::Agc), + ); + let object = metadata + .as_object() + .expect("route metadata should be an object"); + + for forbidden_key in [ + "authorization", + "accessToken", + "token", + "apiKey", + "cookie", + "signature", + "signedUrl", + "requestBody", + ] { + assert!( + !object.contains_key(forbidden_key), + "route metadata must not contain {forbidden_key}" + ); + } + assert_eq!(metadata["client"], "agc"); + } #[test] fn route_normalization_preserves_static_segments_and_replaces_ids() { @@ -897,6 +1656,533 @@ mod tests { } } + #[test] + fn agc_account_routes_have_explicit_tracking_specs() { + use module_runtime::RuntimeTrackingScopeKind::User; + + for (method, path, event_key, module_key, handled_by_existing_event) in [ + (Method::GET, "/api/auth/me", "auth_me_view", "auth", false), + ( + Method::POST, + "/api/auth/entry", + "auth_password_login_success", + "auth", + false, + ), + ( + Method::POST, + "/api/auth/phone/login", + "auth_phone_login_success", + "auth", + false, + ), + ( + Method::POST, + "/api/auth/logout", + "auth_logout", + "auth", + false, + ), + ( + Method::GET, + "/api/editor/assets/library", + "editor_asset_library_view", + "editor", + false, + ), + ( + Method::GET, + "/api/assets/read-url", + "asset_read_url_view", + "asset", + false, + ), + ( + Method::GET, + "/api/assets/read-bytes", + "asset_read_bytes_view", + "asset", + false, + ), + ( + Method::GET, + "/api/profile/dashboard", + "profile_dashboard_view", + "profile", + false, + ), + ( + Method::GET, + "/api/profile/recharge-center", + "recharge_center_view", + "profile", + false, + ), + ( + Method::POST, + "/api/profile/recharge/orders", + "recharge_order_create", + "profile", + false, + ), + ( + Method::POST, + "/api/profile/recharge/orders/order-225/wechat/confirm", + "recharge_order_wechat_confirm", + "profile", + false, + ), + ( + Method::GET, + "/api/profile/wallet-ledger", + "wallet_ledger_view", + "profile", + false, + ), + ( + Method::POST, + "/api/profile/api-keys", + "profile_api_key_create", + "profile", + false, + ), + ( + Method::POST, + "/api/assets/direct-upload-tickets", + "asset_upload_ticket_create", + "asset", + true, + ), + ( + Method::POST, + "/api/assets/objects/confirm", + "asset_upload_confirm", + "asset", + true, + ), + ( + Method::GET, + "/api/editor/projects", + "editor_projects_view", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/projects", + "editor_project_create", + "editor", + false, + ), + ( + Method::GET, + "/api/editor/projects/project-225", + "editor_project_view", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/projects/project-225/resources", + "editor_project_resource_create", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/assets/folders", + "editor_asset_folder_create", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/images/generations", + "editor_image_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/images/edits", + "editor_image_edit_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/images/background-removals", + "editor_image_background_removal_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/icon-spritesheets/generations", + "editor_icon_spritesheet_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/character-animations/generations", + "editor_character_animation_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/videos/generations", + "editor_video_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/audios/sound-effects/generations", + "editor_sound_effect_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/editor/audios/background-music/generations", + "editor_background_music_generation_submit", + "editor", + false, + ), + ( + Method::GET, + "/api/runtime/external-generation/jobs/job-225", + "external_generation_job_view", + "runtime", + false, + ), + ] { + assert_route_spec( + method, + path, + event_key, + module_key, + User, + handled_by_existing_event, + ); + } + + assert_route_spec( + Method::POST, + "/api/auth/refresh", + "auth_refresh_success", + "auth", + module_runtime::RuntimeTrackingScopeKind::Site, + false, + ); + assert_route_spec( + Method::POST, + "/api/auth/phone/send-code", + "auth_phone_code_send", + "auth", + module_runtime::RuntimeTrackingScopeKind::Site, + false, + ); + } + + #[test] + fn newly_added_agc_routes_require_the_agc_marker() { + for (method, path) in [ + (Method::POST, "/api/auth/entry"), + (Method::POST, "/api/profile/api-keys"), + ( + Method::POST, + "/api/profile/recharge/orders/order-225/wechat/confirm", + ), + (Method::GET, "/api/assets/read-url"), + (Method::GET, "/api/assets/read-bytes"), + (Method::GET, "/api/editor/projects"), + (Method::POST, "/api/editor/projects"), + (Method::GET, "/api/editor/projects/project-225"), + (Method::POST, "/api/editor/projects/project-225/resources"), + (Method::GET, "/api/editor/assets/library"), + (Method::POST, "/api/editor/assets/folders"), + (Method::POST, "/api/editor/images/generations"), + (Method::POST, "/api/editor/images/edits"), + (Method::POST, "/api/editor/images/background-removals"), + (Method::POST, "/api/editor/icon-spritesheets/generations"), + (Method::POST, "/api/editor/character-animations/generations"), + (Method::POST, "/api/editor/videos/generations"), + (Method::POST, "/api/editor/audios/sound-effects/generations"), + ( + Method::POST, + "/api/editor/audios/background-music/generations", + ), + (Method::GET, "/api/runtime/external-generation/jobs/job-225"), + (Method::GET, "/api/external/v1/assets/read-url"), + (Method::GET, "/api/external/v1/editor/projects"), + (Method::POST, "/api/external/v1/editor/projects"), + (Method::GET, "/api/external/v1/editor/projects/project-225"), + ( + Method::POST, + "/api/external/v1/editor/projects/project-225/resources", + ), + (Method::GET, "/api/external/v1/editor/assets/library"), + (Method::POST, "/api/external/v1/editor/assets/folders"), + (Method::POST, "/api/external/v1/editor/images/generations"), + (Method::POST, "/api/external/v1/editor/images/edits"), + ( + Method::POST, + "/api/external/v1/editor/images/background-removals", + ), + ( + Method::POST, + "/api/external/v1/editor/icon-spritesheets/generations", + ), + ( + Method::POST, + "/api/external/v1/editor/character-animations/generations", + ), + (Method::POST, "/api/external/v1/editor/videos/generations"), + ( + Method::POST, + "/api/external/v1/editor/audios/sound-effects/generations", + ), + ( + Method::POST, + "/api/external/v1/editor/audios/background-music/generations", + ), + (Method::GET, "/api/external/v1/generations/task-225"), + ] { + let spec = resolve_route_tracking_spec(&method, path) + .unwrap_or_else(|| panic!("missing AGC route tracking spec: {method} {path}")); + assert!( + spec.requires_agc_marker, + "{method} {path} must require the AGC marker" + ); + } + + for (method, path) in [ + (Method::GET, "/api/auth/me"), + (Method::POST, "/api/auth/phone/login"), + (Method::POST, "/api/auth/logout"), + (Method::GET, "/api/profile/dashboard"), + (Method::POST, "/api/auth/refresh"), + (Method::POST, "/api/auth/phone/send-code"), + (Method::POST, "/api/assets/direct-upload-tickets"), + (Method::POST, "/api/assets/objects/confirm"), + ( + Method::POST, + "/api/external/v1/assets/direct-upload-tickets", + ), + (Method::POST, "/api/external/v1/assets/objects/confirm"), + ] { + let spec = resolve_route_tracking_spec(&method, path) + .unwrap_or_else(|| panic!("missing existing route tracking spec: {method} {path}")); + assert!( + !spec.requires_agc_marker, + "{method} {path} must preserve its existing tracking policy" + ); + } + } + + #[test] + fn agc_external_routes_keep_external_paths_and_specs() { + use module_runtime::RuntimeTrackingScopeKind::User; + + for (method, path, event_key, module_key, handled_by_existing_event) in [ + ( + Method::POST, + "/api/external/v1/assets/direct-upload-tickets", + "asset_upload_ticket_create", + "asset", + true, + ), + ( + Method::POST, + "/api/external/v1/assets/objects/confirm", + "asset_upload_confirm", + "asset", + true, + ), + ( + Method::GET, + "/api/external/v1/assets/read-url", + "asset_read_url_view", + "asset", + false, + ), + ( + Method::GET, + "/api/external/v1/editor/projects", + "editor_projects_view", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/projects", + "editor_project_create", + "editor", + false, + ), + ( + Method::GET, + "/api/external/v1/editor/projects/project-225", + "editor_project_view", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/projects/project-225/resources", + "editor_project_resource_create", + "editor", + false, + ), + ( + Method::GET, + "/api/external/v1/editor/assets/library", + "editor_asset_library_view", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/assets/folders", + "editor_asset_folder_create", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/images/generations", + "editor_image_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/images/edits", + "editor_image_edit_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/images/background-removals", + "editor_image_background_removal_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/icon-spritesheets/generations", + "editor_icon_spritesheet_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/character-animations/generations", + "editor_character_animation_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/videos/generations", + "editor_video_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/audios/sound-effects/generations", + "editor_sound_effect_generation_submit", + "editor", + false, + ), + ( + Method::POST, + "/api/external/v1/editor/audios/background-music/generations", + "editor_background_music_generation_submit", + "editor", + false, + ), + ( + Method::GET, + "/api/external/v1/generations/task-225", + "external_generation_job_view", + "runtime", + false, + ), + ] { + assert_route_spec( + method, + path, + event_key, + module_key, + User, + handled_by_existing_event, + ); + } + + let spec = resolve_route_tracking_spec( + &Method::GET, + "/api/external/v1/editor/projects/project-225", + ) + .expect("external project route should resolve"); + let request_context = RequestContext::new( + "request-225-route".to_string(), + "GET /api/external/v1/editor/projects/project-225".to_string(), + Duration::ZERO, + false, + ); + let metadata = build_route_tracking_metadata( + &spec, + &request_context, + &Method::GET, + "/api/external/v1/editor/projects/project-225", + axum::http::StatusCode::OK, + Some(TrackingClientMarker::Agc), + ); + assert_eq!( + metadata["route"], + "/api/external/v1/editor/projects/project-225" + ); + assert_eq!(metadata["client"], "agc"); + } + + #[test] + fn external_discovery_and_mcp_routes_are_not_business_tracking_specs() { + for (method, path) in [ + (Method::GET, "/api/external/v1/openapi.json"), + (Method::GET, "/api/external/v1/agent-integration.json"), + (Method::GET, "/api/external/v1/skill/SKILL.md"), + (Method::GET, "/api/external/v1/skill.zip"), + (Method::POST, "/api/external/v1/mcp"), + ] { + assert!( + resolve_route_tracking_spec(&method, path).is_none(), + "{method} {path} must stay outside business tracking" + ); + } + } + + #[test] + fn agc_dynamic_route_segments_remain_normalized() { + assert_eq!( + normalize_route_path("/api/external/v1/editor/projects/project-225/resources"), + "/api/external/v1/editor/projects/{id}/resources" + ); + assert_eq!( + normalize_route_path("/api/external/v1/generations/task-225"), + "/api/external/v1/generations/{id}" + ); + assert_eq!( + normalize_route_path("/api/runtime/external-generation/jobs/job-225"), + "/api/runtime/external-generation/jobs/{id}" + ); + } + #[test] fn retired_play_paths_are_not_route_tracking_exclusions() { for path in [ diff --git a/server-rs/crates/api-server/src/tracking_outbox.rs b/server-rs/crates/api-server/src/tracking_outbox.rs index 272f08df4..390d71011 100644 --- a/server-rs/crates/api-server/src/tracking_outbox.rs +++ b/server-rs/crates/api-server/src/tracking_outbox.rs @@ -589,6 +589,52 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[tokio::test] + async fn enqueue_round_trips_agc_metadata_and_tracking_identity() { + let dir = test_dir("agc-round-trip"); + let outbox = test_outbox(dir.clone(), 500, 1024 * 1024); + let event = RuntimeTrackingEventInput { + event_id: "agc-event-225".to_string(), + event_key: "editor_image_generation_submit".to_string(), + scope_kind: module_runtime::RuntimeTrackingScopeKind::User, + scope_id: "owner-225".to_string(), + user_id: None, + owner_user_id: Some("owner-225".to_string()), + profile_id: None, + module_key: Some("editor".to_string()), + metadata_json: serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "method": "POST", + "status": 202, + "operation": "generateExternalEditorImage", + "client": "agc", + }) + .to_string(), + occurred_at_micros: 1_756_800_000_000_000, + }; + + assert!(matches!( + outbox.enqueue(event.clone()).await.unwrap(), + TrackingOutboxEnqueueOutcome::Enqueued + )); + + let events = read_outbox_events(&dir.join(ACTIVE_FILE_NAME)) + .await + .expect("outbox record should deserialize"); + assert_eq!(events, vec![event]); + let metadata = serde_json::from_str::(&events[0].metadata_json) + .expect("outbox metadata should remain valid JSON"); + assert_eq!(metadata["client"], "agc"); + assert_eq!( + metadata["route"], + "/api/external/v1/editor/images/generations" + ); + assert_eq!(events[0].owner_user_id.as_deref(), Some("owner-225")); + assert_eq!(events[0].user_id, None); + + let _ = std::fs::remove_dir_all(dir); + } + #[tokio::test] async fn enqueue_drops_when_outbox_exceeds_max_bytes() { let dir = test_dir("max-bytes"); diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 0fc648db8..138bb2cf3 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -1643,4 +1643,55 @@ mod tests { }) ); } + + #[test] + fn tracking_input_normalizes_and_preserves_agc_metadata_and_owner() { + let input = build_runtime_tracking_event_input( + "event-225".to_string(), + "editor_image_generation_submit".to_string(), + RuntimeTrackingScopeKind::User, + "owner-225".to_string(), + None, + Some("owner-225".to_string()), + None, + Some("editor".to_string()), + r#" { "route": "/api/external/v1/editor/images/generations", "client": "agc" } "# + .to_string(), + 1_756_800_000_000_000, + ) + .expect("tracking input should accept object metadata"); + + assert_eq!(input.event_id, "event-225"); + assert_eq!(input.scope_id, "owner-225"); + assert_eq!(input.owner_user_id.as_deref(), Some("owner-225")); + assert_eq!(input.user_id, None); + assert_eq!(input.module_key.as_deref(), Some("editor")); + assert_eq!( + serde_json::from_str::(&input.metadata_json) + .expect("normalized metadata should parse"), + serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "client": "agc", + }) + ); + } + + #[test] + fn tracking_input_rejects_non_object_metadata() { + let error = build_runtime_tracking_event_input( + "event-225-invalid".to_string(), + "editor_projects_view".to_string(), + RuntimeTrackingScopeKind::User, + "user-225".to_string(), + Some("user-225".to_string()), + Some("user-225".to_string()), + None, + Some("editor".to_string()), + r#"["not-an-object"]"#.to_string(), + 1_756_800_000_000_000, + ) + .expect_err("tracking metadata must remain an object"); + + assert_eq!(error, RuntimeProfileFieldError::InvalidInviteCodeMetadata); + } } diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 5484cca0b..0737447c7 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,9 +21,10 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 63] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 64] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), + command("project.rename", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), command("project.index", GameCreationAppPermission::Auto), command("project.checkpoint", GameCreationAppPermission::Confirm), @@ -965,7 +966,7 @@ mod tests { #[test] fn command_contract_keeps_expected_permissions() { - assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 63); + assert_eq!(GAME_CREATION_APP_COMMANDS.len(), 64); let command_ids = GAME_CREATION_APP_COMMANDS .iter() @@ -1018,6 +1019,15 @@ mod tests { .expect("command should exist"); assert_eq!(command.permission, GameCreationAppPermission::Confirm); + let project_rename = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "project.rename") + .expect("project.rename should exist"); + assert_eq!( + project_rename.permission, + GameCreationAppPermission::Confirm + ); + let command_exec = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "command.exec") diff --git a/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs index 5c39b0de0..97ab82524 100644 --- a/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs +++ b/server-rs/crates/spacetime-client/src/active/mapper/runtime.rs @@ -180,3 +180,46 @@ pub(crate) fn map_runtime_tracking_scope_kind_back( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tracking_input_mapper_preserves_agc_metadata_and_external_owner() { + let input = module_runtime::RuntimeTrackingEventInput { + event_id: "external-event-225".to_string(), + event_key: "editor_image_generation_submit".to_string(), + scope_kind: DomainRuntimeTrackingScopeKind::User, + scope_id: "owner-225".to_string(), + user_id: None, + owner_user_id: Some("owner-225".to_string()), + profile_id: None, + module_key: Some("editor".to_string()), + metadata_json: serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "client": "agc", + }) + .to_string(), + occurred_at_micros: 1_756_800_000_000_000, + }; + + let mapped: RuntimeTrackingEventInput = input.into(); + + assert_eq!(mapped.event_id, "external-event-225"); + assert_eq!(mapped.event_key, "editor_image_generation_submit"); + assert_eq!(mapped.scope_kind, RuntimeTrackingScopeKind::User); + assert_eq!(mapped.scope_id, "owner-225"); + assert_eq!(mapped.user_id, None); + assert_eq!(mapped.owner_user_id.as_deref(), Some("owner-225")); + assert_eq!(mapped.module_key.as_deref(), Some("editor")); + assert_eq!( + serde_json::from_str::(&mapped.metadata_json) + .expect("mapped metadata should remain valid JSON"), + serde_json::json!({ + "route": "/api/external/v1/editor/images/generations", + "client": "agc", + }) + ); + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 0b637e331..4a7f87731 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -114,12 +114,8 @@ export default defineConfig({ 'src/components/platform-entry/PlatformActiveProfileView.test.tsx', 'src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx', 'src/components/platform-entry/PlatformProfileModalShell.test.tsx', - 'src/components/platform-entry/PlatformProfileQrScannerModal.test.tsx', - 'src/components/platform-entry/PlatformProfileRechargeModal.test.tsx', 'src/components/platform-entry/PlatformProfileReferralModal.test.tsx', 'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.test.tsx', - 'src/components/platform-entry/PlatformProfileTaskCenterModal.test.tsx', - 'src/components/platform-entry/PlatformProfileWalletLedgerModal.test.tsx', 'src/components/platform-entry/platformProfile*.test.ts', 'src/components/platform-entry/usePlatformProfileCenterController*.test.tsx', 'src/hooks/useHostNavigationCanGoBack.test.tsx',