Add editor generation model pricing admin UI & API

Introduce centralized model pricing for editor generations: add EditorGenerationPricing types and admin API client methods (GET/POST), backend default config and runtime override support, and server route/handler changes to expose admin editor-generation-pricing. Add an AdminEditorGenerationPricingPage component, styles, route entry, icon, and tests, plus a test for admin routes. Update docs and decision log to describe the pricing contract and management workflow. This enables managing per-model pricing (perGeneration / perSecond, flat or tiered) from the admin panel and exposes runtime pricing to the main site.
This commit is contained in:
2026-06-22 16:57:29 +08:00
parent 0c78a6dc21
commit 163b2fa077
59 changed files with 3002 additions and 514 deletions
+22
View File
@@ -25,6 +25,7 @@ import type {
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
EditorGenerationPricingConfigPayload,
ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse,
ProfileRechargeProductConfigAdminListResponse,
@@ -228,6 +229,27 @@ export function upsertAdminPublicWorkInteractions(
);
}
export function getAdminEditorGenerationPricing(token: string) {
return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing',
{ token },
);
}
export function upsertAdminEditorGenerationPricing(
token: string,
payload: EditorGenerationPricingConfigPayload,
) {
return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing',
{
method: 'POST',
token,
body: payload,
},
);
}
export function listAdminWorkVisibility(token: string) {
return request<AdminWorkVisibilityListResponse>(
'/admin/api/works/visibility',
+13
View File
@@ -210,6 +210,19 @@ export interface AdminUpsertPublicWorkInteractionConfigRequest {
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
}
/** 图片画布生成模型泥点定价配置。 */
export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond';
export interface EditorGenerationModelPricingPayload {
unit: EditorGenerationPricingUnitPayload;
price?: number;
prices?: Record<string, number>;
}
export interface EditorGenerationPricingConfigPayload {
models: Record<string, EditorGenerationModelPricingPayload>;
}
/** 后台统一创作工作台契约表单的传输结构。 */
export interface UnifiedCreationSpecPayload {
playId: string;
+7
View File
@@ -23,6 +23,7 @@ import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
@@ -235,6 +236,12 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{routeId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+2
View File
@@ -1,6 +1,7 @@
import {
Bug,
BadgeDollarSign,
Coins,
LayoutDashboard,
LogOut,
Megaphone,
@@ -36,6 +37,7 @@ const routeIcons = {
invite: TicketCheck,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'editor-generation-pricing': Coins,
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
@@ -14,3 +14,17 @@ test('后台入口公告路由可通过导航和 hash 访问', () => {
);
expect(routeHash('creation-announcement')).toBe('#creation-announcement');
});
test('后台模型定价路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-generation-pricing',
label: '模型定价',
hash: '#editor-generation-pricing',
});
expect(resolveAdminRoute('#editor-generation-pricing')).toBe(
'editor-generation-pricing',
);
expect(routeHash('editor-generation-pricing')).toBe(
'#editor-generation-pricing',
);
});
+2
View File
@@ -8,6 +8,7 @@ export type AdminRouteId =
| 'invite'
| 'tasks'
| 'recharge-products'
| 'editor-generation-pricing'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
@@ -28,6 +29,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
@@ -0,0 +1,92 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminEditorGenerationPricing,
upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient';
import type {EditorGenerationPricingConfigPayload} from '../api/adminApiTypes';
import {AdminEditorGenerationPricingPage} from './AdminEditorGenerationPricingPage';
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: {
'gemini-3.1-flash-image-preview': {
unit: 'perGeneration',
prices: {'0.5K': 8, '1K': 12, '2K': 24},
},
'gpt-image-2': {
unit: 'perGeneration',
prices: {'1K': 20, '2K': 40},
},
'seedance2.0-fast': {
unit: 'perSecond',
prices: {'480p': 10, '720p': 20, '1080p': 40},
},
'seedance2.0': {
unit: 'perSecond',
prices: {'480p': 12, '720p': 24, '1080p': 48},
},
'audio1.0': {unit: 'perGeneration', price: 10},
'chirp-v5': {unit: 'perGeneration', price: 5},
},
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminEditorGenerationPricing).mockResolvedValue(pricing);
vi.mocked(upsertAdminEditorGenerationPricing).mockResolvedValue({
...pricing,
models: {
...pricing.models,
'gpt-image-2': {
unit: 'perGeneration',
prices: {'1K': 20, '2K': 58},
},
},
});
});
test('模型定价后台按模型展示单位并保存尺寸定价', async () => {
const user = userEvent.setup();
render(
<AdminEditorGenerationPricingPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
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: '确认'}));
await waitFor(() => {
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
models: expect.objectContaining({
'gpt-image-2': expect.objectContaining({
unit: 'perGeneration',
prices: expect.objectContaining({
'2K': 58,
}),
}),
}),
}),
);
});
});
@@ -0,0 +1,224 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
getAdminEditorGenerationPricing,
upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient';
import type {
EditorGenerationModelPricingPayload,
EditorGenerationPricingConfigPayload,
EditorGenerationPricingUnitPayload,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminEditorGenerationPricingPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const emptyPricing: EditorGenerationPricingConfigPayload = {
models: {},
};
const unitLabels: Record<EditorGenerationPricingUnitPayload, string> = {
perGeneration: '按次',
perSecond: '按秒',
};
export function AdminEditorGenerationPricingPage({
token,
onUnauthorized,
}: AdminEditorGenerationPricingPageProps) {
const [pricing, setPricing] =
useState<EditorGenerationPricingConfigPayload>(emptyPricing);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshPricing();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshPricing() {
setIsLoading(true);
setErrorMessage('');
try {
setPricing(await getAdminEditorGenerationPricing(token));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
const confirmed = await confirmWrite({
action: '保存模型定价',
target: '图片画布生成',
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
setPricing(await upsertAdminEditorGenerationPricing(token, pricing));
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function updateFlatPrice(model: string, value: string) {
setPricing((current) => {
const modelPricing = current.models[model];
if (!modelPricing) {
return current;
}
return {
...current,
models: {
...current.models,
[model]: {
...modelPricing,
price: parsePositiveInteger(value),
},
},
};
});
}
function updateTierPrice(model: string, tier: string, value: string) {
setPricing((current) => {
const modelPricing = current.models[model];
if (!modelPricing) {
return current;
}
return {
...current,
models: {
...current.models,
[model]: {
...modelPricing,
prices: {
...(modelPricing.prices ?? {}),
[tier]: parsePositiveInteger(value),
},
},
},
};
});
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>模型定价</h2>
<p>生成泥点消耗</p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshPricing}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</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]) =>
renderModelPricingCard({
model,
modelPricing,
onFlatChange: updateFlatPrice,
onTierChange: updateTierPrice,
}),
)}
</div>
<button className="admin-primary-button" disabled={isSaving} type="submit">
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存定价'}</span>
</button>
</form>
{confirmDialog}
</section>
);
}
function renderModelPricingCard({
model,
modelPricing,
onFlatChange,
onTierChange,
}: {
model: string;
modelPricing: EditorGenerationModelPricingPayload;
onFlatChange: (model: string, value: string) => void;
onTierChange: (model: string, tier: string, value: string) => void;
}) {
const unitLabel = unitLabels[modelPricing.unit] ?? modelPricing.unit;
return (
<section className="admin-panel admin-pricing-model-card" key={model}>
<div className="admin-pricing-model-heading">
<strong>{model}</strong>
<span className="admin-pricing-unit">{unitLabel}</span>
</div>
{modelPricing.prices ? (
<div className="admin-form-row">
{Object.entries(modelPricing.prices).map(([tier, price]) => (
<label className="admin-field" key={`${model}-${tier}`}>
<span>{tier}</span>
<input
aria-label={`${model} ${tier}`}
min={1}
step={1}
type="number"
value={price}
onChange={(event) => onTierChange(model, tier, event.target.value)}
/>
</label>
))}
</div>
) : (
<label className="admin-field">
<span>泥点</span>
<input
aria-label={`${model} 泥点`}
min={1}
step={1}
type="number"
value={modelPricing.price ?? 0}
onChange={(event) => onFlatChange(model, event.target.value)}
/>
</label>
)}
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
+44
View File
@@ -972,6 +972,49 @@ button:disabled {
box-shadow: 0 2px 8px rgba(112, 57, 30, 0.08);
}
.admin-pricing-grid,
.admin-pricing-field-list,
.admin-pricing-model-list,
.admin-pricing-model-card {
display: grid;
gap: 12px;
}
.admin-pricing-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-pricing-model-card {
border: 1px solid #eaded2;
border-radius: 8px;
background: #fffdf9;
padding: 12px;
}
.admin-pricing-model-card strong {
color: #3d1f10;
overflow-wrap: anywhere;
}
.admin-pricing-model-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.admin-pricing-unit {
flex: 0 0 auto;
border-radius: 999px;
background: #f2e3d7;
color: #7b4a2f;
font-size: 12px;
font-weight: 800;
line-height: 1;
padding: 5px 8px;
}
.admin-bottom-nav {
display: none;
}
@@ -1007,6 +1050,7 @@ button:disabled {
.admin-overview-grid,
.admin-two-column,
.admin-two-column-wide,
.admin-pricing-grid,
.admin-form-row,
.admin-filter-grid,
.admin-table-query-grid,
+2
View File
@@ -25,6 +25,8 @@
`/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,第一版采用前端 JSZip 打包画布中有效图层引用的上传图、生成图和修改结果,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
@@ -51,10 +51,10 @@
## 2026-06-19 图片画布生成按钮价格统一绑定模型定价配置
- 背景:图片画布的生成图片、生成视频、生成规范、生成角色、生成素材、生成 UI、宣发素材、快速编辑、重绘和音频生成入口都在按钮内显示泥点;如果按钮文案、前端提交和后端校验各自写固定数值,后续调整模型价格会出现展示价、提交价和扣费价不一致。
- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动画、图标素材、音效和背景音乐也使用同一函数。后端用 `server-rs/crates/api-server/src/editor_generation_config.rs` 的同名语义配置重新计算并校验 / 扣费。当前正式模型均已覆盖定价:图片类 `nanobanana2`(真实模型 `gemini-3.1-flash-image-preview`)为 12,`gpt-image-2` 为 20;生成规范固定 `gpt-image-2` 为 5;视频按模型和清晰度分档,`seedance2.0-fast` 为 480p 每秒 10 / 720p 每秒 20,`seedance2.0` 为 12 / 24,`kling3.0` 为 15 / 30,`kling3.0-omni` 为 20 / 40,Veo 旧布局兼容价仍为 10 / 20。画板 UI 统一显示 `nanobanana2`,历史输入或旧布局中的 `nano-banana` 必须归一到真实模型 ID 后再提交和计费。
- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config` 和 Lovart 生成类面板文档。
- 验证方式:运行 `npm run test -- src/components/image-editor/ImageCanvasGenerationModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`,并执行 `npm run typecheck`、`npm run check:encoding`、`git diff --check`。
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`。
- 决策:所有画板生成按钮价格必须从 `src/components/image-editor/ImageCanvasGenerationModel.ts` 的模型定价配置函数计算;需要提交 `priceMudPoints` 的视频、角色动作、图标素材、音效和背景音乐也使用同一函数。后端默认配置独立放在 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,运行时 override 默认写入 `.app/editor-generation-pricing.override.json`,可用 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写路径;后台“模型定价”通过 `/admin/api/editor-generation-pricing` 读取和保存完整 `models` 配置,主站通过 `/api/editor/generation-pricing` 动态下发。后端提交校验 / 扣费仍以 `AppState` 当前运行时配置为准,前端内置定价只作为接口失败兜底。模型定价不再按图片 / 规范、视频 / 动作用途拆分,只按模型区分:图片模型按尺寸单次计价,`gemini-3.1-flash-image-preview` 必须配置 `0.5K / 1K / 2K`,`gpt-image-2` 必须配置 `1K / 2K`,规范固定读取 `gpt-image-2` 的 `2K`;视频和角色动作共用视频模型分辨率每秒价格,角色动作仍固定 `seedance2.0-fast`。后台管理页必须显示定价单位“按次 / 按秒”。画板 UI 统一显示 `nanobanana2`,历史输入或旧布局中的 `nano-banana` 必须归一到真实模型 ID 后再提交和计费。
- 影响范围:图片画布生成类面板、生成提交模型、编辑器图片 / 视频 / 音频 BFF、`editor_generation_config`、后台管理端和 Lovart 生成类面板文档。
- 验证方式:运行 `cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_generation_config::tests editor_generation_pricing_route -- --nocapture`、`npx vitest run src/components/image-editor/ImageCanvasGenerationModel.test.ts src/services/image-editor/editorProjectClient.test.ts apps/admin-web/src/pages/AdminEditorGenerationPricingPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`。
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
@@ -15,6 +15,7 @@
| 本地启动、验证、部署、埋点和运营查询 | `docs/【开发运维】本地开发验证与生产运维-2026-05-15.md` |
| 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` |
| UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` |
| 图片画布生成面板与模型定价 | `docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md` |
## 阅读顺序
@@ -26,7 +26,7 @@
## 交互规则
- `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。
- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。
- 右上角缩放控件只展示当前缩放百分比;点击后弹出菜单:放大、缩小、显示画布所有元素、缩放至 50%、缩放至 100%、缩放至 200%。缩放百分比以实际画布 `viewport.scale = 0.5` 作为显示 `100%` 的基准,菜单中的 `50% / 100% / 200%` 分别对应实际 `0.25 / 0.5 / 1`,工程持久化保存和读取仍使用用户可见缩放语义。
- 缩放菜单支持 `Ctrl/Cmd +`、`Ctrl/Cmd -` 和 `Shift + 1`;快捷键只改变 viewport,不修改工程资源。
- 背景色控件只修改编辑器工作区底色,不恢复网格线或棋盘格底纹,也不影响图片本体。
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;拖拽结束后只保存最终图层布局,不保存临时参考线。
@@ -66,7 +66,7 @@
- `PATCH /api/editor/assets/{assetId}`:重命名素材或移动素材到文件夹。
- `DELETE /api/editor/assets/{assetId}`:删除素材。已放入画布的 project resource 不被级联删除,避免旧画布丢图。
- `POST /api/editor/images/generations`:按提示词调用 VectorEngine 生成图片;角色生成可携带 `model`、`aspectRatio`、`imageSize` 和 `referenceImageSrcs`。`nanobanana2` 参考图作为 `inline_data` 进入 `generateContent`,`gpt-image-2` 参考图进入 edits。携带参考图的快速编辑也走该接口,前端必须把参考图源预读成图片 Data URL 后放入 `referenceImageSrcs`;请求可携带 `projectId`、`assetFolderId`、`assetKind`、`generationInputs` 和 `sourceResourceId`,后端生成成功后创建 project resource / 账号素材并在响应中返回 resource / asset 快照。
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置的 `icon` 档位(首版 12 泥点),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。
- `POST /api/editor/icon-spritesheets/generations`:按图标规范图和素材描述数组生成 spritesheet,再由后端切分为独立透明图标。请求支持 `model`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。后端把 spritesheet 和拆分后的 icon 都保存为 project resource / 账号素材,并随响应返回对应快照。
- `POST /api/editor/ui-designs/assets/extractions`:以 UI 设计图 Data URL 作为参考图,固定 `gpt-image-2` 和提示词 `提取画面中的所有独立并整理成spritesheet` 生成素材 spritesheet,再按连通域自动拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,后端保存 spritesheet / 拆分素材并返回对应 resource / asset 快照;前端必须把 spritesheet 原图与拆分素材都加入画布。
- `POST /api/editor/images/edits`:按提示词和当前图片 Data URL 调用 VectorEngine edits,返回新的生成图片元数据;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。
- `POST /api/editor/videos/generations`:按视频描述、模型、比例、时长、分辨率、模式、声音、默认联网搜索标记和泥点价格生成视频。前端可选模型为 `seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni`,默认 `seedance2.0-fast`;后端必须将 `seedance2.0-fast` 映射到 `doubao-seedance-2-0-fast-260128`,将 `seedance2.0` 映射到 `doubao-seedance-2-0-260128`,两者不得混用。后端允许 6 类比例、4 到 15 秒整数、`480p / 720p / 1080p`,并拒绝 `seedance2.0-fast + 1080p`;`sound=on/off` 映射 Ark `generate_audio=true/false`。后端复用 Ark / VectorEngine content generation task 轮询链路,下载最终视频并持久化到 OSS,返回 `videoSrc`、尺寸、prompt、model、provider、taskId、durationSeconds、resolution 和 `priceMudPoints`。
@@ -92,7 +92,7 @@
- 生成中的占位图聚焦后支持键盘 `Delete` / `Backspace` 删除,不新增可见删除按钮;删除后对应异步回写必须按生成器 ID 判空并丢弃,不能把已删除素材重新落回画布。音乐 / 音频生成占位和已生成音频图层同样必须支持键盘删除。
- 生成器快照刷新后必须恢复;待生成、生成中、失败和已生成后跟随成品图层的生成器都不能因为刷新丢失输入、参数、参考图或占位框位置。宣发素材生成器刷新后必须继续显示正确的卡片类型、游戏名、分类、描述和已绑定参考图。
- 画布多选语义必须同时覆盖普通图层和仍显示占位框的生成器对象:Shift 点选或框选可把生成器加入当前选择;拖动任一已选图层或生成器时,所有已选普通图层和生成器占位框同步移动;删除 / Backspace / Delete 作用于完整选择集合,移除所有已选图层和生成器对象。生成器对象在选择集合中使用稳定 `generation-dialog:<id>` 目标 ID,不把生成器伪装成普通图层,也不新增后端表。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。
- 生成类入口打开画布内面板时,底部 AI 工具栏必须保持可见;`生成规范`、角色 / 图标规范来源、角色常规参考图来源这类轻量菜单通过页面级 fixed portal 渲染,不能留在底部工具栏或参考图横向滚动容器内部,避免被局部 `overflow` 裁切。角色规范和常规参考图来源菜单必须向上弹出;常规参考图点击后先选择“从画布中选择”或“上传图片”,从画布取图时只绑定参考图,不触发普通画布图层选中、聚焦、面板隐藏或拖拽逻辑,绑定后退出画布选择状态。所有生成面板参考图槽位统一为方形图标组件;角色规范槽位只显示规范 logo 和 `角色规范` 四字,绑定来源标题只保留给可访问名称、悬浮 title 和图片信息。已有参考图槽位只有在 hover / focus 时显示右上角 `×`,点击后只解绑对应参考图。角色形象生成面板每次成功绑定角色规范后,在当前编辑器生命周期内缓存为上一张角色规范;再次新建角色形象时自动带入该缓存。图标素材和 UI 设计图面板每次成功绑定图标规范后,同样缓存为上一张图标规范;再次新建需要图标规范的素材时自动带入该缓存。生成规范菜单里的图标规范对象自身只把首行参考图作为可选参考,不要求必须先绑定图标规范。
- 生成规范类图片面板底部必须以禁用态参数按钮显示 `16:9·2K` 和 `gpt-image-2`,视觉对齐可编辑面板参数控件,提交到 `/api/editor/images/generations` 时也固定携带这些参数。
- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;参考图请求顺序为额外参考图在前、原图作为隐式最后一张提交,信息面板输入快照只展示用户额外选择的参考图。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
@@ -0,0 +1,77 @@
# 编辑器模型定价配置管理方案
## 背景
图片画布的生成图片、生成规范、生成角色、生成素材、生成 UI、生成视频、角色动作、音效和背景音乐都需要在生成按钮旁展示泥点消耗,并在后端提交时校验 `priceMudPoints`。定价不能散落在前端组件和具体 handler 中,也不能按“图片 / 规范”“视频 / 动作”等用途拆出不同价格事实源。
## 配置来源
- 默认配置文件:`server-rs/crates/api-server/config/editor-generation-pricing.default.json`。
- 运行时覆盖文件:默认 `.app/editor-generation-pricing.override.json`。
- 生产或特殊环境可通过 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 指定可写覆盖文件路径。
默认文件进入 Git,作为空 override 或 override 丢失时的兜底。覆盖文件属于运行态配置,不提交 Git。
## 配置结构
定价只按模型区分,不按用途区分。同一个模型用于多个入口时必须读取同一条模型配置:
- `gpt-image-2` 同时用于普通图片、规范、UI 设计等图片类入口。
- `seedance2.0-fast` 同时用于生成视频和角色动作;角色动作入口仍只允许该模型。
```json
{
"models": {
"gemini-3.1-flash-image-preview": {
"unit": "perGeneration",
"prices": { "0.5K": 8, "1K": 12, "2K": 24 }
},
"gpt-image-2": {
"unit": "perGeneration",
"prices": { "1K": 20, "2K": 40 }
},
"seedance2.0-fast": {
"unit": "perSecond",
"prices": { "480p": 10, "720p": 20, "1080p": 40 }
},
"audio1.0": { "unit": "perGeneration", "price": 10 }
}
}
```
字段规则:
- `unit`:定价单位,`perGeneration` 表示按次,`perSecond` 表示按秒。
- `price`:单一价格,适合音效、背景音乐等单次生成模型。
- `prices`:档位价格,图片模型按尺寸档位配置,视频模型按分辨率配置。
- 生图模型必须补齐支持尺寸:`gemini-3.1-flash-image-preview` 配 `0.5K / 1K / 2K`,`gpt-image-2` 配 `1K / 2K`。
后端保存前校验当前正式模型、必要尺寸和必要分辨率都存在且大于 0。
## 后端契约
- `GET /api/editor/generation-pricing`:主站读取当前模型定价。
- `GET /admin/api/editor-generation-pricing`:后台读取当前模型定价。
- `POST /admin/api/editor-generation-pricing`:后台保存完整模型定价,并写入 override 文件。
后端 `AppState` 启动时加载默认配置和 override。提交视频、角色动作、图标素材、音效和背景音乐时继续以运行时配置重新计算价格,前端传入的 `priceMudPoints` 只作为一致性校验值。普通图片生成和宣发素材扣费也读取同一运行时模型配置。
## 管理端
后台“模型定价”页面按模型统一展示和编辑,不再拆“图片模型 / 规范模型 / 视频模型 / 角色动作”等用途分组。每个模型卡片显示:
- 模型名。
- 定价单位:按次 / 按秒。
- 单价或档位价格;生图模型展示 `0.5K / 1K / 2K` 等尺寸档位,视频模型展示 `480p / 720p / 1080p` 等分辨率档位。
## 前端展示
主站画板启动后调用 `GET /api/editor/generation-pricing`,成功后覆盖前端内置兜底价格并触发重渲染。接口失败时保留内置兜底,避免画板不可用。图片类价格计算必须传入当前模型和 `imageSize`;规范生成固定读取 `gpt-image-2` 的 `2K` 定价。
后续新增模型时必须先补默认 JSON、后端校验、前端兜底和测试,再暴露到模型选择框。
## 验证
- 后端配置解析、override、路由保存与公开读取测试。
- 前端价格读取、运行时覆盖、图片尺寸档位计算测试。
- 管理端模型定价页面单位展示和档位保存测试。
@@ -32,7 +32,7 @@
3. 角色规范参考图组件只展示一个方形参考图图标;图标内上方是规范 logo,下方固定短标 `角色规范`,不再展示绑定状态、来源说明或长标题。已有绑定图片的原始标题只作为可访问名称、悬浮 title 和图片信息回看使用。
4. 参考图图标尽量少文字;必要文字写在图标块内或短标签内,不写规则说明。
5. 已有参考图在鼠标悬停或键盘聚焦到对应参考图槽位时,右上角显示一个 `×` 删除按钮;鼠标不在槽位上时不显示。点击 `×` 只移除该参考图绑定,不触发来源菜单、不删除画布图片。
6. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。
6. 普通参考图支持连续追加:已有图缩略图后始终保留一个 `+` 入口。点击入口只弹出“从画布中选择 / 上传图片”来源选项,不再直接打开系统文件选择器;生成图片、生成视频、角色常规参考图等同类参考图入口都遵循同一交互。角色规范和图标规范引用在浏览器本地分别缓存最近一次成功绑定的对象;再次新建角色形象、图标素材或 UI 设计图时自动带入对应缓存,刷新编辑器后仍可恢复。新建图标规范对象不依赖该图标规范缓存,未选择参考图也可以直接生成。
7. 单文本输入面板不显示文本框标题,用问题式 placeholder:
- 生成图片:`今天想生成什么画面?`
- 生成角色:`你希望角色如何设计?`
@@ -74,8 +74,8 @@
- 本次消耗泥点必须显示在生成按钮内部。
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点`、`生成40泥点`;不使用泥点图标替代文字。
- 画板内所有会提交外部生成任务的生成按钮,价格都必须从模型定价配置函数推导,不允许在按钮文案或提交 payload 中散落固定泥点数字;修改 `ImageCanvasGenerationModel.ts` 与 `api-server/src/editor_generation_config.rs` 的同名定价配置后,按钮展示、前端提交的 `priceMudPoints` 和后端校验 / 扣费应同步变化。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动画走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。
- 泥点配置统一收口到 `api-server` 的编辑器生成配置模块;前端只保留与后端配置同名的展示兜底,后续可接接口动态下发。当前不是运行时动态配置接口,若要后台改价实时影响前端,需要新增配置下发能力。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2` 的 `2K` 定价。
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`,JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整 override 到 `.app/editor-generation-pricing.override.json`(可由 `GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH` 覆盖路径),主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
- 生成图标素材面板提交 `POST /api/editor/icon-spritesheets/generations` 时必须携带 `priceMudPoints`,取同一份 `icon` 计费配置;后端用 `editor_generation_config` 校验,不允许绕过配置继续生成。
- 生成视频、角色动画、音效和背景音乐提交时携带的 `priceMudPoints` 也必须由同一模型配置函数计算,后端按归一后的模型、清晰度、时长或音频模型重新计算并校验。
- `提取素材` 是 UI 设计图工具栏动作,不是面板生成按钮;它固定使用 `gpt-image-2` 和图标素材拆分链路,后端响应里的 `priceMudPoints` 仍必须来自 `editor_generation_config`,不能写死。
@@ -117,22 +117,19 @@
## 第一版计费配置
```text
生成图片 / 生成角色形象 / 生成图标素材 / 生成UI设计图 / 宣发素材 / 快速编辑 / 重绘:nanobanana2 为 12 泥点,gpt-image-2 为 20 泥点
生成规范:固定 gpt-image-2,5 泥点
生成视频:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20,不支持 1080p;seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80
角色动画:480p 每秒 10 泥点,720p 每秒 20 泥点
生成音效:10 泥点
生成背景音乐:5 泥点
图片模型按尺寸单次计价:nanobanana2 / gemini-3.1-flash-image-preview 为 0.5K 8 泥点、1K 12 泥点、2K 24 泥点;gpt-image-2 为 1K 20 泥点、2K 40 泥点
生成规范不再单独定价:固定读取 gpt-image-2 的 2K 定价
视频 / 角色动作按同一视频模型分辨率每秒计价:seedance2.0-fast 为 480p 每秒 10 / 720p 每秒 20 / 1080p 每秒 40(生成视频入口仍禁止 fast 选 1080p);seedance2.0 为 480p 每秒 12 / 720p 每秒 24 / 1080p 每秒 48;kling3.0 为 480p 每秒 15 / 720p 每秒 30 / 1080p 每秒 60;kling3.0-omni 为 480p 每秒 20 / 720p 每秒 40 / 1080p 每秒 80
生成音效:audio1.0 按次 10 泥点
生成背景音乐:chirp-v5 按次 5 泥点
```
当前必须显式覆盖的正式模型定价配置:
当前必须显式覆盖的正式模型定价配置(默认值来自 `editor-generation-pricing.default.json`,运行时可由后台 override 修改):
- 图片类:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)为 12 泥点;`gpt-image-2` 为 20 泥点。
- 规范:`gpt-image-2` 为 5 泥点。
- 视频:`seedance2.0-fast` 为 10 / 20 泥点每秒;`seedance2.0` 为 12 / 24 泥点每秒;`kling3.0` 为 15 / 30 泥点每秒;`kling3.0-omni` 为 20 / 40 泥点每秒。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留 10 / 20 泥点每秒定价配置,但前端模型菜单不展示。
- 角色动画:`seedance2.0-fast`。
- 音效:`audio1.0`。
- 背景音乐:`chirp-v5`。
- 图片模型:`gemini-3.1-flash-image-preview`(UI 显示与历史别名统一为 `nanobanana2`)必须配置 `0.5K / 1K / 2K`;`gpt-image-2` 必须配置 `1K / 2K`。
- 视频模型:`seedance2.0-fast`、`seedance2.0`、`kling3.0`、`kling3.0-omni` 按 `480p / 720p / 1080p` 配每秒泥点。兼容旧布局回放的 `veo3.1`、`veo3.1-fast` 也要保留同样分辨率定价配置,但前端模型菜单不展示。
- 音效:`audio1.0` 按次定价。
- 背景音乐:`chirp-v5` 按次定价。
本次审计未发现当前正式入口缺少定价配置的模型;若后续新增模型,必须先补前端展示配置、前端提交计算、后端校验配置和对应测试,再出现在任一模型选择框中。
@@ -0,0 +1,75 @@
{
"models": {
"gemini-3.1-flash-image-preview": {
"unit": "perGeneration",
"prices": {
"0.5K": 8,
"1K": 12,
"2K": 24
}
},
"gpt-image-2": {
"unit": "perGeneration",
"prices": {
"1K": 20,
"2K": 40
}
},
"seedance2.0-fast": {
"unit": "perSecond",
"prices": {
"480p": 10,
"720p": 20,
"1080p": 40
}
},
"seedance2.0": {
"unit": "perSecond",
"prices": {
"480p": 12,
"720p": 24,
"1080p": 48
}
},
"kling3.0": {
"unit": "perSecond",
"prices": {
"480p": 15,
"720p": 30,
"1080p": 60
}
},
"kling3.0-omni": {
"unit": "perSecond",
"prices": {
"480p": 20,
"720p": 40,
"1080p": 80
}
},
"veo3.1": {
"unit": "perSecond",
"prices": {
"480p": 10,
"720p": 20,
"1080p": 40
}
},
"veo3.1-fast": {
"unit": "perSecond",
"prices": {
"480p": 10,
"720p": 20,
"1080p": 40
}
},
"audio1.0": {
"unit": "perGeneration",
"price": 10
},
"chirp-v5": {
"unit": "perGeneration",
"price": 5
}
}
}
+41
View File
@@ -36,6 +36,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{
api_response::json_success_body,
editor_generation_config::EditorGenerationPricingConfig,
http_error::AppError,
request_context::RequestContext,
state::{AdminRuntime, AppState},
@@ -297,6 +298,31 @@ pub async fn admin_upsert_public_work_interaction_config(
))
}
/// 后台读取画布生成模型定价配置。
pub async fn admin_get_editor_generation_pricing(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, AppError> {
let pricing = state
.editor_generation_pricing()
.map_err(map_admin_editor_generation_pricing_error)?;
Ok(json_success_body(Some(&request_context), pricing))
}
/// 后台保存画布生成模型定价配置,写入运行时 override 文件。
pub async fn admin_upsert_editor_generation_pricing(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(_admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<EditorGenerationPricingConfig>,
) -> Result<Json<Value>, AppError> {
let pricing = state
.save_editor_generation_pricing(payload)
.map_err(map_admin_editor_generation_pricing_error)?;
Ok(json_success_body(Some(&request_context), pricing))
}
pub async fn admin_list_work_visibility(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -433,6 +459,21 @@ fn map_admin_spacetime_error(error: spacetime_client::SpacetimeClientError) -> A
}))
}
fn map_admin_editor_generation_pricing_error(
error: crate::editor_generation_config::EditorGenerationPricingError,
) -> AppError {
let status = match error {
crate::editor_generation_config::EditorGenerationPricingError::Invalid(_) => {
StatusCode::BAD_REQUEST
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
AppError::from_status(status).with_details(serde_json::json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
}
pub async fn require_admin_auth(
State(state): State<AppState>,
mut request: Request,
+154
View File
@@ -4367,6 +4367,160 @@ mod tests {
);
}
#[tokio::test]
async fn public_editor_generation_pricing_route_returns_default_config() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/api/editor/generation-pricing")
.body(Body::empty())
.expect("pricing request should build"),
)
.await
.expect("pricing request should succeed");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("pricing body should collect")
.to_bytes();
let payload: Value = serde_json::from_slice(&body).expect("pricing payload should be json");
assert_eq!(
payload["models"]["gemini-3.1-flash-image-preview"]["prices"]["1K"],
Value::Number(12.into())
);
assert_eq!(
payload["models"]["gpt-image-2"]["prices"]["2K"],
Value::Number(40.into())
);
assert_eq!(
payload["models"]["seedance2.0"]["unit"],
Value::String("perSecond".to_string())
);
assert_eq!(
payload["models"]["seedance2.0"]["prices"]["720p"],
Value::Number(24.into())
);
}
#[tokio::test]
async fn admin_editor_generation_pricing_route_saves_override_and_updates_public_route() {
let temp_dir = std::env::temp_dir().join(format!(
"genarrative-admin-pricing-route-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock should work")
.as_nanos()
));
std::fs::create_dir_all(&temp_dir).expect("temp dir should create");
let mut config = AppConfig::default();
config.admin_username = Some("root".to_string());
config.admin_password = Some("secret123".to_string());
config.editor_generation_pricing_override_path =
temp_dir.join("editor-generation-pricing.override.json");
let app = build_router(AppState::new(config).expect("state should build"));
let admin_token = read_admin_access_token(app.clone()).await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/editor-generation-pricing")
.header("authorization", format!("Bearer {admin_token}"))
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"models": {
"gemini-3.1-flash-image-preview": {
"unit": "perGeneration",
"prices": { "0.5K": 9, "1K": 18, "2K": 36 }
},
"gpt-image-2": {
"unit": "perGeneration",
"prices": { "1K": 31, "2K": 62 }
},
"seedance2.0-fast": {
"unit": "perSecond",
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"seedance2.0": {
"unit": "perSecond",
"prices": { "480p": 13, "720p": 26, "1080p": 52 }
},
"kling3.0": {
"unit": "perSecond",
"prices": { "480p": 16, "720p": 32, "1080p": 64 }
},
"kling3.0-omni": {
"unit": "perSecond",
"prices": { "480p": 21, "720p": 42, "1080p": 84 }
},
"veo3.1": {
"unit": "perSecond",
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"veo3.1-fast": {
"unit": "perSecond",
"prices": { "480p": 11, "720p": 22, "1080p": 44 }
},
"audio1.0": { "unit": "perGeneration", "price": 15 },
"chirp-v5": { "unit": "perGeneration", "price": 9 }
}
})
.to_string(),
))
.expect("pricing save request should build"),
)
.await
.expect("pricing save request should succeed");
assert_eq!(response.status(), StatusCode::OK);
assert!(
temp_dir
.join("editor-generation-pricing.override.json")
.exists()
);
let public_response = app
.oneshot(
Request::builder()
.uri("/api/editor/generation-pricing")
.body(Body::empty())
.expect("public pricing request should build"),
)
.await
.expect("public pricing request should succeed");
let body = public_response
.into_body()
.collect()
.await
.expect("public pricing body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("public pricing payload should be json");
assert_eq!(
payload["models"]["gpt-image-2"]["prices"]["2K"],
Value::Number(62.into())
);
assert_eq!(
payload["models"]["audio1.0"]["unit"],
Value::String("perGeneration".to_string())
);
assert_eq!(
payload["models"]["seedance2.0"]["prices"]["720p"],
Value::Number(26.into())
);
std::fs::remove_dir_all(&temp_dir).expect("temp dir should remove");
}
/// 中文注释:验证入口公告拒绝可执行脚本,避免后台配置变成不受控注入。
#[tokio::test]
async fn admin_creation_entry_banners_route_rejects_script_html() {
@@ -52,6 +52,7 @@ use crate::{
custom_world_asset_prompts::{
build_character_animation_prompt, build_fallback_moderation_safe_animation_prompt,
},
editor_generation_config::EditorGenerationPricingConfig,
http_error::AppError,
platform_errors::map_oss_error,
prompt::role_asset_studio::{
@@ -546,8 +547,18 @@ pub(crate) async fn generate_editor_character_animation_for_owner(
)
})?;
let normalized = normalize_editor_character_animation_request(payload)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let pricing = state.editor_generation_pricing().map_err(|error| {
character_animation_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
let normalized =
normalize_editor_character_animation_request_with_pricing(payload, &pricing)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let settings = require_editor_character_animation_settings(&state, &normalized)
.map_err(|error| character_animation_error_response(&request_context, error))?;
let extraction_settings = resolve_backend_frame_extraction_settings(&state);
@@ -636,7 +647,16 @@ pub(crate) async fn generate_editor_video_for_owner(
)
})?;
let normalized = normalize_editor_video_request(payload)
let pricing = state.editor_generation_pricing().map_err(|error| {
editor_video_error_response(
&request_context,
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
})),
)
})?;
let normalized = normalize_editor_video_request_with_pricing(payload, &pricing)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let settings = require_editor_video_settings(&state, normalized.model.as_str())
.map_err(|error| editor_video_error_response(&request_context, error))?;
@@ -2379,8 +2399,18 @@ fn resolve_character_animation_model(payload: &CharacterAnimationGenerateRequest
normalize_required_text(candidate, CHARACTER_ANIMATION_MODEL)
}
#[cfg(test)]
fn normalize_editor_character_animation_request(
payload: EditorCharacterAnimationGenerateRequest,
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("默认模型定价配置必须合法");
normalize_editor_character_animation_request_with_pricing(payload, &pricing)
}
fn normalize_editor_character_animation_request_with_pricing(
payload: EditorCharacterAnimationGenerateRequest,
pricing: &EditorGenerationPricingConfig,
) -> Result<NormalizedEditorCharacterAnimationRequest, AppError> {
let source_layer_id = normalize_required_text(payload.source_layer_id.as_str(), "");
if source_layer_id.is_empty() {
@@ -2404,12 +2434,11 @@ fn normalize_editor_character_animation_request(
let frame_count = normalize_editor_character_animation_frame_count(payload.frame_count)?;
let duration_seconds =
normalize_editor_character_animation_duration(payload.duration_seconds, frame_count)?;
let expected_price =
crate::editor_generation_config::editor_character_animation_model_mud_points(
Some(payload.model.as_str()),
resolution,
duration_seconds,
);
let expected_price = pricing.character_animation_model_mud_points(
Some(payload.model.as_str()),
resolution,
duration_seconds,
);
if payload.price_mud_points != expected_price {
return Err(editor_character_animation_bad_request(format!(
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。"
@@ -2490,8 +2519,18 @@ fn require_editor_character_animation_settings(
})
}
#[cfg(test)]
fn normalize_editor_video_request(
payload: EditorVideoGenerateRequest,
) -> Result<NormalizedEditorVideoRequest, AppError> {
let pricing = crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
.expect("默认模型定价配置必须合法");
normalize_editor_video_request_with_pricing(payload, &pricing)
}
fn normalize_editor_video_request_with_pricing(
payload: EditorVideoGenerateRequest,
pricing: &EditorGenerationPricingConfig,
) -> Result<NormalizedEditorVideoRequest, AppError> {
let prompt = payload.prompt.trim().chars().take(4000).collect::<String>();
if prompt.is_empty() {
@@ -2510,11 +2549,7 @@ fn normalize_editor_video_request(
"seedance2.0-fast 不支持 1080p 清晰度。",
));
}
let expected_price = crate::editor_generation_config::editor_video_model_generation_mud_points(
Some(model),
resolution,
duration_seconds,
);
let expected_price = pricing.video_model_mud_points(Some(model), resolution, duration_seconds);
if payload.price_mud_points != expected_price {
return Err(editor_video_bad_request(format!(
"priceMudPoints 与分辨率和时长不一致,应为 {expected_price}。"
+34
View File
@@ -50,6 +50,7 @@ pub struct AppConfig {
pub wallet_refund_outbox_batch_size: usize,
pub wallet_refund_outbox_flush_interval: Duration,
pub wallet_refund_outbox_max_bytes: u64,
pub editor_generation_pricing_override_path: PathBuf,
pub log_filter: String,
pub otel_enabled: bool,
pub admin_username: Option<String>,
@@ -270,6 +271,8 @@ impl Default for AppConfig {
wallet_refund_outbox_batch_size: 100,
wallet_refund_outbox_flush_interval: Duration::from_millis(1_000),
wallet_refund_outbox_max_bytes: 64 * 1024 * 1024,
editor_generation_pricing_override_path:
crate::editor_generation_config::default_editor_generation_pricing_override_path(),
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
admin_username: None,
@@ -443,6 +446,11 @@ impl AppConfig {
{
config.log_filter = log_filter;
}
if let Some(pricing_override_path) =
read_first_non_empty_env(&["GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH"])
{
config.editor_generation_pricing_override_path = PathBuf::from(pricing_override_path);
}
if let Some(listen_backlog) =
read_first_positive_i32_env(&["GENARRATIVE_API_LISTEN_BACKLOG"])
{
@@ -2042,4 +2050,30 @@ mod tests {
std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED");
}
}
#[test]
fn from_env_reads_editor_generation_pricing_override_path() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH");
std::env::set_var(
"GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH",
"server-rs/.data/editor-pricing.json",
);
}
let config = AppConfig::from_env();
assert_eq!(
config.editor_generation_pricing_override_path,
std::path::PathBuf::from("server-rs/.data/editor-pricing.json")
);
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -494,6 +494,20 @@ pub async fn load_recent_editor_project(
))
}
/// 公开读取画布生成模型定价;用于前端按钮泥点展示,后端仍会在提交时重新校验。
pub async fn get_editor_generation_pricing(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<Value>, AppError> {
let pricing = state.editor_generation_pricing().map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?;
Ok(json_success_body(Some(&request_context), pricing))
}
pub async fn list_editor_projects(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -989,10 +1003,18 @@ pub(crate) async fn generate_editor_image_for_owner(
} else {
image_size
};
let configured_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(
let configured_price_mud_points = state
.editor_generation_pricing()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
Some(generation_options.image_size),
);
let generate_operation = async {
if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 {
@@ -1432,8 +1454,10 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
payload.image_size.as_deref(),
);
let expected_price_mud_points = validate_editor_icon_spritesheet_price(
state,
payload.price_mud_points,
Some(generation_options.model),
Some(generation_options.image_size),
)?;
let size = generation_options.size.as_str();
let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions);
@@ -1722,10 +1746,15 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner(
model: GPT_IMAGE_2_MODEL.to_string(),
provider: "VectorEngine",
task_id: generated.task_id,
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
Some(GPT_IMAGE_2_MODEL),
),
price_mud_points: state
.editor_generation_pricing()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), Some(GPT_IMAGE_2_MODEL), Some("1K")),
spritesheet_resource: spritesheet_record.resource,
spritesheet_asset: spritesheet_record.asset,
},
@@ -2256,11 +2285,20 @@ fn editor_icon_spritesheet_bad_request(message: impl Into<String>) -> AppError {
}
fn validate_editor_icon_spritesheet_price(
state: &AppState,
price_mud_points: u32,
model: Option<&str>,
image_size: Option<&str>,
) -> Result<u32, AppError> {
let expected_price_mud_points =
crate::editor_generation_config::editor_image_generation_mud_points(Some("icon"), model);
let expected_price_mud_points = state
.editor_generation_pricing()
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(Some("icon"), model, image_size);
if price_mud_points != expected_price_mud_points {
return Err(editor_icon_spritesheet_bad_request(format!(
"priceMudPoints 与素材生成计费配置不一致,应为 {expected_price_mud_points}。"
@@ -2562,6 +2600,7 @@ pub(crate) fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppConfig;
#[test]
fn editor_image_generation_size_keeps_quick_edit_canvas_ratio_presets() {
@@ -3158,16 +3197,22 @@ mod tests {
#[test]
fn editor_icon_spritesheet_request_price_uses_generation_config() {
let state = AppState::new(AppConfig::default()).expect("state should build");
assert_eq!(
validate_editor_icon_spritesheet_price(12, Some("gemini-3.1-flash-image-preview"))
.unwrap(),
validate_editor_icon_spritesheet_price(
&state,
12,
Some("gemini-3.1-flash-image-preview"),
Some("1K")
)
.unwrap(),
12
);
assert_eq!(
validate_editor_icon_spritesheet_price(20, Some("gpt-image-2")).unwrap(),
validate_editor_icon_spritesheet_price(&state, 20, Some("gpt-image-2"), Some("1K")).unwrap(),
20
);
let error = validate_editor_icon_spritesheet_price(5, Some("gpt-image-2"))
let error = validate_editor_icon_spritesheet_price(&state, 5, Some("gpt-image-2"), Some("1K"))
.expect_err("wrong icon spritesheet price should fail");
assert!(error.body_text().contains("priceMudPoints"));
let response = EditorIconSpritesheetGenerationResponse {
@@ -3190,6 +3235,7 @@ mod tests {
price_mud_points: crate::editor_generation_config::editor_image_generation_mud_points(
Some("icon"),
None,
Some("1K"),
),
spritesheet_resource: None,
spritesheet_asset: None,
@@ -5,10 +5,11 @@ use axum::{
use crate::{
admin::{
admin_debug_http, admin_get_creation_entry_config, admin_list_database_table_rows,
admin_list_database_tables, admin_list_tracking_events, admin_list_work_visibility,
admin_login, admin_me, admin_overview, admin_update_work_visibility,
admin_upsert_creation_entry_config, admin_upsert_creation_entry_event_banners_config,
admin_debug_http, admin_get_creation_entry_config, admin_get_editor_generation_pricing,
admin_list_database_table_rows, admin_list_database_tables, admin_list_tracking_events,
admin_list_work_visibility, admin_login, admin_me, admin_overview,
admin_update_work_visibility, admin_upsert_creation_entry_config,
admin_upsert_creation_entry_event_banners_config, admin_upsert_editor_generation_pricing,
admin_upsert_public_work_interaction_config, require_admin_auth,
},
runtime_profile::{
@@ -87,6 +88,15 @@ pub fn router(state: AppState) -> Router<AppState> {
middleware::from_fn_with_state(state.clone(), require_admin_auth),
),
)
.route(
"/admin/api/editor-generation-pricing",
get(admin_get_editor_generation_pricing)
.post(admin_upsert_editor_generation_pricing)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/works/visibility",
get(admin_list_work_visibility)
@@ -12,9 +12,9 @@ use crate::{
create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder,
delete_editor_project, edit_editor_image, extract_editor_ui_design_assets,
generate_editor_icon_spritesheet, generate_editor_image, get_editor_asset_library,
get_editor_project, list_editor_projects, load_recent_editor_project,
rename_editor_project, save_editor_project_layout, update_editor_asset,
update_editor_asset_folder,
get_editor_generation_pricing, get_editor_project, list_editor_projects,
load_recent_editor_project, rename_editor_project, save_editor_project_layout,
update_editor_asset, update_editor_asset_folder,
},
state::AppState,
};
@@ -23,6 +23,10 @@ const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
pub fn router(state: AppState) -> Router<AppState> {
Router::new()
.route(
"/api/editor/generation-pricing",
get(get_editor_generation_pricing),
)
.route(
"/api/editor/projects/recent",
get(load_recent_editor_project).route_layer(middleware::from_fn_with_state(
+22
View File
@@ -39,6 +39,9 @@ use tokio::sync::{Semaphore, broadcast};
use tracing::{info, warn};
use crate::config::AppConfig;
use crate::editor_generation_config::{
EditorGenerationPricingConfig, EditorGenerationPricingError, EditorGenerationPricingStore,
};
use crate::puzzle_gallery_cache::PuzzleGalleryCache;
use crate::tracking_outbox::TrackingOutbox;
use crate::wallet_refund_outbox::WalletRefundOutbox;
@@ -265,6 +268,7 @@ pub struct AppStateInner {
puzzle_gallery_cache: PuzzleGalleryCache,
tracking_outbox: Option<Arc<TrackingOutbox>>,
wallet_refund_outbox: Option<Arc<WalletRefundOutbox>>,
editor_generation_pricing_store: EditorGenerationPricingStore,
llm_client: Option<LlmClient>,
creative_agent_gpt5_client: Option<LlmClient>,
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
@@ -410,6 +414,10 @@ impl AppState {
let tracking_outbox = TrackingOutbox::from_config(&config, spacetime_client.clone());
let wallet_refund_outbox =
WalletRefundOutbox::from_config(&config, spacetime_client.clone());
let editor_generation_pricing_store = EditorGenerationPricingStore::load(
config.editor_generation_pricing_override_path.clone(),
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let llm_client = build_llm_client(&config)?;
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
@@ -446,6 +454,7 @@ impl AppState {
puzzle_gallery_cache: PuzzleGalleryCache::new(),
tracking_outbox,
wallet_refund_outbox,
editor_generation_pricing_store,
llm_client,
creative_agent_gpt5_client,
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
@@ -472,6 +481,19 @@ impl AppState {
self.http_request_permit_pools.clone()
}
pub(crate) fn editor_generation_pricing(
&self,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
self.editor_generation_pricing_store.snapshot()
}
pub(crate) fn save_editor_generation_pricing(
&self,
next: EditorGenerationPricingConfig,
) -> Result<EditorGenerationPricingConfig, EditorGenerationPricingError> {
self.editor_generation_pricing_store.save_override(next)
}
pub fn is_ready(&self) -> bool {
self.ready.load(Ordering::Acquire)
}

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