Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fc439612b0 | |||
| 6ed1fd26ed | |||
| 2370dc509b | |||
| 4912aa4df0 | |||
| bb69e09b8d | |||
| 571cb00fe4 |
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
|
||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
|
||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
||||
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all nine through:
|
||||
Poll all ten through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
|
||||
Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
|
||||
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
|
||||
|
||||
## Art Spec and Image Request
|
||||
|
||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
|
||||
|
||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
||||
|
||||
|
||||
@@ -145,15 +145,6 @@ ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
|
||||
ELEVENLABS_API_KEY=""
|
||||
ELEVENLABS_REQUEST_TIMEOUT_MS="180000"
|
||||
|
||||
# Tripo 3D 生成:地址与密钥必须显式给出,没有内置默认值。
|
||||
# 缺任意一个,会提交或会执行 3D job 的进程(api / external-generation-worker / all)
|
||||
# 在启动期直接拒绝启动——不会带着未知网关或空密钥把进程跑起来。
|
||||
TRIPO_BASE_URL="https://openapi.tripo3d.com/v3"
|
||||
TRIPO_API_KEY=""
|
||||
# 两个运行旋钮可缺省(沿用内置值);显式给出时会校验,非法即拒绝启动。
|
||||
TRIPO_REQUEST_TIMEOUT_MS="60000"
|
||||
TRIPO_RETRIES="2"
|
||||
|
||||
# 阿里云 OSS 配置。
|
||||
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
|
||||
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。
|
||||
|
||||
-84
@@ -67,90 +67,6 @@ _Avoid_: 每步一个 sidecar 状态机、把切分专用的 SeparationState 泛
|
||||
|
||||
## Language
|
||||
|
||||
### 3D Model Generation
|
||||
|
||||
**3D 模型生成操作**:
|
||||
由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
|
||||
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
|
||||
|
||||
**Provider task**:
|
||||
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
|
||||
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
|
||||
|
||||
**临时 provider URL**:
|
||||
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
|
||||
_Avoid_: 永久 OSS URL、资源 ID
|
||||
|
||||
**模型 artifact**:
|
||||
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
|
||||
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
|
||||
|
||||
**Tripo API facade**:
|
||||
面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。
|
||||
_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo
|
||||
|
||||
**Tripo operation**:
|
||||
Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationId,Tripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。
|
||||
_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API
|
||||
|
||||
**模型产物持久化**:
|
||||
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
|
||||
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
|
||||
|
||||
**3D provider 配置门禁**:
|
||||
Tripo 网关与密钥的可用性在「会提交或会执行 3D job 的进程」(`api` / `external-generation-worker` / `all`)启动期判定:`TRIPO_BASE_URL` 与 `TRIPO_API_KEY` 没有内置默认值,缺失、空白或网关非法即拒绝启动;请求期返回 503 只是第二道防线,不承担发现部署缺失的职责。
|
||||
_Avoid_: 内置默认网关、缺配置时静默沿用某个地址或兜底密钥、把「少配置」留到用户提交后才发现
|
||||
|
||||
**3D 生成底价**:
|
||||
一次 3D 生成在指定端点和模型版本下、按是否带贴图区分的基准泥点价,不包含任何叠加项,也不随请求的其它参数变化。
|
||||
_Avoid_: 把底价与加价项合并成一个档位价、把 provider 的 credit 数值当底价
|
||||
|
||||
**3D 生成加价项**:
|
||||
叠加在底价之上、由请求参数判定的可选泥点加价;每个端点各自持有一份加价项价目,同一加价项在不同端点可以是不同价。
|
||||
_Avoid_: 全局唯一一份加价项价目、按用途而不是按请求参数判定加价
|
||||
|
||||
**定价配置归一化**:
|
||||
读取持久化定价时把它与当前契约对齐的过程:缺的键用受控默认值补齐,契约之外的遗留键剔除并告警;它只发生在读取方向,写入方向仍要求完整合法。
|
||||
_Avoid_: 把「省略即沿用」当作保存语义、把归一化当成静默改价
|
||||
|
||||
**模型生成落点**:
|
||||
3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。
|
||||
_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数
|
||||
|
||||
**模型输入资源引用**:
|
||||
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
|
||||
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
|
||||
|
||||
**3D 模型生成工具**:
|
||||
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**。
|
||||
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
|
||||
|
||||
**3D 生成定价**:
|
||||
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。该段的权威事实源是 SpacetimeDB 定价表里的两段强类型列,公开读取时投影回迁移前的旧形状。
|
||||
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行、把内部两段结构直接暴露给画布
|
||||
|
||||
**定价版本(乐观锁)**:
|
||||
后台读取完整模型定价时一并拿到的版本号(SpacetimeDB 行的更新时间微秒值),保存时必须原样回传;与服务端当前版本不一致即整笔拒绝并返回 409,提示重新读取,避免静默覆盖他人的改价。
|
||||
_Avoid_: 在前端自造或缓存该版本、把版本不匹配当成可重试的普通失败、把「省略字段」当成「沿用当前值」
|
||||
|
||||
### 3D 模型资源预览
|
||||
|
||||
**3D 模型资源**:
|
||||
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
|
||||
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
|
||||
|
||||
**模型预览图**:
|
||||
3D 模型资源的静态代表图,落在资源投影的 `imageSrc` 与 `thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
|
||||
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
|
||||
|
||||
**模型本体**:
|
||||
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
|
||||
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
|
||||
|
||||
**3D 查看器预览**:
|
||||
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
|
||||
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
|
||||
|
||||
### Puzzle Clear
|
||||
|
||||
**拼消消**:
|
||||
|
||||
@@ -60,7 +60,6 @@ import type {
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUpdateAgcTemplateRequest,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorGenerationPricingRequest,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
@@ -520,7 +519,7 @@ export function getAdminEditorGenerationPricing(token: string) {
|
||||
|
||||
export function upsertAdminEditorGenerationPricing(
|
||||
token: string,
|
||||
payload: AdminUpsertEditorGenerationPricingRequest,
|
||||
payload: EditorGenerationPricingConfigPayload,
|
||||
) {
|
||||
return request<EditorGenerationPricingConfigPayload>(
|
||||
'/admin/api/editor-generation-pricing',
|
||||
|
||||
@@ -431,43 +431,8 @@ export interface EditorGenerationModelPricingPayload {
|
||||
prices?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 某个 3D 模型版本在「无贴图 / 带贴图」两种形态下的泥点底价。 */
|
||||
export interface EditorGenerationModel3dVersionPricePayload {
|
||||
noTexture: number;
|
||||
texture: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个 3D 生成端点的整套定价:模型版本底价 + 该端点自己的加价项价目。
|
||||
*
|
||||
* 键集合由后端契约决定,后台只改数值:模型版本键与加价项键都直接来自接口返回,
|
||||
* 不在前端硬编码清单,也不能新增、删除或停用。
|
||||
*/
|
||||
export interface EditorGenerationModel3dEndpointPricingPayload {
|
||||
versionPrices: Record<string, EditorGenerationModel3dVersionPricePayload>;
|
||||
addOnPrices: Record<string, number>;
|
||||
}
|
||||
|
||||
/** 3D 生成定价段:两个端点并列,各持自己的设置。 */
|
||||
export interface EditorGenerationModel3dPricingPayload {
|
||||
textToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
|
||||
imageToModelPricing: EditorGenerationModel3dEndpointPricingPayload;
|
||||
}
|
||||
|
||||
export interface EditorGenerationPricingConfigPayload {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
/** 后端尚未配置 3D 段时缺省;缺失即 3D 生成不可提交,后台也不能新建整段。 */
|
||||
model3d?: EditorGenerationModel3dPricingPayload | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台保存定价的请求体:`models` 与 3D 两段都必须显式给出。
|
||||
*
|
||||
* 不带定价版本:保存即整段覆盖,服务端不比对版本(后台同时只有一个管理员在操作)。
|
||||
*/
|
||||
export interface AdminUpsertEditorGenerationPricingRequest {
|
||||
models: Record<string, EditorGenerationModelPricingPayload>;
|
||||
model3d: EditorGenerationModel3dPricingPayload | null;
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListQuery {
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import type {
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationModel3dVersionPricePayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
EDITOR_MODEL3D_ENDPOINT_SECTIONS,
|
||||
editorModel3dAddOnLabel,
|
||||
updateEditorModel3dEndpointPricing,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { parsePositiveInteger } from './pageUtils';
|
||||
|
||||
/**
|
||||
* 一个模型版本的两个底价字段。两栏只有字段键与展示名不同,用描述表驱动渲染,
|
||||
* 避免两段近乎相同的 JSX 各自演化。
|
||||
*/
|
||||
const VERSION_PRICE_FIELDS = [
|
||||
{ key: 'noTexture', label: '无贴图' },
|
||||
{ key: 'texture', label: '带贴图' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: keyof EditorGenerationModel3dVersionPricePayload;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
interface AdminEditorGenerationModel3dPricingSectionProps {
|
||||
model3d: EditorGenerationModel3dPricingPayload;
|
||||
onChange: (next: EditorGenerationModel3dPricingPayload) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D 生成定价区块:按端点分成「文生 3D」与「图生 3D」两组,每组是该端点自己的
|
||||
* 「模型版本 × {无贴图, 带贴图}」底价表与加价项单值输入。
|
||||
*
|
||||
* 键集合全部来自接口返回,后台只能改数值:不能新增、删除键,也不能停用整段。
|
||||
*/
|
||||
export function AdminEditorGenerationModel3dPricingSection({
|
||||
model3d,
|
||||
onChange,
|
||||
}: AdminEditorGenerationModel3dPricingSectionProps) {
|
||||
function updateEndpoint(
|
||||
section: (typeof EDITOR_MODEL3D_ENDPOINT_SECTIONS)[number]['key'],
|
||||
updater: Parameters<typeof updateEditorModel3dEndpointPricing>[2],
|
||||
) {
|
||||
onChange(updateEditorModel3dEndpointPricing(model3d, section, updater));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel admin-pricing-model-card">
|
||||
<div className="admin-pricing-model-heading">
|
||||
<strong>3D 生成定价</strong>
|
||||
<span className="admin-pricing-unit">按次</span>
|
||||
</div>
|
||||
<p className="admin-pricing-model3d-hint">
|
||||
模型版本与加价项由后端契约给出,只能改数值;两段必须完整给出,缺失即 3D
|
||||
生成不可提交。
|
||||
</p>
|
||||
<div className="admin-pricing-grid">
|
||||
{EDITOR_MODEL3D_ENDPOINT_SECTIONS.map(({ key, label }) => (
|
||||
<section className="admin-panel admin-pricing-model-card" key={key}>
|
||||
<div className="admin-pricing-model-heading">
|
||||
<strong>{label}</strong>
|
||||
</div>
|
||||
<div className="admin-pricing-model3d-list">
|
||||
{Object.entries(model3d[key].versionPrices).map(
|
||||
([version, price]) => (
|
||||
<div className="admin-pricing-model3d-row" key={version}>
|
||||
<span className="admin-pricing-model3d-row-label">
|
||||
{version}
|
||||
</span>
|
||||
{VERSION_PRICE_FIELDS.map(
|
||||
({ key: field, label: fieldLabel }) => (
|
||||
<label className="admin-field" key={field}>
|
||||
<span>{fieldLabel}</span>
|
||||
<input
|
||||
aria-label={`${label} ${version} ${fieldLabel}`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price[field]}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
versionPrices: {
|
||||
...current.versionPrices,
|
||||
[version]: {
|
||||
...price,
|
||||
[field]: parsePositiveInteger(
|
||||
event.target.value,
|
||||
),
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
{Object.entries(model3d[key].addOnPrices).map(
|
||||
([addOn, price]) => (
|
||||
<label className="admin-field" key={addOn}>
|
||||
<span>{editorModel3dAddOnLabel(addOn)}</span>
|
||||
<input
|
||||
aria-label={`${label} ${editorModel3dAddOnLabel(addOn)}`}
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={price}
|
||||
onChange={(event) =>
|
||||
updateEndpoint(key, (current) => ({
|
||||
...current,
|
||||
addOnPrices: {
|
||||
...current.addOnPrices,
|
||||
[addOn]: parsePositiveInteger(event.target.value),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import {
|
||||
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
|
||||
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('../api/adminApiClient')>(
|
||||
'../api/adminApiClient',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getAdminEditorGenerationPricing: vi.fn(),
|
||||
upsertAdminEditorGenerationPricing: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminEditorGenerationPricing: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
upsertAdminEditorGenerationPricing: vi.fn(),
|
||||
}));
|
||||
|
||||
const pricing: EditorGenerationPricingConfigPayload = {
|
||||
models: {
|
||||
@@ -43,78 +41,36 @@ const pricing: EditorGenerationPricingConfigPayload = {
|
||||
'audio1.0': { unit: 'perGeneration', price: 5 },
|
||||
'chirp-v5': { unit: 'perGeneration', price: 12 },
|
||||
},
|
||||
model3d: {
|
||||
textToModelPricing: {
|
||||
versionPrices: {
|
||||
'v3.1-20260211': { noTexture: 8, texture: 16 },
|
||||
'P2-20260801': { noTexture: 80, texture: 88 },
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 8,
|
||||
ultraTexture: 16,
|
||||
quadMesh: 4,
|
||||
generateParts: 16,
|
||||
},
|
||||
},
|
||||
imageToModelPricing: {
|
||||
versionPrices: {
|
||||
'v3.1-20260211': { noTexture: 16, texture: 24 },
|
||||
'P2-20260801': { noTexture: 80, texture: 88 },
|
||||
},
|
||||
addOnPrices: {
|
||||
hdTexture: 8,
|
||||
ultraTexture: 16,
|
||||
quadMesh: 4,
|
||||
generateParts: 16,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function savedPricing(
|
||||
overrides: Partial<EditorGenerationPricingConfigPayload> = {},
|
||||
) {
|
||||
return {
|
||||
...pricing,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
|
||||
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue(
|
||||
savedPricing({
|
||||
models: {
|
||||
...pricing.models,
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '1K': 20, '2K': 58 },
|
||||
},
|
||||
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
|
||||
...pricing,
|
||||
models: {
|
||||
...pricing.models,
|
||||
'gpt-image-2': {
|
||||
unit: 'perGeneration',
|
||||
prices: { '1K': 20, '2K': 58 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText('3D 生成定价');
|
||||
}
|
||||
|
||||
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByLabelText('gpt-image-2 2K'), {
|
||||
target: { value: '58' },
|
||||
});
|
||||
const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
|
||||
fireEvent.change(gptImage2kInput, { target: { value: '58' } });
|
||||
await user.click(screen.getByRole('button', { name: '保存定价' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
@@ -134,82 +90,3 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () =>
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('3D 区块按端点渲染版本底价与加价项,并随保存回传整段与定价版本', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
expect(screen.getByText('文生 3D')).toBeTruthy();
|
||||
expect(screen.getByText('图生 3D')).toBeTruthy();
|
||||
expect(screen.getAllByText('高清贴图').length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('文生 3D v3.1-20260211 带贴图'), {
|
||||
target: { value: '18' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('图生 3D P2-20260801 无贴图'), {
|
||||
target: { value: '86' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
|
||||
target: { value: '6' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存定价' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
|
||||
'admin-token',
|
||||
expect.objectContaining({
|
||||
model3d: {
|
||||
textToModelPricing: expect.objectContaining({
|
||||
versionPrices: expect.objectContaining({
|
||||
'v3.1-20260211': { noTexture: 8, texture: 18 },
|
||||
}),
|
||||
addOnPrices: expect.objectContaining({ quadMesh: 6 }),
|
||||
}),
|
||||
imageToModelPricing: expect.objectContaining({
|
||||
versionPrices: expect.objectContaining({
|
||||
'P2-20260801': { noTexture: 86, texture: 88 },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('数值小于 1 时禁用保存并给出中文提示', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('文生 3D 四边形网格'), {
|
||||
target: { value: '0' },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/以下价格必须是不小于 1 的整数/)).toBeTruthy();
|
||||
const saveButton = screen.getByRole('button', {
|
||||
name: '保存定价',
|
||||
}) as HTMLButtonElement;
|
||||
expect(saveButton.disabled).toBe(true);
|
||||
await user.click(saveButton);
|
||||
expect(upsertAdminEditorGenerationPricing).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('后端未配置 3D 段时给出提示并禁止保存', async () => {
|
||||
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue({
|
||||
...pricing,
|
||||
model3d: null,
|
||||
});
|
||||
render(
|
||||
<AdminEditorGenerationPricingPage
|
||||
token="admin-token"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/当前部署未配置 3D 定价段/)).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '保存定价' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,18 +6,12 @@ import {
|
||||
upsertAdminEditorGenerationPricing,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationModelPricingPayload,
|
||||
EditorGenerationPricingConfigPayload,
|
||||
EditorGenerationPricingUnitPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { AdminEditorGenerationModel3dPricingSection } from './AdminEditorGenerationModel3dPricingSection';
|
||||
import {
|
||||
buildAdminEditorGenerationPricingRequest,
|
||||
collectInvalidEditorGenerationPricingLabels,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminEditorGenerationPricingPageProps {
|
||||
token: string;
|
||||
@@ -26,7 +20,6 @@ interface AdminEditorGenerationPricingPageProps {
|
||||
|
||||
const emptyPricing: EditorGenerationPricingConfigPayload = {
|
||||
models: {},
|
||||
model3d: null,
|
||||
};
|
||||
|
||||
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
|
||||
@@ -64,7 +57,7 @@ export function AdminEditorGenerationPricingPage({
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSaving || !canSave) {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
setErrorMessage('');
|
||||
@@ -78,12 +71,7 @@ export function AdminEditorGenerationPricingPage({
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
setPricing(
|
||||
await upsertAdminEditorGenerationPricing(
|
||||
token,
|
||||
buildAdminEditorGenerationPricingRequest(pricing),
|
||||
),
|
||||
);
|
||||
setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -132,15 +120,6 @@ export function AdminEditorGenerationPricingPage({
|
||||
});
|
||||
}
|
||||
|
||||
function updateModel3dPrice(next: EditorGenerationModel3dPricingPayload) {
|
||||
setPricing((current) => ({ ...current, model3d: next }));
|
||||
}
|
||||
|
||||
const invalidPriceLabels =
|
||||
collectInvalidEditorGenerationPricingLabels(pricing);
|
||||
const isModel3dMissing = !pricing.model3d;
|
||||
const canSave = invalidPriceLabels.length === 0 && !isModel3dMissing;
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide">
|
||||
<div className="admin-page-heading">
|
||||
@@ -165,20 +144,6 @@ export function AdminEditorGenerationPricingPage({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isModel3dMissing ? (
|
||||
<div className="admin-alert" role="status">
|
||||
当前部署未配置 3D 定价段(缺失即 3D
|
||||
生成不可提交):需先由受控默认配置提供两段价格,
|
||||
后台不在页面里新建或停用整段。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{invalidPriceLabels.length > 0 ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{`以下价格必须是不小于 1 的整数:${invalidPriceLabels.join('、')}`}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form className="admin-stack" onSubmit={handleSave}>
|
||||
<div className="admin-pricing-model-list admin-pricing-model-list--single">
|
||||
{Object.entries(pricing.models).map(([model, modelPricing]) =>
|
||||
@@ -191,16 +156,9 @@ export function AdminEditorGenerationPricingPage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pricing.model3d ? (
|
||||
<AdminEditorGenerationModel3dPricingSection
|
||||
model3d={pricing.model3d}
|
||||
onChange={updateModel3dPrice}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving || !canSave}
|
||||
disabled={isSaving}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
@@ -265,3 +223,8 @@ function renderModelPricingCard({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '../api/adminApiClient';
|
||||
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminProfileWalletConfigPageProps {
|
||||
token: string;
|
||||
@@ -197,3 +197,8 @@ export function AdminProfileWalletConfigPage({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
ProfileRechargeProductKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminRechargeProductPageProps {
|
||||
token: string;
|
||||
@@ -546,6 +546,11 @@ function formatPrice(priceCents: number) {
|
||||
return `¥${(priceCents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = parseInteger(value);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value: string) {
|
||||
const parsed = parseInteger(value);
|
||||
return parsed > 0 ? parsed : 0;
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ProfileRedeemCodeMode,
|
||||
} from '../api/adminApiTypes';
|
||||
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
|
||||
import { handlePageError, parsePositiveInteger, splitLines } from './pageUtils';
|
||||
import { handlePageError, splitLines } from './pageUtils';
|
||||
|
||||
interface AdminRedeemCodePageProps {
|
||||
token: string;
|
||||
@@ -422,6 +422,11 @@ export function AdminRedeemCodePage({
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function redeemModeLabel(value: ProfileRedeemCodeMode) {
|
||||
return redeemModes.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
filterAdminTrackingEventKeyOptions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import { handlePageError, parsePositiveInteger } from './pageUtils';
|
||||
import { handlePageError } from './pageUtils';
|
||||
|
||||
interface AdminTaskConfigPageProps {
|
||||
token: string;
|
||||
@@ -571,6 +571,11 @@ export function AdminTaskConfigPage({
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
|
||||
import {
|
||||
buildAdminEditorGenerationPricingRequest,
|
||||
collectInvalidEditorGenerationPricingLabels,
|
||||
editorModel3dAddOnLabel,
|
||||
} from './adminEditorGenerationPricing';
|
||||
import { parsePositiveInteger } from './pageUtils';
|
||||
|
||||
function pricingFixture(): EditorGenerationPricingConfigPayload {
|
||||
return {
|
||||
models: {
|
||||
'gpt-image-2': { unit: 'perGeneration', prices: { '1K': 3, '2K': 5 } },
|
||||
'audio1.0': { unit: 'perGeneration', price: 5 },
|
||||
},
|
||||
model3d: {
|
||||
textToModelPricing: {
|
||||
versionPrices: { 'v3.1-20260211': { noTexture: 8, texture: 16 } },
|
||||
addOnPrices: { hdTexture: 8, unknownAddOn: 7 },
|
||||
},
|
||||
imageToModelPricing: {
|
||||
versionPrices: { 'v3.1-20260211': { noTexture: 16, texture: 24 } },
|
||||
addOnPrices: { hdTexture: 8 },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('数值解析把空串、非数字、小数与 0 收敛成 0', () => {
|
||||
expect(parsePositiveInteger('12')).toBe(12);
|
||||
expect(parsePositiveInteger('')).toBe(0);
|
||||
expect(parsePositiveInteger('abc')).toBe(0);
|
||||
expect(parsePositiveInteger('12abc')).toBe(0);
|
||||
expect(parsePositiveInteger('3.9')).toBe(0);
|
||||
expect(parsePositiveInteger('0')).toBe(0);
|
||||
expect(parsePositiveInteger('-3')).toBe(0);
|
||||
});
|
||||
|
||||
test('未登记的 3D 加价项键回退显示原键名', () => {
|
||||
expect(editorModel3dAddOnLabel('hdTexture')).toBe('高清贴图');
|
||||
expect(editorModel3dAddOnLabel('unknownAddOn')).toBe('unknownAddOn');
|
||||
});
|
||||
|
||||
test('校验覆盖模型档位与 3D 两段的全部数值', () => {
|
||||
expect(collectInvalidEditorGenerationPricingLabels(pricingFixture())).toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
const invalid = pricingFixture();
|
||||
const invalidImagePricing = invalid.models['gpt-image-2'];
|
||||
const invalidTextEndpoint = invalid.model3d?.textToModelPricing;
|
||||
const invalidImageEndpoint = invalid.model3d?.imageToModelPricing;
|
||||
if (
|
||||
!invalidImagePricing?.prices ||
|
||||
!invalidTextEndpoint ||
|
||||
!invalidImageEndpoint
|
||||
) {
|
||||
throw new Error('夹具必须包含图片档位与 3D 两段');
|
||||
}
|
||||
const imageVersionPrice = invalidImageEndpoint.versionPrices['v3.1-20260211'];
|
||||
if (!imageVersionPrice) {
|
||||
throw new Error('夹具必须包含图生 3D 的版本底价');
|
||||
}
|
||||
invalidImagePricing.prices['2K'] = 0;
|
||||
invalidTextEndpoint.addOnPrices.hdTexture = 0;
|
||||
imageVersionPrice.noTexture = 0;
|
||||
expect(collectInvalidEditorGenerationPricingLabels(invalid)).toEqual([
|
||||
'gpt-image-2 2K',
|
||||
'文生 3D 高清贴图',
|
||||
'图生 3D v3.1-20260211 无贴图',
|
||||
]);
|
||||
});
|
||||
|
||||
test('保存请求整段回传两段且不带定价版本,3D 缺失时回传 null', () => {
|
||||
const pricing = pricingFixture();
|
||||
expect(buildAdminEditorGenerationPricingRequest(pricing)).toEqual({
|
||||
models: pricing.models,
|
||||
model3d: pricing.model3d,
|
||||
});
|
||||
expect(
|
||||
buildAdminEditorGenerationPricingRequest({ ...pricing, model3d: null }),
|
||||
).toEqual({
|
||||
models: pricing.models,
|
||||
model3d: null,
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import type {
|
||||
AdminUpsertEditorGenerationPricingRequest,
|
||||
EditorGenerationModel3dEndpointPricingPayload,
|
||||
EditorGenerationModel3dPricingPayload,
|
||||
EditorGenerationPricingConfigPayload,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
/** 3D 端点在两个并列段里的字段名与后台展示名。 */
|
||||
export const EDITOR_MODEL3D_ENDPOINT_SECTIONS = [
|
||||
{ key: 'textToModelPricing', label: '文生 3D' },
|
||||
{ key: 'imageToModelPricing', label: '图生 3D' },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
key: keyof EditorGenerationModel3dPricingPayload;
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 3D 加价项键到中文标签。键来自接口返回,这里只负责展示;遇到未登记的键回退显示原键名,
|
||||
* 不因为标签缺失就把该项漏掉。
|
||||
*/
|
||||
const EDITOR_MODEL3D_ADD_ON_LABELS: Record<string, string> = {
|
||||
hdTexture: '高清贴图',
|
||||
ultraTexture: '超高清贴图',
|
||||
hdGeometry: '高清几何',
|
||||
quadMesh: '四边形网格',
|
||||
smartLowPoly: '智能低面',
|
||||
generateParts: '分件生成',
|
||||
};
|
||||
|
||||
export function editorModel3dAddOnLabel(addOn: string) {
|
||||
return EDITOR_MODEL3D_ADD_ON_LABELS[addOn] ?? addOn;
|
||||
}
|
||||
|
||||
export function isPositiveMudPoints(value: number | null | undefined) {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交前校验:所有泥点数值都必须是不小于 1 的整数,返回不合法项的展示名。
|
||||
*
|
||||
* 后端仍会独立校验一遍;这里只是不把明显非法的 payload 发出去,避免保存后才报错。
|
||||
*/
|
||||
export function collectInvalidEditorGenerationPricingLabels(
|
||||
pricing: EditorGenerationPricingConfigPayload,
|
||||
) {
|
||||
const labels: string[] = [];
|
||||
for (const [model, modelPricing] of Object.entries(pricing.models)) {
|
||||
const tieredPrices = modelPricing.prices;
|
||||
// `prices: {}` 没有档位可校验,不能算「这一项走分层计价」——否则底价会被顺手跳过校验。
|
||||
if (tieredPrices && Object.keys(tieredPrices).length > 0) {
|
||||
for (const [tier, price] of Object.entries(tieredPrices)) {
|
||||
if (!isPositiveMudPoints(price)) {
|
||||
labels.push(`${model} ${tier}`);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!isPositiveMudPoints(modelPricing.price)) {
|
||||
labels.push(`${model} 泥点`);
|
||||
}
|
||||
}
|
||||
|
||||
const model3d = pricing.model3d;
|
||||
if (model3d) {
|
||||
for (const { key, label } of EDITOR_MODEL3D_ENDPOINT_SECTIONS) {
|
||||
// 段与子表都来自接口返回:缺段或缺子表时只跳过这一段,不能让整页校验崩掉。
|
||||
const endpointPricing = model3d[key];
|
||||
if (!endpointPricing) {
|
||||
continue;
|
||||
}
|
||||
for (const [version, price] of Object.entries(
|
||||
endpointPricing.versionPrices ?? {},
|
||||
)) {
|
||||
if (!isPositiveMudPoints(price.noTexture)) {
|
||||
labels.push(`${label} ${version} 无贴图`);
|
||||
}
|
||||
if (!isPositiveMudPoints(price.texture)) {
|
||||
labels.push(`${label} ${version} 带贴图`);
|
||||
}
|
||||
}
|
||||
for (const [addOn, price] of Object.entries(
|
||||
endpointPricing.addOnPrices ?? {},
|
||||
)) {
|
||||
if (!isPositiveMudPoints(price)) {
|
||||
labels.push(`${label} ${editorModel3dAddOnLabel(addOn)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装保存请求:`models` 与 3D 两段整段回传,不带定价版本(保存即覆盖,服务端不比对版本)。
|
||||
* 3D 段缺失时回传 `null`——服务端会以 400 拒绝,后台不允许在这里静默新建或丢弃整段。
|
||||
*/
|
||||
export function buildAdminEditorGenerationPricingRequest(
|
||||
pricing: EditorGenerationPricingConfigPayload,
|
||||
): AdminUpsertEditorGenerationPricingRequest {
|
||||
return {
|
||||
models: pricing.models,
|
||||
model3d: pricing.model3d ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 单个端点段的不可变更新:底价与加价项各改一处,其余原样保留。 */
|
||||
export function updateEditorModel3dEndpointPricing(
|
||||
model3d: EditorGenerationModel3dPricingPayload,
|
||||
section: keyof EditorGenerationModel3dPricingPayload,
|
||||
updater: (
|
||||
current: EditorGenerationModel3dEndpointPricingPayload,
|
||||
) => EditorGenerationModel3dEndpointPricingPayload,
|
||||
): EditorGenerationModel3dPricingPayload {
|
||||
return { ...model3d, [section]: updater(model3d[section]) };
|
||||
}
|
||||
@@ -1,18 +1,5 @@
|
||||
import { formatAdminApiError, isAdminApiError } from '../api/adminApiClient';
|
||||
|
||||
/**
|
||||
* 泥点 / 积分类输入框文本转正整数:空串、非数字、小数、0 与负数一律收敛成 0,
|
||||
* 由各页的提交前校验拦住。
|
||||
*
|
||||
* 用 `Number` 而不是 `parseInt`:`parseInt` 会把 `3.9` 截成 3、`12abc` 截成 12,
|
||||
* 等于把编辑中的半截输入静默当成合法值存下去;再用 `isSafeInteger` 挡掉超出安全整数
|
||||
* 范围的值(那种值在别处会被静默改写)。后台各页统一用这一份,不再各写一遍。
|
||||
*/
|
||||
export function parsePositiveInteger(value: string) {
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
export function handlePageError(
|
||||
error: unknown,
|
||||
onUnauthorized: (message?: string) => void,
|
||||
|
||||
@@ -2972,34 +2972,6 @@ button:disabled {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, 1fr));
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-row-label {
|
||||
color: #5c3a26;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-pricing-model3d-hint {
|
||||
color: #7b5a45;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-pricing-unit {
|
||||
flex: 0 0 auto;
|
||||
border-radius: 999px;
|
||||
@@ -3050,7 +3022,6 @@ button:disabled {
|
||||
.admin-two-column-wide,
|
||||
.admin-accounts-layout,
|
||||
.admin-pricing-grid,
|
||||
.admin-pricing-model3d-row,
|
||||
.admin-form-row,
|
||||
.admin-filter-grid,
|
||||
.admin-table-query-grid,
|
||||
|
||||
+4
-3
@@ -6312,7 +6312,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs-macros 12.0.1",
|
||||
@@ -6333,9 +6334,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
|
||||
@@ -29,10 +29,7 @@ tauri-build = { version = "2.6.2", features = [] }
|
||||
[dependencies]
|
||||
codex-patch-parser = { path = "vendor/codex-patch-parser" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)`
|
||||
# (见 agent/direct_codex_user_item/model.rs),上游修复 #491 未发版,故固定到该 commit;
|
||||
# 等 crates.io 发布 > 12.0.1 后改回 registry 版本。不要改用 `no-serde-warnings` 关告警。
|
||||
ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" }
|
||||
ts-rs = "12.0.1"
|
||||
typed_floats = { version = "1.0.7", features = ["serde"] }
|
||||
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
||||
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||
@@ -69,7 +66,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
@@ -2326,6 +2326,30 @@ fn direct_taonier_reference_matches_local_source(
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn direct_taonier_background_asset_identity(
|
||||
root: &Path,
|
||||
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
|
||||
) -> Option<DirectTaonierArtAssetIdentity> {
|
||||
direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
"scene",
|
||||
expected_reference_source,
|
||||
)
|
||||
.or_else(|| {
|
||||
direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
expected_reference_source,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
|
||||
let Some(art_spec) = direct_taonier_art_asset_identity(
|
||||
root,
|
||||
@@ -2337,14 +2361,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(background) = direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Some(&art_spec),
|
||||
) else {
|
||||
let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else {
|
||||
return false;
|
||||
};
|
||||
let _ = (background, art_spec);
|
||||
@@ -3325,15 +3342,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
|
||||
})
|
||||
.flatten();
|
||||
let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
|
||||
direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Some(art_spec),
|
||||
)
|
||||
.is_some()
|
||||
direct_taonier_background_asset_identity(root, Some(art_spec)).is_some()
|
||||
});
|
||||
let art_spec = match existing_art_spec {
|
||||
Some(identity) => identity,
|
||||
@@ -3409,15 +3418,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
|
||||
}
|
||||
};
|
||||
if mode.regenerates_existing()
|
||||
|| direct_taonier_art_asset_identity(
|
||||
root,
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
Some(&art_spec),
|
||||
)
|
||||
.is_none()
|
||||
|| direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none()
|
||||
{
|
||||
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
|
||||
let outcome = match generate_direct_taonier_art_asset_at(
|
||||
@@ -8294,8 +8295,8 @@ mod tests {
|
||||
vec!["taonier-resource-icon-spec".to_string()],
|
||||
),
|
||||
GameCreationAppAssetKind::Scene => (
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"spec",
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
"scene",
|
||||
vec!["taonier-resource-icon-spec".to_string()],
|
||||
),
|
||||
_ => (
|
||||
@@ -8679,6 +8680,46 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_route_background_registration_still_counts_as_valid_base() {
|
||||
let root = tempfile::tempdir().expect("temp dir");
|
||||
init_local_game_project_at(root.path(), "legacy-background-route", "旧路由背景登记")
|
||||
.expect("init project");
|
||||
std::fs::create_dir_all(root.path().join("assets")).expect("assets dir");
|
||||
register_direct_taonier_art_asset_fixture(
|
||||
root.path(),
|
||||
DIRECT_CODEX_ART_SPEC_ASSET_PATH,
|
||||
GameCreationAppAssetKind::IconSpec,
|
||||
);
|
||||
std::fs::write(
|
||||
root.path().join(DIRECT_CODEX_BACKGROUND_ASSET_PATH),
|
||||
tiny_visible_png(),
|
||||
)
|
||||
.expect("background bytes");
|
||||
register_local_asset_at(
|
||||
root.path(),
|
||||
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
"image/png",
|
||||
"platform-art",
|
||||
GameCreationAppAssetSource {
|
||||
kind: GameCreationAppAssetSourceKind::Canvas,
|
||||
canvas_project_id: Some("taonier-project".to_string()),
|
||||
resource_id: Some("taonier-resource-game-background".to_string()),
|
||||
asset_object_id: Some("taonier-object-game-background".to_string()),
|
||||
task_id: Some("taonier-task-game-background".to_string()),
|
||||
prompt: None,
|
||||
model: Some("gpt-image-2".to_string()),
|
||||
generation_route: Some("/api/external/v1/editor/images/generations".to_string()),
|
||||
generation_kind: Some("spec".to_string()),
|
||||
reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()],
|
||||
},
|
||||
)
|
||||
.expect("register legacy-route background");
|
||||
|
||||
assert!(direct_taonier_art_base_is_valid(root.path()));
|
||||
}
|
||||
|
||||
fn tiny_visible_png() -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
|
||||
|
||||
@@ -2835,14 +2835,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d
|
||||
&& art_spec_asset_type == Some("icon-spec")
|
||||
}
|
||||
GameCreationAppAssetKind::Scene => {
|
||||
snapshot.endpoint == "/api/external/v1/editor/images/generations"
|
||||
&& snapshot.generation_kind == "spec"
|
||||
snapshot.endpoint == "/api/external/v1/editor/scenes/generations"
|
||||
&& snapshot.generation_kind == "scene"
|
||||
&& platform_art_runtime_references_match_request_contract(
|
||||
&snapshot.reference_resource_ids,
|
||||
GameCreationAppAssetKind::Scene,
|
||||
)
|
||||
&& request_asset_kind.as_deref() == Some("game-background")
|
||||
&& art_spec_asset_type == Some("background")
|
||||
&& json_string_field(&request_body, "sceneContent")
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& json_string_field(&request_body, "stylePreset").as_deref() == Some("custom")
|
||||
}
|
||||
GameCreationAppAssetKind::IconSpritesheet => {
|
||||
snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
@@ -3248,6 +3249,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
GameCreationAppAssetKind::Image => "image",
|
||||
GameCreationAppAssetKind::Character => "character",
|
||||
GameCreationAppAssetKind::PublicationMaterial => "publication-material",
|
||||
GameCreationAppAssetKind::Scene => "scene",
|
||||
_ => "spec",
|
||||
};
|
||||
let is_canonical_art_spritesheet =
|
||||
@@ -3291,6 +3293,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else if options.asset_kind == GameCreationAppAssetKind::Scene {
|
||||
(
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
serde_json::json!({
|
||||
"sceneContent": generation_prompt,
|
||||
"stylePreset": "custom",
|
||||
"customStyle": prompt_text!("media.scene_style"),
|
||||
"aspectRatio": options.aspect_ratio,
|
||||
"imageSize": options.image_size,
|
||||
"assetLabel": options.asset_label,
|
||||
"projectId": canvas_context.project_id,
|
||||
"assetFolderId": canvas_context.asset_folder_id,
|
||||
"referenceImageSrcs": references.ordered.clone(),
|
||||
"canvasCompletion": {
|
||||
"title": options.asset_label,
|
||||
"placeholder": external_canvas_placeholder(&options.aspect_ratio),
|
||||
},
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"/api/external/v1/editor/images/generations",
|
||||
@@ -13272,14 +13293,13 @@ mod canvas_generation_tests {
|
||||
let background = write_retained_stage(
|
||||
root,
|
||||
"run-background-canonical-and-user-references",
|
||||
"/api/external/v1/editor/images/generations",
|
||||
"/api/external/v1/editor/scenes/generations",
|
||||
serde_json::json!({
|
||||
"prompt": "保留账本参考合同",
|
||||
"kind": "spec",
|
||||
"assetKind": "game-background",
|
||||
"sceneContent": "保留账本参考合同",
|
||||
"stylePreset": "custom",
|
||||
"customStyle": "原创横屏 Web 游戏场景背景",
|
||||
"projectId": "test-canvas-project",
|
||||
"assetFolderId": "test-asset-folder",
|
||||
"generationInputs": { "artSpec": { "assetType": "background" } },
|
||||
"referenceImageSrcs": ["resource-icon-spec", "user-reference-1"],
|
||||
}),
|
||||
);
|
||||
|
||||
+24
@@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
(generation_kind, reference_resource_ids)
|
||||
}
|
||||
"/api/external/v1/editor/scenes/generations" => {
|
||||
if json_string_field(&request_body, "sceneContent")
|
||||
.is_none_or(|value| value.trim().is_empty())
|
||||
{
|
||||
return Err("External Editor 场景生成账本请求缺少 sceneContent".to_string());
|
||||
}
|
||||
let reference_resource_ids = request_body
|
||||
.get("referenceImageSrcs")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| {
|
||||
"External Editor 场景生成账本请求缺少 referenceImageSrcs".to_string()
|
||||
})?
|
||||
.iter()
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| "External Editor 场景生成账本引用资源 ID 无效".to_string())
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
("scene".to_string(), reference_resource_ids)
|
||||
}
|
||||
"/api/external/v1/editor/icon-spritesheets/generations" => {
|
||||
let reference_resource_id = json_string_field(&request_body, "referenceId")
|
||||
.or_else(|| json_string_field(&request_body, "referenceImageSrc"))
|
||||
|
||||
@@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value
|
||||
endpoint,
|
||||
Some(
|
||||
"/api/external/v1/editor/images/generations"
|
||||
| "/api/external/v1/editor/scenes/generations"
|
||||
| "/api/external/v1/editor/icon-spritesheets/generations"
|
||||
)
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
|
||||
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
|
||||
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
|
||||
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
|
||||
#[cfg(target_os = "linux")]
|
||||
const PROJECT_COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
|
||||
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
|
||||
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
|
||||
@@ -222,14 +224,30 @@ impl ProjectCommandTree {
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
let requested = self.request_owned_group_termination();
|
||||
let _ = child.start_kill();
|
||||
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await;
|
||||
requested?;
|
||||
let waited = tokio::time::timeout_at(deadline, child.wait()).await;
|
||||
waited
|
||||
.map_err(|_| "等待受控命令主进程退出超时")?
|
||||
.map_err(|_| "受控命令主进程退出未确认")?;
|
||||
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Self::Group { pid, .. } = self;
|
||||
// leader 可能与取消同时退出;只要组已停止便无需补发信号。
|
||||
if let Err(error) = wait_linux_project_command_group_exit(*pid, deadline).await {
|
||||
return Err(match requested {
|
||||
Ok(_) => error,
|
||||
Err(request_error) => format!("{request_error};{error}"),
|
||||
});
|
||||
}
|
||||
return Ok("主进程已回收,受控进程组已无活成员;完整子树状态未证明".into());
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
requested?;
|
||||
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,17 +262,39 @@ impl ProjectCommandTree {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Self::Group { pid, .. } = self;
|
||||
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。
|
||||
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。
|
||||
if !linux_project_command_group_has_live_members(*pid)? {
|
||||
return Ok(());
|
||||
}
|
||||
// wait 已回收 leader,不能再用旧 PID 授权发信号。
|
||||
// namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
|
||||
return wait_linux_project_command_group_exit(
|
||||
*pid,
|
||||
tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
self.request_owned_group_termination().map(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn wait_linux_project_command_group_exit(
|
||||
group: u32,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Result<(), String> {
|
||||
while linux_project_command_group_has_live_members(group)? {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(
|
||||
"受控进程组仍有存活成员,退出未确认;不得向身份未确认的进程组补发信号".into(),
|
||||
);
|
||||
}
|
||||
tokio::time::sleep_until(
|
||||
deadline.min(tokio::time::Instant::now() + Duration::from_millis(20)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
|
||||
let inspect = || -> std::io::Result<bool> {
|
||||
@@ -1842,6 +1882,17 @@ where
|
||||
),
|
||||
));
|
||||
}
|
||||
// 目标放行后可能立即退出,必须趁 ready gate 仍持有 launcher 时记录归属。
|
||||
let tree = match ProjectCommandTree::attach(&child) {
|
||||
Ok(tree) => tree,
|
||||
Err(error) => {
|
||||
let termination = terminate_project_command_process_group(&mut child).await;
|
||||
return Err(ProjectCommandError::new(
|
||||
ProjectCommandErrorStage::Preflight,
|
||||
project_command_launch_error_with_termination(error, termination),
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = durable_commit() {
|
||||
let termination = terminate_project_command_process_group(&mut child).await;
|
||||
return Err(ProjectCommandError::new(
|
||||
@@ -1860,12 +1911,7 @@ where
|
||||
// cancelled future must not erase the launch-unknown decision window.
|
||||
let exec = gate.wait_target_exec(Duration::from_secs(3));
|
||||
match exec {
|
||||
Ok(TargetExecState::Established) => {
|
||||
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
|
||||
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
|
||||
})?;
|
||||
Ok(EstablishedProjectCommand { tree, child, gate })
|
||||
}
|
||||
Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { tree, child, gate }),
|
||||
Ok(TargetExecState::Failed { errno }) => {
|
||||
let termination = terminate_project_command_process_group_after_commit(&mut child);
|
||||
Err(ProjectCommandError::new(
|
||||
@@ -1977,7 +2023,11 @@ async fn terminate_project_command_process_group(
|
||||
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
|
||||
let group_result = request_unix_project_command_process_group_termination(process_id);
|
||||
let child_kill_error = child.start_kill().err();
|
||||
let wait_result = child.wait().await;
|
||||
let deadline = tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT;
|
||||
let wait_result = tokio::time::timeout_at(deadline, child.wait())
|
||||
.await
|
||||
.map_err(|_| "等待受控命令主进程退出超时".to_string())?
|
||||
.map_err(|error| error.to_string());
|
||||
if let Err(error) = &group_result {
|
||||
let fallback = match (&child_kill_error, &wait_result) {
|
||||
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
|
||||
@@ -1989,6 +2039,7 @@ async fn terminate_project_command_process_group(
|
||||
return Err(format!("{error};{fallback}"));
|
||||
}
|
||||
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
|
||||
wait_linux_project_command_group_exit(process_id, deadline).await?;
|
||||
Ok(format!(
|
||||
"{}并完成主进程回收",
|
||||
group_result.expect("group termination result checked")
|
||||
@@ -2220,7 +2271,7 @@ where
|
||||
let (exit_code, timed_out, termination_summary) = match wait {
|
||||
ProjectCommandWait::Exited(Ok(status)) => {
|
||||
#[cfg(target_os = "linux")]
|
||||
let _terminal = wait_established_project_command_terminal(gate).await?;
|
||||
let terminal = wait_established_project_command_terminal(gate).await;
|
||||
if let Err(error) = tree.after_main_exit(&mut child).await {
|
||||
stdout_task.abort();
|
||||
stderr_task.abort();
|
||||
@@ -2229,6 +2280,12 @@ where
|
||||
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
|
||||
));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Err(error) = terminal {
|
||||
stdout_task.abort();
|
||||
stderr_task.abort();
|
||||
return Err(error);
|
||||
}
|
||||
(status.code(), false, None)
|
||||
}
|
||||
ProjectCommandWait::Exited(Err(error)) => {
|
||||
@@ -2541,45 +2598,83 @@ mod tests {
|
||||
return;
|
||||
}
|
||||
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
command
|
||||
.args(["-c", "sleep 60 & echo $!; read release"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped());
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = command.spawn().unwrap();
|
||||
let tree = ProjectCommandTree::attach(&child).unwrap();
|
||||
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
|
||||
let mut line = String::new();
|
||||
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
|
||||
.await
|
||||
.unwrap();
|
||||
let descendant: i32 = line.trim().parse().unwrap();
|
||||
drop(child.stdin.take());
|
||||
child.wait().await.unwrap();
|
||||
let live_result = tree.after_main_exit(&mut child).await;
|
||||
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
|
||||
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
descendant as u32,
|
||||
&mut info,
|
||||
libc::WEXITED | libc::WNOWAIT,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
let zombie_result = tree.after_main_exit(&mut child).await;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) },
|
||||
descendant
|
||||
);
|
||||
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理");
|
||||
assert!(error.contains("leader 身份未确认"), "{error}");
|
||||
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对");
|
||||
tree.after_main_exit(&mut child).await.unwrap();
|
||||
struct DescendantGuard(i32);
|
||||
impl Drop for DescendantGuard {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::kill(self.0, libc::SIGKILL);
|
||||
libc::waitpid(self.0, std::ptr::null_mut(), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 正常退出与取消/超时共用的 terminate 都覆盖 leader 已回收的窗口。
|
||||
for terminate in [false, true] {
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
command
|
||||
.args(["-c", "sleep 60 & echo $!; read release"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = command.spawn().unwrap();
|
||||
let tree = ProjectCommandTree::attach(&child).unwrap();
|
||||
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
|
||||
let mut line = String::new();
|
||||
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
|
||||
.await
|
||||
.unwrap();
|
||||
let descendant: i32 = line.trim().parse().unwrap();
|
||||
let descendant_guard = DescendantGuard(descendant);
|
||||
drop(child.stdin.take());
|
||||
child.wait().await.unwrap();
|
||||
let ProjectCommandTree::Group { pid, .. } = &tree;
|
||||
let error = tree
|
||||
.request_owned_group_termination()
|
||||
.expect_err("存活成员缺少 leader 身份时必须拒绝发送信号");
|
||||
assert!(error.contains("leader 身份未确认"), "{error}");
|
||||
let error = wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
|
||||
.await
|
||||
.expect_err("持续存活成员必须在预算用尽时失败");
|
||||
assert!(error.contains("仍有存活成员"), "{error}");
|
||||
{
|
||||
let cleanup = async {
|
||||
if terminate {
|
||||
tree.terminate(&mut child).await.map(|_| ())
|
||||
} else {
|
||||
tree.after_main_exit(&mut child).await
|
||||
}
|
||||
};
|
||||
tokio::pin!(cleanup);
|
||||
// 先 poll 生产清理,确认它确实等待,才让后代进入僵尸态。
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = &mut cleanup => panic!("后代仍存活时提前结束清理:{result:?}"),
|
||||
_ = tokio::task::yield_now() => {}
|
||||
}
|
||||
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
|
||||
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::waitid(
|
||||
libc::P_PID,
|
||||
descendant as u32,
|
||||
&mut info,
|
||||
libc::WEXITED | libc::WNOWAIT,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
cleanup.await.expect("leader 消失后,组成员停止应完成清理");
|
||||
}
|
||||
tree.after_main_exit(&mut child)
|
||||
.await
|
||||
.expect("僵尸不应阻止完成");
|
||||
wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
|
||||
.await
|
||||
.expect("无活成员时不消耗等待预算");
|
||||
drop(descendant_guard);
|
||||
tree.after_main_exit(&mut child).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3426,6 +3521,9 @@ raise SystemExit(code)'
|
||||
.expect("run timeout test");
|
||||
assert!(timed_out.timed_out);
|
||||
assert_eq!(timed_out.status, "failed");
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(timed_out.output.contains("主进程已回收"));
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
assert!(timed_out.output.contains("请求终止受控进程组"));
|
||||
assert!(timed_out.output.contains("不等同完整 OS sandbox"));
|
||||
|
||||
|
||||
@@ -1266,11 +1266,13 @@
|
||||
max-width: calc(100% - 28px);
|
||||
}
|
||||
|
||||
/* 「生成规范」的二级菜单(`bottom:100%+6px`)必须可见:换行与不裁剪由共享工具栏保证
|
||||
(`@genarrative/image-canvas-react` 的 `.genarrative-image-canvas__toolbar` 已从
|
||||
`overflow-x: auto` 改成 `flex-wrap: wrap`),这里只限宽,不再重复覆盖 overflow。 */
|
||||
/* 共享 chrome 的 `.genarrative-image-canvas__toolbar` 是横向滚动条(`overflow-x:auto`),
|
||||
会把它自己弹出的二级菜单(`bottom:100%+6px`)裁掉。这里改成换行、不裁切:
|
||||
「生成规范」的二级菜单必须可见,窄屏用换行兜底而不是横向滚动。 */
|
||||
.game-resource-bottom-toolbar-strip {
|
||||
max-width: 100%;
|
||||
overflow: visible;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.game-resource-bottom-toolbar-action {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user