refactor/优化陶泥平台发布 #670
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+7
-6
@@ -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>/`) |
|
||||
|
||||
# 一些情况:
|
||||
|
||||
+2
-2
@@ -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(®istry_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>(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user