feat: add yuanjie pricing sync
This commit is contained in:
@@ -133,6 +133,7 @@ All routes in this section require admin unless noted.
|
||||
| POST | `/api/admin/system-apis/smart-import` | `src/app/api/admin/system-apis/smart-import/route.ts` | Admin-only intelligent Manifest import. Creates one global `system_api_configs` row per imported profile/model, resolves the visible API request URL from the Manifest profile/provider, rejects configs without a resolvable relay API request URL, writes `system-api-manifests/<systemApiId>.json`, and leaves API Key as `待填写` for admin review. Optional `profile.capabilities` is returned through system model config for selected-model image option filtering. Imported rows also carry platform-default visibility, membership-tier allowlist, and default polling fields. |
|
||||
| GET | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in image/video template preview retained for the system-default-model template path, not for the `智能配置 API` UI. Returns `capabilitiesText`, image templates from `src/lib/yuanjie-image-model-templates.ts`, and video templates from `src/lib/yuanjie-video-model-templates.ts`; it does not call 元界 `/v1/skills` or `/v1/skills/guide`. |
|
||||
| POST | `/api/admin/system-apis/yuanjie-capabilities` | `src/app/api/admin/system-apis/yuanjie-capabilities/route.ts` | Admin-only 元界 AI built-in installer retained for system-default-model template management, not for the generic smart import UI. `{ syncModels: true }` resets only `provider = '元界 AI' AND type = 'image'` rows and installs 17 inactive image rows. `{ syncVideoModels: true }` resets only `provider = '元界 AI' AND type = 'video'` rows and installs inactive video rows with `videoUsageModes`. Rows have no API Key by default; admins must edit each model to set Key, pricing, visibility/member scope, polling, usage mode, and enable it before users can generate. |
|
||||
| GET/POST | `/api/admin/system-apis/yuanjie-pricing` | `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` | Admin-only manual 元界 AI pricing sync. GET previews built-in pricing targets from `src/lib/yuanjie-pricing-sync.ts`. POST updates only existing `provider = '元界 AI'` image/video system API rows by `model_name`, synchronizing `billing_mode` and the 元界计费同步 `price_note` while preserving administrator-entered numeric prices and non-元界 providers such as mozheAPI. Optional body `{ type: "image"|"video" }` limits the sync. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/model-recommendations` | `src/app/api/admin/model-recommendations/route.ts` | Managed model recommendations. All methods require admin bearer auth. |
|
||||
| GET/DELETE | `/api/admin/generation-jobs` | `src/app/api/admin/generation-jobs/route.ts` | Admin task listing and deletion. |
|
||||
| GET | `/api/admin/gallery/works` | `src/app/api/admin/gallery/works/route.ts` | Admin public gallery work listing for prompt moderation. |
|
||||
@@ -176,6 +177,8 @@ Primary SQL tables touched directly in API routes include:
|
||||
|
||||
`src/lib/yuanjie-video-model-templates.ts` is the canonical source for built-in 元界 AI video model definitions from the local video docs. It maps each model to a Manifest request body, records whether the model supports 文生视频 and/or 图生视频, and stores aspect ratio, resolution, duration, and quality/mode capability options for the selected system video model. Video templates use the same documented `is_final` plus `state` polling rule.
|
||||
|
||||
`src/lib/yuanjie-pricing-sync.ts` is the canonical source for manual 元界 AI pricing metadata sync. It derives billing modes from the built-in image/video templates and local docs: image models default to fixed per-use pricing, duration-sensitive video models sync to `duration`, Seedance token-billed video models sync to `token`, and special variable-cost video models sync to `ratio` with a warning note. The sync is manual from the admin system-default-model page and only updates existing 元界 rows.
|
||||
|
||||
Yuanjie Manifest references use `$inputImages.urls` for provider-facing JSON fields. For image-to-image, `/api/generate/image` reads the primary `image` plus `extraImages` and sends all references to `src/lib/user-api-manifest-executor.ts`; the executor uploads data URL references into storage before rendering Yuanjie `params.images`, top-level `images`, or `base64Array`. Yuanjie video templates keep documented model-specific fields inside `src/lib/yuanjie-video-model-templates.ts`, including first/last reference fields and mode fields such as `input_reference`, `reference_urls`, `img_url`, `image_tail`, `ratio`, `size`, and `generation_mode`.
|
||||
|
||||
Profile naming convention: `profiles.nickname` is the stable login username; `profiles.display_nickname` is the public nickname shown in navbar/gallery/profile UI. APIs return `username` plus `nickname`/`display_nickname` so older clients can keep reading `nickname` as the display name.
|
||||
|
||||
@@ -161,7 +161,7 @@ At generation time, `src/lib/server-api-config.ts` returns `manifestPath` for us
|
||||
|
||||
Manifest template rendering exposes input images in two forms: `$inputImages.dataUrls` keeps the raw uploaded data for multipart/file manifests, while `$inputImages.urls` is normalized for providers that require URL references. The executor converts data URL references to storage-backed public URLs before rendering JSON templates, using object-storage signed URLs when available or the app public base URL plus `/api/local-storage/<key>` otherwise.
|
||||
|
||||
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If a system image supplier fails because a stream request idles until Cloudflare 524, `/api/generate/image` retries that candidate once with `stream:false`; 502/503/504 gateway responses are retried once by the shared transport. If every supplier still fails or returns no usable result, the route returns the last actionable upstream error when available, otherwise the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
|
||||
Admin system intelligent API imports live in `src/components/admin/api-management-tab.tsx` and `src/app/api/admin/system-apis/smart-import/route.ts`. The `智能配置 API` section is generic Manifest import only: each imported profile/model becomes one global `system_api_configs` row with its own `manifest_path`, backed by `system-api-manifests/<systemApiId>.json`, and the visible `api_url` is resolved from the Manifest profile/provider. Incomplete configs without a resolvable relay API request URL are rejected. Optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for system models. Provider-specific built-in templates such as 元界 AI are not exposed in this smart import UI; 元界 definitions remain in `src/lib/yuanjie-image-model-templates.ts` and `src/lib/yuanjie-video-model-templates.ts` for the system-default-model management path, where admins configure each model row's Key, pricing/member visibility/polling, `video_usage_modes`, and enablement before it is available to users. 元界 price and billing metadata sync is also provider-specific and manual: `/api/admin/system-apis/yuanjie-pricing` uses `src/lib/yuanjie-pricing-sync.ts` to update only existing `provider = '元界 AI'` rows with derived billing mode and price notes, preserving API keys, Manifest paths, mozheAPI rows, and administrator-entered numeric prices. Admin Manifest files must remain separate from user-level files and must keep using the system pricing/credit deduction policy for the selected model. System API rows also own `is_default`, `allowed_membership_tiers`, `polling_mode`, and `polling_order`; `/api/model-config` returns only one active platform-default row per allowed media type plus admin display name so the create page shows a single default model label, and image generation expands the selected row back into all allowed supplier candidates with the same display name. The upstream `model_name` can differ between suppliers and is only used as that supplier's request model. Video model billing supports per-use count (`fixed`), per-second duration (`duration_price_per_second`), and token mode. Token billing prices shown in the admin console are credits per 1M tokens for both input and output; older storage/API field names containing `1k` remain compatibility names and must not be shown to admins as per-K pricing. If a system image supplier fails because a stream request idles until Cloudflare 524, `/api/generate/image` retries that candidate once with `stream:false`; 502/503/504 gateway responses are retried once by the shared transport. If every supplier still fails or returns no usable result, the route returns the last actionable upstream error when available, otherwise the generic model-busy message. This polling fallback is only for admin default system models and must not be applied to user custom API keys.
|
||||
|
||||
After production migration, app runtime tables in `public` should be owned by the app DB user from `LOCAL_DB_URL`. Runtime compatibility helpers use `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` and index creation; if restored tables remain owned by `postgres`, public routes such as `/api/model-config`, profile refresh, or generation jobs can fail with `must be owner of table ...`.
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
|
||||
- Yuanjie image and video templates that accept reference media should render provider-facing references with `$inputImages.urls`, not `$inputImages.dataUrls`. The Manifest executor uploads data URL references into storage first, then exposes object/local-storage public URLs as `$inputImages.urls` while keeping raw data URLs available as `$inputImages.dataUrls` for multipart/file-upload manifests.
|
||||
- Yuanjie GPT Image 2 / GPT Image 2 official-transfer image-to-image must pass all frontend references. `src/components/create/image-to-image.tsx` sends the primary image as `image` and additional references as `extraImages`; `src/app/api/generate/image/route.ts` must normalize them into the Manifest `inputImages` array before calling `src/lib/user-api-manifest-executor.ts`.
|
||||
- Yuanjie video templates must stay in `src/lib/yuanjie-video-model-templates.ts` and map documented model-specific fields there instead of changing generic mozheAPI/custom-API request builders. Current documented special mappings include `sora-2.params.input_reference`, `wan2.6-cankaosheng.params.reference_urls`, `wan2.6-shouzheng.params.img_url`, `kling-v3-omni-shouweizhen.params.image/image_tail`, `happyhorse-r2v.params.ratio`, `grok-video-3.params.size`, and `veo3.1.params.generation_mode/enhance_prompt/enable_upsample`.
|
||||
- 元界价格/计费方式同步 is manual only. Admins trigger it from the `系统默认模型` provider view; the route is `/api/admin/system-apis/yuanjie-pricing` and the logic lives in `src/lib/yuanjie-pricing-sync.ts`. It updates only existing `provider = '元界 AI'` image/video rows, synchronizing `billing_mode` and a `元界计费同步` price note from local built-in template metadata. It must not delete, create, or rewrite mozheAPI rows, generic smart-import rows, API keys, Manifest paths, or administrator-entered numeric prices.
|
||||
|
||||
## mozheAPI
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Custom API transport | `src/lib/custom-api-fetch.ts`, `src/lib/custom-image-fallback.ts` | Headers, one retry for 502/503/504 gateway failures, progress JSON parsing, upstream error parsing, stream-to-sync fallback policy for system image APIs. |
|
||||
| Server API resolution | `src/lib/server-api-config.ts` | Resolves user custom API and admin system API IDs into decrypted credentials, enforces system API default visibility plus membership-tier allowlists before generation, and builds default-model polling candidates by media type plus admin display name (`system_api_configs.name`). The upstream `model_name` remains the per-provider request model only. |
|
||||
| User API smart import | `src/components/profile/api-key-manager.tsx`, `src/app/api/user-api-keys/smart-import/route.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts`, `src/lib/model-display.ts` | The profile API settings page has an `智能配置 API` button next to `添加 API 密钥`. It opens a wide viewport-capped Manifest editor, can copy the LLM prompt, shows guidance under the prompt button explaining the copy-to-chat-AI and paste-and-import flow, can paste clipboard JSON without importing, and can paste-and-import in one action. The prompt instructs the LLM to stop and ask the user for the relay API Base URL when the docs do not contain it. Imports create each profile/model as an independent `user_api_keys` row plus a separate `user-api-manifests/<userId>/<keyId>.json` file and reject incomplete configs without a resolvable request URL. Imported rows should store a human-readable provider name in the editable provider/supplier fields and resolve the visible API request URL from `profile.baseUrl + submit.path` for synchronous endpoints. Generic placeholder notes such as `导入的 API Key` must not be used as model labels; creation/profile UI should prefer a real note plus model, or provider plus model. Optional `profile.capabilities` filters or hides create-page aspect ratio, resolution, image format, and quality controls for the selected model. Polling Manifest query values can include `{task_id}` so task IDs are sent as real query parameters rather than being embedded into pathname strings. Generation routes must use the selected model key's `manifest_path`; do not merge different request configs under one user-level file. |
|
||||
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. |
|
||||
| Admin system API smart import | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/smart-import/route.ts`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/lib/user-api-manifest.ts`, `src/lib/user-api-manifest-executor.ts`, `src/lib/model-capabilities.ts` | The console API management page has a separate `智能配置 API` section for admins, but this section is generic Manifest import only. It supports copy-to-chat-AI and paste-and-import Manifest flow, then creates one independent system API row and `system-api-manifests/<systemApiId>.json` file per imported profile/model. Imported rows resolve the visible API request URL from the Manifest profile/provider before save, and optional `profile.capabilities` can constrain or hide create-page image/video parameter choices for the selected system model. Provider-specific built-in template management, including 元界 AI, belongs in the `系统默认模型` management flow and should not be exposed in the smart import UI. 元界价格/计费方式手动同步 uses `src/app/api/admin/system-apis/yuanjie-pricing/route.ts` and `src/lib/yuanjie-pricing-sync.ts`; it updates only existing `provider = '元界 AI'` image/video rows and leaves mozheAPI/global smart-import configs untouched. |
|
||||
| Admin console active page persistence | `src/modules/console/pages/console-dashboard-page.tsx` | The console active view is stored in `sessionStorage`, so browser refresh keeps the current admin page/tab. Logout clears the value, and closing/reopening the console starts from the dashboard because `sessionStorage` is tab-scoped. |
|
||||
| Manifest input image URLs | `src/lib/user-api-manifest-executor.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Manifest templates can use `$inputImages.dataUrls` for raw uploaded data and `$inputImages.urls` for provider-facing public references. The executor converts data URL input images into storage-backed URLs before rendering templates. Image-to-image generation normalizes the primary `image` plus `extraImages` into Manifest `inputImages`, so multi-reference providers such as Yuanjie GPT Image 2 receive all references. |
|
||||
|
||||
@@ -90,7 +90,7 @@ Use this document to jump directly to code before broad searching.
|
||||
| Public model config API | `src/app/api/model-config/route.ts`, `src/app/api/style-presets/route.ts` | Returns model/provider config plus DB-backed image style presets for clients. |
|
||||
| User custom API keys | `src/lib/custom-api-store.ts`, `src/app/api/user-api-keys/route.ts`, `src/components/profile/api-key-manager.tsx` | User-owned encrypted API credentials. |
|
||||
| Admin provider presets | `src/app/api/admin/providers/route.ts`, `src/components/admin/api-management-tab.tsx` | Provider registry, default API URL/model/type. Reads and mutations require admin bearer auth; the admin tab must send `Authorization` for the initial list fetch too. |
|
||||
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. Video models can be priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
|
||||
| Admin system API configs | `src/components/admin/api-management-tab.tsx`, `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts` | Encrypted shared system API credentials, pricing metadata, platform-default visibility, per-model membership-tier allowlists, and default-model polling fields (`polling_mode`, `polling_order`). The admin list browses system models by provider, then model type, then individual model rows for editing. When browsing 元界 AI, admins can manually click `同步元界价格` to call `/api/admin/system-apis/yuanjie-pricing`, which syncs billing mode and a 元界 pricing note from built-in template metadata without overwriting manually entered numeric prices. Video models can be priced by per-use count (`fixed`), per-second duration (`duration` using `duration_price_per_second`), or token mode. Token billing input/output prices are configured as credits per 1M tokens in the console UI; the `input_price_per_1k`/`output_price_per_1k` DB/API field names are legacy-compatible storage names only. |
|
||||
| Model recommendations | `src/app/api/admin/model-recommendations/route.ts`, `src/components/admin/api-management-tab.tsx` | Admin-controlled displayed/recommended model lists. Reads and mutations require admin bearer auth. |
|
||||
|
||||
## Profile, Credits, Orders
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
|
||||
"test:yuanjie-media-manifest-mapping": "tsx ./scripts/test-yuanjie-media-manifest-mapping.mjs",
|
||||
"test:yuanjie-image2-persistence": "tsx ./scripts/test-yuanjie-image2-persistence.mjs",
|
||||
"test:yuanjie-pricing-sync": "tsx ./scripts/test-yuanjie-pricing-sync.mjs",
|
||||
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
|
||||
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
|
||||
"pm2:save": "pm2 save",
|
||||
|
||||
136
scripts/test-yuanjie-pricing-sync.mjs
Normal file
136
scripts/test-yuanjie-pricing-sync.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
|
||||
const {
|
||||
YUANJIE_PROVIDER_NAME,
|
||||
} = await import('../src/lib/yuanjie-image-model-templates.ts');
|
||||
const {
|
||||
getYuanjiePricingSyncTargets,
|
||||
mergeYuanjiePricingNote,
|
||||
syncYuanjiePricingMetadata,
|
||||
} = await import('../src/lib/yuanjie-pricing-sync.ts');
|
||||
|
||||
async function runTest(name, fn) {
|
||||
try {
|
||||
await fn();
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`FAIL ${name}`);
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8');
|
||||
}
|
||||
|
||||
function createFakeClient(rows) {
|
||||
const calls = [];
|
||||
const client = {
|
||||
calls,
|
||||
async query(sql, params = []) {
|
||||
const text = String(sql);
|
||||
calls.push({ sql: text, params });
|
||||
if (text.includes('FROM system_api_configs')) {
|
||||
return { rows };
|
||||
}
|
||||
if (text.includes('UPDATE system_api_configs')) {
|
||||
return { rows: [{ id: params.at(-2) || params.at(-1) }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
await runTest('builds Yuanjie pricing sync targets from built-in image and video templates', () => {
|
||||
const targets = getYuanjiePricingSyncTargets();
|
||||
assert.ok(targets.length >= 40, 'expected image and video templates to be represented');
|
||||
|
||||
const gptImage2 = targets.find(item => item.modelName === 'gpt-image-2');
|
||||
assert.ok(gptImage2, 'missing GPT Image 2 pricing target');
|
||||
assert.equal(gptImage2.type, 'image');
|
||||
assert.equal(gptImage2.billingMode, 'fixed');
|
||||
assert.match(gptImage2.priceNote, /元界计费同步/);
|
||||
assert.match(gptImage2.priceNote, /cost/);
|
||||
|
||||
const seedanceToken = targets.find(item => item.modelName === 'kwvideo-v2-ref');
|
||||
assert.ok(seedanceToken, 'missing Seedance token pricing target');
|
||||
assert.equal(seedanceToken.billingMode, 'token');
|
||||
assert.match(seedanceToken.priceNote, /Token/);
|
||||
|
||||
const happyhorseDuration = targets.find(item => item.modelName === 'happyhorse-t2v');
|
||||
assert.ok(happyhorseDuration, 'missing HappyHorse duration pricing target');
|
||||
assert.equal(happyhorseDuration.billingMode, 'duration');
|
||||
assert.match(happyhorseDuration.priceNote, /按秒/);
|
||||
});
|
||||
|
||||
await runTest('merges Yuanjie pricing note without deleting admin custom note', () => {
|
||||
const target = getYuanjiePricingSyncTargets().find(item => item.modelName === 'gpt-image-2');
|
||||
assert.ok(target);
|
||||
|
||||
const merged = mergeYuanjiePricingNote('管理员自定义:高峰期加价', target);
|
||||
assert.match(merged, /管理员自定义/);
|
||||
assert.match(merged, /元界计费同步/);
|
||||
|
||||
const replaced = mergeYuanjiePricingNote(merged, target);
|
||||
assert.equal((replaced.match(/元界计费同步/g) || []).length, 1);
|
||||
});
|
||||
|
||||
await runTest('sync updates only Yuanjie system API rows and leaves mozheAPI untouched', async () => {
|
||||
const client = createFakeClient([
|
||||
{
|
||||
id: '11111111-1111-1111-1111-111111111111',
|
||||
provider: YUANJIE_PROVIDER_NAME,
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
price_note: '管理员自定义:保留',
|
||||
fixed_price: '12',
|
||||
credits_per_use: 12,
|
||||
},
|
||||
{
|
||||
id: '22222222-2222-2222-2222-222222222222',
|
||||
provider: 'mozheAPI',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
price_note: 'mozhe should not change',
|
||||
fixed_price: '99',
|
||||
credits_per_use: 99,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await syncYuanjiePricingMetadata(client);
|
||||
assert.equal(result.updated, 1);
|
||||
assert.equal(result.skipped, 0);
|
||||
assert.equal(result.unmatched.length, 0);
|
||||
|
||||
const selectCall = client.calls.find(call => call.sql.includes('FROM system_api_configs'));
|
||||
assert.ok(selectCall, 'expected a system_api_configs select');
|
||||
assert.match(selectCall.sql, /provider\s*=\s*\$1/);
|
||||
assert.equal(selectCall.params[0], YUANJIE_PROVIDER_NAME);
|
||||
|
||||
const updateCalls = client.calls.filter(call => call.sql.includes('UPDATE system_api_configs'));
|
||||
assert.equal(updateCalls.length, 1);
|
||||
assert.match(updateCalls[0].sql, /provider\s*=\s*\$/);
|
||||
assert.equal(updateCalls[0].params.includes('22222222-2222-2222-2222-222222222222'), false);
|
||||
});
|
||||
|
||||
await runTest('admin page exposes a manual Yuanjie pricing sync button', () => {
|
||||
const source = read('src/components/admin/api-management-tab.tsx');
|
||||
assert.match(source, /syncYuanjiePricing/);
|
||||
assert.match(source, /\/api\/admin\/system-apis\/yuanjie-pricing/);
|
||||
assert.match(source, /同步元界价格/);
|
||||
});
|
||||
|
||||
await runTest('admin route is documented and registered separately from generic smart import', () => {
|
||||
const apiReference = read('docs/codex-miaojing/api-reference.md');
|
||||
const customIntegrations = read('docs/codex-miaojing/custom-integrations.md');
|
||||
assert.match(apiReference, /\/api\/admin\/system-apis\/yuanjie-pricing/);
|
||||
assert.match(customIntegrations, /元界价格/);
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
47
src/app/api/admin/system-apis/yuanjie-pricing/route.ts
Normal file
47
src/app/api/admin/system-apis/yuanjie-pricing/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/admin-auth';
|
||||
import { ensureSystemApiSchema } from '@/lib/server-api-config';
|
||||
import {
|
||||
getYuanjiePricingSyncTargets,
|
||||
syncYuanjiePricingMetadata,
|
||||
} from '@/lib/yuanjie-pricing-sync';
|
||||
import { getDbClient } from '@/storage/database/local-db';
|
||||
|
||||
function normalizeType(value: unknown): 'image' | 'video' | null {
|
||||
return value === 'image' || value === 'video' ? value : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
const type = normalizeType(request.nextUrl.searchParams.get('type'));
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pricingTargets: getYuanjiePricingSyncTargets(type),
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const authError = await requireAdmin(request);
|
||||
if (authError) return authError;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const type = normalizeType(body.type);
|
||||
|
||||
const client = await getDbClient();
|
||||
try {
|
||||
await ensureSystemApiSchema(client);
|
||||
const result = await syncYuanjiePricingMetadata(client, { type });
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
pricingTargets: getYuanjiePricingSyncTargets(type),
|
||||
message: `已同步 ${result.updated} 个元界模型的计费方式和价格备注。`,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: error instanceof Error ? error.message : '同步元界价格和计费方式失败',
|
||||
}, { status: 500 });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import type { ManagedApiProvider, ManagedModelRecommendation, ManagedModelType } from '@/lib/model-config-types';
|
||||
import { Bot, Check, ClipboardPaste, Coins, Copy, Edit3, Film, Globe, Image, Key, Loader2, MessageSquare, Plus, Save, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Bot, Check, ClipboardPaste, Coins, Copy, Edit3, Film, Globe, Image, Key, Loader2, MessageSquare, Plus, RefreshCw, Save, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MODEL_TYPE_LABELS: Record<ManagedModelType, string> = {
|
||||
@@ -44,7 +44,7 @@ const MEMBERSHIP_TIER_OPTIONS: Array<{ value: 'free' | 'pro' | 'max' | 'ultra';
|
||||
{ value: 'ultra', label: 'Ultra' },
|
||||
];
|
||||
|
||||
const SYSTEM_API_PROVIDER_OPTIONS = ['mozheAPI', 'New API'] as const;
|
||||
const SYSTEM_API_PROVIDER_OPTIONS = ['mozheAPI', 'New API', '元界 AI'] as const;
|
||||
|
||||
function formatSystemApiPricing(api: SystemApiConfig): string {
|
||||
const billingMode = api.billingMode || 'fixed';
|
||||
@@ -131,6 +131,11 @@ function authHeaders(accessToken: string | null): HeadersInit {
|
||||
};
|
||||
}
|
||||
|
||||
function isYuanjieProvider(provider: string | null | undefined): boolean {
|
||||
const normalized = String(provider || '').replace(/\s+/g, '').toLowerCase();
|
||||
return normalized.includes('元界') || normalized.includes('yuanjie');
|
||||
}
|
||||
|
||||
function SectionMenu<T extends string>({
|
||||
items,
|
||||
activeValue,
|
||||
@@ -195,6 +200,7 @@ export default function ApiManagementTab() {
|
||||
const [activeSection, setActiveSection] = useState<ApiManagementSection>('providers');
|
||||
const [smartConfigText, setSmartConfigText] = useState(SMART_IMPORT_DEFAULT_CONFIG);
|
||||
const [smartImporting, setSmartImporting] = useState(false);
|
||||
const [yuanjiePricingSyncing, setYuanjiePricingSyncing] = useState(false);
|
||||
const [systemProviderView, setSystemProviderView] = useState<string | null>(null);
|
||||
const [systemTypeView, setSystemTypeView] = useState<'image' | 'video' | 'text' | null>(null);
|
||||
|
||||
@@ -549,6 +555,25 @@ export default function ApiManagementTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const syncYuanjiePricing = async (type?: 'image' | 'video' | null) => {
|
||||
setYuanjiePricingSyncing(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/system-apis/yuanjie-pricing', {
|
||||
method: 'POST',
|
||||
headers: authHeaders(accessToken),
|
||||
body: JSON.stringify({ type: type || undefined }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || '同步元界价格失败');
|
||||
await refreshSystemApis();
|
||||
toast.success(data.message || `已同步 ${data.updated || 0} 个元界模型的计费方式`);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : '同步元界价格失败');
|
||||
} finally {
|
||||
setYuanjiePricingSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAllowedTier = (tier: 'free' | 'pro' | 'max' | 'ultra', checked: boolean) => {
|
||||
setFormAllowedTiers(prev => {
|
||||
const next = checked ? [...prev, tier] : prev.filter(item => item !== tier);
|
||||
@@ -1205,7 +1230,21 @@ export default function ApiManagementTab() {
|
||||
<div className="text-sm text-muted-foreground">模型供应商</div>
|
||||
<div className="text-lg font-semibold">{systemProviderView}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSystemProviderView(null)}>返回供应商</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isYuanjieProvider(systemProviderView) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => syncYuanjiePricing()}
|
||||
disabled={yuanjiePricingSyncing}
|
||||
>
|
||||
{yuanjiePricingSyncing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
同步元界价格
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setSystemProviderView(null)}>返回供应商</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{(['image', 'video', 'text'] as const).map(type => {
|
||||
@@ -1238,6 +1277,18 @@ export default function ApiManagementTab() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isYuanjieProvider(systemProviderView) && systemTypeView !== 'text' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => syncYuanjiePricing(systemTypeView)}
|
||||
disabled={yuanjiePricingSyncing}
|
||||
>
|
||||
{yuanjiePricingSyncing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||
同步元界价格
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => setSystemTypeView(null)}>返回类型</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => selectedSystemProviderGroup && startAddSystemApiForProvider(selectedSystemProviderGroup, systemTypeView)}>
|
||||
<Plus className="h-3.5 w-3.5" />加模型
|
||||
|
||||
189
src/lib/yuanjie-pricing-sync.ts
Normal file
189
src/lib/yuanjie-pricing-sync.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { ManagedModelType } from '@/lib/model-config-types';
|
||||
import {
|
||||
YUANJIE_IMAGE_MODEL_TEMPLATES,
|
||||
YUANJIE_PROVIDER_NAME,
|
||||
type YuanjieImageModelTemplate,
|
||||
} from '@/lib/yuanjie-image-model-templates';
|
||||
import {
|
||||
YUANJIE_VIDEO_MODEL_TEMPLATES,
|
||||
type YuanjieVideoModelTemplate,
|
||||
} from '@/lib/yuanjie-video-model-templates';
|
||||
|
||||
type BillingMode = 'fixed' | 'ratio' | 'token' | 'duration';
|
||||
|
||||
type QueryResult = {
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount?: number | null;
|
||||
};
|
||||
|
||||
type QueryableClient = {
|
||||
query(sql: string, params?: unknown[]): Promise<QueryResult>;
|
||||
};
|
||||
|
||||
export type YuanjiePricingSyncTarget = {
|
||||
provider: typeof YUANJIE_PROVIDER_NAME;
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
type: Extract<ManagedModelType, 'image' | 'video'>;
|
||||
billingMode: BillingMode;
|
||||
sourceDoc: string;
|
||||
priceNote: string;
|
||||
};
|
||||
|
||||
export type YuanjiePricingSyncResult = {
|
||||
matched: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
unmatched: string[];
|
||||
};
|
||||
|
||||
const TOKEN_BILLED_VIDEO_MODELS = new Set([
|
||||
'kwvideo-v2-quannengcankao',
|
||||
'kwvideo-v2-ref',
|
||||
'kwvideo-v2',
|
||||
]);
|
||||
|
||||
const SPECIAL_COST_NOTE_BY_MODEL = new Map<string, string>([
|
||||
[
|
||||
'happyhorse-video-edit',
|
||||
'元界文档说明该模型按输入视频时长 + 输出视频时长合计扣费,当前平台无法自动读取输入视频秒数,建议管理员设置保守固定价或倍率备注。',
|
||||
],
|
||||
]);
|
||||
|
||||
const SYNC_NOTE_PREFIX = '元界计费同步:';
|
||||
|
||||
function normalizeProvider(value: unknown): string {
|
||||
return String(value || '').replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function isYuanjieProvider(value: unknown): boolean {
|
||||
const normalized = normalizeProvider(value);
|
||||
return normalized === normalizeProvider(YUANJIE_PROVIDER_NAME) || normalized.includes('yuanjie') || normalized.includes('元界');
|
||||
}
|
||||
|
||||
function buildImagePricingTarget(template: YuanjieImageModelTemplate): YuanjiePricingSyncTarget {
|
||||
return {
|
||||
provider: YUANJIE_PROVIDER_NAME,
|
||||
modelName: template.modelName,
|
||||
displayName: template.displayName,
|
||||
type: 'image',
|
||||
billingMode: 'fixed',
|
||||
sourceDoc: template.sourceDoc,
|
||||
priceNote: `${SYNC_NOTE_PREFIX}图片模型,元界任务终态会返回 cost 字段;平台建议按每张图/每次固定积分计费,管理员按元界后台成本手动设置“每次积分/固定价格”。来源:${template.sourceDoc}`,
|
||||
};
|
||||
}
|
||||
|
||||
function inferVideoBillingMode(template: YuanjieVideoModelTemplate): BillingMode {
|
||||
if (TOKEN_BILLED_VIDEO_MODELS.has(template.modelName)) return 'token';
|
||||
if (SPECIAL_COST_NOTE_BY_MODEL.has(template.modelName)) return 'ratio';
|
||||
if (template.capabilities.durations?.length) return 'duration';
|
||||
if (template.capabilities.resolutions?.length || template.capabilities.qualities?.length) return 'ratio';
|
||||
return 'fixed';
|
||||
}
|
||||
|
||||
function buildVideoPricingNote(template: YuanjieVideoModelTemplate, billingMode: BillingMode): string {
|
||||
const specialNote = SPECIAL_COST_NOTE_BY_MODEL.get(template.modelName);
|
||||
if (specialNote) {
|
||||
return `${SYNC_NOTE_PREFIX}${specialNote} 元界任务终态会返回 cost 字段。来源:${template.sourceDoc}`;
|
||||
}
|
||||
if (billingMode === 'token') {
|
||||
return `${SYNC_NOTE_PREFIX}视频模型按元界文档的 Token/分辨率分档计费,元界任务终态会返回 cost 字段;管理员可在 Token 计费项中按每 1M tokens 折算平台积分,预检仍以每次积分作保守占用。来源:${template.sourceDoc}`;
|
||||
}
|
||||
if (billingMode === 'duration') {
|
||||
return `${SYNC_NOTE_PREFIX}视频模型的时长会影响元界成本,元界任务终态会返回 cost 字段;平台建议按秒计费,管理员按元界后台成本设置“每秒积分”,分辨率/质量差异可在备注或倍率中体现。来源:${template.sourceDoc}`;
|
||||
}
|
||||
if (billingMode === 'ratio') {
|
||||
return `${SYNC_NOTE_PREFIX}视频模型的分辨率/质量/模式会影响元界成本,元界任务终态会返回 cost 字段;管理员可用倍率计费或固定价折算平台积分。来源:${template.sourceDoc}`;
|
||||
}
|
||||
return `${SYNC_NOTE_PREFIX}视频模型,元界任务终态会返回 cost 字段;管理员按元界后台成本手动设置平台固定积分。来源:${template.sourceDoc}`;
|
||||
}
|
||||
|
||||
function buildVideoPricingTarget(template: YuanjieVideoModelTemplate): YuanjiePricingSyncTarget {
|
||||
const billingMode = inferVideoBillingMode(template);
|
||||
return {
|
||||
provider: YUANJIE_PROVIDER_NAME,
|
||||
modelName: template.modelName,
|
||||
displayName: template.displayName,
|
||||
type: 'video',
|
||||
billingMode,
|
||||
sourceDoc: template.sourceDoc,
|
||||
priceNote: buildVideoPricingNote(template, billingMode),
|
||||
};
|
||||
}
|
||||
|
||||
export function getYuanjiePricingSyncTargets(
|
||||
type?: Extract<ManagedModelType, 'image' | 'video'> | null,
|
||||
): YuanjiePricingSyncTarget[] {
|
||||
const targets = [
|
||||
...YUANJIE_IMAGE_MODEL_TEMPLATES.map(buildImagePricingTarget),
|
||||
...YUANJIE_VIDEO_MODEL_TEMPLATES.map(buildVideoPricingTarget),
|
||||
];
|
||||
return type ? targets.filter(target => target.type === type) : targets;
|
||||
}
|
||||
|
||||
export function getYuanjiePricingTargetForModel(
|
||||
modelName: string,
|
||||
type?: Extract<ManagedModelType, 'image' | 'video'> | null,
|
||||
): YuanjiePricingSyncTarget | undefined {
|
||||
return getYuanjiePricingSyncTargets(type).find(target => target.modelName === modelName);
|
||||
}
|
||||
|
||||
export function mergeYuanjiePricingNote(existingNote: unknown, target: YuanjiePricingSyncTarget): string {
|
||||
const existing = String(existingNote || '').trim();
|
||||
const preserved = existing
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith(SYNC_NOTE_PREFIX))
|
||||
.join('\n');
|
||||
return [preserved, target.priceNote].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
export async function syncYuanjiePricingMetadata(
|
||||
client: QueryableClient,
|
||||
options: { type?: Extract<ManagedModelType, 'image' | 'video'> | null } = {},
|
||||
): Promise<YuanjiePricingSyncResult> {
|
||||
const targets = getYuanjiePricingSyncTargets(options.type);
|
||||
const targetByKey = new Map(targets.map(target => [`${target.type}:${target.modelName}`, target]));
|
||||
const result = await client.query(
|
||||
`SELECT id, provider, model_name, type, price_note
|
||||
FROM system_api_configs
|
||||
WHERE provider = $1
|
||||
AND type = ANY($2::text[])`,
|
||||
[YUANJIE_PROVIDER_NAME, options.type ? [options.type] : ['image', 'video']],
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
const skipped = 0;
|
||||
const unmatched: string[] = [];
|
||||
|
||||
for (const row of result.rows) {
|
||||
if (!isYuanjieProvider(row.provider)) {
|
||||
continue;
|
||||
}
|
||||
const type = row.type === 'video' ? 'video' : row.type === 'image' ? 'image' : null;
|
||||
const modelName = String(row.model_name || '');
|
||||
const target = type ? targetByKey.get(`${type}:${modelName}`) : undefined;
|
||||
if (!target) {
|
||||
unmatched.push(modelName || String(row.id || 'unknown'));
|
||||
continue;
|
||||
}
|
||||
const nextNote = mergeYuanjiePricingNote(row.price_note, target);
|
||||
const updateResult = await client.query(
|
||||
`UPDATE system_api_configs
|
||||
SET billing_mode = $1,
|
||||
price_note = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $3
|
||||
AND provider = $4`,
|
||||
[target.billingMode, nextNote, row.id, YUANJIE_PROVIDER_NAME],
|
||||
);
|
||||
updated += updateResult.rowCount || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
matched: result.rows.length,
|
||||
updated,
|
||||
skipped,
|
||||
unmatched,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user