Merge remote-tracking branch 'origin/master' into feat/design_agent_simple
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-09-03 08:41:57 +00:00
38 changed files with 4382 additions and 136 deletions
@@ -0,0 +1 @@
你是项目命名助手。只根据用户输入提炼一个原创、简短、中文的项目名称;不要使用知名作品名、路径、URL、凭据、Markdown、引号或解释。输出必须有且只有一行纯文本,长度为 2 到 16 个字符。信息不足时输出:未命名游戏原型
@@ -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<String> {
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<String, String> {
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<Option<String>, 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<PathBuf,
pub(crate) fn create_automatic_local_game_project_at(
projects_root: &Path,
requested_name: Option<&str>,
) -> Result<InitLocalProjectResult, String> {
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<String>,
) -> Result<InitLocalProjectResult, String> {
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<RenameLocalProjectResult, String> {
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());
}
@@ -1836,6 +1947,15 @@ pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
check_game_creator_llm_config_from_config()
}
#[tauri::command]
pub(crate) async fn suggest_automatic_project_name(
prompt: String,
) -> Result<Option<String>, 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()
@@ -2475,6 +2475,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,
@@ -7,6 +7,24 @@ const MANIFEST_LOCK_WAIT_MILLIS: u64 = 10;
static MANIFEST_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
pub(crate) const GAME_CREATION_PROJECT_NAME_MAX_CHARS: usize = 80;
pub(crate) fn normalize_game_creation_project_name(value: &str) -> Result<String, String> {
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)? {
@@ -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("不能包含符号链接"));
@@ -114,6 +114,11 @@ export interface InitLocalProjectResult {
manifest: GameCreationAppManifest;
}
export interface RenameLocalProjectResult {
manifest: GameCreationAppManifest;
revision: number;
}
export interface LocalProjectDirectoryStatus {
projectPath: string;
exists: boolean;
@@ -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"
>
<button
type="button"
role="menuitem"
disabled={!project.canOpen}
onClick={() => {
setOpen(false);
onRename(project);
}}
>
<PenLine size={15} aria-hidden="true" />
</button>
<button
type="button"
role="menuitem"
@@ -159,6 +181,60 @@ export function ProjectsPage({
}) {
const rows = recentProjects.filteredProjectRows;
const hasStoredProjects = recentProjects.projectRows.length > 0;
const [renamingPath, setRenamingPath] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [renameSaving, setRenameSaving] = useState(false);
const [renameError, setRenameError] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!renamingPath) {
return;
}
renameInputRef.current?.focus();
renameInputRef.current?.select();
}, [renamingPath]);
function startRename(project: RecentProjectRow) {
setRenamingPath(project.path);
setRenameValue(project.name);
setRenameError(null);
}
function cancelRename() {
setRenamingPath(null);
setRenameValue('');
setRenameSaving(false);
setRenameError(null);
}
async function submitRename(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!renamingPath || renameSaving) {
return;
}
const nextName = renameValue.trim();
if (!nextName) {
setRenameError('项目名称不能为空');
return;
}
if (nextName.length > 80) {
setRenameError('项目名称最多支持 80 个字符');
return;
}
setRenameSaving(true);
setRenameError(null);
try {
await homeProject.renameProject(renamingPath, nextName);
await recentProjects.refreshRecentWorkspace(renamingPath);
cancelRename();
} catch (error) {
setRenameError(error instanceof Error ? error.message : '重命名项目失败');
} finally {
setRenameSaving(false);
}
}
return (
<section className="launcher-page launcher-projects-page">
<header className="launcher-projects-toolbar">
@@ -227,47 +303,110 @@ export function ProjectsPage({
</div>
<div className="launcher-project-table">
{rows.length > 0 ? (
rows.map((project) => (
<article key={project.path}>
<button
type="button"
className="launcher-project-row-main"
aria-label={`打开项目 ${project.name}`}
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
rows.map((project) => {
const renaming = renamingPath === project.path;
return (
<article key={project.path}>
{renaming ? (
<form
className="launcher-project-rename"
id={`project-rename-${project.path}`}
onSubmit={submitRename}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
</span>
<div className="launcher-project-name-cell">
<label htmlFor={`project-name-${project.path}`}>
</label>
<input
ref={renameInputRef}
id={`project-name-${project.path}`}
value={renameValue}
maxLength={80}
autoComplete="off"
onChange={(event) =>
setRenameValue(event.target.value)
}
/>
<small title={project.path}>{project.path}</small>
{renameError ? (
<small
className="launcher-project-rename-error"
role="alert"
>
{renameError}
</small>
) : null}
</div>
</form>
) : (
<button
type="button"
className="launcher-project-row-main"
aria-label={`打开项目 ${project.name}`}
disabled={!project.canOpen}
onClick={() => {
homeProject.setProjectPath(project.path);
void homeProject.openProject(project.path, 'open');
}}
>
<span className="launcher-project-kind-icon">
{project.projectKind === 'godot' ? (
<Gamepad2 size={18} aria-hidden="true" />
) : (
<FolderKanban size={18} aria-hidden="true" />
)}
</span>
<span className="launcher-project-name-cell">
<strong>{project.name}</strong>
<small title={project.path}>{project.path}</small>
</span>
</button>
)}
<span className="launcher-project-kind-cell">
{projectKindLabel(project)}
</span>
<span className="launcher-project-name-cell">
<strong>{project.name}</strong>
<small title={project.path}>{project.path}</small>
<span
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
>
{projectStatusTone(project) === 'warning' ? (
<CircleAlert size={14} aria-hidden="true" />
) : null}
{project.status}
</span>
</button>
<span className="launcher-project-kind-cell">
{projectKindLabel(project)}
</span>
<span
className={`launcher-project-status launcher-project-status-${projectStatusTone(project)}`}
>
{projectStatusTone(project) === 'warning' ? (
<CircleAlert size={14} aria-hidden="true" />
) : null}
{project.status}
</span>
<ProjectMoreMenu
project={project}
recentProjects={recentProjects}
/>
</article>
))
{renaming ? (
<div
className="launcher-project-rename-actions"
aria-label="重命名操作"
>
<button
type="submit"
form={`project-rename-${project.path}`}
disabled={renameSaving}
>
<Check size={15} aria-hidden="true" />
{renameSaving ? '保存中' : '保存'}
</button>
<button type="button" onClick={cancelRename}>
</button>
</div>
) : (
<ProjectMoreMenu
project={project}
recentProjects={recentProjects}
onRename={startRename}
/>
)}
</article>
);
})
) : hasStoredProjects ? (
<div className="launcher-empty-projects">
<Search size={26} aria-hidden="true" />
@@ -106,7 +106,11 @@ export function WorkspaceLauncherShell({
manifest: current.manifest,
source: 'initial',
});
}, [currentProjectContext?.createdAt, currentProjectContext?.projectPath]);
}, [
currentProjectContext?.createdAt,
currentProjectContext?.projectName,
currentProjectContext?.projectPath,
]);
const applyManifestSnapshot = useCallback(
(snapshot: ProjectManifestSnapshot) => {
@@ -22,6 +22,7 @@ import type {
LocalProjectFileResult,
PendingNonEmptyProject,
ProjectStartMode,
RenameLocalProjectResult,
TauriInvoke,
UploadLocalAssetResult,
} from '../../app/types';
@@ -70,6 +71,21 @@ function createTextAttachmentFile(content: string) {
return file;
}
async function suggestAutomaticProjectName(
invoke: TauriInvoke,
draft: HomeDraft,
) {
try {
return await invoke<string | null>('suggest_automatic_project_name', {
prompt: draft.prompt,
});
} catch {
// 项目命名是增强能力:配置缺失、Provider 失败或非法输出都回退默认名,
// 不阻断用户真正请求的自动创建和首轮创作。
return null;
}
}
export function useHomeProjectCreation({
setStatus,
setLauncherView,
@@ -140,7 +156,7 @@ export function useHomeProjectCreation({
);
const file = createTextAttachmentFile(result.content);
await createHomeDraftAutomatically(
await createHomeDraftAutomaticallyWithOptions(
{
creationType: 'game',
prompt: APPROVED_GDD_BUILD_PROMPT,
@@ -152,6 +168,7 @@ export function useHomeProjectCreation({
],
},
'direct-build',
{ suggestName: false },
);
} finally {
approvedGddStartInFlightRef.current = false;
@@ -555,14 +572,31 @@ export function useHomeProjectCreation({
async function createHomeDraftAutomatically(
draft: HomeDraft,
startMode: ProjectStartMode,
) {
return createHomeDraftAutomaticallyWithOptions(draft, startMode, {
suggestName: true,
});
}
async function createHomeDraftAutomaticallyWithOptions(
draft: HomeDraft,
startMode: ProjectStartMode,
options: { suggestName: boolean },
) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
setStatus(options.suggestName ? '正在提炼项目名称' : '正在创建工作区');
const suggestedName = options.suggestName
? await suggestAutomaticProjectName(invoke, draft)
: null;
setStatus('正在创建工作区');
const result = await invoke<InitLocalProjectResult>(
'create_automatic_local_game_project',
{
name: suggestedName,
},
);
try {
await enterCreatedHomeProject(
@@ -654,6 +688,31 @@ export function useHomeProjectCreation({
}
}
async function renameProject(nextProjectPath: string, nextName: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在 Tauri App 内运行');
}
const result = await invoke<RenameLocalProjectResult>(
'rename_local_game_project',
{
projectPath: nextProjectPath,
name: nextName,
},
);
setCurrentProjectContext((current) =>
current && current.projectPath === nextProjectPath
? {
...current,
projectName: result.manifest.name,
manifest: result.manifest,
projectRevision: result.revision,
}
: current,
);
return result;
}
return {
projectPath,
setProjectPath,
@@ -673,6 +732,7 @@ export function useHomeProjectCreation({
createHomeDraft,
createHomeDraftAutomatically,
openProject,
renameProject,
pickAndOpenProject,
pickAndCreateProject,
confirmCreateInNonEmptyFolder,
@@ -30,6 +30,21 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
async function inspectRecentWorkspace(
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
workspace: string,
): Promise<[string, LocalProjectDirectoryStatus | null]> {
try {
const result = await invoke<LocalProjectDirectoryStatus>(
'inspect_local_project_directory',
{ projectPath: workspace },
);
return [workspace, result];
} catch {
return [workspace, null];
}
}
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
@@ -40,17 +55,9 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
let disposed = false;
setRecentWorkspaceRefreshing(true);
void Promise.all(
recentWorkspaces.map(async (workspace) => {
try {
const result = await invoke<LocalProjectDirectoryStatus>(
'inspect_local_project_directory',
{ projectPath: workspace },
);
return [workspace, result] as const;
} catch {
return [workspace, null] as const;
}
}),
recentWorkspaces.map((workspace) =>
inspectRecentWorkspace(invoke, workspace),
),
).then((entries) => {
if (disposed) {
return;
@@ -68,6 +75,18 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
setRecentWorkspaceRefreshKey((current) => current + 1);
}
async function refreshRecentWorkspace(projectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
const [, status] = await inspectRecentWorkspace(invoke, projectPath);
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
}
function handleRecentWorkspaceRemove(projectPath: string) {
setRecentWorkspaces(removeRecentWorkspace(projectPath));
setRecentWorkspaceStatuses((current) => {
@@ -137,6 +156,7 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
.filter((project) => project.canOpen)
.slice(0, 3),
rememberRecentWorkspace,
refreshRecentWorkspace,
handleRecentWorkspaceRemove,
handleRevealProjectDirectory,
};
@@ -1,4 +1,4 @@
import { ArrowUp } from 'lucide-react';
import { ArrowUp, Loader2 } from 'lucide-react';
import type {
ComponentProps,
Dispatch,
@@ -108,6 +108,7 @@ export function ProjectSupervisorView({
: runtimePanelProps.controlBusy
? '思考中'
: '发送';
const submitting = runtimePanelProps.controlBusy && !needsUserInput;
return (
<section
className={`project-supervisor-surface${directCodex ? ' is-direct-codex' : ''}`}
@@ -264,7 +265,11 @@ export function ProjectSupervisorView({
title={submitLabel}
disabled={runtimePanelProps.controlBusy || needsUserInput}
>
<ArrowUp size={16} aria-hidden="true" />
{submitting ? (
<Loader2 size={16} aria-hidden="true" className="animate-spin" />
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
</form>
<small className="project-supervisor-workspace-status">
+73 -1
View File
@@ -1169,7 +1169,7 @@ textarea {
display: grid;
grid-template-columns:
minmax(360px, 1fr) minmax(120px, 160px) minmax(150px, 190px)
56px;
104px;
align-items: center;
min-width: 0;
}
@@ -1235,6 +1235,78 @@ textarea {
cursor: pointer;
}
.launcher-project-table article > .launcher-project-rename {
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 100%;
min-width: 0;
gap: 12px;
}
.launcher-project-rename label {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.launcher-project-rename input {
width: min(100%, 320px);
min-width: 0;
min-height: 30px;
padding: 0 8px;
border: 1px solid var(--platform-surface-border);
border-radius: 7px;
color: var(--platform-text-strong);
font-size: 13px;
font-weight: 700;
}
.launcher-project-rename input:focus-visible {
border-color: var(--platform-warm-text);
outline: 2px solid transparent;
}
.launcher-project-rename-error {
color: #a64735 !important;
}
.launcher-project-rename-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 5px;
justify-self: end;
width: 100%;
}
.launcher-project-rename-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
gap: 3px;
padding: 0 7px;
border-radius: 7px;
font-size: 11px;
font-weight: 700;
}
.launcher-project-rename-actions button:first-child {
border: 0;
background: var(--platform-warm-text);
color: #fff;
}
.launcher-project-rename-actions button:last-child {
border: 1px solid var(--platform-surface-border);
background: transparent;
color: var(--platform-text-strong);
}
.launcher-project-table article > .launcher-project-row-main:disabled {
cursor: not-allowed;
opacity: 0.62;
@@ -5,6 +5,7 @@ import {
FolderOpen,
Gamepad2,
Image,
Loader2,
type LucideIcon,
Sparkles,
} from 'lucide-react';
@@ -248,7 +249,15 @@ export default function HomeView({
}
disabled={homeCreationBusy}
>
<ArrowUp size={16} aria-hidden="true" />
{homeCreationBusy ? (
<Loader2
size={16}
aria-hidden="true"
className="animate-spin"
/>
) : (
<ArrowUp size={16} aria-hidden="true" />
)}
</button>
</div>
</RichInputArea>
@@ -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',
@@ -2755,6 +2767,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<string, unknown>) => {
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<string, unknown>) => {
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<string, unknown>) => {
+1
View File
@@ -48,6 +48,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)
@@ -26,6 +26,32 @@
- 验证方式:按 `docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md` 的 P0~P5 阶段验收执行;至少覆盖第 8 个问题、上限后 question 抑制、Provider 失败、非法输出、批准/修改/退回、重启恢复、旧会话切换强制失败、迟到 Provider 结果丢弃和当前空能力快照。
- 关联文档:`docs/technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md``docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md`
## 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 GDD 审批卡的后台 hydrate 不抢占已加载决定
- 背景:项目页首次加载和运行态刷新可能并发 hydrate。卡片已经显示后,短暂的 `hydrateBusy` 会让已打开的评论弹层提交按钮瞬时变灰,用户无法提交已输入的修改意见。
@@ -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。
@@ -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 故障。
@@ -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/pathExternal v1 不得只记录客户端账本中的内部映射路径。
### 4.3 认证主体归属
| 请求类型 | `user_id` | `owner_user_id` | `scope_kind/scope_id` | 说明 |
|---|---|---|---|---|
| 登录账号态 | 沿用 `AuthenticatedAccessToken.claims().user_id()` | 沿用现有行为,通常与 user_id 相同 | 沿用 route specUser 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 再次修改,也不需要先新增数据库字段。
@@ -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. 阶段 1Marker 解析与 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 ownerExternal 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/ownerExternal 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. 阶段 4tracking_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 和 modulemodule-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. 阶段 1marker 解析与 tracking 入口。
2. 阶段 2:主体归属与 metadata 合并。
3. 阶段 3route spec 与 External v1 覆盖。
4. 阶段 4~5:持久化/后台验证、定向测试和安全回归。
5. 阶段 6:验收记录、Issue 交接评论和最终门禁。
如仓库要求单提交,也应在 PR 描述中按上述五组列出,确保每组都有独立验收标准。
@@ -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`
@@ -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 时干净;本记录及计划/方案更新属于本阶段待提交文档变更 |
阶段 15 的实现提交:
| 阶段 | 提交 |
|---|---|
| 阶段 1marker 解析与 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 写入 SpacetimeDBoutbox 不可用时沿用同步 `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 passed0 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 passed0 failed |
| module-runtime tracking input | `cargo test --locked --manifest-path server-rs/Cargo.toml -p module-runtime tracking_input_ -- --nocapture` | 2 passed0 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 passed0 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 passed0 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 的设计或代码范围。
@@ -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',
@@ -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' },
-7
View File
@@ -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',

Some files were not shown because too many files have changed in this diff Show More