refactor/优化陶泥平台发布 #670

Merged
k88936 merged 105 commits from style/polish-taonier-publish into master 2026-10-08 18:07:42 +08:00
395 changed files with 10035 additions and 5725 deletions
+10 -3
View File
@@ -1551,7 +1551,10 @@ export function listAdminGameDistributionThemes(
) {
const requestedLimit = options.limit ?? ADMIN_THEME_LIST_LIMIT_MAX;
const normalizedLimit = Number.isFinite(requestedLimit)
? Math.min(Math.max(Math.trunc(requestedLimit), 1), ADMIN_THEME_LIST_LIMIT_MAX)
? Math.min(
Math.max(Math.trunc(requestedLimit), 1),
ADMIN_THEME_LIST_LIMIT_MAX,
)
: ADMIN_THEME_LIST_LIMIT_MAX;
const params = new URLSearchParams({ limit: String(normalizedLimit) });
const status = options.status?.trim();
@@ -1573,7 +1576,9 @@ export function createAdminGameDistributionTheme(
{
token,
method: 'POST',
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
headers: {
'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey),
},
body: payload,
},
);
@@ -1591,7 +1596,9 @@ export function updateAdminGameDistributionTheme(
{
token,
method: 'PUT',
headers: { 'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey) },
headers: {
'Idempotency-Key': normalizeThemeIdempotencyKey(idempotencyKey),
},
body: payload,
},
);
@@ -136,9 +136,7 @@ test('创建主题:先校验再写,写操作带幂等键,重放要提示',
// 名称为空:本地就拦下,不发请求。
fireEvent.click(within(dialog).getByRole('button', { name: '创建' }));
expect(
await within(dialog).findByText('主题名不能为空'),
).toBeTruthy();
expect(await within(dialog).findByText('主题名不能为空')).toBeTruthy();
expect(createAdminGameDistributionTheme).not.toHaveBeenCalled();
fireEvent.change(within(dialog).getByLabelText('新建主题主题名'), {
@@ -162,9 +160,8 @@ test('创建主题:先校验再写,写操作带幂等键,重放要提示',
await waitFor(() =>
expect(createAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
);
const [token, idempotencyKey, payload] = vi.mocked(
createAdminGameDistributionTheme,
).mock.calls[0] ?? [];
const [token, idempotencyKey, payload] =
vi.mocked(createAdminGameDistributionTheme).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(payload).toEqual({
name: '星际防线二',
@@ -231,9 +228,8 @@ test('编辑主题:整体覆盖字段,并提示归档后公开侧不可读',
await waitFor(() =>
expect(updateAdminGameDistributionTheme).toHaveBeenCalledTimes(1),
);
const [token, themeId, , payload] = vi.mocked(
updateAdminGameDistributionTheme,
).mock.calls[0] ?? [];
const [token, themeId, , payload] =
vi.mocked(updateAdminGameDistributionTheme).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(themeId).toBe('theme_1');
expect(payload).toMatchObject({
@@ -425,9 +421,7 @@ test('成员面板:成员多于首页时可按游标加载更多', async () =>
const dialog = await screen.findByRole('dialog');
await within(dialog).findByRole('list', { name: '主题成员名单' });
fireEvent.click(
within(dialog).getByRole('button', { name: '加载更多成员' }),
);
fireEvent.click(within(dialog).getByRole('button', { name: '加载更多成员' }));
await waitFor(() =>
expect(listAdminGameDistributionThemeMembers).toHaveBeenLastCalledWith(
@@ -440,7 +434,9 @@ test('成员面板:成员多于首页时可按游标加载更多', async () =>
expect(within(dialog).getByText('game_root_2')).toBeTruthy(),
);
// 末页没有 nextCursor → 按钮消失。
expect(within(dialog).queryByRole('button', { name: '加载更多成员' })).toBeNull();
expect(
within(dialog).queryByRole('button', { name: '加载更多成员' }),
).toBeNull();
});
test('成员面板:读取失败时如实报错,不渲染成空名单', async () => {
@@ -57,13 +57,15 @@ const THEME_STATUS_OPTIONS: {
{ value: 'archived', label: '已归档(公开侧不可读)' },
];
const STATUS_FILTER_OPTIONS: { value: AdminThemeStatusFilter; label: string }[] =
[
{ value: 'all', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '已发布' },
{ value: 'archived', label: '已归档' },
];
const STATUS_FILTER_OPTIONS: {
value: AdminThemeStatusFilter;
label: string;
}[] = [
{ value: 'all', label: '全部状态' },
{ value: 'draft', label: '草稿' },
{ value: 'published', label: '已发布' },
{ value: 'archived', label: '已归档' },
];
/** 与接口契约同值的文本上限(前端先拦,服务端仍会独立校验)。 */
const THEME_NAME_MAX_CHARS = 40;
@@ -166,7 +168,10 @@ export function validateThemeForm(draft: ThemeFormDraft): string {
if (Array.from(draft.badge.trim()).length > THEME_BADGE_MAX_CHARS) {
return `角标最多 ${THEME_BADGE_MAX_CHARS} 个字符`;
}
if (!Number.isFinite(Number(draft.sortOrder)) || !Number.isInteger(Number(draft.sortOrder))) {
if (
!Number.isFinite(Number(draft.sortOrder)) ||
!Number.isInteger(Number(draft.sortOrder))
) {
return '排序号必须是整数';
}
return '';
@@ -201,8 +206,7 @@ export function AdminGameThemesPage({
const [themes, setThemes] = useState<GameDistributionAdminTheme[]>([]);
const [statusFilter, setStatusFilter] =
useState<AdminThemeStatusFilter>('all');
const [draftFilter, setDraftFilter] =
useState<AdminThemeStatusFilter>('all');
const [draftFilter, setDraftFilter] = useState<AdminThemeStatusFilter>('all');
const [isLoading, setIsLoading] = useState(false);
const [hasLoaded, setHasLoaded] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
@@ -211,7 +215,9 @@ export function AdminGameThemesPage({
const [createDraft, setCreateDraft] =
useState<ThemeFormDraft>(EMPTY_THEME_FORM);
const [createError, setCreateError] = useState('');
const [editing, setEditing] = useState<GameDistributionAdminTheme | null>(null);
const [editing, setEditing] = useState<GameDistributionAdminTheme | null>(
null,
);
const [editDraft, setEditDraft] = useState<ThemeFormDraft>(EMPTY_THEME_FORM);
const [editError, setEditError] = useState('');
const [memberTheme, setMemberTheme] =
@@ -240,7 +246,9 @@ export function AdminGameThemesPage({
const writeConfirm = useAdminWriteConfirm();
const listController = useRef<AbortController | null>(null);
// 幂等键按「动作 + 请求指纹」复用:草稿不变时重试同一个键,改了内容才换键。
const idempotencyRef = useRef<{ fingerprint: string; key: string } | null>(null);
const idempotencyRef = useRef<{ fingerprint: string; key: string } | null>(
null,
);
const loadThemes = useCallback(
async (status: AdminThemeStatusFilter) => {
@@ -336,10 +344,14 @@ export function AdminGameThemesPage({
setMemberListState('loading');
setMemberListError('');
try {
const response = await listAdminGameDistributionThemeMembers(token, themeId, {
limit: MEMBER_PAGE_SIZE,
cursor,
});
const response = await listAdminGameDistributionThemeMembers(
token,
themeId,
{
limit: MEMBER_PAGE_SIZE,
cursor,
},
);
setMemberRows((current) => [...current, ...(response.members ?? [])]);
setMemberTotal(response.totalMembers);
setMemberNextCursor(response.nextCursor);
@@ -353,9 +365,13 @@ export function AdminGameThemesPage({
/** 成员名单重新读第一页(增删成员后调用,避免游标失效)。 */
async function reloadMembers(themeId: string) {
try {
const response = await listAdminGameDistributionThemeMembers(token, themeId, {
limit: MEMBER_PAGE_SIZE,
});
const response = await listAdminGameDistributionThemeMembers(
token,
themeId,
{
limit: MEMBER_PAGE_SIZE,
},
);
setMemberRows(response.members ?? []);
setMemberTotal(response.totalMembers);
setMemberNextCursor(response.nextCursor);
@@ -537,7 +553,8 @@ export function AdminGameThemesPage({
}
const currentTheme = memberTheme
? themes.find((theme) => theme.themeId === memberTheme.themeId) ?? memberTheme
? (themes.find((theme) => theme.themeId === memberTheme.themeId) ??
memberTheme)
: null;
return (
@@ -667,7 +684,10 @@ export function AdminGameThemesPage({
<td>{theme.sortOrder}</td>
<td>{theme.memberCount}</td>
<td>
<code className="admin-table-cell-ellipsis" title={theme.themeId}>
<code
className="admin-table-cell-ellipsis"
title={theme.themeId}
>
{theme.themeId}
</code>
</td>
@@ -870,7 +890,9 @@ export function AdminGameThemesPage({
disabled={busy}
onClick={() => void removeMember(member.rootGameId)}
>
{memberBusyId === member.rootGameId ? '移除中…' : '移除'}
{memberBusyId === member.rootGameId
? '移除中…'
: '移除'}
</AdminButton>
</li>
);
@@ -913,7 +935,8 @@ export function AdminGameThemesPage({
不假装给出一份成员名单:只能按 ID 移除。列表里的成员数是成员行总数。
*/}
<p className="admin-muted-text">
目前只能按根作品 ID 移除成员(后台暂无成员读取接口);成员总数见上方「成员数」。
目前只能按根作品 ID
移除成员(后台暂无成员读取接口);成员总数见上方「成员数」。
</p>
</AdminDialog>
) : null}
@@ -942,9 +965,7 @@ function ThemeFormFields({
aria-label={`${namePrefix}主题名`}
maxLength={THEME_NAME_MAX_CHARS}
value={draft.name}
onChange={(event) =>
onChange({ ...draft, name: event.target.value })
}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
disabled={disabled}
/>
</AdminField>
@@ -134,9 +134,10 @@ const allowedUncalledTauriCommands = [
// React 不再把页面消息当作持久化事实;策划/Runtime 由 Rust coordinator 写入,
// 该通用命令仅保留给 Rust 内部链路与测试。
'append_local_conversation_message',
// 发行 facade 由 typed native service 通过注入的 invoke 调用;发布状态回读也走同一条
// native service,不允许重新接回 React 直连 HTTP。封面生成 / 封面报价 / 资料建议三条命令
// 已随旧的发布表单一起退役(Rust 侧实现与注册都已删除),不再登记。
// 发行 facade 由 typed native service 通过注入的 invoke 调用;发布状态回读与发布灰度
// 读取也走同一条 native service,不允许重新接回 React 直连 HTTP。封面生成 / 封面报价 /
// 资料建议三条命令已随旧的发布表单一起退役(Rust 侧实现与注册都已删除),不再登记。
'read_game_publish_availability',
'read_game_distribution_publication',
// 「开始共创」确认页的父作品封面:命令名由渲染层以常量 `COVER_COMMAND` 传给**注入的**
// invoke(`src/features/platform-fork/platformForkCover.ts`,注入是为了让封面解析能单测),
@@ -195,7 +195,7 @@
"scripts/pack.mjs",
"scripts/vite.config.taonier.mjs"
],
"sha256": "c73edcd88384e8824c5d6b932562196020b305e926a7837929272130df515364"
"sha256": "0a63c68ef0565fb186885208697200d70d8050372446e94fa5dd77b457acc494"
},
{
"name": "vite-export-xhs-minitool",
@@ -220,7 +220,7 @@
"scripts/validate.mjs",
"scripts/vite.config.xhs-minitool.mjs"
],
"sha256": "fd869242fdd40bb46fc3fbf30d1509c79d306e769e87dad3430b2ab0e5449b12"
"sha256": "bf061887e04c582465eb0706ef1ebd8aefdb81a52094598ab8e2d617009f5130"
}
]
}
@@ -10,18 +10,19 @@ metadata:
* 实现打包脚本 `build:taonier`: 用 `scripts/vite.config.taonier.mjs` 把 vite 项目构建到 `dist-taonier`,
再用 `scripts/pack.mjs` 产出 zip 到固定文件: `.export/taonier.zip`
1. 复制 (并按实际情况修改) `scripts/vite.config.taonier.mjs` 作为 vite 构建配置, 这个配置把 dist 收尾成可托管的 H5
1. 复制 (并按实际情况修改) `scripts/vite.config.taonier.mjs` 作为 (另一个)vite 构建配置, 这个配置把 dist 收尾成可托管的
H5
根目录: 相对路径入口、根目录 `index.html`、清空空目录. 该配置不做语法降级.
2. 使用 `scripts/pack.mjs` 打包成 zip; `--zip-out` 默认 **`../.export/taonier.zip`** (假设 cwd 是 `game/` 子工程,
产物落在 **项目根** `.export/taonier.zip`; 相对 cwd 解析).
3. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本.
3. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource)到项目里 (以免依赖skill), 并形成最终的打包脚本.
* 不干扰正常的web构建
# 平台要求:
* 仅支持单个 `.zip` 压缩包.
* zip 解压后**根目录**须有 `index.html`(**不得有外层文件夹**): 服务端只认存档根条目 `index.html`,
* zip 解压后 **根目录**须有 `index.html`( **不得有外层文件夹**): 服务端只认存档根条目 `index.html`,
`game/index.html` 之类会被判缺入口而拒收.
* 因此 `scripts/pack.mjs` 默认把构建产物内容直接放到 zip 存档根 (`index.html`、`assets/...`), 结构不合格时
整体失败, 不会留下可误传的 zip. `--root-dir` 是可选参数: 只有调用方显式给出时才会额外套一层顶层文件夹.
@@ -35,9 +36,9 @@ metadata:
# 脚本参数:
| 脚本 | 参数 |
|-----------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `scripts/vite.config.taonier.mjs` | `build.outDir`(默认 `dist-taonier`;与打包时的 `--vite-built-dir` 必须是同一个目录) |
| 脚本 | 参数 |
|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `scripts/vite.config.taonier.mjs` | `build.outDir`(默认 `dist-taonier`;与打包时的 `--vite-built-dir` 必须是同一个目录) |
| `scripts/pack.mjs` | `--vite-built-dir <dir>`(默认 `dist-taonier`)、`--zip-out <path>`(默认 `../.export/taonier.zip`,必须以 `.zip` 结尾)、`--root-dir <name>`(**可选**,默认不套外层文件夹;给出时才把内容套进单层文件夹 `<name>/`) |
# 一些情况:
@@ -9,12 +9,12 @@ metadata:
# 目标:
* 实现打包脚本 `build:xhs-minitool` 支持vite构建, validate, pack, 产出zip到固定文件: .export/xhs-minitool.zip
1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为vite构建配置, 这个配置保证了产物符合小红书小工具的规范.
1. 复制 (并按实际情况修改) `scripts/vite.config.xhs-minitool.mjs` 作为(另一个)vite构建配置, 这个配置保证了产物符合小红书小工具的规范.
2. 使用 `scripts/validate.mjs` 验证上述配置构建产物目录, 这个脚本实现了一些硬性检查, 此外有一些手动检查项:
`references/manual-checks.md`
3. 使用 `scripts/pack.mjs` 打包成zip; `--zip-out` 默认 **`../.export/xhs-minitool.zip`** (假设 cwd 是 `game/` 子工程,
产物落在 **项目根** `.export/xhs-minitool.zip`; 相对 cwd 解析)
4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource )到项目里 (以免依赖skill), 并形成最终的打包脚本
4. 跑通流程后请把需要的脚本配置复制 (agc_install_skill_resource)到项目里 (以免依赖skill), 并形成最终的打包脚本
* 不干扰正常的web构建
@@ -18,18 +18,32 @@ fn external_asset_http_detail(body: &str) -> Option<String> {
.as_ref()
.and_then(|value| value.get("error"))
.or_else(|| parsed.as_ref());
let detail = ["message", "detail", "code", "additionalDetails"]
.into_iter()
.filter_map(|field| {
error
.and_then(|value| value.get(field))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
.collect::<Vec<_>>()
.join(";");
let field_text = |value: &serde_json::Value, field: &str| {
value
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|text| !text.is_empty())
.map(ToString::to_string)
};
let mut parts = Vec::new();
if let Some(error) = error {
for field in ["message", "detail", "code", "additionalDetails"] {
if let Some(text) = field_text(error, field) {
parts.push(text);
}
}
// 平台把 4xx 兜底成 `message = 请求参数不合法`,真正的原因压在 `details` 里
// (OSS 签名失败就是 `details.message`)。只读顶层会把排障引向「参数传错了」这个假方向。
if let Some(details) = error.get("details").filter(|value| value.is_object()) {
for field in ["provider", "message", "objectKey"] {
if let Some(text) = field_text(details, field) {
parts.push(text);
}
}
}
}
let detail = parts.join(";");
let detail = (!detail.is_empty()).then_some(detail).or_else(|| {
let safe = crate::agent::redact_agent_runtime_error(
Path::new("__agc_no_project_root__"),
@@ -642,6 +642,13 @@ pub(crate) fn resolve_project_command_spec_at(
/// It intentionally bypasses the general command.exec npm allow-list: callers
/// must use the dedicated `project.bootstrap` action, which only accepts the
/// literal `npm install` in the project's `game` directory.
///
/// 生产调用方(`project/export.rs`)已随 master 的 exports 退役删除,现存唯一引用是本文件的
/// `npm_command_targets_node_plus_npm_cli_on_linux` 用例;因此按**真实使用面**收窄到
/// `all(target_os = "linux", test)`——与同文件 `project_command_actual_target` 的
/// `cfg(target_os = "linux")` 相比更窄,因为那个函数 Linux 生产仍在调用,这个已不在。
/// 引用侧的有效 cfg(`#[cfg(test)] mod tests` + `#[cfg(target_os = "linux")]` 用例)与定义侧一致。
#[cfg(all(target_os = "linux", test))]
pub(crate) fn resolve_project_bootstrap_spec_at(
root: &Path,
timeout_seconds: u64,
@@ -2505,16 +2512,6 @@ pub(crate) async fn run_project_command_with_output_at(
.await
}
pub(crate) async fn run_project_bootstrap_command_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectCommandResult, ProjectCommandError> {
let spec = resolve_project_bootstrap_spec_at(root, timeout_seconds)?;
let launch = prepare_project_command_launch_spec(root, &spec)?;
let staged = stage_project_command_launch_spec(&spec, launch)?;
run_prepared_project_command_with_output_at(root, &spec, staged, None, || Ok(())).await
}
pub(crate) async fn run_prepared_project_command_with_output_at<F>(
root: &Path,
spec: &ProjectCommandSpec,
@@ -628,6 +628,7 @@ pub(super) fn run() {
login_client_with_password,
login_client_with_phone_code,
logout_client_session,
read_game_publish_availability,
list_platform_game_catalog,
read_public_game_cover_preview,
read_public_game_detail,
@@ -9,7 +9,8 @@ use ts_rs::TS;
/// `deny_unknown_fields` 是刻意的:这个文件也由 code agent 手写,字段漂移必须当场可见,不能
/// 被静默忽略后带着半份表单继续走。
///
/// `cover_path` 为空字符串表示「沿用线上封面」;`screenshot_paths` 为空数组表示「整组沿用线上」。
/// `cover_path` 为空字符串表示「沿用上一版本封面」;`screenshot_paths` 逐槽是「项目内相对路径」
/// 或「上一版本发布媒体 objectKey」,前者发布时上传新图、后者按 key 沿用。
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
@@ -17,18 +18,18 @@ pub(crate) struct TaonierExportForm {
pub(crate) title: String,
pub(crate) summary: String,
pub(crate) category: String,
/// 项目内相对路径。空字符串表示沿用线上封面。
/// 项目内相对路径。空字符串表示沿用上一版本封面。
pub(crate) cover_path: String,
/// 项目内相对路径列表。空数组表示整组沿用线上截图。
/// 逐槽列表:项目内相对路径(上传新图)或上一版本发布媒体 objectKey(沿用)。
pub(crate) screenshot_paths: Vec<String>,
}
impl TaonierExportForm {
/// 落盘形态:文本字段去掉首尾空白,路径列表逐项去空白并**丢弃空串**。
/// 落盘形态:文本字段去掉首尾空白,槽位列表逐项去空白并**丢弃空串**。
///
/// 界面按去空白后的文本校验并计数(`" "` 算空、尾随空格不占字数),所以落盘也必须去空白,
/// 否则「校验通过的值」与「写进文件的值」不是同一个,冲突比较也会被空白差异搅乱。截图列表里的
/// 空串是界面清空某项后的残留,不能作为一条「有路径」的记录落盘。
/// 空串是界面清空某项后的残留,不能作为一条「有路径/有 key」的记录落盘。
pub(crate) fn normalized(&self) -> Self {
Self {
title: self.title.trim().to_string(),
@@ -22,4 +22,4 @@ pub(crate) mod registry;
mod script;
/// 发布链路读固定产物的入口。只读 `.export/taonier.zip`,不接用户路径。
pub(crate) use build::{read_artifact_package, TaonierArtifactPackage};
pub(crate) use build::read_artifact_package;
@@ -80,17 +80,6 @@ pub(crate) fn save(
Ok(RegistrySnapshot { form, content_hash })
}
/// 发布链路只读草稿表单。
///
/// 发布不是写草稿的入口,所以这里只取表单、不关心指纹,也不做冲突判定;文件缺失时与读命令
/// 同一口径(`load_or_create` 补一份空表单),让后续的封面/张数校验给出确定的「请先选择封面」
/// 提示,而不是把「草稿不存在」误报成宿主故障。
pub(crate) fn read_form(root: &Path) -> Result<TaonierExportForm, String> {
let loaded = load_or_create(&registry_path(root), &TaonierExportRegistry::default())
.map_err(|error| error.to_string())?;
Ok(loaded.value.form)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -18,7 +18,8 @@ pub(crate) struct NpmScript {
pub(crate) cwd_relative: &'static str,
/// `package.json` 里声明的脚本文本。交给运行器做反漂移的 `expectedCommand`。
pub(crate) command: String,
/// 相对项目根的 `package.json` 路径,报错与提示都用它。
/// 相对项目根的 `package.json` 路径;当前只被用例读取。
#[cfg(test)]
pub(crate) package_json_relative: String,
}
@@ -59,6 +60,7 @@ pub(crate) fn find(root: &Path) -> Option<NpmScript> {
return Some(NpmScript {
cwd_relative: cwd,
command: command.to_string(),
#[cfg(test)]
package_json_relative: package_json_relative(cwd),
});
}
File diff suppressed because it is too large Load Diff
@@ -332,6 +332,17 @@ pub(crate) fn fork_game_id_from_deep_link(raw_url: &str) -> Result<String, Strin
Ok(game_id)
}
/// 测试断言用:与运行视图 / 发布导出同源,「项目里已有可运行原型」的唯一事实是
/// `manifest.tasks[code-prototype] == completed`(见 `register_forked_project_state_at` 的注释)。
///
/// 只服务 `game_fork` 的建项收口用例,因此留在 `cfg(test)` 内,不新增生产 API。
#[cfg(test)]
fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
manifest.tasks.iter().any(|task| {
task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2,7 +2,7 @@
use super::*;
use crate::game_distribution_publish::{fetch_platform_game_fork_source, require_platform_session};
use serde_json::{json, Value};
use shared_contracts::game_distribution::GameDistributionForkSourceKind;
use shared_contracts::game_distribution::GameDistributionSourceKind;
use tauri::{Emitter, Manager};
use tauri_plugin_deep_link::DeepLinkExt;
@@ -144,8 +144,8 @@ pub(crate) async fn create_local_project_from_platform_game(
&source.version_id,
match source.source {
// 取件形态决定建项方式:工程源包解压即工程;成品包生成脚手架 + 参考副本。
GameDistributionForkSourceKind::Project => ProjectForkSourceKind::Project,
GameDistributionForkSourceKind::Package => ProjectForkSourceKind::Package,
GameDistributionSourceKind::Project => ProjectForkSourceKind::Project,
GameDistributionSourceKind::Package => ProjectForkSourceKind::Package,
},
&package_bytes,
name.as_deref(),
@@ -17,7 +17,6 @@ mod runtime;
pub(crate) use runtime::{
game_package_upload_staging_dir, progress_event_payload, upload_staged_version_asset,
GamePackageUploadOutcome, GamePackageUploadRequest, PackageUploadAsset,
GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
};
/// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。
@@ -71,6 +70,10 @@ pub(crate) fn stage_game_package_bytes(
}
/// 暂存文件必须落在暂存目录内;渲染进程不能借这条命令读任意路径。
///
/// 生产接入已随 exports 退役(master `b04aceb57` 删除 `project/export.rs` 与 `commands/desktop.rs`
/// 的发布链路)删除,当前只有本文件的用例调用它。
#[cfg(test)]
pub(crate) fn ensure_staging_path_in_dir(
staging_dir: &Path,
staging_path: &str,
@@ -105,9 +108,17 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option<ChunkP
})
}
fn upload_url(base_url: &str, version_id: &str, asset: PackageUploadAsset, suffix: &str) -> String {
/// 版本级资产的续传 URL:身份只在路径的 `{game_id}/versions/{version_number}` 里,
/// 与发行端「路径即身份」口径一致(body 不重复路径参数)。
fn upload_url(
base_url: &str,
game_id: &str,
version_number: u64,
asset: PackageUploadAsset,
suffix: &str,
) -> String {
format!(
"{}/api/game-distribution/versions/{version_id}/{}{suffix}",
"{}/api/game-distribution/games/{game_id}/versions/{version_number}/{}{suffix}",
base_url.trim_end_matches('/'),
asset.url_segment()
)
@@ -198,20 +209,22 @@ mod tests {
assert_eq!(
upload_url(
"https://dev.test/",
"version_1",
"game_1",
7,
PackageUploadAsset::ReleasePackage,
"/chunk"
),
"https://dev.test/api/game-distribution/versions/version_1/package/chunk"
"https://dev.test/api/game-distribution/games/game_1/versions/7/package/chunk"
);
assert_eq!(
upload_url(
"https://dev.test",
"version_1",
"game_1",
7,
PackageUploadAsset::ProjectBundle,
"/upload-state"
),
"https://dev.test/api/game-distribution/versions/version_1/project-bundle/upload-state"
"https://dev.test/api/game-distribution/games/game_1/versions/7/project-bundle/upload-state"
);
assert_eq!(
PackageUploadAsset::ReleasePackage.offset_mismatch_code(),
@@ -252,20 +265,22 @@ mod tests {
assert_eq!(
upload_url(
"https://dev.genarrative.world/",
"ver-1",
"game-1",
7,
PackageUploadAsset::ReleasePackage,
"/chunk"
),
"https://dev.genarrative.world/api/game-distribution/versions/ver-1/package/chunk"
"https://dev.genarrative.world/api/game-distribution/games/game-1/versions/7/package/chunk"
);
assert_eq!(
upload_url(
"http://127.0.0.1:10001",
"ver 1",
"game 1",
2,
PackageUploadAsset::ReleasePackage,
"/complete"
),
"http://127.0.0.1:10001/api/game-distribution/versions/ver 1/package/complete"
"http://127.0.0.1:10001/api/game-distribution/games/game 1/versions/2/package/complete"
);
}
@@ -8,8 +8,6 @@ use std::{
time::Duration,
};
pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client";
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
@@ -191,7 +189,8 @@ impl From<GamePackageUploadError> for String {
async fn read_upload_state(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
game_id: &str,
version_number: u64,
access_token: &str,
asset: PackageUploadAsset,
) -> Result<PackageUploadState, GamePackageUploadError> {
@@ -199,7 +198,7 @@ async fn read_upload_state(
client,
access_token,
reqwest::Method::GET,
&upload_url(base_url, version_id, asset, "/upload-state"),
&upload_url(base_url, game_id, version_number, asset, "/upload-state"),
)
.send()
.await
@@ -232,7 +231,8 @@ async fn read_upload_state(
async fn upload_chunk(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
game_id: &str,
version_number: u64,
access_token: &str,
idempotency_key: &str,
asset: PackageUploadAsset,
@@ -243,7 +243,7 @@ async fn upload_chunk(
client,
access_token,
reqwest::Method::PUT,
&upload_url(base_url, version_id, asset, "/chunk"),
&upload_url(base_url, game_id, version_number, asset, "/chunk"),
)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.header(UPLOAD_OFFSET_HEADER, plan.offset.to_string())
@@ -320,16 +320,18 @@ enum ChunkUploadError {
async fn complete_upload(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
game_id: &str,
version_number: u64,
access_token: &str,
idempotency_key: &str,
asset: PackageUploadAsset,
fallback_version_id: &str,
) -> Result<GamePackageUploadOutcome, GamePackageUploadError> {
let response = platform_request(
client,
access_token,
reqwest::Method::POST,
&upload_url(base_url, version_id, asset, "/complete"),
&upload_url(base_url, game_id, version_number, asset, "/complete"),
)
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
.send()
@@ -355,7 +357,7 @@ async fn complete_upload(
version_id: payload
.get("versionId")
.and_then(Value::as_str)
.unwrap_or(version_id)
.unwrap_or(fallback_version_id)
.to_string(),
status: payload
.get("status")
@@ -369,7 +371,11 @@ async fn complete_upload(
pub(crate) struct GamePackageUploadRequest<'a> {
pub(crate) asset: PackageUploadAsset,
pub(crate) staging_path: &'a Path,
/// 版本身份只在路径里:`…/games/{game_id}/versions/{version_number}/…`。
/// `version_id` 只用于响应缺失时的本地回退展示,不再出现在 URL。
pub(crate) version_id: &'a str,
pub(crate) game_id: &'a str,
pub(crate) version_number: u64,
pub(crate) api_base_url: &'a str,
pub(crate) access_token: &'a str,
pub(crate) idempotency_key: &'a str,
@@ -411,7 +417,8 @@ pub(crate) async fn upload_staged_version_asset(
let state = read_upload_state(
client,
request.api_base_url,
request.version_id,
request.game_id,
request.version_number,
&access_token,
asset,
)
@@ -443,7 +450,8 @@ pub(crate) async fn upload_staged_version_asset(
match upload_chunk(
client,
request.api_base_url,
request.version_id,
request.game_id,
request.version_number,
&access_token,
request.idempotency_key,
asset,
@@ -475,7 +483,8 @@ pub(crate) async fn upload_staged_version_asset(
let authoritative = read_upload_state(
client,
request.api_base_url,
request.version_id,
request.game_id,
request.version_number,
&access_token,
asset,
)
@@ -488,10 +497,12 @@ pub(crate) async fn upload_staged_version_asset(
let mut outcome = complete_upload(
client,
request.api_base_url,
request.version_id,
request.game_id,
request.version_number,
&access_token,
request.idempotency_key,
asset,
request.version_id,
)
.await?;
outcome.uploaded_bytes = total_bytes;
@@ -6,7 +6,6 @@ mod agent_db;
mod asset_export;
#[cfg(not(test))]
mod asset_rename;
mod bootstrap;
mod checkpoint;
mod conversation;
mod external_editor_bindings;
@@ -28,7 +27,6 @@ pub(crate) use agent_db::*;
pub(crate) use asset_export::*;
#[cfg(not(test))]
pub(crate) use asset_rename::*;
pub(crate) use bootstrap::*;
pub(crate) use checkpoint::*;
pub(crate) use conversation::*;
pub(crate) use external_editor_bindings::*;
@@ -1,113 +0,0 @@
use super::*;
use sha2::{Digest, Sha256};
const BOOTSTRAP_PACKAGE_MAX_BYTES: u64 = 512 * 1024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ProjectBootstrapResult {
pub(crate) status: String,
pub(crate) output: String,
pub(crate) package_sha256: String,
pub(crate) lock_sha256: Option<String>,
pub(crate) log_path: String,
pub(crate) updated_at: u64,
}
fn read_bootstrap_file(path: &Path, label: &str) -> Result<Vec<u8>, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取 {label} 失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(format!("project.bootstrap 要求 {label} 是普通文件"));
}
if metadata.len() > BOOTSTRAP_PACKAGE_MAX_BYTES {
return Err(format!(
"project.bootstrap {label} 超过 {} 字节上限",
BOOTSTRAP_PACKAGE_MAX_BYTES
));
}
prepare_game_creator_private_path_for_read(path, false, label)?;
fs::read(path).map_err(|error| format!("读取 {label} 失败:{error}"))
}
pub(crate) async fn run_project_bootstrap_at(
root: &Path,
timeout_seconds: u64,
) -> Result<ProjectBootstrapResult, String> {
validate_project_root(root)?;
let game = resolve_local_project_path(root, "game")?;
if !game.is_dir() {
return Err("project.bootstrap 只允许项目内 game 目录".to_string());
}
let package = read_bootstrap_file(&game.join("package.json"), "game/package.json")?;
let package_json: serde_json::Value = serde_json::from_slice(&package)
.map_err(|error| format!("解析 game/package.json 失败:{error}"))?;
if let Some(manager) = package_json
.get("packageManager")
.and_then(serde_json::Value::as_str)
{
if !manager.trim().starts_with("npm@") && manager.trim() != "npm" {
return Err("project.bootstrap 当前只支持 npm packageManager".to_string());
}
}
for lock_name in ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] {
if game.join(lock_name).exists() {
return Err(format!("project.bootstrap 检测到非 npm 锁文件 {lock_name}"));
}
}
if !package_json
.get("scripts")
.is_some_and(serde_json::Value::is_object)
{
return Err("project.bootstrap 要求 game/package.json 包含 scripts 对象".to_string());
}
let lock = match fs::symlink_metadata(game.join("package-lock.json")) {
Ok(_) => Some(read_bootstrap_file(
&game.join("package-lock.json"),
"game/package-lock.json",
)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(format!("读取 game/package-lock.json 失败:{error}")),
};
let command = crate::command_exec::run_project_bootstrap_command_at(root, timeout_seconds)
.await
.map_err(|error| error.to_string())?;
let completed = command.exit_code == Some(0) && !command.timed_out;
let status = if completed { "completed" } else { "failed" };
let updated_at = unix_timestamp();
let log_path = resolve_local_project_path(root, ".agent/logs/command.log")?;
let package_sha256 = format!("{:x}", Sha256::digest(&package));
let lock_sha256 = lock
.as_ref()
.map(|bytes| format!("{:x}", Sha256::digest(bytes)));
let output = sanitize_project_verification_output(&command.output);
let line = format!(
"{updated_at} project.bootstrap status={} packageSha256={} lockSha256={} cwd=game\n{}\n",
status,
package_sha256,
lock_sha256.as_deref().unwrap_or("none"),
output
);
append_game_creator_private_file(&log_path, line.as_bytes(), "命令日志")?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: "project.bootstrap".to_string(),
status: if completed {
GameCreationAppCommandRunStatus::Completed
} else {
GameCreationAppCommandRunStatus::Failed
},
output: output.clone(),
log_path: ".agent/logs/command.log".to_string(),
updated_at,
},
)?;
Ok(ProjectBootstrapResult {
status: status.to_string(),
output,
package_sha256,
lock_sha256,
log_path: ".agent/logs/command.log".to_string(),
updated_at,
})
}
@@ -659,30 +659,9 @@ where
})
}
pub(crate) async fn run_project_verification_with_commit_at<F>(
root: &Path,
script: &str,
expected_command: &str,
timeout_seconds: u64,
cwd_relative: &str,
durable_commit: F,
) -> Result<ProjectVerificationResult, String>
where
F: FnOnce() -> Result<(), String>,
{
let spec = resolve_project_verification_spec_with_cwd_at(
root,
script,
expected_command,
timeout_seconds,
cwd_relative,
)?;
run_project_verification_spec_with_commit_at(root, spec, durable_commit).await
}
/// 跑一份**已解析好**的验证规格。
///
/// 从 [`run_project_verification_with_commit_at`] 拆出来,是为了让调用方能先把「项目侧预检」
/// 从「解析 + 运行」合一的入口拆出来,是为了让调用方能先把「项目侧预检」
/// (package.json / 脚本缺失或漂移 / 包管理器不支持等)与「跑起来之后的宿主侧事实」分开处理:
/// 预检错误由调用方自己决定怎么表达,不必跟沙箱、日志这类宿主故障混成同一个字符串错误。
pub(crate) async fn run_project_verification_spec_with_commit_at<F>(
+1 -1
View File
@@ -1820,7 +1820,7 @@ export function App({
ensureConversationReadAllowed={ensureDirectHistoryReadAllowed}
ensureConversationWriteAllowed={ensureDirectTurnWriteAllowed}
initialTurn={initialDirectTurn}
onRequestExport={
onRequestPublish={
exportProjectPath ? () => setExportPanelOpen(true) : undefined
}
onCancelConfirmation={cancelUiCommandConfirmation}
@@ -7,11 +7,11 @@ import {
import {
GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION,
type GameDistributionCategory,
type GameDistributionCreateGameRequest,
type GameDistributionDeviceSupport,
type GameDistributionForkAuthorization,
type GameDistributionInputMode,
type GameDistributionOrientation,
type GameMetadata,
} from '../../../../packages/shared/src/contracts/gameDistribution';
import { resolveTauriInvoke } from '../app/tauri';
import type { TauriInvoke } from '../app/types';
@@ -53,6 +53,21 @@ export type GameDistributionPublishMetadata = {
/** 游戏截图上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
export const MAX_AGC_GAME_SCREENSHOTS = 6;
/**
* 发布媒体 objectKey 前缀,与 server-rs
* `shared_contracts::game_distribution::GAME_DISTRIBUTION_MEDIA_PREFIX` 对齐。
*
* 陶泥儿表单的图片槽位是「项目内相对路径或上一版本 objectKey」二选一:带这个前缀的字符串就是
* 上一版本的发布媒体 key,发布时沿用;其余非空字符串按项目内本地图读取字节直传。
*/
export const GAME_DISTRIBUTION_MEDIA_OBJECT_KEY_PREFIX =
'agc/project-snapshots/v1/game-distribution/media/';
/** 判断一个图片槽位存的是不是上一版本的发布媒体 objectKey。 */
export function isGameDistributionMediaObjectKey(value: string): boolean {
return value.trim().startsWith(GAME_DISTRIBUTION_MEDIA_OBJECT_KEY_PREFIX);
}
function requireNativeInvoke(invoke?: TauriInvoke) {
const resolved = invoke ?? resolveTauriInvoke();
if (!resolved) {
@@ -61,6 +76,19 @@ function requireNativeInvoke(invoke?: TauriInvoke) {
return resolved;
}
/**
* 读取当前账号的发布灰度。
*
* 与网页端共用 `/api/runtime/frontend-config`:后端未配置 `game-distribution:publish`
* 时对已登录作者默认开放,运营收紧后只有白名单/灰度命中的作者返回 `true`。读取失败按
* “不开放入口”处理——写入口本身还会再拦一次,AGC 面板不作为唯一把关点。
*/
export async function readGamePublishAvailability(
invoke?: TauriInvoke,
): Promise<boolean> {
return requireNativeInvoke(invoke)('read_game_publish_availability');
}
export type GameDistributionPublishResult = {
gameId: string;
versionId: string;
@@ -69,6 +97,10 @@ export type GameDistributionPublishResult = {
packageSha256: string;
packageSizeBytes: number;
fileCount: number;
/** 本次发布解析出的封面 objectKey(新上传落桶的键或沿用的已有键)。 */
coverObjectKey?: string | null;
/** 本次发布解析出的截图 objectKey,按草稿槽位顺序一一对应(含沿用槽位)。 */
screenshotObjectKeys?: string[];
/** 发布成功并回写绑定后的整份 manifest;渲染层据此同步工作台状态。 */
manifest?: GameCreationAppManifest | null;
publication?: GameCreationAppPublicationBinding | null;
@@ -151,7 +183,7 @@ export type GameDistributionPublicationReadResult =
* 发布面板打开时由原生恢复本地绑定与线上作品状态。
*
* 原生按当前会话(账号 + origin)隔离绑定:切换账号/环境后不会接管他人作品;无本地绑定时
* 按作者作品的 `localProjectId` 精确匹配做一次性恢复。网络/401/5xx 返回 `unavailable`,
* 按作者作品的 `projectKey` 精确匹配做一次性恢复。网络/401/5xx 返回 `unavailable`,
* 调用方不得据此按首次发布继续。
*/
export async function readGameDistributionPublication(
@@ -225,12 +257,10 @@ function normalizeMetadata(
};
}
function toCreateGameRequest(
function toGameMetadata(
metadata: GameDistributionPublishMetadata,
localProjectId: string,
): GameDistributionCreateGameRequest {
): GameMetadata {
return {
localProjectId,
title: metadata.title,
summary: metadata.summary,
description: metadata.description,
@@ -318,8 +348,8 @@ export async function publishLocalProjectGame(args: {
throw new Error('发布需要绑定本地项目');
}
const metadata = normalizeMetadata(args.manifest, args.metadata);
const localProjectId = args.manifest.projectId.trim();
if (!localProjectId) {
const projectKey = args.manifest.projectId.trim();
if (!projectKey) {
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
}
const gameId = args.gameId?.trim() ?? '';
@@ -347,7 +377,7 @@ export async function publishLocalProjectGame(args: {
) {
throw new Error('缺少公开修订号,请重新打开发布面板后再试');
}
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
const gameMetadata = toGameMetadata(metadata);
const rootKey = args.idempotencyKey?.trim() ?? '';
// 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递
// 已归一化的资料、面板给出的本地媒体路径、项目版本和本地项目标识,不接触 token、
@@ -357,6 +387,8 @@ export async function publishLocalProjectGame(args: {
'publish_local_project_game',
{
projectPath,
// 身份字段与展示资料分开传:`projectKey` 只出现在顶层,`gameMetadata` 不再重复携带。
projectKey,
metadata: gameMetadata,
coverPath: args.coverPath?.trim() ?? '',
screenshotPaths: (args.screenshotPaths ?? []).map((path) => path.trim()),
@@ -7,7 +7,7 @@ import {
/**
* AGC 发布进度事件:Rust 在发布编排的每个阶段回传,渲染层只做展示。
*
* 事件名与 Rust `game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT` 绑定:
* 事件名与 Rust 发布编排回传的 `game-package-upload-progress` 事件绑定:
* 分片上传的字节进度和「准备 / 上传 / 校验 / 送审」阶段切换走同一条事件,避免两条
* 事件流在 UI 上互相覆盖。载荷只含标识、阶段、文案与字节数,不含 token、本地路径
* 或整包字节。

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