OSS 写入显式使用 v1 签名
Project CI / AI game creator shell Rust shard 1/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (push) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (push) Failing after 19s
Project CI / AI game creator shell Rust shard 3/4 (push) Failing after 19s
Project CI / AI game creator shell Rust crates (push) Failing after 18s
Project CI / Native shell tests (push) Failing after 18s
Project CI / Backend tests (push) Failing after 18s
Project CI / AI game creator shell Rust smoke (push) Failing after 19s
Project CI / Frontend tests (push) Failing after 7s
Project CI / AI game creator shell web tests (push) Failing after 11s
Project CI / Repository checks (push) Failing after 11s

ossutil v2 默认 v4 签名,缺 region 会直接失败\n总号写入与回读统一走 buildOssutilArgs,默认 --sign-version v1,可切 v4 并配合 AGC_OSS_REGION\n补充参数构造单测
This commit is contained in:
2026-09-20 18:33:02 +08:00
parent 752116fe41
commit 39aed2e486
239 changed files with 2727 additions and 2020 deletions
@@ -5,9 +5,11 @@ import {
executeAdminRechargeRefund, executeAdminRechargeRefund,
getAdminFeatureGateConfig, getAdminFeatureGateConfig,
getAdminUserDetail, getAdminUserDetail,
listAdminGameDistributionReviews,
listAdminRechargeOrders, listAdminRechargeOrders,
reconcileAdminUserConsumption, reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview, resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
updateAdminAccount, updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage, uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig, upsertAdminFeatureGateConfig,
@@ -364,3 +366,87 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}), }),
); );
}); });
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+42
View File
@@ -29,6 +29,9 @@ import type {
AdminExternalApiKeyListQuery, AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse, AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse, AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminLoginResponse, AdminLoginResponse,
AdminMeResponse, AdminMeResponse,
AdminOverviewResponse, AdminOverviewResponse,
@@ -1176,3 +1179,42 @@ export function saveAgcModelCatalog(
{ token, method: 'PUT', body }, { token, method: 'PUT', body },
); );
} }
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
+30
View File
@@ -1031,3 +1031,33 @@ export interface AdminAgcModelCatalog {
defaultModelId: string; defaultModelId: string;
models: AdminAgcModel[]; models: AdminAgcModel[];
} }
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
+7
View File
@@ -26,6 +26,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage'; import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage'; import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage'; import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'; import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage'; import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage'; import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -300,6 +301,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized} onUnauthorized={handleUnauthorized}
/> />
) : null} ) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? ( {activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage <AdminEditorAssetQueryPage
token={token} token={token}
+2
View File
@@ -5,6 +5,7 @@ import {
Coins, Coins,
Database, Database,
FolderArchive, FolderArchive,
Gamepad2,
GitBranch, GitBranch,
Images, Images,
LayoutDashboard, LayoutDashboard,
@@ -49,6 +50,7 @@ const routeIcons = {
'recharge-orders': ReceiptText, 'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins, 'editor-generation-pricing': Coins,
'editor-showcase': Star, 'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images, 'editor-assets': Images,
'project-snapshots': FolderArchive, 'project-snapshots': FolderArchive,
accounts: Users, accounts: Users,
@@ -147,3 +147,24 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}), }),
).not.toContainEqual(route); ).not.toContainEqual(route);
}); });
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
+2
View File
@@ -15,6 +15,7 @@ export type AdminRouteId =
| 'recharge-orders' | 'recharge-orders'
| 'editor-generation-pricing' | 'editor-generation-pricing'
| 'editor-showcase' | 'editor-showcase'
| 'game-distribution'
| 'editor-assets' | 'editor-assets'
| 'project-snapshots' | 'project-snapshots'
| 'agc-models' | 'agc-models'
@@ -54,6 +55,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
}, },
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true }, { id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' }, { id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' }, { id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' }, { id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true }, { id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -165,6 +165,35 @@ export async function resolveSeedBaseline({
return baseline; return baseline;
} }
/**
* 组装 ossutil 参数。
*
* 该桶与凭据按 v1 签名使用(ossutil v2 默认 v4,缺 region 会直接失败),
* 因此默认显式传 `--sign-version v1`;需要 v4 时用 `AGC_OSS_SIGN_VERSION=v4`
* 并同时给 `AGC_OSS_REGION`。
*/
export function buildOssutilArgs({
args,
endpoint,
accessKeyId,
accessKeySecret,
env = process.env,
}) {
const finalArgs = [...args, '--endpoint', endpoint];
const region = env.AGC_OSS_REGION?.trim();
if (region) finalArgs.push('--region', region);
finalArgs.push('--sign-version', env.AGC_OSS_SIGN_VERSION?.trim() || 'v1');
if (accessKeyId) {
finalArgs.push(
'--access-key-id',
accessKeyId,
'--access-key-secret',
accessKeySecret,
);
}
return finalArgs;
}
function runOssutil(args, { env = process.env } = {}) { function runOssutil(args, { env = process.env } = {}) {
const binary = env.OSSUTIL_BIN?.trim() || 'ossutil'; const binary = env.OSSUTIL_BIN?.trim() || 'ossutil';
const endpoint = const endpoint =
@@ -174,12 +203,15 @@ function runOssutil(args, { env = process.env } = {}) {
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
} }
const credentialArgs = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawnSync( const result = spawnSync(
binary, binary,
[...args, '--endpoint', endpoint, ...credentialArgs], buildOssutilArgs({
args,
endpoint,
accessKeyId,
accessKeySecret,
env,
}),
{ stdio: 'inherit', shell: false, env }, { stdio: 'inherit', shell: false, env },
); );
if (result.error) { if (result.error) {
@@ -3,6 +3,7 @@ import { test } from 'node:test';
import { import {
assertRequestedVersionNotBelowChannel, assertRequestedVersionNotBelowChannel,
buildOssutilArgs,
issueGlobalVersion, issueGlobalVersion,
maxVersion, maxVersion,
nextVersion, nextVersion,
@@ -181,3 +182,25 @@ test('nextVersion 只在 patch 位递增', () => {
assert.equal(nextVersion('1.0.0'), '1.0.1'); assert.equal(nextVersion('1.0.0'), '1.0.1');
assert.throws(() => nextVersion('0.1'), /不是有效的三段版本号/u); assert.throws(() => nextVersion('0.1'), /不是有效的三段版本号/u);
}); });
test('ossutil 参数默认使用 v1 签名,并可按需带 region 与 v4', () => {
const base = {
args: ['cp', '--force', '/tmp/a.json', 'oss://agc-dev/agc/global-version.json'],
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
accessKeyId: 'id',
accessKeySecret: 'secret',
env: {},
};
const v1 = buildOssutilArgs(base);
assert.equal(v1[v1.indexOf('--sign-version') + 1], 'v1');
assert.ok(!v1.includes('--region'));
assert.equal(v1[v1.indexOf('--access-key-id') + 1], 'id');
assert.equal(v1[v1.indexOf('--access-key-secret') + 1], 'secret');
const v4 = buildOssutilArgs({
...base,
env: { AGC_OSS_SIGN_VERSION: 'v4', AGC_OSS_REGION: 'cn-beijing' },
});
assert.equal(v4[v4.indexOf('--region') + 1], 'cn-beijing');
assert.equal(v4[v4.indexOf('--sign-version') + 1], 'v4');
});
@@ -5933,6 +5933,16 @@ pub(crate) fn export_local_project_package(
export_local_project_package_at(root) export_local_project_package_at(root)
} }
#[tauri::command]
pub(crate) fn read_local_project_export_package(
project_path: String,
package_relative_path: String,
) -> Result<LocalProjectExportPackagePayload, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
read_local_project_export_package_at(root, package_relative_path.trim())
}
#[tauri::command] #[tauri::command]
pub(crate) fn list_local_project_export_packages( pub(crate) fn list_local_project_export_packages(
project_path: String, project_path: String,
@@ -2692,6 +2692,7 @@ fn main() {
build_local_project_index, build_local_project_index,
create_local_project_checkpoint, create_local_project_checkpoint,
export_local_project_package, export_local_project_package,
read_local_project_export_package,
list_local_project_export_packages, list_local_project_export_packages,
diff_local_project_checkpoint, diff_local_project_checkpoint,
restore_local_project_checkpoint, restore_local_project_checkpoint,
@@ -1,5 +1,27 @@
use super::*; use super::*;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::io::{Cursor, Read};
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackageFileDigest {
pub(crate) path: String,
pub(crate) size_bytes: u64,
pub(crate) sha256: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectExportPackagePayload {
pub(crate) package_relative_path: String,
pub(crate) package_bytes: Vec<u8>,
pub(crate) package_sha256: String,
pub(crate) package_size_bytes: u64,
pub(crate) files: Vec<LocalProjectExportPackageFileDigest>,
}
pub(crate) fn export_local_project_package_at( pub(crate) fn export_local_project_package_at(
root: &Path, root: &Path,
) -> Result<LocalProjectExportPackageResult, String> { ) -> Result<LocalProjectExportPackageResult, String> {
@@ -110,6 +132,114 @@ pub(crate) fn export_local_project_package_at(
}) })
} }
/// Read a previously exported package for the explicit AGC publish flow.
///
/// The caller receives the package bytes and a deterministic file manifest, but
/// never receives a filesystem path that it could accidentally send to the API.
pub(crate) fn read_local_project_export_package_at(
root: &Path,
package_relative_path: &str,
) -> Result<LocalProjectExportPackagePayload, String> {
validate_project_root(root)?;
let normalized = normalize_export_package_entry_path(package_relative_path)?;
if !normalized.starts_with("exports/playtest-package-")
|| !normalized.ends_with(".zip")
|| normalized.contains('/') && normalized.split('/').count() != 2
{
return Err("发行包路径必须是 exports/playtest-package-*.zip".to_string());
}
let package_path = resolve_local_project_path(root, &normalized)?;
prepare_game_creator_private_path_for_read(&package_path, false, "发行包")?;
let metadata = checked_export_package_metadata(&package_path, &normalized)?;
if !metadata.is_file() {
return Err("发行包必须是普通文件".to_string());
}
if metadata.len() == 0 || metadata.len() > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("发行包大小超出本地发布上限".to_string());
}
let package_bytes =
fs::read(&package_path).map_err(|error| format!("读取发行包失败:{error}"))?;
if package_bytes.len() as u64 != metadata.len() {
return Err("发行包在读取期间发生变化,请重新导出".to_string());
}
let mut archive = zip::ZipArchive::new(Cursor::new(&package_bytes))
.map_err(|error| format!("读取发行包 ZIP 失败:{error}"))?;
let mut entries = Vec::with_capacity(archive.len());
let mut seen = BTreeSet::new();
for index in 0..archive.len() {
let mut entry = archive
.by_index(index)
.map_err(|error| format!("读取发行包条目失败:{error}"))?;
if entry.is_dir() {
continue;
}
let source_path = normalize_export_package_entry_path(entry.name())?;
// 本地试玩包以 game/index.html 为入口,而平台发行合同要求根
// index.html。把 game/ 前缀剥离到内存 ZIP,避免上传本地路径或修改
// 工作区里的原始导出文件;根目录的 README/assets 等公共条目原样保留。
let path = source_path
.strip_prefix("game/")
.unwrap_or(source_path.as_str())
.to_string();
let path = normalize_export_package_entry_path(&path)?;
if !seen.insert(path.clone()) {
return Err(format!("发行包包含重复条目:{path}"));
}
let expected_size = entry.size();
let mut content = Vec::with_capacity(expected_size.min(16 * 1024 * 1024) as usize);
entry
.read_to_end(&mut content)
.map_err(|error| format!("读取发行包文件失败:{path}: {error}"))?;
if content.len() as u64 != expected_size {
return Err(format!("发行包条目长度不一致:{path}"));
}
entries.push((path, content));
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
if entries.is_empty() {
return Err("发行包没有可上传文件".to_string());
}
let mut normalized_writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (path, content) in &entries {
normalized_writer
.start_file(path, options)
.map_err(|error| format!("写入发行包条目失败:{path}: {error}"))?;
normalized_writer
.write_all(content)
.map_err(|error| format!("写入发行包文件失败:{path}: {error}"))?;
}
let normalized_cursor = normalized_writer
.finish()
.map_err(|error| format!("完成发行包失败:{error}"))?;
let package_bytes = normalized_cursor.into_inner();
if package_bytes.is_empty() || package_bytes.len() as u64 > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err("归一化发行包大小超出本地发布上限".to_string());
}
let package_sha256 = format!("{:x}", Sha256::digest(&package_bytes));
let files = entries
.into_iter()
.map(|(path, content)| LocalProjectExportPackageFileDigest {
size_bytes: content.len() as u64,
sha256: format!("{:x}", Sha256::digest(&content)),
path,
})
.collect::<Vec<_>>();
if !files.iter().any(|file| file.path == "index.html") {
return Err("归一化发行包缺少根 index.html".to_string());
}
Ok(LocalProjectExportPackagePayload {
package_relative_path: normalized,
package_size_bytes: package_bytes.len() as u64,
package_bytes,
package_sha256,
files,
})
}
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> { pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
let seed = unix_millis(); let seed = unix_millis();
for suffix in 0..1000 { for suffix in 0..1000 {
@@ -3906,6 +3906,57 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
fs::remove_dir_all(root).ok(); fs::remove_dir_all(root).ok();
} }
#[test]
fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() {
let root = unique_project_path();
init_existing_html_project_at(&root, "project-publish", "在线试玩项目").expect("project init");
write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html)
.expect("write playable html");
write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme");
let exported = export_local_project_package_at(&root).expect("export package");
let payload = read_local_project_export_package_at(&root, &exported.package_relative_path)
.expect("read publish payload");
assert_eq!(
payload.package_relative_path,
exported.package_relative_path
);
assert_eq!(
payload.package_size_bytes,
payload.package_bytes.len() as u64
);
assert_eq!(payload.files.len(), 2);
assert!(payload
.files
.iter()
.any(|file| file.path == "index.html"));
assert!(payload
.files
.iter()
.any(|file| file.path == "exports/README.md"));
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(&payload.package_bytes))
.expect("read normalized package");
let names = (0..archive.len())
.map(|index| {
archive
.by_index(index)
.expect("normalized entry")
.name()
.to_string()
})
.collect::<Vec<_>>();
assert!(names.iter().any(|name| name == "index.html"));
assert!(!names.iter().any(|name| name.starts_with("game/")));
assert_eq!(payload.package_sha256.len(), 64);
assert!(payload
.package_sha256
.chars()
.all(|value| value.is_ascii_hexdigit()));
fs::remove_dir_all(root).ok();
}
#[test] #[test]
fn local_project_export_package_list_only_returns_recent_playtest_zips() { fn local_project_export_package_list_only_returns_recent_playtest_zips() {
let root = unique_project_path(); let root = unique_project_path();
+32
View File
@@ -40,6 +40,7 @@ import {
} from './app/constants'; } from './app/constants';
import { useEscapeToClose } from './app/dialogs'; import { useEscapeToClose } from './app/dialogs';
import { resolveTauriInvoke } from './app/tauri'; import { resolveTauriInvoke } from './app/tauri';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import type { import type {
AgentBackgroundSubmitMode, AgentBackgroundSubmitMode,
AgentProgressEvent, AgentProgressEvent,
@@ -1070,6 +1071,9 @@ export function App({
const [filePath, setFilePath] = useState('game/index.html'); const [filePath, setFilePath] = useState('game/index.html');
const [fileDraft, setFileDraft] = useState(''); const [fileDraft, setFileDraft] = useState('');
const [fileStatus, setFileStatus] = useState('未读取'); const [fileStatus, setFileStatus] = useState('未读取');
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
const [agentRunTrace, setAgentRunTrace] = const [agentRunTrace, setAgentRunTrace] =
useState<GameCreationAgentRunTrace | null>(null); useState<GameCreationAgentRunTrace | null>(null);
const [agentRunHistory, setAgentRunHistory] = useState<AgentRunHistoryItem[]>( const [agentRunHistory, setAgentRunHistory] = useState<AgentRunHistoryItem[]>(
@@ -7483,6 +7487,8 @@ export function App({
{ projectPath: nextProjectPath }, { projectPath: nextProjectPath },
); );
setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`); setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`);
setPublishPackageResult(result);
setPublishPanelOpen(true);
setCommandLog((current) => [...current, 'project.export_package']); setCommandLog((current) => [...current, 'project.export_package']);
void refreshManifest(nextProjectPath); void refreshManifest(nextProjectPath);
if (announceToChat) { if (announceToChat) {
@@ -11785,6 +11791,15 @@ export function App({
workspaceStatus={workspaceStatus} workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId} expectedRunId={projectSupervisorExpectedRunId}
versions={chatProjectVersions} versions={chatProjectVersions}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={supervisorProjectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/> />
); );
} }
@@ -11970,6 +11985,15 @@ export function App({
onProfessionalToolAction={handleProjectProfessionalAgentToolAction} onProfessionalToolAction={handleProjectProfessionalAgentToolAction}
onProfessionalRetry={handleProjectProfessionalAgentRetry} onProfessionalRetry={handleProjectProfessionalAgentRetry}
onUserInput={handleProjectSupervisorUserInput} onUserInput={handleProjectSupervisorUserInput}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/> />
); );
} }
@@ -12077,6 +12101,14 @@ export function App({
workspaceStatus={workspaceStatus} workspaceStatus={workspaceStatus}
/> />
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
{runtimeConfigOpen ? ( {runtimeConfigOpen ? (
<RuntimeConfigDialog <RuntimeConfigDialog
projectPath={localProject?.projectPath} projectPath={localProject?.projectPath}
@@ -794,6 +794,20 @@ export interface LocalProjectExportPackageResult {
totalBytes: number; totalBytes: number;
} }
export interface LocalProjectExportPackageFileDigest {
path: string;
sizeBytes: number;
sha256: string;
}
export interface LocalProjectExportPackagePayload {
packageRelativePath: string;
packageBytes: number[];
packageSha256: string;
packageSizeBytes: number;
files: LocalProjectExportPackageFileDigest[];
}
export interface LocalProjectExportPackageSummary { export interface LocalProjectExportPackageSummary {
packagePath: string; packagePath: string;
packageRelativePath: string; packageRelativePath: string;
@@ -150,6 +150,7 @@ function AgentReasoning({
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>; type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
type ProjectSupervisorViewProps = RuntimePanelProps & { type ProjectSupervisorViewProps = RuntimePanelProps & {
overlay?: ReactNode;
activeVersionId?: string | null; activeVersionId?: string | null;
/** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */ /** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */
attachments?: DirectCodexTurnAttachment[]; attachments?: DirectCodexTurnAttachment[];
@@ -215,6 +216,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
}; };
export function ProjectSupervisorView({ export function ProjectSupervisorView({
overlay,
activeVersionId = null, activeVersionId = null,
attachments = [], attachments = [],
attachmentNotice = '', attachmentNotice = '',
@@ -892,6 +894,7 @@ export function ProjectSupervisorView({
closeOnEscape={false} closeOnEscape={false}
/> />
) : null} ) : null}
{overlay}
</section> </section>
); );
} }
@@ -2,6 +2,7 @@ import { Send, Settings } from 'lucide-react';
import type { import type {
ComponentProps, ComponentProps,
FormEventHandler, FormEventHandler,
ReactNode,
Ref, Ref,
RefObject, RefObject,
UIEvent, UIEvent,
@@ -42,6 +43,7 @@ type RuntimeControlProps = ComponentProps<
const CHAT_SCROLL_BOTTOM_THRESHOLD = 24; const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = { type SupervisorChatOnlyViewProps = {
overlay?: ReactNode;
activeVersionId?: string | null; activeVersionId?: string | null;
chatAgentBusy: boolean; chatAgentBusy: boolean;
chatInput: string; chatInput: string;
@@ -80,6 +82,7 @@ type SupervisorChatOnlyViewProps = {
}; };
export function SupervisorChatOnlyView({ export function SupervisorChatOnlyView({
overlay,
activeVersionId = null, activeVersionId = null,
chatAgentBusy, chatAgentBusy,
chatInput, chatInput,
@@ -351,6 +354,7 @@ export function SupervisorChatOnlyView({
onClose={onCloseRuntimeConfig} onClose={onCloseRuntimeConfig}
/> />
) : null} ) : null}
{overlay}
</> </>
); );
} }
+187
View File
@@ -3768,6 +3768,193 @@ textarea {
color: #fff; color: #fff;
} }
.game-distribution-publish-panel {
width: min(520px, 100%);
max-height: min(760px, calc(100vh - 48px));
overflow-y: auto;
padding: 24px;
border: 1px solid rgb(104 77 57 / 16%);
border-radius: 20px;
box-shadow: 0 24px 80px rgb(74 48 33 / 20%);
}
.game-distribution-publish-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.game-distribution-publish-panel__header h2 {
margin: 5px 0 0;
color: var(--platform-text-strong);
font-size: 24px;
letter-spacing: -0.03em;
}
.game-distribution-publish-panel__header > button {
width: 32px;
height: 32px;
border: 1px solid rgb(104 77 57 / 16%);
border-radius: 50%;
background: transparent;
color: var(--platform-text-muted);
font-size: 22px;
line-height: 1;
}
.game-distribution-publish-panel__eyebrow {
color: #a8663d;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.game-distribution-publish-panel__intro {
margin: 18px 0;
color: var(--platform-text-muted);
font-size: 13px;
line-height: 1.6;
}
.game-distribution-publish-panel__package {
display: grid;
gap: 5px;
margin-bottom: 18px;
padding: 12px 14px;
border: 1px solid rgb(168 102 61 / 16%);
border-radius: 12px;
background: rgb(168 102 61 / 6%);
color: var(--platform-text-muted);
font-size: 12px;
}
.game-distribution-publish-panel__package span:first-child {
overflow: hidden;
color: var(--platform-text-strong);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-distribution-publish-panel__fields {
display: grid;
gap: 14px;
}
.game-distribution-publish-panel__fields label {
display: grid;
gap: 6px;
color: var(--platform-text-strong);
font-size: 12px;
font-weight: 700;
}
.game-distribution-publish-panel__fields input,
.game-distribution-publish-panel__fields textarea,
.game-distribution-publish-panel__fields select {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid rgb(104 77 57 / 18%);
border-radius: 10px;
background: rgb(255 255 255 / 76%);
color: var(--platform-text-strong);
font: inherit;
line-height: 1.45;
}
.game-distribution-publish-panel__fields textarea {
resize: vertical;
}
.game-distribution-publish-panel__fields input:focus-visible,
.game-distribution-publish-panel__fields textarea:focus-visible,
.game-distribution-publish-panel__fields select:focus-visible {
border-color: #a8663d;
outline: 2px solid rgb(168 102 61 / 20%);
outline-offset: 1px;
}
.game-distribution-publish-panel__error {
margin: 14px 0 0;
color: #b42318;
font-size: 12px;
line-height: 1.5;
}
.game-distribution-publish-panel__success {
margin-top: 18px;
padding: 16px;
border-radius: 14px;
background: rgb(51 125 87 / 9%);
color: var(--platform-text-strong);
}
.game-distribution-publish-panel__success p {
margin: 8px 0 0;
color: var(--platform-text-muted);
font-size: 13px;
line-height: 1.55;
}
.game-distribution-publish-panel__mono {
font-variant-numeric: tabular-nums;
}
.game-distribution-publish-panel__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
.game-distribution-publish-panel__actions button {
min-height: 38px;
padding: 0 16px;
border: 1px solid rgb(104 77 57 / 20%);
border-radius: 10px;
background: transparent;
color: var(--platform-text-strong);
cursor: pointer;
font: inherit;
font-weight: 700;
}
.game-distribution-publish-panel__actions button:last-child {
border-color: #a8663d;
background: #a8663d;
color: #fff;
}
.game-distribution-publish-panel__actions button:disabled,
.game-distribution-publish-panel__header > button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@media (max-width: 560px) {
.game-distribution-publish-panel {
padding: 18px;
border-radius: 16px;
}
.game-distribution-publish-panel__header h2 {
font-size: 21px;
}
.game-distribution-publish-panel__actions {
display: grid;
grid-template-columns: 1fr 1fr;
}
.game-distribution-publish-panel__actions button {
width: 100%;
}
}
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
background: #f5f7fb; background: #f5f7fb;
+1 -1
View File
@@ -140,7 +140,7 @@ http {
try_files /index.html =404; try_files /index.html =404;
} }
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { location ~* "^/(?:components|creation|design-system|editor/canvas|games|games/detail|games/mine|games/play|games/publish|profile|project)/?$" {
try_files $uri /index.html =404; try_files $uri /index.html =404;
} }
# END GENARRATIVE MAIN SPA ROUTES # END GENARRATIVE MAIN SPA ROUTES
+1 -1
View File
@@ -189,7 +189,7 @@ server {
try_files /index.html =404; try_files /index.html =404;
} }
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { location ~* "^/(?:components|creation|design-system|editor/canvas|games|games/detail|games/mine|games/play|games/publish|profile|project)/?$" {
error_page 503 /maintenance.html; error_page 503 /maintenance.html;
if ($genarrative_maintenance) { if ($genarrative_maintenance) {
+1 -1
View File
@@ -209,7 +209,7 @@ server {
try_files /index.html =404; try_files /index.html =404;
} }
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" { location ~* "^/(?:components|creation|design-system|editor/canvas|games|games/detail|games/mine|games/play|games/publish|profile|project)/?$" {
error_page 503 /maintenance.html; error_page 503 /maintenance.html;
if ($genarrative_maintenance) { if ($genarrative_maintenance) {
+2
View File
@@ -30,8 +30,10 @@
- [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):已完成;当前策划入口统一使用 Design Agent,采用阶段审批与用户工作区文件浏览。旧 V1/V2 会话、命令、专用展示和测试不再作为兼容目标。 - [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):已完成;当前策划入口统一使用 Design Agent,采用阶段审批与用户工作区文件浏览。旧 V1/V2 会话、命令、专用展示和测试不再作为兼容目标。
- [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。 - [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。
- [Responses API 与 Agents SDK 迁移评估](./technical/【技术评估】Responses%20API与Agents%20SDK迁移评估-2026-09-07.md):评估 AGC 核心 Runtime 是否迁移到外部 Agent SDK 及其边界。
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。 - [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
- [AGC 后端框架整理与演进路线](./technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md):共享 Runtime、本地执行宿主、云端控制面、领域/平台适配器及分阶段收口边界。
- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。 - [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。
- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):历史方案,仅用于追溯 V2 的实现与退役过程,不作为当前实现依据。
@@ -55,6 +55,13 @@ Godot 编辑器操控复用既有 AGC 插件宿主、EditorAdapter、Runner 和
- 验证方式:`provider_transient_retry_` 7 项中重写后的档位用例与 upstream-400 用例通过(断言 `maxRetries` 直取设置值、400 与其它瞬态共用同一预算),`provider_retry_` 其余 26/28 通过;该组 2 项(`provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds``provider_transient_retry_backoff_is_exponential_and_capped_at_thirty_seconds`)与 `provider_retry_waiting_final_reply_*` 2 项在本机改动前后同为失败(`stash` 基线复跑确认,现象是等待自动重试唤醒超时)。本机串行全量套件另有既有环境失败(`tempfile::tempdir()` 归属校验、缺少 npm 构建产物、Windows 启动失败 MessageBox 阻塞 `startup_log_slot_fail_without_path...`);抽查其中 5 项在 `stash` 基线上同样失败,与本次改动无关。仓库 `cargo fmt --check``npm run check:encoding``git diff --check` 通过。 - 验证方式:`provider_transient_retry_` 7 项中重写后的档位用例与 upstream-400 用例通过(断言 `maxRetries` 直取设置值、400 与其它瞬态共用同一预算),`provider_retry_` 其余 26/28 通过;该组 2 项(`provider_transient_retry_transport_failure_closes_then_stable_retry_succeeds``provider_transient_retry_backoff_is_exponential_and_capped_at_thirty_seconds`)与 `provider_retry_waiting_final_reply_*` 2 项在本机改动前后同为失败(`stash` 基线复跑确认,现象是等待自动重试唤醒超时)。本机串行全量套件另有既有环境失败(`tempfile::tempdir()` 归属校验、缺少 npm 构建产物、Windows 启动失败 MessageBox 阻塞 `startup_log_slot_fail_without_path...`);抽查其中 5 项在 `stash` 基线上同样失败,与本次改动无关。仓库 `cargo fmt --check``npm run check:encoding``git diff --check` 通过。
- 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。 - 关联文档:[AI游戏创作智能体App实施计划](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)、[踩坑记录](pitfalls.md)。
## 2026-09-18 AGC backend 采用共享 Runtime、本地宿主与云端控制面分层
- 决策:AGC backend 统一按“`agent-runtime-core`/`agent-runtime-orchestration` 共享内核 + Tauri 本地执行宿主 + `server-rs` 云端控制面 + `module-*`/`platform-*` 领域与外部适配器”整理;先建立 application facade、能力合同和跨边界状态映射,不新建第二套 Agent Runtime、会话库或业务真相。
- 数据边界:本地项目文件、manifest、JSONL、checkpoint、锁和 Runner 状态由 AGC 本地宿主持有;认证、模型目录、编辑器资源、异步生成、计费、快照元数据和诊断由云端持有;大对象按现有 OSS 合同保存。
- 约束:Runtime core 不依赖 Tauri/Axum/SpacetimeDB/Provider;领域规则留在 `module-*`HTTP/SSE/BFF 留在 `api-server`SpacetimeDB 访问统一经 `spacetime-client`;外部服务统一经 `platform-*`;前端只消费后端或本地宿主投影。
- 权威文档:[AGC 后端框架整理与演进路线](../../technical/【技术方案】AGC后端框架整理与演进路线-2026-09-18.md)。
## 2026-09-17 AGC 抠图提交使用远端画布项目身份 ## 2026-09-17 AGC 抠图提交使用远端画布项目身份
- 背景:AGC 已通过本地项目 ID 建立并持久化本地项目到主站远端画布项目的绑定,但 `agc_remove_background` 提交请求仍把本地 `manifest.project_id` 放入 `projectId``assetFolderId` 已使用远端素材目录 ID。主站因此按项目不存在或不属于当前账号返回 404,主站抠图和 BgFilter 本身均正常。 - 背景:AGC 已通过本地项目 ID 建立并持久化本地项目到主站远端画布项目的绑定,但 `agc_remove_background` 提交请求仍把本地 `manifest.project_id` 放入 `projectId``assetFolderId` 已使用远端素材目录 ID。主站因此按项目不存在或不属于当前账号返回 404,主站抠图和 BgFilter 本身均正常。
@@ -5762,3 +5762,25 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:① 依赖里只放数据,回调走 ref`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。 - **处理(现行口径)**:① 依赖里只放数据,回调走 ref`openActiveProjectRef`)——effect 不再因回调换身份而重跑;② `useDirectActiveTurns` 轮询只在快照内容变化时才 `setActiveTurns`(并给空态做引用稳定),避免每 5 秒换一次数组身份去带动下游 effect;③ `WindowChrome` 的 context value 用 `useMemo` 收口。判断类问题的通行判据:**凡是把"每次渲染新生成的函数/对象"写进 effect 依赖的,一律视为 bug**。
- **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**renderer 工作集 **254 MB**(修复前 4.24.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。 - **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**renderer 工作集 **254 MB**(修复前 4.24.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。
- **关联**`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx``apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts` - **关联**`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx``apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`
## 2026-09-20 复用注册端口段时可能连到别的 worktree 的 SpacetimeDB
- **现象**:在 `/data/dsk/Genarrative``npm run dev:api-server` 后,日志显示端口段 `10000-10099 (dsk)`、spacetime `http://127.0.0.1:10002`,但 api-server 反复报 `ws://127.0.0.1:10002/v1/database/xushi-p4wfr/subscribe` 返回 `HTTP error: 404 Not Found`,且始终不响应 `/healthz`
- **原因**:该端口段是**按用户**登记的,同一用户的其他 worktree 实例已占用 `10002`;启动器只探测到端口被占用就按"已复用"继续,api-server 于是连到了另一份 data-dir 的 standalone,那里没有当前 database,发布步骤也没有落到这个实例上。api-server 在启动恢复阶段会一直重试,**accept 了连接但不返回任何响应**,所以 `curl` 表现为超时而不是连接拒绝。
- **处理(现行口径)**:核对 `ss -ltnp | grep :10002` 的进程与 `--data-dir` 是否属于当前仓库;不属于就换用空闲端口段(`GENARRATIVE_DEV_PORT_RANGE` / `--port-range`)或先停掉确认无用的实例,不要把 404 当作 schema 缺失去改代码。排查"健康检查通过但接口 404"时不要只跑 `/healthz`
- **关联**`scripts/dev.mjs``scripts/dev-stack-port-utils.mjs``/var/tmp/genarrative-dev-port-ranges/registry.json`、[`.codex/skills/genarrative-dev-stack-port-routing/SKILL.md`](../../../.codex/skills/genarrative-dev-stack-port-routing/SKILL.md)。
## 2026-09-20 新增 API 命名空间在本地返回 404:Vite 代理是前缀白名单
- **现象**api-server 上 `GET /api/game-distribution/games` 直连返回 200,但浏览器里 `http://127.0.0.1:<web>/games``404`,页面显示「读取游戏目录失败」。同一 URL 换成 `curl` 直连后端却正常。
- **原因**`vite.config.ts``server.proxy` 是**逐个前缀白名单**`/api/auth``/api/profile``/api/runtime``/api/editor``/api/assets``/api/llm``/api/ws`),没有兜底 `/api/`。未登记的新命名空间不会转发到 Rust 后端,而是回退到 SPA 静态资源,前端再按 JSON 解析就失败。生产 nginx 走的是通用 `location ^~ /api/`,所以症状只出现在本地 dev。
- **处理(现行口径)**:新增任何 `/api/<namespace>` 时,同一次变更里补 `vite.config.ts` 代理项和 `src/config/viteProxyConfig.test.ts` 断言;`src/config/**` 已加入 `vitest.config.ts` 的 include,漏测会直接红。注意该测试文件里可能残留已退役前缀(例如已退役的 `/api/creation-entry`)的断言,退役命名空间按「四不写」直接删断言,不要为它补代理。
- **关联**`vite.config.ts``src/config/viteProxyConfig.test.ts``vitest.config.ts``server-rs/crates/api-server/src/app.rs``deploy/nginx/genarrative.conf`
## 2026-09-20 发行网关用 CORP same-origin 会让沙箱内游戏加载不了自己的脚本
- **现象**:平台游玩页的 iframe 明明 `onLoad` 了(加载遮罩消失、`game-player-frame--ready`),但控制台出现 `net::ERR_BLOCKED_BY_RESPONSE.NotSameOrigin … /releases/<gameId>/assets/app.js`,游戏内的脚本从未执行;直接在新标签页打开同一个 `index.html` 却一切正常,很容易误判成「已经能玩」。
- **原因**:按安全合同 iframe 必须只用 `sandbox="allow-scripts"`(禁止 `allow-same-origin`),文档因此是不透明来源(opaque origin)。此时它对同包资源的请求不再与网关同源,而响应上的 `Cross-Origin-Resource-Policy: same-origin` 会把请求判为跨来源并拦下;ES modules 还会额外走 CORS,需要 `Access-Control-Allow-Origin`
- **处理(现行口径)**:发行网关的公开静态响应使用 `Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`,继续保留 `X-Content-Type-Options: nosniff`、内容类型白名单、HTML 最小权限 CSP 和「带 Cookie 一律 403」。这些都是公开静态文件,放宽 CORP/CORS 不暴露凭据;容器隔离靠沙箱、CSP 与独立来源,不靠 CORP。
- **验证方式**:不要只用 `onLoad` 判断可玩。要在真实浏览器里点「开始游戏」,确认控制台没有 `ERR_BLOCKED_BY_RESPONSE`/CSP 报错,并核对 api-server 访问日志里该版本资源的 `http.response.status_code=200`
- **关联**`server-rs/crates/api-server/src/modules/game_distribution.rs``release_asset_response`)、`src/components/game-distribution/GamePlayPage.tsx`、[`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`](../../【玩法创作】平台入口与玩法链路-2026-05-15.md)。
@@ -658,6 +658,28 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 2026-09-05 修订:`/api/llm/responses``/api/llm/chat/completions` 仍在 Router provisioning 前用钱包总余额阻止零余额账号创建或续期;解析凭据后、上游调用前再执行累计额度同步,以扣除退款占用后的剩余可消费余额为准。余额为 `0` 时返回 `409 MUD_POINTS_INSUFFICIENT`;余额或额度同步失败时失败关闭。上游已成功时,后置同步失败只记录错误并留待下次调用前补结算,不把成功模型响应改写为失败。 - 2026-09-05 修订:`/api/llm/responses``/api/llm/chat/completions` 仍在 Router provisioning 前用钱包总余额阻止零余额账号创建或续期;解析凭据后、上游调用前再执行累计额度同步,以扣除退款占用后的剩余可消费余额为准。余额为 `0` 时返回 `409 MUD_POINTS_INSUFFICIENT`;余额或额度同步失败时失败关闭。上游已成功时,后置同步失败只记录错误并留待下次调用前补结算,不把成功模型响应改写为失败。
- Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。 - Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。
### `game_distribution_game`
- Rust 结构体:`GameDistributionGame`
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。
- 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。
- 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。
### `game_distribution_version`
- Rust 结构体:`GameDistributionVersion`
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:不可变发行版本与真实包确认事实。创建后冻结 `package_sha256`、字节数、文件数、根入口和版本号;后续只推进上传、校验、审核、公开、撤回状态,并记录私有对象键、文件清单、入口 URL、审核者和阶段时间。
- 索引:`by_game_distribution_version_game_id``by_game_distribution_version_owner_user_id`。真实 ZIP 由 `api-server` 校验并写入私有 OSS 后,才通过 facade 确认 `uploaded`;表不保存 ZIP 正文。
### `game_distribution_idempotency_receipt`
- Rust 结构体:`GameDistributionIdempotencyReceipt`
- 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs`
- 用途:创建、上传、提交、审核、撤回和下架操作的幂等收据。`owner_user_id + action + idempotency_key` 组合唯一,保存请求摘要、结果 ID、有限结果 JSON、创建/过期时间和完成时间;同 key 不同摘要必须返回冲突,收据不保存凭据或包正文。
- 索引:owner、game、version 和 `by_game_distribution_receipt_scope` 组合索引;默认保留窗口由服务端清理策略控制。
### `llm_router_account` ### `llm_router_account`
- 当前 AGC Router 需求由 `llm_router_account` 表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在该表;不依赖 `external_api_key`。api-server 每次上游调用都读取权威 `llm_router_account` active/revoked 状态并即时解密当前密文,不做 TTL 凭据缓存,避免任意实例轮换或撤销后继续使用旧 Key。 - 当前 AGC Router 需求由 `llm_router_account` 表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在该表;不依赖 `external_api_key`。api-server 每次上游调用都读取权威 `llm_router_account` active/revoked 状态并即时解密当前密文,不做 TTL 凭据缓存,避免任意实例轮换或撤销后继续使用旧 Key。

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