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
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:
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.2–4.4 GB);`apps/ai-game-creator-shell/tests/directActiveTurns.test.tsx` 断言轮询返回值不变时快照引用不变。
|
- **验证**:修复后同一台机器、同一路径下 35 秒内新增 `Maximum update depth` **0 条**,renderer 工作集 **254 MB**(修复前 4.2–4.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
Reference in New Issue
Block a user