fix: enforce generation credit policy
This commit is contained in:
@@ -77,7 +77,7 @@ All email sends route through `src/lib/email-service.ts`, which renders HTML and
|
||||
|
||||
| Method | Path | Auth | Source | Request | Response/Side Effects |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. |
|
||||
| POST | `/api/generation-jobs` | User | `src/app/api/generation-jobs/route.ts` | `{ type: "image"|"video", payload: {...} }` | Inserts `generation_jobs`, starts worker, increments selected image `styleLabel` usage, returns `202` with `jobId`, `status`, `estimateSeconds`, `eta`. System-default image/video jobs preflight the selected `system_api_configs` price plus existing queued/running system-default jobs for the same user and return `402` when the available balance is insufficient. |
|
||||
| GET | `/api/generation-jobs/[id]` | User/admin | `src/app/api/generation-jobs/[id]/route.ts` | Path UUID | Job status/result/error/progress. Owner or admin only. |
|
||||
|
||||
## Admin Invitation Routes
|
||||
@@ -129,7 +129,7 @@ All routes in this section require admin unless noted.
|
||||
| GET/POST/PUT/DELETE | `/api/admin/redeem-codes` | `src/app/api/admin/redeem-codes/route.ts` | Admin redeem-code management. GET lists codes by status/search, POST generates 1-500 unique single-use credit or membership codes, PUT enables/disables unused codes, and DELETE removes unused codes. Membership-code payloads include `membershipTier`, `membershipDurationValue`, and `membershipDurationUnit` (`day`, `month`, `year`). The redeem-code management UI also saves the shared external mall URL through `/api/site-config` as `redeemCodeMallUrl`. |
|
||||
| GET/PUT | `/api/admin/payment-methods` | `src/app/api/admin/payment-methods/route.ts` | Payment config. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/providers` | `src/app/api/admin/providers/route.ts` | Provider registry CRUD. All methods require admin bearer auth. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`. |
|
||||
| GET/POST/PUT/DELETE | `/api/admin/system-apis` | `src/app/api/admin/system-apis/route.ts` | System API config CRUD with encrypted keys, pricing metadata, platform-default visibility, allowed membership tiers, default-model polling fields `pollingMode`/`pollingOrder`, and video entry usage modes `videoUsageModes`. Successful system-default image/video generation jobs charge user credits from this selected row's pricing through `src/lib/generation-credit-service.ts`; queued/running system-default jobs are counted during new-job balance preflight, and failed jobs do not write consume transactions. |
|
||||
| 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. |
|
||||
|
||||
@@ -108,13 +108,14 @@ Auth is not implicit. Each route must call the correct helper:
|
||||
Create panel
|
||||
-> src/lib/generation-job-client.ts
|
||||
-> POST /api/generation-jobs
|
||||
-> system-default credit preflight counts selected job plus same-user queued/running jobs
|
||||
-> generation_jobs row inserted
|
||||
-> src/lib/generation-job-worker.ts
|
||||
-> src/lib/generation-job-runner.ts
|
||||
-> POST /api/generate/image or /api/generate/video
|
||||
-> SDK or custom/system API upstream call
|
||||
-> src/lib/local-storage.ts persists result
|
||||
-> src/lib/generation-credit-service.ts deducts selected system API credits on success
|
||||
-> src/lib/generation-credit-service.ts deducts selected system API credits only after success
|
||||
-> generation_jobs updated with result/error/progress
|
||||
-> client polls GET /api/generation-jobs/[id]
|
||||
-> history/gallery persistence via works APIs
|
||||
|
||||
@@ -81,7 +81,7 @@ Use this guide when the user reports behavior. Start from the symptom row, inspe
|
||||
| Default model group shows raw API model name instead of the admin display name | `src/lib/model-display.ts`, `src/app/api/model-config/route.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Frontend system model labels should use `system_api_configs.name` first. `model_name` is the upstream request model identifier and should remain available for generation dispatch, but it should not override the admin-facing display name in the create-page default model group. |
|
||||
| Backend default models are configured but `/api/model-config` returns only `{"providers":[],"recommendations":[]}` or no `systemApis` | `src/app/api/model-config/route.ts`, `src/lib/server-api-config.ts`, production database owner/grants | Check PM2 logs for `must be owner of table system_api_configs`. After migration, runtime tables must be owned by the app DB user, or optional schema checks should not be allowed to empty the public model-config response. Fix ownership/grants first, then verify `/api/model-config` includes `systemApis`. |
|
||||
| System API saved but not used | `src/app/api/admin/system-apis/route.ts`, `src/lib/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | `systemApiId` in request payload, active config, decrypted key, type matches image/video/text, `is_default` is true, and `allowed_membership_tiers` includes the current user's normalized tier. For admin default image models, also verify same media type plus same admin display name (`system_api_configs.name`) polling candidates, `polling_mode`, and `polling_order`; `model_name` is only the upstream request model. User custom APIs should not enter this polling path. |
|
||||
| System default model generates successfully but user credits do not decrease | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-credit-service.ts`, `src/lib/server-api-config.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx` | Credit deduction must happen on the server after a successful system-default generation, using the selected frontend `systemApiId` row in `system_api_configs` for pricing. The create button should not show predicted credits; completed result cards should show the `creditsCost` returned in the generation job result, and the profile balance should refresh from `creditsBalance`. |
|
||||
| System default model generates successfully but user credits do not decrease | `src/app/api/generation-jobs/route.ts`, `src/lib/generation-job-worker.ts`, `src/lib/generation-credit-service.ts`, `src/lib/server-api-config.ts`, `src/components/create/text-to-image.tsx`, `src/components/create/image-to-image.tsx`, `src/components/create/text-to-video.tsx`, `src/components/create/image-to-video.tsx` | Credit deduction must happen on the server after a successful system-default image/video generation, using the selected frontend `systemApiId` row in `system_api_configs` for pricing. Failed jobs must not deduct credits. New-job balance preflight should subtract queued/running system-default jobs for that user so rapid repeated submissions cannot overbook credits. Create buttons should not show predicted credits; completed result cards should show the `creditsCost` returned in the generation job result, and the profile balance should refresh from `creditsBalance`. |
|
||||
| User custom API saved but not used | `src/app/api/user-api-keys/route.ts`, `src/lib/custom-api-store.ts`, `src/lib/server-api-config.ts` | `customApiKeyId`, owner auth, encrypted key exists, `is_active`. |
|
||||
| Intelligent API dialog is too narrow, clipped, or shows only JSON | `src/components/profile/api-key-manager.tsx`, `src/components/ui/dialog.tsx` | Smart import dialogs must override the shared dialog's `sm:max-w-lg` with explicit wide sizing such as `w-[min(...)] max-w-none sm:max-w-none`, cap height to the viewport, and keep the JSON editor inside an internal scrollable/flexible area so title, actions, and footer remain visible. |
|
||||
| Intelligent API import creates wrong or mixed requests | `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/server-api-config.ts`, `src/app/api/generate/image/route.ts`, `src/app/api/generate/video/route.ts` | Each imported profile/model must have its own `user_api_keys` row and `user-api-manifests/<userId>/<keyId>.json` file. Verify `manifest_path` on the selected `customApiKeyId`, not a user-level shared file. Imported edit forms should show a human-readable provider name and a non-empty API request URL derived from `profile.baseUrl + submit.path` only when the Manifest provides enough endpoint data; never invent an OpenAI default URL for a third-party relay document. Editing a key should preserve `manifest_path`; generation should execute the selected manifest before legacy custom API fallback. |
|
||||
|
||||
@@ -19,7 +19,7 @@ Use this document before changing non-generic provider/platform behavior. If a u
|
||||
- 元界 media submit responses may return the task identifier under nested result objects such as `result.task_id`, `result.taskId`, or `result.id`. The Manifest executor must extract task IDs from those nested objects before polling `v1/media/status`.
|
||||
- Do not add `自动` back to controls where the user explicitly asked for explicit manual choices. Image count should default to `1` when automatic inference is not part of the requested workflow.
|
||||
- Admin default models must use `system_api_configs.name` as the frontend display name, while `model_name` remains the upstream request model.
|
||||
- When 元界 is used as a system default model, credit deduction must still follow the selected `system_api_configs` row's pricing through the generation job backend. The create UI should display only the completed job's returned `creditsCost`, not a separate predicted button cost.
|
||||
- When 元界 is used as a system default model, credit deduction must still follow the selected `system_api_configs` row's pricing through the generation job backend. New-job balance preflight should include same-user queued/running system-default jobs. Image and video create UI should display only the completed job's returned `creditsCost` and refresh the profile balance from `creditsBalance`, not a separate predicted button cost. Failed jobs must not write consume transactions.
|
||||
|
||||
## mozheAPI
|
||||
|
||||
|
||||
@@ -68,13 +68,13 @@ Use this document to jump directly to code before broad searching.
|
||||
| Responsibility | Primary Files | Notes |
|
||||
| --- | --- | --- |
|
||||
| Client-side job polling | `src/lib/generation-job-client.ts` | Create/poll jobs from create panels. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`. |
|
||||
| Job creation API | `src/app/api/generation-jobs/route.ts` | Inserts `generation_jobs`, starts worker, increments selected image style preset usage, and preflights system-default-model credit balance through `src/lib/generation-credit-service.ts`, including queued/running system-default jobs already waiting for the same user. |
|
||||
| Job status API | `src/app/api/generation-jobs/[id]/route.ts` | Owner/admin visibility, stale running job handling. |
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. |
|
||||
| Worker loop | `src/lib/generation-job-worker.ts` | Picks and processes queued jobs. After successful system default image/video generation, it calls `src/lib/generation-credit-service.ts` to deduct credits from `profiles.credits_balance`, insert `credit_transactions`, and add `creditsCost`/`creditsBalance` to the job result for frontend display. Failed generation jobs do not enter the charge path. |
|
||||
| Internal runner | `src/lib/generation-job-runner.ts` | Calls `/api/generate/image` or `/api/generate/video` with internal headers. |
|
||||
| ETA/progress | `src/lib/generation-job-estimates.ts` | Runtime schema, ETA samples, progress payload. |
|
||||
| Image route | `src/app/api/generate/image/route.ts` | SDK + custom/system API + New API image compatibility, persistence. New image originals persist through `src/lib/media-storage.ts` into object storage, while local WEBP thumbnails are returned as `thumbnails`/`thumbnailUrls` for preview rendering. For admin default system models, image generation resolves all same-type/same-display-name default API candidates, automatically retries stream-timeout failures once with `stream:false`, and returns actionable upstream timeout/gateway messages when all candidates fail. User custom APIs remain single-config and do not use this polling fallback. |
|
||||
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. |
|
||||
| Video route | `src/app/api/generate/video/route.ts` | SDK + custom/system API video, persistence. Video create panels must use backend returned `creditsCost`/`creditsBalance` after job success; they should not locally predict or deduct credits. |
|
||||
| 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. |
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"start": "bash ./scripts/start.sh",
|
||||
"test:admin-gallery-prompt": "node --no-warnings ./scripts/test-admin-gallery-prompt-service.mjs",
|
||||
"test:custom-image-fallback": "tsx ./scripts/test-custom-image-fallback.mjs",
|
||||
"test:generation-credit-policy": "tsx ./scripts/test-generation-credit-policy.mjs",
|
||||
"test:gallery-response": "node --no-warnings ./scripts/test-gallery-response.mjs",
|
||||
"test:ops-hardening": "node --no-warnings ./scripts/test-ops-hardening.mjs",
|
||||
"pm2:restart": "pm2 startOrReload ecosystem.config.cjs --update-env",
|
||||
|
||||
227
scripts/test-generation-credit-policy.mjs
Normal file
227
scripts/test-generation-credit-policy.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
const {
|
||||
chargeGenerationCredits,
|
||||
ensureGenerationCreditsAvailable,
|
||||
resolveGenerationCreditCost,
|
||||
} = await import('../src/lib/generation-credit-service.ts');
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, '..');
|
||||
const SYSTEM_API_ID = '11111111-1111-1111-1111-111111111111';
|
||||
const USER_ID = '22222222-2222-2222-2222-222222222222';
|
||||
|
||||
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({ apiRow, creditsBalance = 100, pendingJobs = [] } = {}) {
|
||||
const calls = [];
|
||||
const client = {
|
||||
calls,
|
||||
async query(sql, params = []) {
|
||||
const text = String(sql);
|
||||
calls.push({ sql: text, params });
|
||||
if (text.includes('FROM generation_jobs')) {
|
||||
return { rows: pendingJobs };
|
||||
}
|
||||
if (text.includes('FROM system_api_configs')) {
|
||||
return { rows: apiRow ? [apiRow] : [] };
|
||||
}
|
||||
if (text.includes('SELECT credits_balance FROM profiles') && text.includes('FOR UPDATE')) {
|
||||
return { rows: [{ credits_balance: creditsBalance }] };
|
||||
}
|
||||
if (text.includes('SELECT credits_balance FROM profiles')) {
|
||||
return { rows: [{ credits_balance: creditsBalance }] };
|
||||
}
|
||||
if (text.includes('UPDATE profiles SET credits_balance')) {
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (text.includes('INSERT INTO credit_transactions')) {
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
await runTest('calculates fixed system image credits from backend system_api_configs pricing', async () => {
|
||||
const client = createFakeClient({
|
||||
apiRow: {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: 'mozheAPI',
|
||||
name: 'gpt-image-2(主)',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
credits_per_use: 3,
|
||||
billing_mode: 'fixed',
|
||||
fixed_price: '3.0000',
|
||||
},
|
||||
});
|
||||
|
||||
const cost = await resolveGenerationCreditCost(client, {
|
||||
type: 'image',
|
||||
payload: { customApiConfig: { systemApiId: SYSTEM_API_ID } },
|
||||
result: { images: ['a', 'b'] },
|
||||
});
|
||||
|
||||
assert.equal(cost?.creditsCost, 6);
|
||||
assert.equal(cost?.description, '图片生成 - gpt-image-2(主)(mozheAPI)');
|
||||
});
|
||||
|
||||
await runTest('calculates duration video credits from backend system_api_configs pricing', async () => {
|
||||
const client = createFakeClient({
|
||||
apiRow: {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: '元界AI',
|
||||
name: '视频模型',
|
||||
model_name: 'video-model',
|
||||
type: 'video',
|
||||
credits_per_use: 0,
|
||||
billing_mode: 'duration',
|
||||
fixed_price: '0',
|
||||
duration_price_per_second: '2.5',
|
||||
},
|
||||
});
|
||||
|
||||
const cost = await resolveGenerationCreditCost(client, {
|
||||
type: 'video',
|
||||
payload: { duration: '6', customApiConfig: { systemApiId: SYSTEM_API_ID } },
|
||||
result: { videos: ['v'] },
|
||||
});
|
||||
|
||||
assert.equal(cost?.creditsCost, 15);
|
||||
assert.equal(cost?.description, '视频生成 - 视频模型(元界AI)');
|
||||
});
|
||||
|
||||
await runTest('does not charge user custom or platform SDK generation without systemApiId', async () => {
|
||||
const client = createFakeClient();
|
||||
|
||||
const charge = await chargeGenerationCredits(client, {
|
||||
userId: USER_ID,
|
||||
type: 'image',
|
||||
payload: { customApiConfig: { customApiKeyId: '33333333-3333-3333-3333-333333333333' } },
|
||||
result: { images: ['a'] },
|
||||
});
|
||||
|
||||
assert.equal(charge, null);
|
||||
assert.equal(client.calls.some(call => call.sql.includes('UPDATE profiles SET credits_balance')), false);
|
||||
assert.equal(client.calls.some(call => call.sql.includes('INSERT INTO credit_transactions')), false);
|
||||
});
|
||||
|
||||
await runTest('blocks queued system generation before running when credits are insufficient', async () => {
|
||||
const client = createFakeClient({
|
||||
creditsBalance: 2,
|
||||
apiRow: {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: 'mozheAPI',
|
||||
name: 'gpt-image-2(主)',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
credits_per_use: 3,
|
||||
billing_mode: 'fixed',
|
||||
fixed_price: '3.0000',
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => ensureGenerationCreditsAvailable(client, USER_ID, {
|
||||
type: 'image',
|
||||
payload: { count: 1, customApiConfig: { systemApiId: SYSTEM_API_ID } },
|
||||
}),
|
||||
/积分不足/,
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('counts queued and running system generation cost before accepting a new job', async () => {
|
||||
const apiRow = {
|
||||
id: SYSTEM_API_ID,
|
||||
provider: 'mozheAPI',
|
||||
name: 'gpt-image-2(主)',
|
||||
model_name: 'gpt-image-2',
|
||||
type: 'image',
|
||||
credits_per_use: 3,
|
||||
billing_mode: 'fixed',
|
||||
fixed_price: '3.0000',
|
||||
};
|
||||
const client = createFakeClient({
|
||||
creditsBalance: 5,
|
||||
apiRow,
|
||||
pendingJobs: [
|
||||
{
|
||||
type: 'image',
|
||||
payload: {
|
||||
prompt: 'pending image',
|
||||
count: 1,
|
||||
customApiConfig: { systemApiId: SYSTEM_API_ID },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => ensureGenerationCreditsAvailable(client, USER_ID, {
|
||||
type: 'image',
|
||||
payload: {
|
||||
prompt: 'new image',
|
||||
count: 1,
|
||||
customApiConfig: { systemApiId: SYSTEM_API_ID },
|
||||
},
|
||||
}),
|
||||
/积分不足/,
|
||||
);
|
||||
});
|
||||
|
||||
await runTest('job creation keeps credit preflight and insertion in one database transaction', () => {
|
||||
const source = read('src/app/api/generation-jobs/route.ts');
|
||||
const begin = source.indexOf("await client.query('BEGIN')");
|
||||
const preflight = source.indexOf('await ensureGenerationCreditsAvailable');
|
||||
const insert = source.indexOf('INSERT INTO generation_jobs');
|
||||
const commit = source.lastIndexOf("await client.query('COMMIT')");
|
||||
const rollback = source.indexOf("await client.query('ROLLBACK')");
|
||||
|
||||
assert.ok(begin > -1, 'job creation should start a transaction');
|
||||
assert.ok(preflight > begin, 'credit preflight should run inside the transaction');
|
||||
assert.ok(insert > preflight, 'job insertion should happen after credit preflight');
|
||||
assert.ok(commit > insert, 'job creation should commit after insertion');
|
||||
assert.ok(rollback > -1, 'job creation should rollback failed transactions');
|
||||
});
|
||||
|
||||
await runTest('worker charges credits only after upstream generation returns a successful result', () => {
|
||||
const source = read('src/lib/generation-job-worker.ts');
|
||||
const successPath = source.indexOf('const result = await runGenerationPayload');
|
||||
const chargePath = source.indexOf('const creditCharge = await settleJobCredits');
|
||||
const failurePath = source.indexOf("status: 'failed'");
|
||||
|
||||
assert.ok(successPath > -1, 'worker should call upstream generation');
|
||||
assert.ok(chargePath > successPath, 'credit charge must happen after successful upstream result');
|
||||
assert.ok(failurePath > chargePath, 'failure handler must be outside the success charge path');
|
||||
});
|
||||
|
||||
await runTest('video panels use backend returned creditsCost and creditsBalance instead of local predicted deduction', () => {
|
||||
for (const relativePath of [
|
||||
'src/components/create/text-to-video.tsx',
|
||||
'src/components/create/image-to-video.tsx',
|
||||
]) {
|
||||
const source = read(relativePath);
|
||||
assert.match(source, /creditsCost\?: number; creditsBalance\?: number/, relativePath);
|
||||
assert.match(source, /const creditsCost = Math\.max\(0, Number\(data\.creditsCost \|\| 0\)\)/, relativePath);
|
||||
assert.match(source, /updateProfile\(\{ creditsBalance: data\.creditsBalance \}\)/, relativePath);
|
||||
assert.doesNotMatch(source, /addCreditRecord\(/, relativePath);
|
||||
assert.doesNotMatch(source, /balanceAfter: Math\.max\(0, currentCredits - credits\)/, relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.exitCode) process.exit(process.exitCode);
|
||||
@@ -42,13 +42,18 @@ export async function POST(request: NextRequest) {
|
||||
let etaSampleCount = 0;
|
||||
let etaWindowDays: number | null = null;
|
||||
let jobIdentity = { provider: '', modelName: '', apiUrl: '' };
|
||||
let transactionStarted = false;
|
||||
try {
|
||||
await ensureGenerationJobRuntimeSchema(client);
|
||||
await client.query('BEGIN');
|
||||
transactionStarted = true;
|
||||
const identity = await resolveGenerationJobIdentity(client, userId, payload);
|
||||
jobIdentity = identity;
|
||||
try {
|
||||
await ensureGenerationCreditsAvailable(client, userId, { type, payload });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
transactionStarted = false;
|
||||
const message = error instanceof Error ? error.message : '积分不足';
|
||||
return NextResponse.json({ error: message }, { status: 402 });
|
||||
}
|
||||
@@ -71,6 +76,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
const row = existing.rows[0];
|
||||
await client.query('COMMIT');
|
||||
transactionStarted = false;
|
||||
return NextResponse.json({
|
||||
jobId: row.id,
|
||||
status: row.status,
|
||||
@@ -100,11 +107,18 @@ export async function POST(request: NextRequest) {
|
||||
],
|
||||
);
|
||||
jobId = result.rows[0].id as string;
|
||||
await client.query('COMMIT');
|
||||
transactionStarted = false;
|
||||
if (type === 'image' && typeof payload.styleLabel === 'string') {
|
||||
await incrementImageStylePresetUsage(client, payload.styleLabel).catch(error => {
|
||||
console.warn('[generation-jobs] style preset usage update failed:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (transactionStarted) {
|
||||
await client.query('ROLLBACK').catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
@@ -14,20 +14,17 @@ import {
|
||||
isCustomModel,
|
||||
isSystemModel,
|
||||
getCustomKeyId,
|
||||
getSystemApiId,
|
||||
buildCustomModelId,
|
||||
buildSystemModelId,
|
||||
calcVideoCredits,
|
||||
getSystemApiId,
|
||||
buildCustomModelId,
|
||||
buildSystemModelId,
|
||||
} from '@/lib/model-config';
|
||||
import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-display';
|
||||
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
|
||||
import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisible } from '@/lib/model-capabilities';
|
||||
import { Sparkles, Loader2, Download, Upload, Wand2, Film, History, ChevronDown, ChevronUp, Plus, X, KeyRound, Share2 } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||
import { downloadFile } from '@/lib/utils';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { downloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
@@ -57,9 +54,7 @@ interface RefImage {
|
||||
}
|
||||
|
||||
export function ImageToVideoPanel() {
|
||||
const { user, accessToken } = useAuth();
|
||||
const { config: siteConfig } = useSiteConfig();
|
||||
const membershipEnabled = siteConfig.membershipEnabled !== false;
|
||||
const { user, accessToken, updateProfile } = useAuth();
|
||||
const { videoKeys, textKeys } = useCustomApiKeys();
|
||||
const managedSystemApis = useManagedSystemApis();
|
||||
|
||||
@@ -357,8 +352,6 @@ export function ImageToVideoPanel() {
|
||||
setRefImages(prev => prev.filter(img => img.id !== id));
|
||||
}, []);
|
||||
|
||||
const credits = calcVideoCredits(duration, selectedModel, selectedSystemApi);
|
||||
|
||||
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
|
||||
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
|
||||
}, []);
|
||||
@@ -410,7 +403,7 @@ export function ImageToVideoPanel() {
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string }>(
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
|
||||
@@ -418,30 +411,27 @@ export function ImageToVideoPanel() {
|
||||
await runGenerationFinalCountdown((seconds) => updateActiveTask(taskId, { finalCountdownSeconds: seconds }), 3);
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
setResults(prev => [...data.videos!, ...prev]);
|
||||
setGenerationError(null);
|
||||
for (const url of data.videos) {
|
||||
addRecord({
|
||||
type: 'video', url, prompt: prompt.trim(),
|
||||
setGenerationError(null);
|
||||
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
|
||||
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.videos.length)) : 0;
|
||||
if (typeof data.creditsBalance === 'number') {
|
||||
updateProfile({ creditsBalance: data.creditsBalance });
|
||||
}
|
||||
for (const url of data.videos) {
|
||||
addRecord({
|
||||
type: 'video', url, prompt: prompt.trim(),
|
||||
negativePrompt: negativePrompt.trim() || undefined,
|
||||
model: selectedModel,
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
|
||||
referenceImage: primaryImage,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
params: { creationMode: 'img2video', aspectRatio, duration, cameraMovement, refImageCount: refImages.length },
|
||||
});
|
||||
}
|
||||
toast.success('视频生成成功');
|
||||
if (membershipEnabled && credits > 0 && user) {
|
||||
const currentCredits = typeof user.creditsBalance === 'number' ? user.creditsBalance : 0;
|
||||
addCreditRecord({
|
||||
type: 'consume',
|
||||
amount: -credits,
|
||||
balanceAfter: Math.max(0, currentCredits - credits),
|
||||
description: `图生视频 - ${getCurrentModelLabel()}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
referenceImage: primaryImage,
|
||||
referenceImages: refImages.map(img => img.dataUrl),
|
||||
params: { creationMode: 'img2video', aspectRatio, duration, cameraMovement, refImageCount: refImages.length },
|
||||
creditsCost: creditsPerVideo,
|
||||
});
|
||||
}
|
||||
toast.success('视频生成成功');
|
||||
} else {
|
||||
setGenerationError(createGenerationError(data.error || '视频生成失败'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -452,7 +442,7 @@ export function ImageToVideoPanel() {
|
||||
}
|
||||
}
|
||||
finally { removeActiveTask(taskId); }
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, refImages, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, removeActiveTask, updateActiveTask]);
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, refImages, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, updateProfile, removeActiveTask, updateActiveTask]);
|
||||
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
const result = await downloadFile(url, `miaojing-img2vid-${Date.now()}-${index}.mp4`);
|
||||
@@ -649,7 +639,7 @@ export function ImageToVideoPanel() {
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成视频 {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成视频</>)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,17 +19,14 @@ import {
|
||||
getSystemApiId,
|
||||
buildCustomModelId,
|
||||
buildSystemModelId,
|
||||
calcVideoCredits,
|
||||
} from '@/lib/model-config';
|
||||
import { getCustomApiModelLabel, getSystemApiModelLabel } from '@/lib/model-display';
|
||||
import { GroupedModelSelectItems } from '@/components/create/grouped-model-select-items';
|
||||
import { ensureSelectedOption, getVideoCapabilityOptions, keepSelectedOptionVisible } from '@/lib/model-capabilities';
|
||||
import { Sparkles, Loader2, Download, Wand2, Video, Film, History, ChevronDown, ChevronUp, KeyRound, Share2, Plus } from 'lucide-react';
|
||||
import { useCreationHistory, getCreationMode, isPlaceholder, shareToGallery, isUrlPublished, type CreationRecord } from '@/lib/creation-history-store';
|
||||
import { addCreditRecord } from '@/lib/credit-records-store';
|
||||
import { downloadFile } from '@/lib/utils';
|
||||
import { runGenerationFinalCountdown, runGenerationJob, type GenerationJobStatus } from '@/lib/generation-job-client';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { CreationDetailDialog } from '@/components/creation-detail-dialog';
|
||||
@@ -51,9 +48,7 @@ const VIDEO_RESOLUTION_OPTIONS = [
|
||||
] as const;
|
||||
|
||||
export function TextToVideoPanel() {
|
||||
const { user, accessToken } = useAuth();
|
||||
const { config: siteConfig } = useSiteConfig();
|
||||
const membershipEnabled = siteConfig.membershipEnabled !== false;
|
||||
const { user, accessToken, updateProfile } = useAuth();
|
||||
const { videoKeys, textKeys } = useCustomApiKeys();
|
||||
const managedSystemApis = useManagedSystemApis();
|
||||
|
||||
@@ -249,8 +244,6 @@ export function TextToVideoPanel() {
|
||||
finally { setOptimizing(false); }
|
||||
}, [prompt, user, accessToken, textModelOptions, getCurrentModelLabel]);
|
||||
|
||||
const credits = calcVideoCredits(duration, selectedModel, selectedSystemApi);
|
||||
|
||||
const updateActiveTask = useCallback((taskId: string, update: Partial<ActiveGenerationTask>) => {
|
||||
setActiveTasks(prev => prev.map(task => task.id === taskId ? { ...task, ...update } : task));
|
||||
}, []);
|
||||
@@ -298,7 +291,7 @@ export function TextToVideoPanel() {
|
||||
requestBody = { ...requestBody, model: api.modelName, customApiConfig: { systemApiId: api.id, modelName: api.modelName } };
|
||||
}
|
||||
}
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string }>(
|
||||
const data = await runGenerationJob<{ videos?: string[]; error?: string; creditsCost?: number; creditsBalance?: number }>(
|
||||
'video',
|
||||
requestBody,
|
||||
{ timeoutMs: 600_000, onStatus: (status: GenerationJobStatus) => updateActiveTask(taskId, { jobStatus: status }) },
|
||||
@@ -307,6 +300,11 @@ export function TextToVideoPanel() {
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
setResults(prev => [...data.videos!, ...prev]);
|
||||
setGenerationError(null);
|
||||
const creditsCost = Math.max(0, Number(data.creditsCost || 0));
|
||||
const creditsPerVideo = creditsCost > 0 ? Math.ceil(creditsCost / Math.max(1, data.videos.length)) : 0;
|
||||
if (typeof data.creditsBalance === 'number') {
|
||||
updateProfile({ creditsBalance: data.creditsBalance });
|
||||
}
|
||||
for (const url of data.videos) {
|
||||
addRecord({
|
||||
type: 'video', url, prompt: prompt.trim(),
|
||||
@@ -315,18 +313,10 @@ export function TextToVideoPanel() {
|
||||
modelLabel: getCurrentModelLabel(),
|
||||
isCustomModel: isCustomModel(selectedModel) || isSystemModel(selectedModel),
|
||||
params: { creationMode: 'text2video', aspectRatio, duration, cameraMovement, style },
|
||||
creditsCost: creditsPerVideo,
|
||||
});
|
||||
}
|
||||
toast.success('视频生成成功');
|
||||
if (membershipEnabled && credits > 0 && user) {
|
||||
const currentCredits = typeof user.creditsBalance === 'number' ? user.creditsBalance : 0;
|
||||
addCreditRecord({
|
||||
type: 'consume',
|
||||
amount: -credits,
|
||||
balanceAfter: Math.max(0, currentCredits - credits),
|
||||
description: `文生视频 - ${getCurrentModelLabel()}`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setGenerationError(createGenerationError(data.error || '视频生成失败'));
|
||||
}
|
||||
@@ -338,7 +328,7 @@ export function TextToVideoPanel() {
|
||||
}
|
||||
}
|
||||
finally { removeActiveTask(taskId); }
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, style, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, credits, membershipEnabled, removeActiveTask, updateActiveTask]);
|
||||
}, [prompt, negativePrompt, selectedModel, aspectRatio, duration, resolution, cameraMovement, style, user, videoKeys, systemVideoApis, getCurrentModelLabel, addRecord, updateProfile, removeActiveTask, updateActiveTask]);
|
||||
|
||||
const handleDownload = useCallback(async (url: string, index: number) => {
|
||||
const result = await downloadFile(url, `miaojing-video-${Date.now()}-${index}.mp4`);
|
||||
@@ -484,7 +474,7 @@ export function TextToVideoPanel() {
|
||||
</div>
|
||||
|
||||
<Button className="w-full gap-2" size="lg" onClick={handleGenerate} disabled={!hasModels}>
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成视频 {membershipEnabled && credits > 0 && `(${credits} 积分)`}</>)}
|
||||
{generating ? (<><Plus className="h-4 w-4" />继续提交任务</>) : (<><Sparkles className="h-4 w-4" />生成视频</>)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -82,6 +82,36 @@ export async function resolveGenerationCreditCost(
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePendingGenerationCreditCost(
|
||||
client: PoolClient,
|
||||
userId: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
if (!isUuid(userId)) return 0;
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const pendingResult = await client.query(
|
||||
`SELECT type, payload
|
||||
FROM generation_jobs
|
||||
WHERE user_id = $1
|
||||
AND status IN ('queued', 'running')
|
||||
AND payload <> $2::jsonb`,
|
||||
[userId, payloadJson],
|
||||
);
|
||||
|
||||
let pendingCost = 0;
|
||||
for (const row of pendingResult.rows) {
|
||||
const type = row.type === 'image' || row.type === 'video' ? row.type : null;
|
||||
if (!type) continue;
|
||||
const pendingPayload = asRecord(row.payload);
|
||||
const cost = await resolveGenerationCreditCost(client, {
|
||||
type,
|
||||
payload: pendingPayload,
|
||||
});
|
||||
pendingCost += cost?.creditsCost || 0;
|
||||
}
|
||||
return pendingCost;
|
||||
}
|
||||
|
||||
export async function ensureGenerationCreditsAvailable(
|
||||
client: PoolClient,
|
||||
userId: string,
|
||||
@@ -93,12 +123,14 @@ export async function ensureGenerationCreditsAvailable(
|
||||
const cost = await resolveGenerationCreditCost(client, input);
|
||||
if (!cost) return;
|
||||
const profileResult = await client.query(
|
||||
'SELECT credits_balance FROM profiles WHERE id = $1 LIMIT 1',
|
||||
'SELECT credits_balance FROM profiles WHERE id = $1 FOR UPDATE',
|
||||
[userId],
|
||||
);
|
||||
const balance = Number(profileResult.rows[0]?.credits_balance || 0);
|
||||
if (balance < cost.creditsCost) {
|
||||
throw new Error(`积分不足,本次生成需要 ${cost.creditsCost} 积分,当前余额 ${balance} 积分`);
|
||||
const pendingCost = await resolvePendingGenerationCreditCost(client, userId, input.payload);
|
||||
const availableBalance = balance - pendingCost;
|
||||
if (availableBalance < cost.creditsCost) {
|
||||
throw new Error(`积分不足,本次生成需要 ${cost.creditsCost} 积分,已排队任务预占 ${pendingCost} 积分,当前余额 ${balance} 积分`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user