Merge remote-tracking branch 'origin/master' into fix/agc-acceptance-followup
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 18s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Failing after 19s
Project CI / Native shell tests (pull_request) Failing after 18s
Project CI / AI game creator shell Rust crates (pull_request) Failing after 19s
Project CI / AI game creator shell Rust smoke (pull_request) Failing after 19s
Project CI / Backend tests (pull_request) Failing after 19s
Project CI / Frontend tests (pull_request) Failing after 6s
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / AI game creator shell web tests (pull_request) Failing after 12s

This commit is contained in:
2026-09-20 21:27:23 +08:00
238 changed files with 2026 additions and 2668 deletions
@@ -5,11 +5,9 @@ import {
executeAdminRechargeRefund,
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
@@ -366,87 +364,3 @@ 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,9 +29,6 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
@@ -1179,42 +1176,3 @@ export function saveAgcModelCatalog(
{ 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,33 +1031,3 @@ export interface AdminAgcModelCatalog {
defaultModelId: string;
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,7 +26,6 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -301,12 +300,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
-2
View File
@@ -5,7 +5,6 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -50,7 +49,6 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
@@ -147,24 +147,3 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
}),
).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,7 +15,6 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -55,7 +54,6 @@ export const adminRoutes: AdminRouteDefinition[] = [
},
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -5933,16 +5933,6 @@ pub(crate) fn export_local_project_package(
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]
pub(crate) fn list_local_project_export_packages(
project_path: String,
@@ -2692,7 +2692,6 @@ fn main() {
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
@@ -1,27 +1,5 @@
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(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
@@ -132,114 +110,6 @@ 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> {
let seed = unix_millis();
for suffix in 0..1000 {
@@ -3906,57 +3906,6 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() {
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]
fn local_project_export_package_list_only_returns_recent_playtest_zips() {
let root = unique_project_path();
-32
View File
@@ -40,7 +40,6 @@ import {
} from './app/constants';
import { useEscapeToClose } from './app/dialogs';
import { resolveTauriInvoke } from './app/tauri';
import { GameDistributionPublishPanel } from './components/game-distribution/GameDistributionPublishPanel';
import type {
AgentBackgroundSubmitMode,
AgentProgressEvent,
@@ -1071,9 +1070,6 @@ export function App({
const [filePath, setFilePath] = useState('game/index.html');
const [fileDraft, setFileDraft] = useState('');
const [fileStatus, setFileStatus] = useState('未读取');
const [publishPackageResult, setPublishPackageResult] =
useState<LocalProjectExportPackageResult | null>(null);
const [publishPanelOpen, setPublishPanelOpen] = useState(false);
const [agentRunTrace, setAgentRunTrace] =
useState<GameCreationAgentRunTrace | null>(null);
const [agentRunHistory, setAgentRunHistory] = useState<AgentRunHistoryItem[]>(
@@ -7487,8 +7483,6 @@ export function App({
{ projectPath: nextProjectPath },
);
setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`);
setPublishPackageResult(result);
setPublishPanelOpen(true);
setCommandLog((current) => [...current, 'project.export_package']);
void refreshManifest(nextProjectPath);
if (announceToChat) {
@@ -11791,15 +11785,6 @@ export function App({
workspaceStatus={workspaceStatus}
expectedRunId={projectSupervisorExpectedRunId}
versions={chatProjectVersions}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={supervisorProjectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/>
);
}
@@ -11985,15 +11970,6 @@ export function App({
onProfessionalToolAction={handleProjectProfessionalAgentToolAction}
onProfessionalRetry={handleProjectProfessionalAgentRetry}
onUserInput={handleProjectSupervisorUserInput}
overlay={
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
}
/>
);
}
@@ -12101,14 +12077,6 @@ export function App({
workspaceStatus={workspaceStatus}
/>
<GameDistributionPublishPanel
open={publishPanelOpen}
projectPath={localProject?.projectPath ?? projectPath}
manifest={manifest}
packageResult={publishPackageResult}
onClose={() => setPublishPanelOpen(false)}
/>
{runtimeConfigOpen ? (
<RuntimeConfigDialog
projectPath={localProject?.projectPath}
@@ -794,20 +794,6 @@ export interface LocalProjectExportPackageResult {
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 {
packagePath: string;
packageRelativePath: string;
@@ -150,7 +150,6 @@ function AgentReasoning({
type RuntimePanelProps = ComponentProps<typeof ProjectSupervisorRuntimePanel>;
type ProjectSupervisorViewProps = RuntimePanelProps & {
overlay?: ReactNode;
activeVersionId?: string | null;
/** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */
attachments?: DirectCodexTurnAttachment[];
@@ -216,7 +215,6 @@ type ProjectSupervisorViewProps = RuntimePanelProps & {
};
export function ProjectSupervisorView({
overlay,
activeVersionId = null,
attachments = [],
attachmentNotice = '',
@@ -894,7 +892,6 @@ export function ProjectSupervisorView({
closeOnEscape={false}
/>
) : null}
{overlay}
</section>
);
}
@@ -2,7 +2,6 @@ import { Send, Settings } from 'lucide-react';
import type {
ComponentProps,
FormEventHandler,
ReactNode,
Ref,
RefObject,
UIEvent,
@@ -43,7 +42,6 @@ type RuntimeControlProps = ComponentProps<
const CHAT_SCROLL_BOTTOM_THRESHOLD = 24;
type SupervisorChatOnlyViewProps = {
overlay?: ReactNode;
activeVersionId?: string | null;
chatAgentBusy: boolean;
chatInput: string;
@@ -82,7 +80,6 @@ type SupervisorChatOnlyViewProps = {
};
export function SupervisorChatOnlyView({
overlay,
activeVersionId = null,
chatAgentBusy,
chatInput,
@@ -354,7 +351,6 @@ export function SupervisorChatOnlyView({
onClose={onCloseRuntimeConfig}
/>
) : null}
{overlay}
</>
);
}
-187
View File
@@ -3768,193 +3768,6 @@ textarea {
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 {
min-height: 100vh;
background: #f5f7fb;
+1 -1
View File
@@ -140,7 +140,7 @@ http {
try_files /index.html =404;
}
location ~* "^/(?:components|creation|design-system|editor/canvas|games|games/detail|games/mine|games/play|games/publish|profile|project)/?$" {
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" {
try_files $uri /index.html =404;
}
# END GENARRATIVE MAIN SPA ROUTES
+1 -1
View File
@@ -189,7 +189,7 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:components|creation|design-system|editor/canvas|games|games/detail|games/mine|games/play|games/publish|profile|project)/?$" {
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
+1 -1
View File
@@ -209,7 +209,7 @@ server {
try_files /index.html =404;
}
location ~* "^/(?:components|creation|design-system|editor/canvas|games|games/detail|games/mine|games/play|games/publish|profile|project)/?$" {
location ~* "^/(?:creation|editor/canvas|profile|project)/?$" {
error_page 503 /maintenance.html;
if ($genarrative_maintenance) {
@@ -73,13 +73,6 @@ 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` 通过。
- 关联文档:[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 抠图提交使用远端画布项目身份
- 背景:AGC 已通过本地项目 ID 建立并持久化本地项目到主站远端画布项目的绑定,但 `agc_remove_background` 提交请求仍把本地 `manifest.project_id` 放入 `projectId``assetFolderId` 已使用远端素材目录 ID。主站因此按项目不存在或不属于当前账号返回 404,主站抠图和 BgFilter 本身均正常。
+5 -22
View File
@@ -1,5 +1,10 @@
# 踩坑与排障记录
## Godot 原生插件在 MSVC C 模式下的对齐声明
- `native.c` 若首先报 `max_align_t` 语法错误,后面的 `Storage``retained_script` 等未声明通常是连带错误。MSVC C 模式不提供该类型;ABI 存储使用 C11 `_Alignas(16)` 显式对齐并保持 128 字节容量,通过实际 MSVC DLL 构建验证,不逐条修补连带错误。
- `No C compiler found` 则属于开发环境问题:先初始化 Visual Studio x64 开发环境,同时设置 PATH、INCLUDE 与 LIB,再运行构建。只把 `cl.exe` 所在目录加到 PATH 不足以提供头文件和链接库。
## Rust 同步回调的测试记录按线程隔离
- `shared-contracts` 的资源 kind reporter 是进程级回调。仅给注册和断言加锁,无法阻止其他并行 manifest 测试触发该回调,导致日志数量和内容断言偶发混入其他测试记录。
@@ -5762,25 +5767,3 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:① 依赖里只放数据,回调走 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` 断言轮询返回值不变时快照引用不变。
- **关联**`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)。
@@ -33,6 +33,8 @@ DLL 原件随 AGC 安装包放在插件资源目录中;Godot Windows 加载器
## 分发与描述文件
- Windows 原生构建使用 x64 C11 编译器;MSVC 需先初始化 Visual Studio 的 x64 开发环境,以同时提供 PATH、INCLUDE 与 LIB。ABI 临时存储使用 128 字节、C11 `_Alignas(16)` 显式对齐,不依赖 MSVC C 模式未提供的 `max_align_t`;定向验证运行 `plugins/agc-godot-editor/native/gdextension/build.ps1 -Compiler cl.exe`
- 安装资源布局为 `plugins/agc-godot-editor/native/gdextension/bin/win-x64/agc_godot_editor.dll`,邻接元数据记录协议、构建身份和 DLL SHA256。开发模式允许宿主提供仓库插件目录中的同结构产物;RPC 不接受自定义 DLL 候选。
- 缓存根仅由宿主提供,为其私有配置目录下的 `godot-editor-runtime`;按 `PID + startedFileTime + buildId` 隔离,所有路径分量受控且拒绝链接/reparse point。复制前验证安装原件及元数据,缓存已有文件必须匹配来源、归属及 SHA,不能加载被替换的同名文件。受管描述同时保留原件与加载副本身份,重启恢复不得把工程给出的任意 DLL 路径当作受信任来源。
- 实际模块核验同时覆盖加载副本及 Godot 在同目录生成的精确 `~agc_godot_editor.dll`,要求规范化路径和 DLL 字节身份一致。确认原生卸载后,只清理本实例、本构建、内容未变的缓存文件;不能跨编辑器删除或复用影子副本。安装位置更新和实例 PID 重用都必须重新验证。
@@ -658,28 +658,6 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 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 项目根入口的内部路径仍不获得任意提权资格。
### `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`
- 当前 AGC Router 需求由 `llm_router_account` 表单独承载:API Key 核心字段、加密凭据、Router 账号元数据、生命周期与 provisioning 状态均在该表;不依赖 `external_api_key`。api-server 每次上游调用都读取权威 `llm_router_account` active/revoked 状态并即时解密当前密文,不做 TTL 凭据缓存,避免任意实例轮换或撤销后继续使用旧 Key。
@@ -50,129 +50,3 @@
- 供应商密钥、Token、Cookie 和本地私密路径不得进入前端或文档示例。
- 生成或导入资产必须经过后端鉴权、对象确认和换签读取;上传失败或换签失败时显示可操作错误,不回退为公开裸路径。
## AGC 游戏分发与在线游玩合同
| 字段 | 值 |
| --- | --- |
| Version | 0.2 |
| Status | proposed(待评审,未上线) |
| Date | 2026-09-18 |
| 适用边界 | 本节是新增业务提案;评审与实现验收前,不改变上文现役入口和移动端合同 |
### 交付目标与范围
交付“AGC 一键提交游戏 / 网页上传游戏 ZIP → 后端收取真实发行包 → 校验与人工审核 → 主站发现、详情、游客在线游玩 → 更新与下架”的完整闭环。验收必须使用真实上传、真实存储、真实审核状态和隔离发行域名;静态演示卡片、metadata-only 请求或前端本地发布状态不能作为完成证据。
首版支持可以离线运行的静态 Web 游戏:HTML、JavaScript、CSS、JSON 与图片、字体、音视频。AGC 首先支持现有 npm/Vite Web 工程;网页入口允许上传符合相同包合同的 ZIP,不以游戏引擎名称限制普通静态产物。Godot/Cocos 工程源码、原生可执行文件、Wasm、服务端进程、外部 API、多人联机、云存档和跨版本存档迁移不在首版内。暂不做评分、评论、关注、榜单、推荐算法、交易和创作者收入。
### 入口与产品体验
- 提议网页根路径 `/` 进入 `/games`;桌面一级导航为“游戏 / 创作 / 项目 / 我的”,移动端为“游戏 / 我的”。图片创作及项目编辑仍遵守原有桌面端边界,移动端不再以仅支持作品展示的欢迎遮罩阻断游戏浏览和游玩。该变更仅在本节通过评审并完成验收后生效。
- 游戏目录 `/games`、详情 `/games/detail?id=<gameId>`、游玩 `/games/play?id=<gameId>`;作者管理 `/games/mine` 只列当前账号的游戏和发行状态。平台壳及既有账号页提供入口,不建立第二套账号或作品系统。
- 目录使用重点推荐位和紧凑卡片网格,首版推荐位只消费后台明确设置且已公开的游戏;其余列表按发布时间排列。支持关键词、分类与设备筛选,筛选写入 URL,返回列表恢复筛选和滚动位置;没有内容时展示真实空态。
- 详情的主动作是“立即玩”,展示封面、标题、作者、短简介、设备标识、截图和简短操作方式;不虚构评分、玩家数、排名和收藏状态。游客浏览详情及启动游戏均无需登录;上传、更新、撤回和作者管理需要当前平台登录态。
- 游玩页先展示封面与开始按钮,用户点击后加载游戏,避免列表自动加载多个 iframe 或自动播音;提供加载、超时、重试、返回详情和全屏。iframe 的 `load` 事件只表示文档已加载,不宣称已经通过运行验证;首版不把 iframe 消息当平台业务状态。
- 移动端优先采用全宽内容、底部主要动作、至少 44px 点击区域,覆盖安全区、横竖屏和键盘焦点。游戏声明仅支持桌面时,手机详情保留可浏览内容并禁用开始,明确显示“请在电脑上游玩”;横屏游戏提供旋转提示,但不能把系统方向锁定成功作为启动前提。
- 视觉延续当前暖色主题 token、米白/浅暖背景、橙色主动作与现有字体;通过封面占比、留白、圆角和清晰状态层级现代化呈现,不另建配色系统。通用卡片动作、弹窗、上传、状态反馈、空态与媒体预览优先复用共享组件。发布打开独立弹窗/抽屉,不在当前页面末尾追加表单;技术信息仅在失败诊断或确需用户决策时展示。
### 参考产品验证
本次交互取已能核验的公开模式:TapTap 的详情主动作和阶段化审核发布([详情示例](https://www.taptap.cn/app/29648)、[开发者创建游戏](https://developer.taptap.cn/docs/store/release/publish/create-game/));Astrocade 的精选/趋势分区、卡片播放量和作者入口([首页](https://www.astrocade.com/)、[作者页](https://www.astrocade.com/creators));4399 的“无需下载,点开即玩”、适配设备与包体提示([在线玩](https://h.4399.com/)、[上传中心](https://www.4399.com/gameupload.htm));7k7k 的分类、玩法说明、加载提示和玩家上传声明([首页](https://www.7k7k.com/)、[上传页](https://www.7k7k.com/html/upload.htm))。星匣仅确认其公开定位为 AI 一键生成与即点即玩,页面正文为动态应用,未把无法核验的栏目当作合同依据;限定调研内未找到可确认官方域名的“造梦工坊”,不据此推断产品行为。
### 真实发行包与资料合同
1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。
4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。
5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。
6. `supportedDevices` 至少包含 `desktop``mobile``inputModes` 来自 `keyboard``mouse``touch`;声明移动端必须包含 `touch``orientation``landscape``portrait``responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。
7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。
8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff``Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。
### 身份、状态、审核与更新
- `gameId` 是服务端分配的稳定游戏身份;`ownerUserId` 只从当前认证主体派生。AGC 的本地 `projectId` 只能作为作者名下的关联提示,不能证明云端游戏所有权。网页上传和 AGC 发布使用相同游戏、版本与上传记录,不建立两套发行系统。
- 同一作者用相同 `localProjectId` 再次发布时复用既有 `gameId` 并只新增版本;游戏身份、版本号和服务端校验都不依赖客户端传来的路径或 ID 可信度。缺少 `localProjectId` 的旧客户端仍可发布,但会被视为新建游戏。
- 每次发行分配唯一 `versionId` 与游戏内递增 `versionNumber`。版本的游戏归属、包摘要、已确认字节和送审资料冻结后不可变;改包或改送审资料必须创建新版本。版本状态可以流转,内容不能原地覆盖。
- 游戏单独保存 `publicationRevision``activeVersionId` 和可见性 `unpublished | published | suspended`;正式可见性由服务端持久化事实决定。未通过审核时 `activeVersionId` 为空;`suspended` 是管理员安全下架,作者不能自行解除。
- 版本状态为 `awaiting_upload → uploaded → validating → pending_review → published`。上传确定失败进入 `upload_failed`,验证失败进入 `validation_failed`,人工拒绝进入 `rejected`;尚未公开版本可以撤回为 `cancelled`,已公开版本可撤销为 `revoked`。已成功上传的相同字节可重新校验;内容改变或审核拒绝后的修改必须新建版本。
- 首版建议人工审核。审核员检查游戏资料、真实桌面运行、声明移动适配、内容与外部请求被阻断的行为;自动包校验通过只进入 `pending_review`,不自动公开。审核记录保存审核者、目标版本、结论、理由和时间。后台只授权现有管理员身份,不让普通作者调用审核动作。
- 更新送审时旧 `activeVersionId` 继续服务目录、详情与游玩。审核通过并完成对象可读验证后,一次事务切换当前公开版本和公开资料;新版本上传、校验、审核或对象安装失败均不改变旧版本。
- 发布页先自动填入可用标题和封面,首次需要确认必需资料,此后复用上次资料;用户一次“提交发布”动作串联校验、上传和送审,并显示真实阶段。等待审核不能显示“已发布”;成功后提供查看详情和复制公开链接。
### 幂等、并发与恢复
- 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。
- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。
- 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。
- 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。
- 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。
### HTTP 与持久化边界(拟定)
现役内部命名空间使用 `/api/game-distribution`;除标注为完整后台路径的行外,下表路径都带该前缀。标注「已实现」的路由已在 `api-server` 挂载并有定向测试,标注「待评审」的仍未落地。公开目录只消费后端投影;所有变更经过现有鉴权、限流和埋点中间件。账号内部 API 不扩展 `/api/external/v1`,后续若对外开放再同步 External OpenAPI。
| 方法与路径 | 身份 | 行为 |
| --- | --- | --- |
| `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 |
| `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 |
| `GET /game-distribution/releases/{gameId}/{assetPath}` | 游客 | **已实现**:发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` |
| `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 |
| `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` |
| `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 |
| `PUT /versions/{versionId}/package` | owner | **已实现**:接收真实 ZIP、重算摘要与文件清单并写入私有对象;不执行游戏代码 |
| `GET /versions/{versionId}` | owner/管理员 | 回读状态、错误代码、恢复动作及审核结论,普通游客不可读 |
| `POST /versions/{versionId}/submit` | owner | **已实现**:只有已确认完整包可提交,返回 202 并进入 `pending_review` |
| `POST /versions/{versionId}/cancel` | owner | 撤回尚未公开版本,保留幂等结果 |
| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 |
| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准需 HTTPS 发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | 安全下架整个游戏并撤销发行访问;此行是完整后台路径 |
除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。
领域规则进入 `module-*`,游戏/版本/审核/操作账本和事务进入 `spacetime-module`,访问统一通过 `spacetime-client`HTTP 与上传编排进入 `api-server`,对象存储副作用复用 `platform-*`,跨端 DTO 同步 Rust `shared-contracts``packages/shared`。新业务不复用退役玩法表或私有源码快照作为公开事实;实际表字段、索引、受信服务身份及迁移清单在持久化里程碑评审时冻结。已有表若确需加字段,只能末尾追加并给明确默认值;删除/改名/重排/改类型必须另行确认迁移计划。
### 发行域名、沙箱与网络能力
- 发行域名必须与平台使用不同的可注册站点(不同 eTLD+1),不能仅使用 `*.genarrative.world` 的兄弟子域;域名需在部署前确定。每个游戏有独立 HTTPS origin,例如 `https://g-<gameId>.<发行站点>`,不同游戏不能共用 origin;版本固定在 `/releases/<versionId>/index.html`
- 主站仅接受服务端配置允许的 HTTPS 发行 host 与发行版本路径,拒绝任意 URL、重定向目标、用户输入 URL、`javascript:``srcdoc`。发行站点不设置平台 Cookie、不接收平台 Bearer、不挂载主站 API;请求和日志也不得携带平台认证数据。
- iframe 首版仅使用 `sandbox="allow-scripts"`,全屏通过明确的 iframe 能力授权与用户手势开放。禁止 `allow-same-origin`、顶层导航、弹窗、表单提交、下载、模态对话框、相机、麦克风、剪贴板和地理位置;不提供持久 localStorage/IndexedDB 存档保证,不启用 Service Worker。
- 网关对所有发行 HTML 强制 CSP:默认拒绝;脚本只允许本游戏 origin 和必要内联脚本,不开放 `unsafe-eval`;样式允许本游戏 origin 与内联样式;图片/字体/音频只允许本游戏静态源及必要 `data:`/`blob:``connect-src` 仅为当前游戏静态 origin`worker-src``frame-src``object-src``form-action``none``base-uri 'none'``frame-ancestors` 仅主站明确 origin。不能由包内 meta 放宽响应头策略。
- 首版允许加载同游戏发行包内 JSON/二进制素材,禁止外部 API、远程分析、广告、第三方 SDK 网络依赖及任意外站 fetch/WebSocket。静态网关不代理任意外部地址。为兼容 opaque sandbox 下的 ES modules,发行静态资源提供不带 credentials 的 CORS;此能力只对获准发行文件生效,不能扩到主站或私有存储。
- 发行响应固定正确 MIME 与 `X-Content-Type-Options: nosniff`,不把未知扩展名返回为 HTML;ZIP 校验和人工审核不能代替这些运行时隔离措施。真实浏览器验收必须确认 npm/Vite 模块、Phaser 素材加载、音频和触屏在该策略下正常,而外站请求与越权访问被阻断。
### 下架、缓存与运维
- 作者下架或管理员封禁成功后,公开列表、详情和启动 API 立即停止返回游戏;发行网关同步按游戏和版本授权拒绝新请求,原始对象不可绕过网关访问。管理员封禁禁止作者自行重发绕过;解除需管理员明确动作并重新审核。
- 建议 HTML、公开状态与启动 API 使用 `no-store`;发行静态资源的浏览器与 CDN 有效期均不超过 60 秒,禁止 `stale-while-revalidate``stale-if-error` 和发行 Service Worker。下架主动 purge 相关 CDN 键,60 秒作为最大缓存撤销窗口,不把 purge 成功当唯一保障。旧版本被更新替代后,新启动只用当前版;旧游戏已经载入的脚本/资源不承诺远程抹除,用户退出或刷新后按当前授权重新判断。
- 发布所需部署依赖包括独立站点域名及通配 TLS、每游戏 host 路由、私有存储、网关 CSP/CORS/MIME、CDN TTL/purge、管理员审核运营入口和可恢复校验执行器;缺少任一项不能宣布公开上线。
- 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量,日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL。原始失败/撤回包建议保留 7 天后清理,公开版本和审核记录的保留周期在上线前确定;清理必须先检查引用,不能删除仍在服务的版本。
- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。
### 验收标准与证据
| 条款 | 必须获得的证据 |
| --- | --- |
| 真实包闭环 | 网页 ZIP 与 AGC dist 分别提交同一管道,服务端实际包 SHA-256/文件清单和发行回读一致;仅 metadata 请求被拒绝 |
| 包与数据边界 | 缺入口、摘要不符、穿越、符号链接、压缩炸弹、超限、凭据路径及未授权对象均被拒绝且未公开 |
| 权限与游客 | 匿名浏览/游玩成功,匿名写入 401,其他作者写入/读取私有版本被拒,伪造 owner 无效 |
| 幂等与恢复 | 双击、响应丢失、上传中断、同 key 不同内容、重启恢复、换账号迟到响应分别验证,不生成重复发行版本 |
| 审核与并发 | 待审不公开;拒绝有理由;旧版在更新失败/待审期间在线;审核与下架并发 CAS 拒绝过期写入 |
| 真正可玩 | 桌面及手机真实浏览器覆盖模块加载、素材、音频、触屏、横竖屏、开始/重试/退出和可用全屏;不以 iframe load 代替 |
| 隔离与撤销 | 真实不同站点和每游戏 origin 下,主站 Cookie/storage/DOM 不可访问,外部网络阻断,旧 URL 在缓存窗口后不能取得新资源 |
| 页面与视觉 | 当前 warm token 下的目录、详情、发布、加载/空/失败/待审态,桌面和移动视口无操作遮挡,键盘可达 |
| 工程门禁 | 定向前后端测试、两端类型检查、真实 SpacetimeDB/API smoke、schema/绑定检查、编码、文档索引及 diff 检查 |
所有运行时证据须注明实际环境和结论;缺少发行域名、登录、存储、审核或真实游戏时写明未验证,不使用演示 fixture 填充为业务成功。
### 待评审决策
1. 是否采纳人工审核及资料随发行版本审核、更新期间旧版保持公开的首版策略;审核负责人、处理时限与申诉/解除封禁口径需确定。
2. 是否采用 `/games` 为网页根入口,以及“游戏 / 创作 / 项目 / 我的”和移动“游戏 / 我的”的导航提案。
3. 是否接受首版离线静态包、无 Wasm/外网/持久存档的范围,以及建议包额度、7 天失败包保留和 60 秒缓存撤销窗口;公开/撤销版本及审核记录保留周期待定。
4. 不同可注册站点发行域名、DNS/TLS/CDN、存储区域及运营责任尚待选定。不同站点和每游戏 origin 是上线门禁,不能退化成主站同源目录。
5. 本节已提供页面行为、发行状态、真实上传、幂等/CAS 和 API 草案,足以评审完整业务;尚不足以直接实现持久化和部署,必须在对应里程碑评审前冻结表/索引/服务身份、额度/清理、最终响应 DTO 与发行基础设施配置。未经评审不建立 `ready` 实施计划,不把 proposed 标记为 accepted。
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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