From 30db7d3bf51cc8c3780ea6d45f146bf6b194a3af Mon Sep 17 00:00:00 2001 From: GinHa <15201596918@163.com> Date: Thu, 25 Jun 2026 16:53:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BA=A4=E6=98=93=E6=B5=81=E6=B0=B4=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E6=98=8E=E7=BB=86=E5=AF=BC=E5=87=BA=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + video-gen-admin/package-lock.json | 169 +++++++ video-gen-admin/package.json | 1 + video-gen-admin/src/api/index.ts | 49 +- .../src/pages/AdminCreditRecords.tsx | 466 ++++++++++++------ video-gen-admin/src/pages/AdminUsers.tsx | 53 +- video-gen-admin/src/types/index.ts | 97 +++- video-gen-admin/src/types/xlsx-js-style.d.ts | 4 + video-gen-admin/src/utils/excelExport.ts | 212 ++++++++ video-gen-api/app/api/v1/admin.py | 120 ++--- video-gen-api/app/api/v1/generation.py | 21 + video-gen-api/app/enums/__init__.py | 6 +- video-gen-api/app/enums/credit_record.py | 193 ++++++++ video-gen-api/app/enums/token_usage.py | 10 + video-gen-api/app/enums/user.py | 22 + video-gen-api/app/models/credit_record.py | 39 +- .../app/models/module_generation_step.py | 10 +- video-gen-api/app/models/token_usage.py | 14 +- video-gen-api/app/models/user.py | 13 +- video-gen-api/app/schemas/admin.py | 81 +++ .../services/admin_credit_record_service.py | 299 +++++++++++ .../services/credit_record_meta_service.py | 360 ++++++++++++++ video-gen-api/app/services/credits.py | 21 + .../services/generation_billing_service.py | 170 ++++++- .../app/services/generation_prompt_service.py | 10 +- .../app/services/generation_refund_service.py | 2 + .../generation_task_factory_service.py | 15 + .../services/hot_opening_replicate_service.py | 121 +++-- .../hot_opening_video_prompt_service.py | 10 +- video-gen-api/app/services/llm.py | 9 +- video-gen-api/app/services/payment.py | 65 +++ .../services/shot_replicate_flow_service.py | 121 +++-- .../services/shot_video_analysis_service.py | 10 +- .../app/tasks/shot_replicate_tasks.py | 23 + 34 files changed, 2491 insertions(+), 327 deletions(-) create mode 100644 video-gen-admin/src/types/xlsx-js-style.d.ts create mode 100644 video-gen-admin/src/utils/excelExport.ts create mode 100644 video-gen-api/app/enums/credit_record.py create mode 100644 video-gen-api/app/enums/token_usage.py create mode 100644 video-gen-api/app/enums/user.py create mode 100644 video-gen-api/app/services/admin_credit_record_service.py create mode 100644 video-gen-api/app/services/credit_record_meta_service.py diff --git a/.gitignore b/.gitignore index 7d9e5bf9..ea6cf067 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ bak/ *.tmp .DS_Store .playwright-mcp +*.zip +*.bat # 否定规则(不忽略) # !important.log diff --git a/video-gen-admin/package-lock.json b/video-gen-admin/package-lock.json index 4508c7be..36cb3390 100644 --- a/video-gen-admin/package-lock.json +++ b/video-gen-admin/package-lock.json @@ -14,6 +14,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", + "xlsx-js-style": "^1.2.0", "zustand": "^5.0.13" }, "devDependencies": { @@ -1235,6 +1236,22 @@ } } }, + "node_modules/adler-32": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.2.0.tgz", + "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", + "license": "Apache-2.0", + "dependencies": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + }, + "bin": { + "adler32": "bin/adler32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/antd": { "version": "6.3.7", "resolved": "https://registry.npmjs.org/antd/-/antd-6.3.7.tgz", @@ -1299,6 +1316,28 @@ "react-dom": ">=18.0.0" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cfb/node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1308,6 +1347,34 @@ "node": ">=6" } }, + "node_modules/codepage": { + "version": "1.14.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.14.0.tgz", + "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "~2.14.1", + "exit-on-epipe": "~1.0.1" + }, + "bin": { + "codepage": "bin/codepage.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage/node_modules/commander": { + "version": "2.14.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.14.1.tgz", + "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, "node_modules/compute-scroll-into-view": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", @@ -1327,6 +1394,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1349,6 +1428,15 @@ "node": ">=8" } }, + "node_modules/exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1367,6 +1455,21 @@ } } }, + "node_modules/fflate": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.3.11.tgz", + "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "license": "MIT" + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1726,6 +1829,18 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/printj": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", + "license": "Apache-2.0", + "bin": { + "printj": "bin/printj.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/react": { "version": "19.2.6", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", @@ -1863,6 +1978,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/string-convert": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", @@ -2001,6 +2128,48 @@ } } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", + "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.2.0", + "cfb": "^1.1.4", + "codepage": "~1.14.0", + "commander": "~2.17.1", + "crc-32": "~1.2.0", + "exit-on-epipe": "~1.0.1", + "fflate": "^0.3.8", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/zustand": { "version": "5.0.13", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", diff --git a/video-gen-admin/package.json b/video-gen-admin/package.json index 31da407d..8fc6aa5d 100644 --- a/video-gen-admin/package.json +++ b/video-gen-admin/package.json @@ -15,6 +15,7 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "react-router-dom": "^7.15.0", + "xlsx-js-style": "^1.2.0", "zustand": "^5.0.13" }, "devDependencies": { diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index 2296e9c2..a7fd3810 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -12,6 +12,7 @@ import type { AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut, VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload, VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut, + AdminCreditRecordListResponse, AdminCreditRecordQueryParams, } from '../types'; // ── Auth ────────────────────────────────────────────────── @@ -99,14 +100,26 @@ export async function getAdminStats(startDate?: string, endDate?: string): Promi return api.get(`/admin/stats${query ? `?${query}` : ''}`); } -export async function getAdminUsers(page = 1, pageSize = 20, search?: string): Promise<{ items: AdminUser[]; total: number }> { +export async function getAdminUsers( + page = 1, + pageSize = 20, + search?: string, + userType?: string, + frontendUserKind?: string, +): Promise<{ items: AdminUser[]; total: number }> { const params = new URLSearchParams(); params.set('page', String(page)); params.set('page_size', String(pageSize)); if (search) params.set('search', search); + if (userType) params.set('user_type', userType); + if (frontendUserKind) params.set('frontend_user_kind', frontendUserKind); return api.get(`/admin/users?${params.toString()}`); } +export async function updateFrontendUserKind(userId: string, frontendUserKind: 'internal' | 'external'): Promise { + return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind }); +} + export async function adjustCredits(userId: string, amount: number, description: string): Promise { await api.post(`/admin/users/${userId}/credits`, { amount, description }); } @@ -171,19 +184,27 @@ export async function uploadLogo(file: File): Promise<{ url: string }> { return res.json(); } -export async function getCreditRecords(filters?: { - user_id?: string; - user_name?: string; - type?: string; - start_date?: string; - end_date?: string; -}): Promise { +function setMaybe(params: URLSearchParams, key: string, value: unknown): void { + if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)); +} + +export async function getCreditRecords(filters?: AdminCreditRecordQueryParams): Promise { const params = new URLSearchParams(); - if (filters?.user_id) params.set('user_id', filters.user_id); - if (filters?.user_name) params.set('user_name', filters.user_name); - if (filters?.type) params.set('type', filters.type); - if (filters?.start_date) params.set('start_date', filters.start_date); - if (filters?.end_date) params.set('end_date', filters.end_date); + setMaybe(params, 'page', filters?.page); + setMaybe(params, 'page_size', filters?.pageSize); + setMaybe(params, 'user_id', filters?.userId); + setMaybe(params, 'user_name', filters?.userName); + setMaybe(params, 'user_type', filters?.userType); + setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind); + setMaybe(params, 'record_type', filters?.recordType || filters?.type); + setMaybe(params, 'credit_subject', filters?.creditSubject); + setMaybe(params, 'media_type', filters?.mediaType); + setMaybe(params, 'charge_kind', filters?.chargeKind); + setMaybe(params, 'source_module', filters?.sourceModule); + setMaybe(params, 'source_step_code', filters?.sourceStepCode); + setMaybe(params, 'billing_scene', filters?.billingScene); + setMaybe(params, 'start_date', filters?.startDate); + setMaybe(params, 'end_date', filters?.endDate); const q = params.toString() ? `?${params}` : ''; return api.get(`/admin/credit-records${q}`); } @@ -324,7 +345,7 @@ export async function deleteMenuConfig(id: string): Promise { // ── User Creation ─────────────────────────────────────── -export async function createUser(data: { username: string; password: string; email?: string; phone?: string; credits: number; user_type: string; allowed_menus?: string[] | null }): Promise { +export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; frontend_user_kind?: string; allowed_menus?: string[] | null }): Promise { return api.post('/admin/users', data); } diff --git a/video-gen-admin/src/pages/AdminCreditRecords.tsx b/video-gen-admin/src/pages/AdminCreditRecords.tsx index 1023ffe9..12c4fdce 100644 --- a/video-gen-admin/src/pages/AdminCreditRecords.tsx +++ b/video-gen-admin/src/pages/AdminCreditRecords.tsx @@ -1,202 +1,358 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { - Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker, + Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography, } from 'antd'; import { - WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined, + ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined, } from '@ant-design/icons'; -import { getCreditRecords } from '../api'; -import { formatDate } from '../utils/formatDate'; +import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport'; import dayjs from 'dayjs'; +import { getCreditRecords } from '../api'; +import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary } from '../types'; +import { formatDate } from '../utils/formatDate'; -interface CreditRecord { - id: string; - username: string; - type: string; - amount: number; - balanceAfter: number; - description: string; - createdAt: string; -} - -const CREDIT_TYPE_MAP: Record = { - recharge: { text: '充值', color: 'green', icon: }, - consume: { text: '消费', color: 'red', icon: }, - refund: { text: '退回', color: 'blue', icon: }, +const DEFAULT_SUMMARY: AdminCreditRecordSummary = { + totalRecharge: 0, + totalConsume: 0, + totalRefund: 0, + transactionCount: 0, + generationCount: 0, + generationAttemptCount: 0, + imageGenerationCount: 0, + videoGenerationCount: 0, + imageConsume: 0, + videoConsume: 0, + textConsume: 0, + analysisConsume: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, }; +const RECORD_TYPE_MAP: Record = { + recharge: { text: '充值', color: 'green', icon: }, + consume: { text: '消费', color: 'red', icon: }, + refund: { text: '回退', color: 'blue', icon: }, +}; + +const userScopeOptions = [ + { value: '', label: '全部用户' }, + { value: 'admin', label: '后台用户' }, + { value: 'frontend_internal', label: '前台内部用户' }, + { value: 'frontend_external', label: '前台外部用户' }, +]; + +const recordTypeOptions = [ + { value: '', label: '全部流水' }, + { value: 'recharge', label: '充值' }, + { value: 'consume', label: '消费' }, + { value: 'refund', label: '回退' }, +]; + +const creditSubjectOptions = [ + { value: '', label: '全部积分类型' }, + { value: 'media', label: '图片/视频生成积分' }, + { value: 'text', label: '提词优化积分' }, + { value: 'module', label: '模块功能积分' }, + { value: 'analysis', label: '分析积分' }, + { value: 'split', label: '切片积分' }, + { value: 'admin_adjust', label: '管理员调整' }, + { value: 'recharge', label: '充值积分' }, + { value: 'unknown', label: '历史未知' }, +]; + +const mediaTypeOptions = [ + { value: '', label: '全部媒体' }, + { value: 'image', label: '图片' }, + { value: 'video', label: '视频' }, +]; + +const chargeKindOptions = [ + { value: '', label: '全部扣费子类' }, + { value: 'media', label: '媒体生成' }, + { value: 'text_prompt', label: '提词优化' }, + { value: 'file_parse', label: '文件解析' }, + { value: 'vision_input', label: '图片理解' }, + { value: 'module_create', label: '创建模块项目' }, + { value: 'video_analysis', label: '视频分析' }, + { value: 'video_split', label: '视频切片' }, + { value: 'admin_adjust', label: '管理员调整' }, +]; + +const sourceModuleOptions = [ + { value: '', label: '全部模块' }, + { value: 'ai_creation', label: 'AI创作' }, + { value: 'generation_record', label: '项目记录' }, + { value: 'hot_opening_replicate', label: '爆款开头复刻' }, + { value: 'shot_replicate', label: '拆镜复刻' }, + { value: 'admin', label: '后台管理' }, + { value: 'payment', label: '支付充值' }, + { value: 'unknown', label: '历史未知' }, +]; + +const sourceStepOptions = [ + { value: '', label: '全部步骤' }, + { value: 'image_prompt_optimize', label: '图片提词优化' }, + { value: 'image_generate', label: '图片生成' }, + { value: 'video_prompt_optimize', label: '视频提词优化' }, + { value: 'video_generate', label: '视频生成' }, + { value: 'video_analysis', label: '视频分析' }, +]; + +const billingSceneOptions = [ + { value: '', label: '全部计费场景' }, + { value: 'ai_creation_image_generate', label: 'AI创作图片生成' }, + { value: 'ai_creation_video_generate', label: 'AI创作视频生成' }, + { value: 'generation_record_text_prompt_optimize', label: '项目记录提词优化' }, + { value: 'generation_record_image_generate', label: '项目记录图片生成' }, + { value: 'generation_record_video_generate', label: '项目记录视频生成' }, + { value: 'generation_record_file_parse', label: '项目记录文件解析' }, + { value: 'generation_record_vision_input', label: '项目记录图片理解' }, + { value: 'hot_opening_project_create', label: '爆款开头复刻创建项目' }, + { value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻图片提词优化' }, + { value: 'hot_opening_image_generate', label: '爆款开头复刻图片生成' }, + { value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻视频提词优化' }, + { value: 'hot_opening_video_generate', label: '爆款开头复刻视频生成' }, + { value: 'shot_video_analysis', label: '拆镜视频分析' }, + { value: 'shot_original_video_analysis', label: '拆镜复刻原视频分析' }, + { value: 'shot_segment_video_analysis', label: '拆镜复刻片段视频分析' }, + { value: 'shot_video_split', label: '拆镜切片' }, + { value: 'shot_segment_replicate_create', label: '从切片创建复刻项目' }, + { value: 'shot_image_prompt_optimize', label: '拆镜复刻图片提词优化' }, + { value: 'shot_image_generate', label: '拆镜复刻图片生成' }, + { value: 'shot_video_prompt_optimize', label: '拆镜复刻视频提词优化' }, + { value: 'shot_video_generate', label: '拆镜复刻视频生成' }, + { value: 'recharge', label: '充值' }, + { value: 'admin_adjust', label: '管理员调整' }, + { value: 'refund', label: '回退' }, + { value: 'unknown', label: '历史未知' }, +]; + +function n(value: number | undefined | null): string { + return Number(value || 0).toLocaleString(); +} + +function engineTypeLabel(type?: string): string { + if (type === 'model') return '提词/分析模型'; + if (type === 'image') return '图片引擎'; + if (type === 'video') return '视频引擎'; + return '执行配置'; +} + +function buildScope(scope: string): Pick { + if (scope === 'admin') return { userType: 'admin' }; + if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' }; + if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' }; + return {}; +} + const AdminCreditRecords: React.FC = () => { - const [records, setRecords] = useState([]); + const [records, setRecords] = useState([]); + const [summary, setSummary] = useState(DEFAULT_SUMMARY); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); + const [exporting, setExporting] = useState(false); + const [exportProgress, setExportProgress] = useState(''); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); - const [typeFilter, setTypeFilter] = useState(''); - const [userNameFilter, setUserNameFilter] = useState(''); + + const [userScope, setUserScope] = useState(''); + const [recordType, setRecordType] = useState(''); + const [creditSubject, setCreditSubject] = useState(''); + const [mediaType, setMediaType] = useState(''); + const [chargeKind, setChargeKind] = useState(''); + const [sourceModule, setSourceModule] = useState(''); + const [sourceStepCode, setSourceStepCode] = useState(''); + const [billingScene, setBillingScene] = useState(''); + const [userNameFilter, setUserNameFilter] = useState(''); const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]); + const query = useMemo(() => ({ + page, + pageSize, + userName: userNameFilter || undefined, + recordType: recordType || undefined, + creditSubject: creditSubject || undefined, + mediaType: mediaType || undefined, + chargeKind: chargeKind || undefined, + sourceModule: sourceModule || undefined, + sourceStepCode: sourceStepCode || undefined, + billingScene: billingScene || undefined, + startDate: dateRange[0]?.format('YYYY-MM-DD'), + endDate: dateRange[1]?.format('YYYY-MM-DD'), + ...buildScope(userScope), + }), [page, pageSize, userNameFilter, recordType, creditSubject, mediaType, chargeKind, sourceModule, sourceStepCode, billingScene, dateRange, userScope]); + const load = async () => { setLoading(true); try { - const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string; page?: number; page_size?: number } = {}; - if (typeFilter) filters.type = typeFilter; - if (userNameFilter) filters.user_name = userNameFilter; - if (dateRange[0]) filters.start_date = dateRange[0].format('YYYY-MM-DD'); - if (dateRange[1]) filters.end_date = dateRange[1].format('YYYY-MM-DD'); - filters.page = page; - filters.page_size = pageSize; - const res = await getCreditRecords(Object.keys(filters).length > 0 ? filters : undefined); + const res = await getCreditRecords(query); setRecords(res.items || []); setTotal(res.total || 0); - } catch { - message.error('加载积分记录失败'); + setSummary(res.summary || DEFAULT_SUMMARY); + } catch (e: any) { + message.error(e?.message || '加载积分记录失败'); } finally { setLoading(false); } }; - useEffect(() => { load(); }, [page, pageSize, typeFilter, userNameFilter, dateRange]); - - const handlePageChange = (p: number, ps: number) => { - setPage(p); - setPageSize(ps); - }; - - useEffect(() => { load(); }, []); - - const handleSearch = () => { - load(); - }; + useEffect(() => { load(); }, [query]); const handleReset = () => { - setTypeFilter(''); + setUserScope(''); + setRecordType(''); + setCreditSubject(''); + setMediaType(''); + setChargeKind(''); + setSourceModule(''); + setSourceStepCode(''); + setBillingScene(''); setUserNameFilter(''); setDateRange([null, null]); - load(); + setPage(1); }; - const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0); - const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0); + const exportExcel = async () => { + setExporting(true); + setExportProgress('准备导出...'); + try { + const exportPageSize = 500; + const baseQuery = { ...query, page: 1, pageSize: exportPageSize }; + const first = await getCreditRecords(baseQuery); + const all: AdminCreditRecord[] = [...(first.items || [])]; + const exportSummary = first.summary || DEFAULT_SUMMARY; + const totalRows = first.total || 0; + const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize)); + setExportProgress(`正在获取 ${all.length} / ${totalRows}`); + for (let p = 2; p <= totalPages; p += 1) { + const res = await getCreditRecords({ ...baseQuery, page: p }); + all.push(...(res.items || [])); + setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`); + } + + const detailColumns: StyledExcelColumn[] = [ + { title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') }, + { title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' }, + { title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' }, + { title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' }, + { title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' }, + { title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' }, + { title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' }, + { title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' }, + { title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' }, + { title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' }, + { title: '计费场景', maxWidth: 32, render: (r) => r.billingSceneLabel || '-' }, + { title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' }, + { title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount }, + { title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter }, + { title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 }, + { title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 }, + { title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 }, + { title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) }, + { title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' }, + { title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' }, + { title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' }, + { title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' }, + { title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' }, + { title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' }, + { title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' }, + { title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' }, + ]; + + const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`; + exportStyledExcel({ + filename, + sheetName: '积分流水', + title: '积分流水汇总', + metadataRows: [ + ['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`], + ['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')], + ['导出条数', totalRows], + ], + summaryRows: [ + ['总充值', exportSummary.totalRecharge], + ['总消费', exportSummary.totalConsume], + ['总回退', exportSummary.totalRefund], + ['交易笔数', exportSummary.transactionCount], + ['生成条数', exportSummary.generationCount], + ['生成尝试次数', exportSummary.generationAttemptCount], + ['图片生成条数', exportSummary.imageGenerationCount], + ['视频生成条数', exportSummary.videoGenerationCount], + ['图片消费积分', exportSummary.imageConsume], + ['视频消费积分', exportSummary.videoConsume], + ['提词消费积分', exportSummary.textConsume], + ['视频分析积分', exportSummary.analysisConsume], + ['总 Token', exportSummary.totalTokens], + ['输入 Token', exportSummary.inputTokens], + ['输出 Token', exportSummary.outputTokens], + ], + columns: detailColumns, + rows: all, + }); + message.success('Excel 已导出'); + } catch (e: any) { + message.error(e?.message || '导出失败'); + } finally { + setExporting(false); + setExportProgress(''); + } + }; const columns = [ - { - title: '用户', dataIndex: 'username', width: 120, - render: (v: string) => {v}, - }, - { - title: '类型', dataIndex: 'type', width: 100, - render: (v: string) => { - const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null }; - return ( - - {cfg.text} - - ); - }, - }, - { - title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount, - render: (v: number) => ( - 0 ? '#10b981' : '#ef4444', fontSize: 15 }}> - {v > 0 ? '+' : ''}{v.toLocaleString()} - - ), - }, - { - title: '变动后余额', dataIndex: 'balanceAfter', width: 120, - render: (v: number) => {v.toLocaleString()}, - }, - { - title: '说明', dataIndex: 'description', ellipsis: true, - }, - { - title: '时间', dataIndex: 'createdAt', width: 160, - render: (v: string) => {formatDate(v)}, - }, + { title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) =>
{v || '-'}
{r.phone || '-'}
}, + { title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => {r.userTypeLabel || '-'} }, + { title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return {cfg.text}; } }, + { title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => {v || '-'} }, + { title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' }, + { title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) =>
{r.billingSceneLabel || '-'}
{r.sourceStepCodeLabel || '-'}
}, + { title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? {v} : '-' }, + { title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)} }, + { title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) }, + { title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) =>
{n(r.totalTokens)}
入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}
}, + { title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) =>
{engineTypeLabel(r.engineType)}
{r.engineName || '-'}
{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}
}, + { title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => {v ? '已删除' : '正常'} }, + { title: '说明', dataIndex: 'description', width: 240, ellipsis: true }, + { title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => {formatDate(v)} }, ]; return (
- {/* Summary Cards */} -
- -
-
-
-
总充值
-
+{totalRecharge.toLocaleString()}
-
-
-
- -
-
-
-
总消费
-
-{totalConsume.toLocaleString()}
-
-
-
- -
-
-
-
交易笔数
-
{total}
-
-
-
+
+
总充值
+{n(summary.totalRecharge)}
+
总消费
-{n(summary.totalConsume)}
+
总回退
+{n(summary.totalRefund)}
+
交易 / 生成
{n(summary.transactionCount)} / {n(summary.generationCount)}
+
+ +
+ 图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分 + 视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分 + 提词消费:{n(summary.textConsume)} 积分 + 视频分析:{n(summary.analysisConsume)} 积分
- setUserNameFilter(e.target.value)} - style={{ width: 180 }} - allowClear - /> - { if (dates) setDateRange([dates[0], dates[1]]); }} - placeholder={['开始日期', '结束日期']} - style={{ width: 250 }} - /> + { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} /> + { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} /> + { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} /> + { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} /> + { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear /> + { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} /> - +
+ {exportProgress &&
{exportProgress}
} { loading={loading} pagination={{ current: page, - pageSize: pageSize, - total: total, - onChange: handlePageChange, + pageSize, + total, + onChange: (p, ps) => { setPage(p); setPageSize(ps); }, showSizeChanger: true, showTotal: (t) => `共 ${t} 条记录`, }} - scroll={{ x: 800 }} + scroll={{ x: 1900 }} /> diff --git a/video-gen-admin/src/pages/AdminUsers.tsx b/video-gen-admin/src/pages/AdminUsers.tsx index e1fbd0ca..eac8c48d 100644 --- a/video-gen-admin/src/pages/AdminUsers.tsx +++ b/video-gen-admin/src/pages/AdminUsers.tsx @@ -5,7 +5,7 @@ import { import { UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, } from '@ant-design/icons'; -import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig } from '../api'; +import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig, updateFrontendUserKind } from '../api'; import type { AdminUser, SystemConfig } from '../types'; import { formatDate } from '../utils/formatDate'; @@ -14,6 +14,7 @@ const AdminUsers: React.FC = () => { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [activeTab, setActiveTab] = useState('frontend'); + const [frontendKindFilter, setFrontendKindFilter] = useState(''); const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null }); const [createModal, setCreateModal] = useState(false); const [createType, setCreateType] = useState('frontend'); @@ -36,14 +37,14 @@ const AdminUsers: React.FC = () => { const load = async () => { setLoading(true); try { - const data = await getAdminUsers(page, pageSize, search || undefined); + const data = await getAdminUsers(page, pageSize, search || undefined, activeTab, activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined); setUsers(data.items || []); setTotal(data.total || 0); } catch { /* auth error handled by client */ } setLoading(false); }; - useEffect(() => { load(); }, [page, pageSize, search]); + useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter]); useEffect(() => { const loadCreditConfigs = async () => { @@ -116,6 +117,7 @@ const AdminUsers: React.FC = () => { phone: userType === 'frontend' ? values.phone : (values.phone || undefined), credits: values.credits || 0, user_type: userType, + frontend_user_kind: values.frontend_user_kind || 'external', }); message.success('用户创建成功'); setCreateModal(false); @@ -176,7 +178,16 @@ const AdminUsers: React.FC = () => { } catch { /* validation */ } }; - const filteredUsers = users.filter(u => u.userType === activeTab); + const handleUpdateFrontendKind = async (user: AdminUser, kind: 'internal' | 'external') => { + try { + await updateFrontendUserKind(user.id, kind); + message.success(kind === 'internal' ? '已设为前台内部用户' : '已设为前台外部用户'); + load(); + } catch (e: any) { + message.error(e?.message || '设置失败'); + } + }; + const isAdminTab = activeTab === 'admin'; const columns = [ @@ -214,6 +225,10 @@ const AdminUsers: React.FC = () => { title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => {v || '-'}, }, + ...(!isAdminTab ? [{ + title: '前台归类', dataIndex: 'frontendUserKind', width: 110, + render: (v: string) => {v === 'internal' ? '内部用户' : '外部用户'}, + }] : []), { title: '状态', dataIndex: 'isActive', width: 80, render: (v: boolean) => ( @@ -238,6 +253,12 @@ const AdminUsers: React.FC = () => { 调整积分 )} + {!isAdminTab && r.frontendUserKind !== 'internal' && ( + + )} + {!isAdminTab && r.frontendUserKind === 'internal' && ( + + )}
{ { value: 'admin', label: '后台管理员' }, ]} /> + {createType === 'frontend' && ( + + diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts index f548ceeb..3646e275 100644 --- a/video-gen-admin/src/types/index.ts +++ b/video-gen-admin/src/types/index.ts @@ -104,6 +104,7 @@ export interface AdminUser { isActive: boolean; isAdmin: boolean; userType: string; + frontendUserKind: 'internal' | 'external'; createdAt: string; lastLoginAt?: string; allowedMenus?: string[] | null; @@ -277,7 +278,6 @@ export interface GenerationAIEngineSnapshot { id?: string; name?: string; provider?: string; - modelName?: string; supportedModels?: string[]; defaultSize?: string; selectedSize?: string; @@ -673,3 +673,98 @@ export interface VideoPromptSchemaPreviewOut { runtimeSchema: Record; timePlan: Record[]; } + + +export interface AdminCreditRecordSummary { + totalRecharge: number; + totalConsume: number; + totalRefund: number; + transactionCount: number; + generationCount: number; + generationAttemptCount: number; + imageGenerationCount: number; + videoGenerationCount: number; + imageConsume: number; + videoConsume: number; + textConsume: number; + analysisConsume: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; +} + +export interface AdminCreditRecord { + id: string; + userId: string; + username?: string; + phone?: string; + email?: string; + userType?: string; + userTypeLabel?: string; + frontendUserKind?: string; + frontendUserKindLabel?: string; + type: string; + recordType: string; + recordTypeLabel?: string; + amount: number; + balanceAfter: number; + description?: string; + relatedId?: string; + bizKey?: string; + refundForBizKey?: string; + ownerType?: string; + ownerId?: string; + ownerDeleted?: boolean; + ownerDeletedAt?: string; + attemptNo?: number; + chargeKind?: string; + chargeKindLabel?: string; + chargeAction?: string; + creditSubject?: string; + creditSubjectLabel?: string; + mediaType?: string; + mediaTypeLabel?: string; + billingScene?: string; + billingSceneLabel?: string; + sourceModule?: string; + sourceModuleLabel?: string; + sourceProjectId?: string; + sourceStepId?: string; + sourceStepCode?: string; + sourceStepCodeLabel?: string; + tokenUsageId?: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; + engineType?: string; + engineId?: string; + engineName?: string; + engineProvider?: string; + engineModelName?: string; + createdAt?: string; +} + +export interface AdminCreditRecordListResponse { + items: AdminCreditRecord[]; + total: number; + summary: AdminCreditRecordSummary; +} + +export interface AdminCreditRecordQueryParams { + page?: number; + pageSize?: number; + userId?: string; + userName?: string; + userType?: string; + frontendUserKind?: string; + recordType?: string; + type?: string; + creditSubject?: string; + mediaType?: string; + chargeKind?: string; + sourceModule?: string; + sourceStepCode?: string; + billingScene?: string; + startDate?: string; + endDate?: string; +} diff --git a/video-gen-admin/src/types/xlsx-js-style.d.ts b/video-gen-admin/src/types/xlsx-js-style.d.ts new file mode 100644 index 00000000..7ccb0cac --- /dev/null +++ b/video-gen-admin/src/types/xlsx-js-style.d.ts @@ -0,0 +1,4 @@ +declare module 'xlsx-js-style' { + export const utils: any; + export function writeFile(workbook: any, filename: string, options?: any): void; +} diff --git a/video-gen-admin/src/utils/excelExport.ts b/video-gen-admin/src/utils/excelExport.ts new file mode 100644 index 00000000..1fc46324 --- /dev/null +++ b/video-gen-admin/src/utils/excelExport.ts @@ -0,0 +1,212 @@ +import * as XLSX from 'xlsx-js-style'; + +export type ExcelCellValue = string | number | boolean | Date | null | undefined; + +export interface StyledExcelColumn { + title: string; + key?: string; + width?: number; + minWidth?: number; + maxWidth?: number; + align?: 'left' | 'center' | 'right'; + numFmt?: string; + render: (row: T, rowIndex: number) => ExcelCellValue; +} + +export interface StyledExcelExportOptions { + filename: string; + sheetName: string; + title?: string; + metadataRows?: ExcelCellValue[][]; + summaryRows?: ExcelCellValue[][]; + columns: StyledExcelColumn[]; + rows: T[]; +} + +const BLACK_BORDER = { + top: { style: 'thin', color: { rgb: '000000' } }, + right: { style: 'thin', color: { rgb: '000000' } }, + bottom: { style: 'thin', color: { rgb: '000000' } }, + left: { style: 'thin', color: { rgb: '000000' } }, +}; + +const BASE_ALIGNMENT = { + vertical: 'center', + wrapText: true, +}; + +const TITLE_STYLE = { + font: { bold: true, sz: 16, color: { rgb: 'FFFFFF' } }, + fill: { fgColor: { rgb: '111827' } }, + alignment: { ...BASE_ALIGNMENT, horizontal: 'center' }, + border: BLACK_BORDER, +}; + +const SECTION_LABEL_STYLE = { + font: { bold: true, color: { rgb: '111827' } }, + fill: { fgColor: { rgb: 'F3F4F6' } }, + alignment: { ...BASE_ALIGNMENT, horizontal: 'center' }, + border: BLACK_BORDER, +}; + +const SECTION_VALUE_STYLE = { + font: { color: { rgb: '111827' } }, + fill: { fgColor: { rgb: 'FFFFFF' } }, + alignment: { ...BASE_ALIGNMENT, horizontal: 'left' }, + border: BLACK_BORDER, +}; + +const HEADER_STYLE = { + font: { bold: true, color: { rgb: 'FFFFFF' } }, + fill: { fgColor: { rgb: '374151' } }, + alignment: { ...BASE_ALIGNMENT, horizontal: 'center' }, + border: BLACK_BORDER, +}; + +const BODY_STYLE = { + font: { color: { rgb: '111827' } }, + fill: { fgColor: { rgb: 'FFFFFF' } }, + alignment: { ...BASE_ALIGNMENT, horizontal: 'left' }, + border: BLACK_BORDER, +}; + +function normalizeCellValue(value: ExcelCellValue): ExcelCellValue { + if (value === null || value === undefined) return ''; + return value; +} + +function visualLength(value: ExcelCellValue): number { + if (value === null || value === undefined) return 0; + const text = value instanceof Date ? value.toISOString() : String(value); + const lines = text.split(/\r?\n/); + return Math.max(...lines.map((line) => Array.from(line).reduce((len, ch) => len + (ch.charCodeAt(0) > 255 ? 2 : 1), 0)), 0); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function estimateColumnWidths(rows: ExcelCellValue[][], columns: StyledExcelColumn[]): { wch: number }[] { + const totalColumns = Math.max(columns.length, ...rows.map((row) => row.length)); + return Array.from({ length: totalColumns }, (_, index) => { + const config = columns[index]; + if (config?.width) return { wch: config.width }; + + const maxLength = rows.reduce((max, row) => Math.max(max, visualLength(row[index])), visualLength(config?.title)); + const minWidth = config?.minWidth ?? 10; + const maxWidth = config?.maxWidth ?? 42; + return { wch: clamp(Math.ceil(maxLength * 1.15) + 2, minWidth, maxWidth) }; + }); +} + +function estimateRowHeight(row: ExcelCellValue[], colWidths: { wch: number }[], baseHeight = 20): number { + const maxLines = row.reduce((max, cell, index) => { + const width = Math.max(8, colWidths[index]?.wch || 12); + const text = cell === null || cell === undefined ? '' : String(cell); + const explicitLines = text.split(/\r?\n/); + const wrappedLines = explicitLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visualLength(line) / Math.max(8, width - 2))), 0); + return Math.max(max, wrappedLines); + }, 1); + + return clamp(baseHeight + (maxLines - 1) * 16, baseHeight, 120); +} + +function applyCellStyle(cell: any, style: any, numFmt?: string): void { + cell.s = { + ...style, + alignment: { ...style.alignment }, + border: BLACK_BORDER, + }; + if (numFmt) cell.z = numFmt; +} + +function isNumericCell(value: ExcelCellValue): boolean { + return typeof value === 'number' && Number.isFinite(value); +} + +export function exportStyledExcel(options: StyledExcelExportOptions): void { + const { filename, sheetName, title, metadataRows = [], summaryRows = [], columns, rows } = options; + const totalColumns = Math.max(1, columns.length); + const aoa: ExcelCellValue[][] = []; + let titleRowIndex = -1; + let headerRowIndex = -1; + + if (title) { + titleRowIndex = aoa.length; + aoa.push([title, ...Array.from({ length: totalColumns - 1 }, () => '')]); + } + + metadataRows.forEach((row) => aoa.push(row.map(normalizeCellValue))); + summaryRows.forEach((row) => aoa.push(row.map(normalizeCellValue))); + + if (metadataRows.length || summaryRows.length) { + aoa.push(Array.from({ length: totalColumns }, () => '')); + } + + headerRowIndex = aoa.length; + aoa.push(columns.map((column) => column.title)); + rows.forEach((row, rowIndex) => { + aoa.push(columns.map((column) => normalizeCellValue(column.render(row, rowIndex)))); + }); + + const worksheet = XLSX.utils.aoa_to_sheet(aoa); + const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1'); + const colWidths = estimateColumnWidths(aoa, columns); + + worksheet['!cols'] = colWidths; + worksheet['!rows'] = aoa.map((row, index) => { + if (index === titleRowIndex) return { hpt: 34 }; + if (index === headerRowIndex) return { hpt: 28 }; + if (row.every((cell) => cell === '')) return { hpt: 10 }; + return { hpt: estimateRowHeight(row, colWidths) }; + }); + + if (titleRowIndex >= 0 && totalColumns > 1) { + worksheet['!merges'] = worksheet['!merges'] || []; + worksheet['!merges'].push({ + s: { r: titleRowIndex, c: 0 }, + e: { r: titleRowIndex, c: totalColumns - 1 }, + }); + } + + worksheet['!autofilter'] = { + ref: XLSX.utils.encode_range({ + s: { r: headerRowIndex, c: 0 }, + e: { r: Math.max(headerRowIndex, aoa.length - 1), c: totalColumns - 1 }, + }), + }; + + for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) { + for (let colIndex = range.s.c; colIndex <= range.e.c; colIndex += 1) { + const address = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex }); + const cell = worksheet[address] || { t: 's', v: '' }; + worksheet[address] = cell; + + if (rowIndex === titleRowIndex) { + applyCellStyle(cell, TITLE_STYLE); + continue; + } + + if (rowIndex === headerRowIndex) { + applyCellStyle(cell, HEADER_STYLE); + continue; + } + + if (rowIndex < headerRowIndex) { + applyCellStyle(cell, colIndex === 0 ? SECTION_LABEL_STYLE : SECTION_VALUE_STYLE, isNumericCell(cell.v) ? '#,##0.00' : undefined); + continue; + } + + const column = columns[colIndex]; + const align = column?.align || (isNumericCell(cell.v) ? 'right' : 'left'); + applyCellStyle(cell, { + ...BODY_STYLE, + alignment: { ...BASE_ALIGNMENT, horizontal: align }, + }, column?.numFmt || (isNumericCell(cell.v) ? '#,##0.00' : undefined)); + } + } + + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + XLSX.writeFile(workbook, filename); +} diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index 8226626f..3d49b55a 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -22,6 +22,7 @@ from app.models.video_engine import VideoEngine from app.models.image_engine import ImageEngine from app.models.credit_ratio import CreditRatio from app.models.operation_log import OperationLog +from app.enums.user import FrontendUserKind, UserType from app.schemas.admin import ( CreditAdjustRequest, ModelConfigCreate, @@ -33,6 +34,7 @@ from app.schemas.admin import ( CreateUserRequest, UpdateMenusRequest, ResetPasswordRequest, + UpdateFrontendUserKindRequest, OperationLogOut, ) from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut @@ -40,6 +42,8 @@ from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut from app.services.credits import add_credits, deduct_credits +from app.services.credit_record_meta_service import build_admin_adjust_meta +from app.services.admin_credit_record_service import list_admin_credit_records from app.services.notification import create_notification from app.services.auth import hash_password, verify_password from app.services.operation_log import log_operation @@ -82,15 +86,24 @@ async def list_users( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=1000), search: str = Query(""), + user_type: str | None = Query(None, pattern="^(frontend|admin)$"), + frontend_user_kind: str | None = Query(None, pattern="^(internal|external)$"), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): query = select(User).order_by(User.created_at.desc()) count_query = select(func.count(User.id)) if search: - search_filter = (User.username.ilike(f"%{search}%")) | (User.email.ilike(f"%{search}%")) + like = f"%{search}%" + search_filter = (User.username.ilike(like)) | (User.email.ilike(like)) | (User.phone.ilike(like)) query = query.where(search_filter) count_query = count_query.where(search_filter) + if user_type: + query = query.where(User.user_type == user_type) + count_query = count_query.where(User.user_type == user_type) + if frontend_user_kind: + query = query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind) + count_query = count_query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind) total = (await db.execute(count_query)).scalar() or 0 result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) items = result.scalars().all() @@ -132,6 +145,7 @@ async def create_user( credits=req.credits, is_admin=(req.user_type == "admin"), user_type=req.user_type, + frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value, allowed_menus=req.allowed_menus, ) user.credits = round(user.credits, 2) @@ -180,9 +194,9 @@ async def adjust_credits( db: AsyncSession = Depends(get_db), ): if req.amount > 0: - await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}") + await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta()) else: - await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}") + await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta()) await create_notification( db, user_id, "积分变动通知", f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}", @@ -208,6 +222,25 @@ async def update_user_status( return {"message": "ok"} +@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut) +async def update_user_frontend_kind( + user_id: str, + req: UpdateFrontendUserKindRequest, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User).where(User.id == user_id).limit(1)) + user = result.scalar_one_or_none() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + if user.user_type != UserType.FRONTEND.value: + raise HTTPException(status_code=400, detail="仅前台用户支持设置内部/外部归类") + user.frontend_user_kind = req.frontend_user_kind or FrontendUserKind.EXTERNAL.value + await db.flush() + await log_operation(db, admin.id, admin.username, f"设置前台用户归类为 {user.frontend_user_kind}", "PUT", f"/admin/users/{user_id}/frontend-kind") + return user + + @router.put("/users/{user_id}/reset-password") async def reset_user_password( user_id: str, @@ -248,66 +281,43 @@ async def admin_change_password( @router.get("/credit-records") async def list_credit_records( page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=500), + page_size: int = Query(20, ge=1, le=1000), user_id: str | None = Query(None), user_name: str | None = Query(None), + user_type: str | None = Query(None), + frontend_user_kind: str | None = Query(None), + record_type: str | None = Query(None), type: str | None = Query(None), + credit_subject: str | None = Query(None), + media_type: str | None = Query(None), + charge_kind: str | None = Query(None), + source_module: str | None = Query(None), + source_step_code: str | None = Query(None), + billing_scene: str | None = Query(None), start_date: str = Query(None), end_date: str = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db), ): - """List all credit transaction records with filters.""" - query = select(CreditRecord, User.username).join( - User, CreditRecord.user_id == User.id, isouter=True - ).order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc()) - - count_query = select(func.count(CreditRecord.id)) - - if user_id: - query = query.where(CreditRecord.user_id == user_id) - count_query = count_query.where(CreditRecord.user_id == user_id) - if user_name: - query = query.where(User.username.like(f'%{user_name}%')) - count_query = count_query.join(User, CreditRecord.user_id == User.id).where(User.username.like(f'%{user_name}%')) - if type: - query = query.where(CreditRecord.type == type) - count_query = count_query.where(CreditRecord.type == type) - - try: - if start_date: - date_start = datetime.strptime(start_date, "%Y-%m-%d") - query = query.where(CreditRecord.created_at >= date_start) - count_query = count_query.where(CreditRecord.created_at >= date_start) - if end_date: - date_end = datetime.strptime(end_date, "%Y-%m-%d") - date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999) - query = query.where(CreditRecord.created_at <= date_end) - count_query = count_query.where(CreditRecord.created_at <= date_end) - except: - pass - - total = (await db.execute(count_query)).scalar() or 0 - result = await db.execute(query.offset((page - 1) * page_size).limit(page_size)) - rows = result.all() - - return { - "total": total, - "items": [ - { - "id": record.id, - "user_id": record.user_id, - "username": username, - "type": record.type, - "amount": round(record.amount, 2), - "balance_after": round(record.balance_after, 2), - "description": record.description, - "related_id": record.related_id, - "created_at": _iso(record.created_at), - } - for record, username in rows - ], - } + """List all credit transaction records with filters and full summary.""" + return await list_admin_credit_records( + db, + page=page, + page_size=page_size, + user_id=user_id, + user_name=user_name, + user_type=user_type, + frontend_user_kind=frontend_user_kind, + record_type=record_type or type, + credit_subject=credit_subject, + media_type=media_type, + charge_kind=charge_kind, + source_module=source_module, + source_step_code=source_step_code, + billing_scene=billing_scene, + start_date=start_date, + end_date=end_date, + ) # ── Notification Admin ─────────────────────────────────── diff --git a/video-gen-api/app/api/v1/generation.py b/video-gen-api/app/api/v1/generation.py index 8b6b4dd0..a4c40c9f 100644 --- a/video-gen-api/app/api/v1/generation.py +++ b/video-gen-api/app/api/v1/generation.py @@ -35,12 +35,15 @@ from app.services.resource_accounting_service import ( ) from app.services.resource_signed_url_service import build_resource_signed_url from app.services.generation_billing_service import ( + CHARGE_TEXT_PROMPT, OWNER_GENERATION_RECORD, + build_credit_biz_key, charge_generation_media_by_params, charge_generation_media_for_record, get_next_credit_attempt_no, ) from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once +from app.services.credit_record_meta_service import build_generation_record_prompt_meta from app.services.video_cover_service import async_create_video_cover_for_local_video from app.utils.id_gen import generate_id from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError @@ -304,9 +307,27 @@ async def optimize( failed_record_id = record.id failed_user_id = current_user.id try: + prompt_attempt_no = 1 + prompt_biz_key = build_credit_biz_key( + owner_type=OWNER_GENERATION_RECORD, + owner_id=record.id, + attempt_no=prompt_attempt_no, + charge_kind=CHARGE_TEXT_PROMPT, + action="charge", + ) + prompt_meta = await build_generation_record_prompt_meta( + db, + record_id=record.id, + attempt_no=prompt_attempt_no, + charge_kind=CHARGE_TEXT_PROMPT, + usage=token_usage, + ) await deduct_credits( db, current_user.id, text_credits, f"提示词优化 - {project.name}", + related_id=record.id, + biz_key=prompt_biz_key, + record_meta=prompt_meta, ) except InsufficientCreditsError as e: # /optimize 阶段只处理提示词优化扣费。 diff --git a/video-gen-api/app/enums/__init__.py b/video-gen-api/app/enums/__init__.py index c26dc623..a116920b 100644 --- a/video-gen-api/app/enums/__init__.py +++ b/video-gen-api/app/enums/__init__.py @@ -1,4 +1,8 @@ from app.enums.common import * from app.enums.hot_opening_replicate import * -from app.enums.video_prompt_schema import * +from app.enums.module_generation_flow import * from app.enums.shot_replicate import * +from app.enums.video_prompt_schema import * +from app.enums.user import * +from app.enums.credit_record import * +from app.enums.token_usage import * diff --git a/video-gen-api/app/enums/credit_record.py b/video-gen-api/app/enums/credit_record.py new file mode 100644 index 00000000..1a6c0e58 --- /dev/null +++ b/video-gen-api/app/enums/credit_record.py @@ -0,0 +1,193 @@ +from enum import Enum + + +class CreditRecordType(str, Enum): + RECHARGE = "recharge" + CONSUME = "consume" + REFUND = "refund" + + +class CreditRecordOwnerType(str, Enum): + GENERATION_RECORD = "generation_record" + CHAT_GENERATION_TASK = "chat_generation_task" + MODULE_GENERATION_PROJECT = "module_generation_project" + MODULE_GENERATION_STEP = "module_generation_step" + SHOT_REPLICATE_TASK_SET = "shot_replicate_task_set" + SHOT_REPLICATE_SEGMENT = "shot_replicate_segment" + PAYMENT_ORDER = "payment_order" + ADMIN_ADJUST = "admin_adjust" + UNKNOWN = "unknown" + + +class CreditRecordChargeKind(str, Enum): + MEDIA = "media" + TEXT_PROMPT = "text_prompt" + FILE_PARSE = "file_parse" + VISION_INPUT = "vision_input" + MODULE_CREATE = "module_create" + VIDEO_ANALYSIS = "video_analysis" + VIDEO_SPLIT = "video_split" + RECHARGE = "recharge" + REFUND = "refund" + ADMIN_ADJUST = "admin_adjust" + UNKNOWN = "unknown" + + +class CreditRecordSubject(str, Enum): + MEDIA = "media" + TEXT = "text" + MODULE = "module" + ANALYSIS = "analysis" + SPLIT = "split" + RECHARGE = "recharge" + REFUND = "refund" + ADMIN_ADJUST = "admin_adjust" + UNKNOWN = "unknown" + + +class CreditRecordMediaType(str, Enum): + IMAGE = "image" + VIDEO = "video" + + +class CreditRecordAction(str, Enum): + CHARGE = "charge" + REFUND = "refund" + + +class CreditRecordSourceModule(str, Enum): + AI_CREATION = "ai_creation" + GENERATION_RECORD = "generation_record" + HOT_OPENING_REPLICATE = "hot_opening_replicate" + SHOT_REPLICATE = "shot_replicate" + PAYMENT = "payment" + ADMIN = "admin" + UNKNOWN = "unknown" + + +class CreditRecordSourceStepCode(str, Enum): + MATERIAL_INPUT = "material_input" + IMAGE_PROMPT_OPTIMIZE = "image_prompt_optimize" + IMAGE_GENERATE = "image_generate" + VIDEO_PROMPT_OPTIMIZE = "video_prompt_optimize" + VIDEO_GENERATE = "video_generate" + VIDEO_ANALYSIS = "video_analysis" + UNKNOWN = "unknown" + + +class CreditRecordBillingScene(str, Enum): + AI_CREATION_IMAGE_GENERATE = "ai_creation_image_generate" + AI_CREATION_VIDEO_GENERATE = "ai_creation_video_generate" + + GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE = "generation_record_text_prompt_optimize" + GENERATION_RECORD_IMAGE_GENERATE = "generation_record_image_generate" + GENERATION_RECORD_VIDEO_GENERATE = "generation_record_video_generate" + GENERATION_RECORD_FILE_PARSE = "generation_record_file_parse" + GENERATION_RECORD_VISION_INPUT = "generation_record_vision_input" + + HOT_OPENING_PROJECT_CREATE = "hot_opening_project_create" + HOT_OPENING_IMAGE_PROMPT_OPTIMIZE = "hot_opening_image_prompt_optimize" + HOT_OPENING_IMAGE_GENERATE = "hot_opening_image_generate" + HOT_OPENING_VIDEO_PROMPT_OPTIMIZE = "hot_opening_video_prompt_optimize" + HOT_OPENING_VIDEO_GENERATE = "hot_opening_video_generate" + + SHOT_VIDEO_ANALYSIS = "shot_video_analysis" + SHOT_ORIGINAL_VIDEO_ANALYSIS = "shot_original_video_analysis" + SHOT_SEGMENT_VIDEO_ANALYSIS = "shot_segment_video_analysis" + SHOT_VIDEO_SPLIT = "shot_video_split" + SHOT_SEGMENT_REPLICATE_CREATE = "shot_segment_replicate_create" + SHOT_IMAGE_PROMPT_OPTIMIZE = "shot_image_prompt_optimize" + SHOT_IMAGE_GENERATE = "shot_image_generate" + SHOT_VIDEO_PROMPT_OPTIMIZE = "shot_video_prompt_optimize" + SHOT_VIDEO_GENERATE = "shot_video_generate" + + RECHARGE = "recharge" + ADMIN_ADJUST = "admin_adjust" + REFUND = "refund" + UNKNOWN = "unknown" + + +CREDIT_RECORD_TYPE_LABELS = { + CreditRecordType.RECHARGE.value: "充值", + CreditRecordType.CONSUME.value: "消费", + CreditRecordType.REFUND.value: "回退", +} + +CREDIT_RECORD_SUBJECT_LABELS = { + CreditRecordSubject.MEDIA.value: "图片/视频生成积分", + CreditRecordSubject.TEXT.value: "提词优化积分", + CreditRecordSubject.MODULE.value: "模块功能积分", + CreditRecordSubject.ANALYSIS.value: "分析积分", + CreditRecordSubject.SPLIT.value: "切片积分", + CreditRecordSubject.RECHARGE.value: "充值积分", + CreditRecordSubject.REFUND.value: "回退积分", + CreditRecordSubject.ADMIN_ADJUST.value: "管理员调整", + CreditRecordSubject.UNKNOWN.value: "历史未知", +} + +CREDIT_RECORD_CHARGE_KIND_LABELS = { + CreditRecordChargeKind.MEDIA.value: "媒体生成", + CreditRecordChargeKind.TEXT_PROMPT.value: "提词优化", + CreditRecordChargeKind.FILE_PARSE.value: "文件解析", + CreditRecordChargeKind.VISION_INPUT.value: "图片理解", + CreditRecordChargeKind.MODULE_CREATE.value: "创建模块项目", + CreditRecordChargeKind.VIDEO_ANALYSIS.value: "视频分析", + CreditRecordChargeKind.VIDEO_SPLIT.value: "视频切片", + CreditRecordChargeKind.RECHARGE.value: "充值", + CreditRecordChargeKind.REFUND.value: "回退", + CreditRecordChargeKind.ADMIN_ADJUST.value: "管理员调整", + CreditRecordChargeKind.UNKNOWN.value: "历史未知", +} + +CREDIT_RECORD_MEDIA_TYPE_LABELS = { + CreditRecordMediaType.IMAGE.value: "图片", + CreditRecordMediaType.VIDEO.value: "视频", +} + +CREDIT_RECORD_SOURCE_MODULE_LABELS = { + CreditRecordSourceModule.AI_CREATION.value: "AI创作", + CreditRecordSourceModule.GENERATION_RECORD.value: "项目记录", + CreditRecordSourceModule.HOT_OPENING_REPLICATE.value: "爆款开头复刻", + CreditRecordSourceModule.SHOT_REPLICATE.value: "拆镜复刻", + CreditRecordSourceModule.PAYMENT.value: "支付充值", + CreditRecordSourceModule.ADMIN.value: "后台管理", + CreditRecordSourceModule.UNKNOWN.value: "历史未知", +} + +CREDIT_RECORD_SOURCE_STEP_CODE_LABELS = { + CreditRecordSourceStepCode.MATERIAL_INPUT.value: "素材输入", + CreditRecordSourceStepCode.IMAGE_PROMPT_OPTIMIZE.value: "图片提词优化", + CreditRecordSourceStepCode.IMAGE_GENERATE.value: "图片生成", + CreditRecordSourceStepCode.VIDEO_PROMPT_OPTIMIZE.value: "视频提词优化", + CreditRecordSourceStepCode.VIDEO_GENERATE.value: "视频生成", + CreditRecordSourceStepCode.VIDEO_ANALYSIS.value: "视频分析", + CreditRecordSourceStepCode.UNKNOWN.value: "历史未知", +} + +CREDIT_RECORD_BILLING_SCENE_LABELS = { + CreditRecordBillingScene.AI_CREATION_IMAGE_GENERATE.value: "AI创作图片生成", + CreditRecordBillingScene.AI_CREATION_VIDEO_GENERATE.value: "AI创作视频生成", + CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value: "项目记录提词优化", + CreditRecordBillingScene.GENERATION_RECORD_IMAGE_GENERATE.value: "项目记录图片生成", + CreditRecordBillingScene.GENERATION_RECORD_VIDEO_GENERATE.value: "项目记录视频生成", + CreditRecordBillingScene.GENERATION_RECORD_FILE_PARSE.value: "项目记录文件解析", + CreditRecordBillingScene.GENERATION_RECORD_VISION_INPUT.value: "项目记录图片理解", + CreditRecordBillingScene.HOT_OPENING_PROJECT_CREATE.value: "爆款开头复刻创建项目", + CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value: "爆款开头复刻图片提词优化", + CreditRecordBillingScene.HOT_OPENING_IMAGE_GENERATE.value: "爆款开头复刻图片生成", + CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value: "爆款开头复刻视频提词优化", + CreditRecordBillingScene.HOT_OPENING_VIDEO_GENERATE.value: "爆款开头复刻视频生成", + CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value: "拆镜视频分析", + CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value: "拆镜复刻原视频分析", + CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value: "拆镜复刻片段视频分析", + CreditRecordBillingScene.SHOT_VIDEO_SPLIT.value: "拆镜切片", + CreditRecordBillingScene.SHOT_SEGMENT_REPLICATE_CREATE.value: "从切片创建复刻项目", + CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value: "拆镜复刻图片提词优化", + CreditRecordBillingScene.SHOT_IMAGE_GENERATE.value: "拆镜复刻图片生成", + CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value: "拆镜复刻视频提词优化", + CreditRecordBillingScene.SHOT_VIDEO_GENERATE.value: "拆镜复刻视频生成", + CreditRecordBillingScene.RECHARGE.value: "充值", + CreditRecordBillingScene.ADMIN_ADJUST.value: "管理员调整", + CreditRecordBillingScene.REFUND.value: "回退", + CreditRecordBillingScene.UNKNOWN.value: "历史未知", +} diff --git a/video-gen-api/app/enums/token_usage.py b/video-gen-api/app/enums/token_usage.py new file mode 100644 index 00000000..9161ff26 --- /dev/null +++ b/video-gen-api/app/enums/token_usage.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class TokenUsageOwnerType(str, Enum): + GENERATION_RECORD = "generation_record" + CHAT_GENERATION_TASK = "chat_generation_task" + MODULE_GENERATION_STEP = "module_generation_step" + SHOT_REPLICATE_TASK_SET = "shot_replicate_task_set" + SHOT_REPLICATE_SEGMENT = "shot_replicate_segment" + UNKNOWN = "unknown" diff --git a/video-gen-api/app/enums/user.py b/video-gen-api/app/enums/user.py new file mode 100644 index 00000000..5ae95afc --- /dev/null +++ b/video-gen-api/app/enums/user.py @@ -0,0 +1,22 @@ +from enum import Enum + + +class UserType(str, Enum): + ADMIN = "admin" + FRONTEND = "frontend" + + +class FrontendUserKind(str, Enum): + INTERNAL = "internal" + EXTERNAL = "external" + + +USER_TYPE_LABELS = { + UserType.ADMIN.value: "后台用户", + UserType.FRONTEND.value: "前台用户", +} + +FRONTEND_USER_KIND_LABELS = { + FrontendUserKind.INTERNAL.value: "前台内部用户", + FrontendUserKind.EXTERNAL.value: "前台外部用户", +} diff --git a/video-gen-api/app/models/credit_record.py b/video-gen-api/app/models/credit_record.py index 908e3c84..d01d2e14 100644 --- a/video-gen-api/app/models/credit_record.py +++ b/video-gen-api/app/models/credit_record.py @@ -1,4 +1,4 @@ -from sqlalchemy import Float, ForeignKey, Index, String +from sqlalchemy import Float, ForeignKey, Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -12,13 +12,17 @@ class CreditRecord(Base, TimestampMixin): Index("uq_credit_records_user_biz_key", "user_id", "biz_key", unique=True), Index("ix_credit_records_user_refund_for_biz_key", "user_id", "refund_for_biz_key"), Index("ix_credit_records_related_type", "related_id", "type"), + Index("ix_credit_records_owner", "owner_type", "owner_id"), + Index("ix_credit_records_subject_media", "credit_subject", "media_type"), + Index("ix_credit_records_source_module_scene", "source_module", "billing_scene"), + Index("ix_credit_records_user_kind_time", "user_type_snapshot", "frontend_user_kind_snapshot", "created_at"), ) id: Mapped[str] = mapped_column(String(32), primary_key=True) user_id: Mapped[str] = mapped_column( String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True ) - type: Mapped[str] = mapped_column(String(16)) + type: Mapped[str] = mapped_column(String(16), index=True) amount: Mapped[float] = mapped_column(Float) balance_after: Mapped[float] = mapped_column(Float) description: Mapped[str] = mapped_column(String(256)) @@ -31,3 +35,34 @@ class CreditRecord(Base, TimestampMixin): # 如果当前流水是退款,记录它退的是哪一次扣费。 # 例如:generation_record:{record_id}:attempt:1:media:charge refund_for_biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True) + + # 账务快照字段:保证业务步骤/资源软删后,流水仍可独立展示。 + owner_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + attempt_no: Mapped[int | None] = mapped_column(Integer, nullable=True) + + charge_kind: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + charge_action: Mapped[str | None] = mapped_column(String(16), nullable=True) + credit_subject: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + media_type: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + billing_scene: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + source_module: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_project_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_step_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + + token_usage_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + + + engine_type: Mapped[str | None] = mapped_column(String(16), nullable=True) + engine_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + engine_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + engine_provider: Mapped[str | None] = mapped_column(String(64), nullable=True) + engine_model_name: Mapped[str | None] = mapped_column(String(128), nullable=True) + + user_type_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) + frontend_user_kind_snapshot: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True) diff --git a/video-gen-api/app/models/module_generation_step.py b/video-gen-api/app/models/module_generation_step.py index 81156be1..1d378050 100644 --- a/video-gen-api/app/models/module_generation_step.py +++ b/video-gen-api/app/models/module_generation_step.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Index, Integer, JSON, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -72,3 +72,11 @@ class ModuleGenerationStep(Base, TimestampMixin, SoftDeleteMixin): error_message: Mapped[str | None] = mapped_column(Text, nullable=True) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # 提词优化成本快照,避免后台/详情页反复解析 output_json.usage。 + token_usage_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + model_config_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + input_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + output_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True) + text_credits_cost: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/video-gen-api/app/models/token_usage.py b/video-gen-api/app/models/token_usage.py index c08fd0d8..de403537 100644 --- a/video-gen-api/app/models/token_usage.py +++ b/video-gen-api/app/models/token_usage.py @@ -1,6 +1,4 @@ -from datetime import datetime - -from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy import ForeignKey, Index, Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.models.base import Base, TimestampMixin @@ -8,6 +6,10 @@ from app.models.base import Base, TimestampMixin class TokenUsage(Base, TimestampMixin): __tablename__ = "token_usage" + __table_args__ = ( + Index("ix_token_usage_owner", "owner_type", "owner_id"), + Index("ix_token_usage_biz_key", "biz_key"), + ) id: Mapped[str] = mapped_column(String(32), primary_key=True) model_config_id: Mapped[str | None] = mapped_column( @@ -19,3 +21,9 @@ class TokenUsage(Base, TimestampMixin): input_tokens: Mapped[int] = mapped_column(Integer, default=0) output_tokens: Mapped[int] = mapped_column(Integer, default=0) total_tokens: Mapped[int] = mapped_column(Integer, default=0) + + owner_type: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + owner_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + biz_key: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True) + source_module: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + source_step_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) diff --git a/video-gen-api/app/models/user.py b/video-gen-api/app/models/user.py index 573d6d87..ae276098 100644 --- a/video-gen-api/app/models/user.py +++ b/video-gen-api/app/models/user.py @@ -1,8 +1,9 @@ from datetime import datetime -from sqlalchemy import Boolean, DateTime, Float, Integer, String, JSON +from sqlalchemy import Boolean, DateTime, Float, String, JSON from sqlalchemy.orm import Mapped, mapped_column +from app.enums.user import FrontendUserKind from app.models.base import Base, TimestampMixin @@ -19,7 +20,15 @@ class User(Base, TimestampMixin): credits: Mapped[float] = mapped_column(Float, default=0.0) is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_admin: Mapped[bool] = mapped_column(Boolean, default=False) - user_type: Mapped[str] = mapped_column(String(16), default="frontend") + user_type: Mapped[str] = mapped_column(String(16), default="frontend", index=True) + # 仅前台用户有业务意义;默认外部用户。取消内部标记时也设置回 external。 + frontend_user_kind: Mapped[str] = mapped_column( + String(16), + default=FrontendUserKind.EXTERNAL.value, + server_default=FrontendUserKind.EXTERNAL.value, + index=True, + nullable=False, + ) last_login_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) diff --git a/video-gen-api/app/schemas/admin.py b/video-gen-api/app/schemas/admin.py index f454475d..8a6abfc6 100644 --- a/video-gen-api/app/schemas/admin.py +++ b/video-gen-api/app/schemas/admin.py @@ -50,6 +50,7 @@ class AdminUserOut(BaseModel): is_active: bool is_admin: bool user_type: str = "frontend" + frontend_user_kind: str = "external" created_at: NaiveDatetime last_login_at: NaiveDatetimeOptional = None allowed_menus: list | None = None @@ -64,9 +65,14 @@ class CreateUserRequest(BaseModel): phone: str | None = None credits: float = 0.0 user_type: str = Field(default="frontend", pattern="^(frontend|admin)$") + frontend_user_kind: str = Field(default="external", pattern="^(internal|external)$") allowed_menus: list | None = None +class UpdateFrontendUserKindRequest(BaseModel): + frontend_user_kind: str = Field(..., pattern="^(internal|external)$") + + class UpdateMenusRequest(BaseModel): allowed_menus: list | None = None @@ -104,3 +110,78 @@ class AdminStatsOut(BaseModel): last_period_records: int = 0 last_period_revenue: float = 0.0 last_period_credits_consumed: float = 0.0 + + +class AdminCreditRecordSummaryOut(BaseModel): + total_recharge: float = 0.0 + total_consume: float = 0.0 + total_refund: float = 0.0 + transaction_count: int = 0 + generation_count: int = 0 + generation_attempt_count: int = 0 + image_generation_count: int = 0 + video_generation_count: int = 0 + image_consume: float = 0.0 + video_consume: float = 0.0 + text_consume: float = 0.0 + analysis_consume: float = 0.0 + total_tokens: int = 0 + input_tokens: int = 0 + output_tokens: int = 0 + + +class AdminCreditRecordOut(BaseModel): + id: str + user_id: str + username: str | None = None + phone: str | None = None + email: str | None = None + user_type: str | None = None + user_type_label: str | None = None + frontend_user_kind: str | None = None + frontend_user_kind_label: str | None = None + type: str + record_type: str + record_type_label: str | None = None + amount: float + balance_after: float + description: str | None = None + related_id: str | None = None + biz_key: str | None = None + refund_for_biz_key: str | None = None + owner_type: str | None = None + owner_id: str | None = None + owner_deleted: bool = False + owner_deleted_at: str | None = None + attempt_no: int | None = None + charge_kind: str | None = None + charge_kind_label: str | None = None + charge_action: str | None = None + credit_subject: str | None = None + credit_subject_label: str | None = None + media_type: str | None = None + media_type_label: str | None = None + billing_scene: str | None = None + billing_scene_label: str | None = None + source_module: str | None = None + source_module_label: str | None = None + source_project_id: str | None = None + source_step_id: str | None = None + source_step_code: str | None = None + source_step_code_label: str | None = None + token_usage_id: str | None = None + input_tokens: int = 0 + output_tokens: int = 0 + total_tokens: int = 0 + engine_type: str | None = None + engine_id: str | None = None + engine_name: str | None = None + engine_provider: str | None = None + engine_model_name: str | None = None + created_at: str | None = None + + +class AdminCreditRecordListOut(BaseModel): + items: list[AdminCreditRecordOut] + total: int + summary: AdminCreditRecordSummaryOut diff --git a/video-gen-api/app/services/admin_credit_record_service.py b/video-gen-api/app/services/admin_credit_record_service.py new file mode 100644 index 00000000..690d1303 --- /dev/null +++ b/video-gen-api/app/services/admin_credit_record_service.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import and_, case, distinct, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_record import ( + CREDIT_RECORD_BILLING_SCENE_LABELS, + CREDIT_RECORD_CHARGE_KIND_LABELS, + CREDIT_RECORD_MEDIA_TYPE_LABELS, + CREDIT_RECORD_SOURCE_MODULE_LABELS, + CREDIT_RECORD_SOURCE_STEP_CODE_LABELS, + CREDIT_RECORD_SUBJECT_LABELS, + CREDIT_RECORD_TYPE_LABELS, + CreditRecordSubject, +) +from app.enums.user import FRONTEND_USER_KIND_LABELS, USER_TYPE_LABELS, UserType +from app.models.chat_generation_task import ChatGenerationTask +from app.models.credit_record import CreditRecord +from app.models.generation_record import GenerationRecord +from app.models.module_generation_project import ModuleGenerationProject +from app.models.module_generation_step import ModuleGenerationStep +from app.models.shot_replicate_segment import ShotReplicateSegment +from app.models.shot_replicate_task_set import ShotReplicateTaskSet +from app.models.user import User + + +def _iso(dt: Any) -> str | None: + if dt is None: + return None + try: + return dt.isoformat() + except Exception: + return str(dt) + + +def _round2(value: Any) -> float: + try: + return round(float(value or 0), 2) + except Exception: + return 0.0 + + +def _as_date_start(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%d") + except Exception: + return None + + +def _as_date_end(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.strptime(value, "%Y-%m-%d").replace(hour=23, minute=59, second=59, microsecond=999999) + except Exception: + return None + + +def _label(mapping: dict[str, str], value: str | None) -> str: + if not value: + return "-" + return mapping.get(value, value) + + +def _build_filters( + *, + user_id: str | None = None, + user_name: str | None = None, + user_type: str | None = None, + frontend_user_kind: str | None = None, + record_type: str | None = None, + credit_subject: str | None = None, + media_type: str | None = None, + charge_kind: str | None = None, + source_module: str | None = None, + source_step_code: str | None = None, + billing_scene: str | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> list[Any]: + filters: list[Any] = [] + if user_id: + filters.append(CreditRecord.user_id == user_id) + if user_name: + like = f"%{user_name}%" + filters.append(or_(User.username.ilike(like), User.phone.ilike(like), User.email.ilike(like))) + if user_type: + filters.append(CreditRecord.user_type_snapshot == user_type) + if frontend_user_kind: + filters.append(CreditRecord.frontend_user_kind_snapshot == frontend_user_kind) + filters.append(CreditRecord.user_type_snapshot == UserType.FRONTEND.value) + if record_type: + filters.append(CreditRecord.type == record_type) + if credit_subject: + filters.append(CreditRecord.credit_subject == credit_subject) + if media_type: + filters.append(CreditRecord.media_type == media_type) + if charge_kind: + filters.append(CreditRecord.charge_kind == charge_kind) + if source_module: + filters.append(CreditRecord.source_module == source_module) + if source_step_code: + filters.append(CreditRecord.source_step_code == source_step_code) + if billing_scene: + filters.append(CreditRecord.billing_scene == billing_scene) + start = _as_date_start(start_date) + end = _as_date_end(end_date) + if start: + filters.append(CreditRecord.created_at >= start) + if end: + filters.append(CreditRecord.created_at <= end) + return filters + + +async def _load_deleted_map(db: AsyncSession, records: list[CreditRecord]) -> dict[tuple[str, str], tuple[bool, str | None]]: + grouped: dict[str, set[str]] = {} + for record in records: + if record.owner_type and record.owner_id: + grouped.setdefault(record.owner_type, set()).add(record.owner_id) + + model_map: dict[str, Any] = { + "chat_generation_task": ChatGenerationTask, + "generation_record": GenerationRecord, + "module_generation_project": ModuleGenerationProject, + "module_generation_step": ModuleGenerationStep, + "shot_replicate_task_set": ShotReplicateTaskSet, + "shot_replicate_segment": ShotReplicateSegment, + } + deleted_map: dict[tuple[str, str], tuple[bool, str | None]] = {} + for owner_type, ids in grouped.items(): + model = model_map.get(owner_type) + if not model or not ids: + continue + result = await db.execute(select(model.id, model.deleted_at).where(model.id.in_(ids))) + found = {row[0]: row[1] for row in result.all()} + for owner_id in ids: + deleted_at = found.get(owner_id) + deleted_map[(owner_type, owner_id)] = (bool(deleted_at), _iso(deleted_at)) if owner_id in found else (False, None) + return deleted_map + + +def _record_to_item(record: CreditRecord, user: User | None, deleted_map: dict[tuple[str, str], tuple[bool, str | None]]) -> dict[str, Any]: + owner_deleted = False + owner_deleted_at = None + if record.owner_type and record.owner_id: + owner_deleted, owner_deleted_at = deleted_map.get((record.owner_type, record.owner_id), (False, None)) + + user_type = record.user_type_snapshot or (user.user_type if user else None) + frontend_kind = record.frontend_user_kind_snapshot or (getattr(user, "frontend_user_kind", None) if user else None) + return { + "id": record.id, + "user_id": record.user_id, + "username": user.username if user else None, + "phone": user.phone if user else None, + "email": user.email if user else None, + "user_type": user_type, + "user_type_label": _label(USER_TYPE_LABELS, user_type), + "frontend_user_kind": frontend_kind, + "frontend_user_kind_label": _label(FRONTEND_USER_KIND_LABELS, frontend_kind), + "type": record.type, + "record_type": record.type, + "record_type_label": _label(CREDIT_RECORD_TYPE_LABELS, record.type), + "amount": _round2(record.amount), + "balance_after": _round2(record.balance_after), + "description": record.description, + "related_id": record.related_id, + "biz_key": record.biz_key, + "refund_for_biz_key": record.refund_for_biz_key, + "owner_type": record.owner_type, + "owner_id": record.owner_id, + "owner_deleted": owner_deleted, + "owner_deleted_at": owner_deleted_at, + "attempt_no": record.attempt_no, + "charge_kind": record.charge_kind, + "charge_kind_label": _label(CREDIT_RECORD_CHARGE_KIND_LABELS, record.charge_kind), + "charge_action": record.charge_action, + "credit_subject": record.credit_subject, + "credit_subject_label": _label(CREDIT_RECORD_SUBJECT_LABELS, record.credit_subject), + "media_type": record.media_type, + "media_type_label": _label(CREDIT_RECORD_MEDIA_TYPE_LABELS, record.media_type), + "billing_scene": record.billing_scene, + "billing_scene_label": _label(CREDIT_RECORD_BILLING_SCENE_LABELS, record.billing_scene), + "source_module": record.source_module, + "source_module_label": _label(CREDIT_RECORD_SOURCE_MODULE_LABELS, record.source_module), + "source_project_id": record.source_project_id, + "source_step_id": record.source_step_id, + "source_step_code": record.source_step_code, + "source_step_code_label": _label(CREDIT_RECORD_SOURCE_STEP_CODE_LABELS, record.source_step_code), + "token_usage_id": record.token_usage_id, + "input_tokens": record.input_tokens or 0, + "output_tokens": record.output_tokens or 0, + "total_tokens": record.total_tokens or 0, + "engine_type": record.engine_type, + "engine_id": record.engine_id, + "engine_name": record.engine_name, + "engine_provider": record.engine_provider, + "engine_model_name": record.engine_model_name, + "created_at": _iso(record.created_at), + } + + +async def list_admin_credit_records( + db: AsyncSession, + *, + page: int, + page_size: int, + user_id: str | None = None, + user_name: str | None = None, + user_type: str | None = None, + frontend_user_kind: str | None = None, + record_type: str | None = None, + credit_subject: str | None = None, + media_type: str | None = None, + charge_kind: str | None = None, + source_module: str | None = None, + source_step_code: str | None = None, + billing_scene: str | None = None, + start_date: str | None = None, + end_date: str | None = None, +) -> dict[str, Any]: + page = max(int(page or 1), 1) + page_size = min(max(int(page_size or 20), 1), 1000) + filters = _build_filters( + user_id=user_id, + user_name=user_name, + user_type=user_type, + frontend_user_kind=frontend_user_kind, + record_type=record_type, + credit_subject=credit_subject, + media_type=media_type, + charge_kind=charge_kind, + source_module=source_module, + source_step_code=source_step_code, + billing_scene=billing_scene, + start_date=start_date, + end_date=end_date, + ) + where_clause = and_(*filters) if filters else None + + base_query = select(CreditRecord, User).join(User, CreditRecord.user_id == User.id, isouter=True) + count_query = select(func.count(CreditRecord.id)).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True) + if where_clause is not None: + base_query = base_query.where(where_clause) + count_query = count_query.where(where_clause) + total = (await db.execute(count_query)).scalar() or 0 + + result = await db.execute( + base_query.order_by(CreditRecord.created_at.desc(), CreditRecord.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + rows = result.all() + records = [row[0] for row in rows] + deleted_map = await _load_deleted_map(db, records) + items = [_record_to_item(record, user, deleted_map) for record, user in rows] + + summary_query = select( + func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0), + func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0), + func.count(CreditRecord.id), + func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), + func.count(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.type == "consume"), 1), else_=None)), + func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), + func.count(distinct(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume"), func.concat(CreditRecord.owner_type, ":", CreditRecord.owner_id)), else_=None))), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "image", CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.MEDIA.value, CreditRecord.media_type == "video", CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.TEXT.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(case((and_(CreditRecord.credit_subject == CreditRecordSubject.ANALYSIS.value, CreditRecord.type == "consume"), func.abs(CreditRecord.amount)), else_=0)), 0), + func.coalesce(func.sum(CreditRecord.total_tokens), 0), + func.coalesce(func.sum(CreditRecord.input_tokens), 0), + func.coalesce(func.sum(CreditRecord.output_tokens), 0), + ).select_from(CreditRecord).join(User, CreditRecord.user_id == User.id, isouter=True) + if where_clause is not None: + summary_query = summary_query.where(where_clause) + s = (await db.execute(summary_query)).one() + summary = { + "total_recharge": _round2(s[0]), + "total_consume": _round2(s[1]), + "total_refund": _round2(s[2]), + "transaction_count": int(s[3] or 0), + "generation_count": int(s[4] or 0), + "generation_attempt_count": int(s[5] or 0), + "image_generation_count": int(s[6] or 0), + "video_generation_count": int(s[7] or 0), + "image_consume": _round2(s[8]), + "video_consume": _round2(s[9]), + "text_consume": _round2(s[10]), + "analysis_consume": _round2(s[11]), + "total_tokens": int(s[12] or 0), + "input_tokens": int(s[13] or 0), + "output_tokens": int(s[14] or 0), + } + return {"items": items, "total": total, "summary": summary} diff --git a/video-gen-api/app/services/credit_record_meta_service.py b/video-gen-api/app/services/credit_record_meta_service.py new file mode 100644 index 00000000..14339537 --- /dev/null +++ b/video-gen-api/app/services/credit_record_meta_service.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Mapping + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.enums.credit_record import ( + CreditRecordAction, + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, + CreditRecordSourceModule, + CreditRecordSourceStepCode, + CreditRecordSubject, +) +from app.enums.user import FrontendUserKind +from app.models.image_engine import ImageEngine +from app.models.model_config import ModelConfig +from app.models.module_generation_step import ModuleGenerationStep +from app.models.user import User +from app.models.video_engine import VideoEngine + + +@dataclass(slots=True) +class CreditRecordMeta: + owner_type: str | None = None + owner_id: str | None = None + attempt_no: int | None = None + + charge_kind: str | None = None + charge_action: str | None = None + credit_subject: str | None = None + media_type: str | None = None + billing_scene: str | None = None + + source_module: str | None = None + source_project_id: str | None = None + source_step_id: str | None = None + source_step_code: str | None = None + + token_usage_id: str | None = None + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + + # 统一执行配置冷备字段: + # - engine_type=model 时,engine_* 来源于 model_configs + # - engine_type=image 时,engine_* 来源于 image_engines + # - engine_type=video 时,engine_* 来源于 video_engines + engine_type: str | None = None + engine_id: str | None = None + engine_name: str | None = None + engine_provider: str | None = None + engine_model_name: str | None = None + + user_type_snapshot: str | None = None + frontend_user_kind_snapshot: str | None = None + + def to_record_kwargs(self) -> dict[str, Any]: + return {k: v for k, v in asdict(self).items() if v is not None} + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + if value is None or value == "": + return default + return int(value) + except Exception: + return default + + +def _normalize_frontend_kind(value: str | None) -> str: + return value or FrontendUserKind.EXTERNAL.value + + +async def with_user_snapshot(db: AsyncSession, meta: CreditRecordMeta, user_id: str) -> CreditRecordMeta: + result = await db.execute(select(User).where(User.id == user_id).limit(1)) + user = result.scalar_one_or_none() + if user: + meta.user_type_snapshot = user.user_type + meta.frontend_user_kind_snapshot = _normalize_frontend_kind(getattr(user, "frontend_user_kind", None)) + return meta + + +async def get_model_snapshot(db: AsyncSession, model_config_id: str | None) -> dict[str, str | None]: + """把 ModelConfig 冷备为统一 engine_* 快照。""" + if not model_config_id: + return {"engine_type": "model"} + result = await db.execute(select(ModelConfig).where(ModelConfig.id == model_config_id).limit(1)) + config = result.scalar_one_or_none() + if not config: + return {"engine_type": "model", "engine_id": model_config_id} + return { + "engine_type": "model", + "engine_id": config.id, + "engine_name": config.name, + "engine_provider": config.provider, + "engine_model_name": config.model_name, + } + + +def _model_snapshot_from_usage(usage: Mapping[str, Any]) -> dict[str, str | None]: + model_config_id = usage.get("model_config_id") + return { + "engine_type": "model", + "engine_id": model_config_id, + "engine_name": usage.get("model_config_name") or usage.get("engine_name"), + "engine_provider": usage.get("model_provider") or usage.get("provider") or usage.get("engine_provider"), + "engine_model_name": usage.get("model_name") or usage.get("model") or usage.get("engine_model_name"), + } + + +async def _apply_model_snapshot(db: AsyncSession, meta: CreditRecordMeta, usage: Mapping[str, Any]) -> CreditRecordMeta: + for key, value in _model_snapshot_from_usage(usage).items(): + setattr(meta, key, value) + if meta.engine_id and not meta.engine_model_name: + for key, value in (await get_model_snapshot(db, meta.engine_id)).items(): + setattr(meta, key, value) + return meta + + +async def get_engine_snapshot(db: AsyncSession, *, gen_type: str, engine_id: str | None) -> dict[str, str | None]: + if not engine_id: + return {"engine_type": (gen_type or None)} + gen_type = (gen_type or "").lower().strip() + if gen_type == "image": + result = await db.execute(select(ImageEngine).where(ImageEngine.id == engine_id).limit(1)) + else: + result = await db.execute(select(VideoEngine).where(VideoEngine.id == engine_id).limit(1)) + engine = result.scalar_one_or_none() + if not engine: + return {"engine_type": gen_type or None, "engine_id": engine_id} + return { + "engine_type": gen_type or None, + "engine_id": engine.id, + "engine_name": getattr(engine, "name", None), + "engine_provider": getattr(engine, "provider", None), + "engine_model_name": getattr(engine, "model_name", None), + } + + +def infer_module_billing_scene(*, source_module: str | None, source_step_code: str | None, media_type: str | None = None) -> str | None: + if source_module == CreditRecordSourceModule.HOT_OPENING_REPLICATE.value: + mapping = { + "image_prompt_optimize": CreditRecordBillingScene.HOT_OPENING_IMAGE_PROMPT_OPTIMIZE.value, + "image_generate": CreditRecordBillingScene.HOT_OPENING_IMAGE_GENERATE.value, + "video_prompt_optimize": CreditRecordBillingScene.HOT_OPENING_VIDEO_PROMPT_OPTIMIZE.value, + "video_generate": CreditRecordBillingScene.HOT_OPENING_VIDEO_GENERATE.value, + } + return mapping.get(source_step_code) + if source_module == CreditRecordSourceModule.SHOT_REPLICATE.value: + mapping = { + "image_prompt_optimize": CreditRecordBillingScene.SHOT_IMAGE_PROMPT_OPTIMIZE.value, + "image_generate": CreditRecordBillingScene.SHOT_IMAGE_GENERATE.value, + "video_prompt_optimize": CreditRecordBillingScene.SHOT_VIDEO_PROMPT_OPTIMIZE.value, + "video_generate": CreditRecordBillingScene.SHOT_VIDEO_GENERATE.value, + CreditRecordSourceStepCode.VIDEO_ANALYSIS.value: CreditRecordBillingScene.SHOT_VIDEO_ANALYSIS.value, + } + return mapping.get(source_step_code) + if source_module == CreditRecordSourceModule.AI_CREATION.value: + return CreditRecordBillingScene.AI_CREATION_IMAGE_GENERATE.value if media_type == "image" else CreditRecordBillingScene.AI_CREATION_VIDEO_GENERATE.value + if source_module == CreditRecordSourceModule.GENERATION_RECORD.value: + return CreditRecordBillingScene.GENERATION_RECORD_IMAGE_GENERATE.value if media_type == "image" else CreditRecordBillingScene.GENERATION_RECORD_VIDEO_GENERATE.value + return None + + +def build_admin_adjust_meta(*, action: str = CreditRecordAction.CHARGE.value) -> CreditRecordMeta: + return CreditRecordMeta( + owner_type=CreditRecordOwnerType.ADMIN_ADJUST.value, + charge_kind=CreditRecordChargeKind.ADMIN_ADJUST.value, + charge_action=action, + credit_subject=CreditRecordSubject.ADMIN_ADJUST.value, + source_module=CreditRecordSourceModule.ADMIN.value, + billing_scene=CreditRecordBillingScene.ADMIN_ADJUST.value, + ) + + +def build_recharge_meta(*, owner_id: str | None = None) -> CreditRecordMeta: + return CreditRecordMeta( + owner_type=CreditRecordOwnerType.PAYMENT_ORDER.value if owner_id else None, + owner_id=owner_id, + charge_kind=CreditRecordChargeKind.RECHARGE.value, + charge_action=CreditRecordAction.CHARGE.value, + credit_subject=CreditRecordSubject.RECHARGE.value, + source_module=CreditRecordSourceModule.PAYMENT.value, + billing_scene=CreditRecordBillingScene.RECHARGE.value, + ) + + +def build_payment_refund_meta(*, owner_id: str | None = None) -> CreditRecordMeta: + return CreditRecordMeta( + owner_type=CreditRecordOwnerType.PAYMENT_ORDER.value if owner_id else None, + owner_id=owner_id, + charge_kind=CreditRecordChargeKind.REFUND.value, + charge_action=CreditRecordAction.REFUND.value, + credit_subject=CreditRecordSubject.REFUND.value, + source_module=CreditRecordSourceModule.PAYMENT.value, + billing_scene=CreditRecordBillingScene.REFUND.value, + ) + + +async def build_generation_media_meta( + db: AsyncSession, + *, + owner_type: str, + owner_id: str, + attempt_no: int, + gen_type: str, + engine_id: str | None, + source_module: str | None = None, + source_project_id: str | None = None, + source_step_id: str | None = None, + source_step_code: str | None = None, + billing_scene: str | None = None, +) -> CreditRecordMeta: + media_type = (gen_type or "").lower().strip() or None + if source_module is None: + source_module = CreditRecordSourceModule.GENERATION_RECORD.value if owner_type == CreditRecordOwnerType.GENERATION_RECORD.value else CreditRecordSourceModule.AI_CREATION.value + if billing_scene is None: + billing_scene = infer_module_billing_scene(source_module=source_module, source_step_code=source_step_code, media_type=media_type) + meta = CreditRecordMeta( + owner_type=owner_type, + owner_id=owner_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.MEDIA.value, + charge_action=CreditRecordAction.CHARGE.value, + credit_subject=CreditRecordSubject.MEDIA.value, + media_type=media_type, + billing_scene=billing_scene, + source_module=source_module, + source_project_id=source_project_id, + source_step_id=source_step_id, + source_step_code=source_step_code, + ) + for key, value in (await get_engine_snapshot(db, gen_type=media_type or "", engine_id=engine_id)).items(): + setattr(meta, key, value) + return meta + + +async def build_generation_record_prompt_meta( + db: AsyncSession, + *, + record_id: str, + attempt_no: int, + charge_kind: str, + usage: Mapping[str, Any], +) -> CreditRecordMeta: + scene_map = { + CreditRecordChargeKind.TEXT_PROMPT.value: CreditRecordBillingScene.GENERATION_RECORD_TEXT_PROMPT_OPTIMIZE.value, + CreditRecordChargeKind.FILE_PARSE.value: CreditRecordBillingScene.GENERATION_RECORD_FILE_PARSE.value, + CreditRecordChargeKind.VISION_INPUT.value: CreditRecordBillingScene.GENERATION_RECORD_VISION_INPUT.value, + } + meta = CreditRecordMeta( + owner_type=CreditRecordOwnerType.GENERATION_RECORD.value, + owner_id=record_id, + attempt_no=attempt_no, + charge_kind=charge_kind, + charge_action=CreditRecordAction.CHARGE.value, + credit_subject=CreditRecordSubject.TEXT.value, + billing_scene=scene_map.get(charge_kind), + source_module=CreditRecordSourceModule.GENERATION_RECORD.value, + token_usage_id=usage.get("token_usage_id"), + input_tokens=_safe_int(usage.get("input_tokens")), + output_tokens=_safe_int(usage.get("output_tokens")), + total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))), + ) + return await _apply_model_snapshot(db, meta, usage) + + +async def build_module_step_prompt_meta( + db: AsyncSession, + *, + step_id: str, + attempt_no: int, + usage: Mapping[str, Any], +) -> CreditRecordMeta: + result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == step_id).limit(1)) + step = result.scalar_one_or_none() + source_module = getattr(step, "module", None) if step else None + source_step_code = getattr(step, "step_code", None) if step else None + meta = CreditRecordMeta( + owner_type=CreditRecordOwnerType.MODULE_GENERATION_STEP.value, + owner_id=step_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.TEXT_PROMPT.value, + charge_action=CreditRecordAction.CHARGE.value, + credit_subject=CreditRecordSubject.TEXT.value, + billing_scene=infer_module_billing_scene(source_module=source_module, source_step_code=source_step_code), + source_module=source_module, + source_project_id=getattr(step, "project_id", None) if step else None, + source_step_id=step_id, + source_step_code=source_step_code, + token_usage_id=usage.get("token_usage_id"), + input_tokens=_safe_int(usage.get("input_tokens")), + output_tokens=_safe_int(usage.get("output_tokens")), + total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))), + ) + return await _apply_model_snapshot(db, meta, usage) + + +async def build_shot_video_analysis_meta( + db: AsyncSession, + *, + owner_type: str, + owner_id: str, + attempt_no: int, + usage: Mapping[str, Any], + billing_scene: str, + source_project_id: str | None = None, + source_step_id: str | None = None, +) -> CreditRecordMeta: + meta = CreditRecordMeta( + owner_type=owner_type, + owner_id=owner_id, + attempt_no=attempt_no, + charge_kind=CreditRecordChargeKind.VIDEO_ANALYSIS.value, + charge_action=CreditRecordAction.CHARGE.value, + credit_subject=CreditRecordSubject.ANALYSIS.value, + media_type="video", + billing_scene=billing_scene, + source_module=CreditRecordSourceModule.SHOT_REPLICATE.value, + source_project_id=source_project_id, + source_step_id=source_step_id, + source_step_code=CreditRecordSourceStepCode.VIDEO_ANALYSIS.value, + token_usage_id=usage.get("token_usage_id"), + input_tokens=_safe_int(usage.get("input_tokens")), + output_tokens=_safe_int(usage.get("output_tokens")), + total_tokens=_safe_int(usage.get("total_tokens"), _safe_int(usage.get("input_tokens")) + _safe_int(usage.get("output_tokens"))), + ) + return await _apply_model_snapshot(db, meta, usage) + + +def build_refund_meta_from_charge(charge: Any, *, attempt_no: int | None = None) -> CreditRecordMeta: + return CreditRecordMeta( + owner_type=getattr(charge, "owner_type", None), + owner_id=getattr(charge, "owner_id", None), + attempt_no=attempt_no or getattr(charge, "attempt_no", None), + charge_kind=getattr(charge, "charge_kind", None), + charge_action=CreditRecordAction.REFUND.value, + credit_subject=getattr(charge, "credit_subject", None) or CreditRecordSubject.REFUND.value, + media_type=getattr(charge, "media_type", None), + billing_scene=CreditRecordBillingScene.REFUND.value, + source_module=getattr(charge, "source_module", None), + source_project_id=getattr(charge, "source_project_id", None), + source_step_id=getattr(charge, "source_step_id", None), + source_step_code=getattr(charge, "source_step_code", None), + token_usage_id=getattr(charge, "token_usage_id", None), + input_tokens=getattr(charge, "input_tokens", None), + output_tokens=getattr(charge, "output_tokens", None), + total_tokens=getattr(charge, "total_tokens", None), + engine_type=getattr(charge, "engine_type", None), + engine_id=getattr(charge, "engine_id", None), + engine_name=getattr(charge, "engine_name", None), + engine_provider=getattr(charge, "engine_provider", None), + engine_model_name=getattr(charge, "engine_model_name", None), + user_type_snapshot=getattr(charge, "user_type_snapshot", None), + frontend_user_kind_snapshot=getattr(charge, "frontend_user_kind_snapshot", None), + ) diff --git a/video-gen-api/app/services/credits.py b/video-gen-api/app/services/credits.py index fc3d045f..d00797bd 100644 --- a/video-gen-api/app/services/credits.py +++ b/video-gen-api/app/services/credits.py @@ -11,6 +11,7 @@ from app.models.image_engine import ImageEngine from app.models.credit_ratio import CreditRatio from app.utils.id_gen import generate_id from app.utils.exceptions import InsufficientCreditsError +from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float: @@ -167,6 +168,7 @@ async def deduct_credits( *, biz_key: str | None = None, refund_for_biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, ) -> User: """扣减用户积分,并写入消费流水。 @@ -196,6 +198,13 @@ async def deduct_credits( raise InsufficientCreditsError() user.credits = round(float(user.credits or 0) - amount, 2) + meta_kwargs = {} + if record_meta: + if isinstance(record_meta, CreditRecordMeta): + record_meta = await with_user_snapshot(db, record_meta, user_id) + meta_kwargs = record_meta.to_record_kwargs() + elif isinstance(record_meta, dict): + meta_kwargs = {k: v for k, v in record_meta.items() if v is not None} record = CreditRecord( id=generate_id(), user_id=user_id, @@ -206,6 +215,7 @@ async def deduct_credits( related_id=related_id, biz_key=biz_key, refund_for_biz_key=refund_for_biz_key, + **meta_kwargs, ) db.add(record) await db.flush() @@ -222,6 +232,7 @@ async def add_credits( record_type: str = "recharge", biz_key: str | None = None, refund_for_biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, ) -> User: """增加用户积分,并写入流水。 @@ -243,6 +254,13 @@ async def add_credits( return user user.credits = round(float(user.credits or 0) + amount, 2) + meta_kwargs = {} + if record_meta: + if isinstance(record_meta, CreditRecordMeta): + record_meta = await with_user_snapshot(db, record_meta, user_id) + meta_kwargs = record_meta.to_record_kwargs() + elif isinstance(record_meta, dict): + meta_kwargs = {k: v for k, v in record_meta.items() if v is not None} record = CreditRecord( id=generate_id(), user_id=user_id, @@ -253,6 +271,7 @@ async def add_credits( related_id=related_id, biz_key=biz_key, refund_for_biz_key=refund_for_biz_key, + **meta_kwargs, ) db.add(record) await db.flush() @@ -268,6 +287,7 @@ async def refund_credits( *, biz_key: str | None = None, refund_for_biz_key: str | None = None, + record_meta: CreditRecordMeta | dict | None = None, ) -> User: """生成失败积分回退。""" return await add_credits( @@ -279,6 +299,7 @@ async def refund_credits( record_type="refund", biz_key=biz_key, refund_for_biz_key=refund_for_biz_key, + record_meta=record_meta, ) diff --git a/video-gen-api/app/services/generation_billing_service.py b/video-gen-api/app/services/generation_billing_service.py index 51604d16..2a6a6417 100644 --- a/video-gen-api/app/services/generation_billing_service.py +++ b/video-gen-api/app/services/generation_billing_service.py @@ -4,23 +4,36 @@ import re from dataclasses import asdict, dataclass from typing import Any, Mapping -from sqlalchemy import func, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordChargeKind, CreditRecordOwnerType, CreditRecordSourceModule from app.models.credit_record import CreditRecord from app.models.generation_record import GenerationRecord +from app.models.module_generation_step import ModuleGenerationStep +from app.models.token_usage import TokenUsage from app.models.system_config import SystemConfig +from app.services.credit_record_meta_service import ( + CreditRecordMeta, + build_generation_media_meta, + build_generation_record_prompt_meta, + build_module_step_prompt_meta, + build_shot_video_analysis_meta, +) from app.services.credits import calc_image_credits, calc_text_credits, calc_video_credits, deduct_credits -CHARGE_TEXT_PROMPT = "text_prompt" -CHARGE_FILE_PARSE = "file_parse" -CHARGE_VISION_INPUT = "vision_input" -CHARGE_MEDIA = "media" +CHARGE_TEXT_PROMPT = CreditRecordChargeKind.TEXT_PROMPT.value +CHARGE_FILE_PARSE = CreditRecordChargeKind.FILE_PARSE.value +CHARGE_VISION_INPUT = CreditRecordChargeKind.VISION_INPUT.value +CHARGE_MEDIA = CreditRecordChargeKind.MEDIA.value +CHARGE_VIDEO_ANALYSIS = CreditRecordChargeKind.VIDEO_ANALYSIS.value -OWNER_GENERATION_RECORD = "generation_record" -OWNER_CHAT_GENERATION_TASK = "chat_generation_task" -OWNER_MODULE_GENERATION_STEP = "module_generation_step" +OWNER_GENERATION_RECORD = CreditRecordOwnerType.GENERATION_RECORD.value +OWNER_CHAT_GENERATION_TASK = CreditRecordOwnerType.CHAT_GENERATION_TASK.value +OWNER_MODULE_GENERATION_STEP = CreditRecordOwnerType.MODULE_GENERATION_STEP.value +OWNER_SHOT_REPLICATE_TASK_SET = CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value +OWNER_SHOT_REPLICATE_SEGMENT = CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value _BIZ_KEY_PATTERN = re.compile( r"^(?P[^:]+):(?P[^:]+):attempt:(?P\d+):(?P[^:]+):(?Pcharge|refund)$" @@ -169,10 +182,12 @@ async def deduct_credits_locked_once( charge_key: str, biz_key: str | None = None, attempt_no: int | None = None, + record_meta: CreditRecordMeta | dict | None = None, ) -> BillingItem: """按 biz_key 做幂等扣费。 charge_key 只保留为业务分类;正式幂等以 biz_key 为准。 + record_meta 负责把业务归属、模块、步骤、token、模型快照写入 CreditRecord。 """ amount = _round2(amount) if amount <= 0: @@ -197,6 +212,7 @@ async def deduct_credits_locked_once( description=description, related_id=related_id, biz_key=biz_key, + record_meta=record_meta, ) return BillingItem(charge_key=charge_key, amount=amount, charged=True, biz_key=biz_key, attempt_no=attempt_no) @@ -208,7 +224,7 @@ async def charge_chatapi_prompt_usage( usage: Mapping[str, Any], project_name: str | None = None, ) -> BillingSummary: - """提示词整理扣费仍按记录维度一次性幂等,不参与生成失败媒体退款。""" + """项目记录提示词整理扣费;不参与生成失败媒体退款。""" project_name = project_name or "AI生成任务" items: list[BillingItem] = [] @@ -219,12 +235,19 @@ async def charge_chatapi_prompt_usage( input_tokens = _safe_int(usage.get("input_tokens")) output_tokens = _safe_int(usage.get("output_tokens")) text_credits = await calc_text_credits(db, input_tokens, output_tokens) + text_meta = await build_generation_record_prompt_meta( + db, + record_id=record.id, + attempt_no=attempt_no, + charge_kind=CHARGE_TEXT_PROMPT, + usage=usage, + ) items.append( await deduct_credits_locked_once( db, user_id=record.user_id, amount=text_credits, - description=f"ChatAPI提示词整理", + description=f"提示词优化 - {project_name}", related_id=record.id, charge_key=CHARGE_TEXT_PROMPT, biz_key=build_credit_biz_key( @@ -235,17 +258,25 @@ async def charge_chatapi_prompt_usage( action="charge", ), attempt_no=attempt_no, + record_meta=text_meta, ) ) file_tokens = usage.get("file_parse_tokens") or usage.get("file_tokens") or usage.get("document_tokens") or 0 file_parse_credits = await _calc_optional_token_credits(db, _safe_int(file_tokens), "file_parse_credits_per_1000_tokens") + file_meta = await build_generation_record_prompt_meta( + db, + record_id=record.id, + attempt_no=attempt_no, + charge_kind=CHARGE_FILE_PARSE, + usage={**dict(usage), "total_tokens": _safe_int(file_tokens), "input_tokens": _safe_int(file_tokens), "output_tokens": 0}, + ) items.append( await deduct_credits_locked_once( db, user_id=record.user_id, amount=file_parse_credits, - description=f"文件解析Token", + description="文件解析Token", related_id=record.id, charge_key=CHARGE_FILE_PARSE, biz_key=build_credit_biz_key( @@ -256,17 +287,25 @@ async def charge_chatapi_prompt_usage( action="charge", ), attempt_no=attempt_no, + record_meta=file_meta, ) ) vision_tokens = usage.get("vision_input_tokens") or usage.get("image_input_tokens") or usage.get("image_tokens") or 0 vision_input_credits = await _calc_optional_token_credits(db, _safe_int(vision_tokens), "vision_input_credits_per_1000_tokens") + vision_meta = await build_generation_record_prompt_meta( + db, + record_id=record.id, + attempt_no=attempt_no, + charge_kind=CHARGE_VISION_INPUT, + usage={**dict(usage), "total_tokens": _safe_int(vision_tokens), "input_tokens": _safe_int(vision_tokens), "output_tokens": 0}, + ) items.append( await deduct_credits_locked_once( db, user_id=record.user_id, amount=vision_input_credits, - description=f"图片理解Token", + description="图片理解Token", related_id=record.id, charge_key=CHARGE_VISION_INPUT, biz_key=build_credit_biz_key( @@ -277,6 +316,7 @@ async def charge_chatapi_prompt_usage( action="charge", ), attempt_no=attempt_no, + record_meta=vision_meta, ) ) @@ -297,7 +337,7 @@ async def charge_module_prompt_usage( description: str, attempt_no: int = 1, ) -> BillingSummary: - """爆款开头复刻模块图片/视频 AI 提词扣文本积分。 + """爆款开头复刻/拆镜复刻模块图片/视频 AI 提词扣文本积分。 文本提词属于已经发生的 LLM 消费: - 调用成功后按 input_tokens + output_tokens 扣费。 @@ -314,6 +354,7 @@ async def charge_module_prompt_usage( charge_kind=CHARGE_TEXT_PROMPT, action="charge", ) + record_meta = await build_module_step_prompt_meta(db, step_id=step_id, attempt_no=attempt_no, usage=usage) item = await deduct_credits_locked_once( db, user_id=user_id, @@ -323,10 +364,92 @@ async def charge_module_prompt_usage( charge_key=CHARGE_TEXT_PROMPT, biz_key=biz_key, attempt_no=attempt_no, + record_meta=record_meta, ) + + result = await db.execute(select(ModuleGenerationStep).where(ModuleGenerationStep.id == step_id).limit(1)) + step = result.scalar_one_or_none() + if step: + step.token_usage_id = record_meta.token_usage_id + step.model_config_id = usage.get("model_config_id") + step.input_tokens = record_meta.input_tokens + step.output_tokens = record_meta.output_tokens + step.total_tokens = record_meta.total_tokens + step.text_credits_cost = text_credits + return BillingSummary(record_id=step_id, user_id=user_id, items=[item]) +async def charge_shot_video_analysis_usage( + db: AsyncSession, + *, + user_id: str, + owner_type: str, + owner_id: str, + usage: Mapping[str, Any], + description: str, + billing_scene: str, + source_project_id: str | None = None, + source_step_id: str | None = None, + attempt_no: int | None = None, +) -> BillingSummary: + """拆镜复刻视频分析扣分析积分。 + + 视频分析属于“文字提示词 + 视频素材”的模型调用类消费, + 按 input_tokens + output_tokens 参考文本积分规则计费, + 但账务归类为 analysis/video_analysis,避免混入提词优化统计。 + """ + attempt_no = attempt_no or await get_next_credit_attempt_no( + db, + owner_type=owner_type, + owner_id=owner_id, + charge_kind=CHARGE_VIDEO_ANALYSIS, + ) + input_tokens = _safe_int(usage.get("input_tokens")) + output_tokens = _safe_int(usage.get("output_tokens")) + amount = await calc_text_credits(db, input_tokens, output_tokens) + biz_key = build_credit_biz_key( + owner_type=owner_type, + owner_id=owner_id, + attempt_no=attempt_no, + charge_kind=CHARGE_VIDEO_ANALYSIS, + action="charge", + ) + record_meta = await build_shot_video_analysis_meta( + db, + owner_type=owner_type, + owner_id=owner_id, + attempt_no=attempt_no, + usage=usage, + billing_scene=billing_scene, + source_project_id=source_project_id, + source_step_id=source_step_id, + ) + item = await deduct_credits_locked_once( + db, + user_id=user_id, + amount=amount, + description=description, + related_id=owner_id, + charge_key=CHARGE_VIDEO_ANALYSIS, + biz_key=biz_key, + attempt_no=attempt_no, + record_meta=record_meta, + ) + + if record_meta.token_usage_id: + result = await db.execute(select(TokenUsage).where(TokenUsage.id == record_meta.token_usage_id).limit(1)) + token_usage = result.scalar_one_or_none() + if token_usage: + token_usage.owner_type = token_usage.owner_type or owner_type + token_usage.owner_id = token_usage.owner_id or owner_id + token_usage.biz_key = token_usage.biz_key or biz_key + token_usage.source_module = token_usage.source_module or CreditRecordSourceModule.SHOT_REPLICATE.value + token_usage.source_step_code = token_usage.source_step_code or "video_analysis" + + return BillingSummary(record_id=owner_id, user_id=user_id, items=[item]) + + async def charge_generation_media_by_params( db: AsyncSession, *, @@ -341,6 +464,11 @@ async def charge_generation_media_by_params( description_prefix: str = "AI创作-", owner_type: str = OWNER_CHAT_GENERATION_TASK, attempt_no: int | None = None, + source_module: str | None = None, + source_project_id: str | None = None, + source_step_id: str | None = None, + source_step_code: str | None = None, + billing_scene: str | None = None, ) -> BillingSummary: """图片/视频媒体生成扣费。 @@ -363,6 +491,19 @@ async def charge_generation_media_by_params( action="charge", ) items: list[BillingItem] = [] + record_meta = await build_generation_media_meta( + db, + owner_type=owner_type, + owner_id=record_id, + attempt_no=attempt_no, + gen_type=gen_type, + engine_id=engine_id, + source_module=source_module, + source_project_id=source_project_id, + source_step_id=source_step_id, + source_step_code=source_step_code, + billing_scene=billing_scene, + ) if gen_type == "image": size = image_size or "2K" @@ -377,6 +518,7 @@ async def charge_generation_media_by_params( charge_key=CHARGE_MEDIA, biz_key=biz_key, attempt_no=attempt_no, + record_meta=record_meta, ) ) elif gen_type == "video": @@ -391,6 +533,7 @@ async def charge_generation_media_by_params( charge_key=CHARGE_MEDIA, biz_key=biz_key, attempt_no=attempt_no, + record_meta=record_meta, ) ) else: @@ -419,4 +562,5 @@ async def charge_generation_media_for_record( description_prefix=description_prefix, owner_type=OWNER_GENERATION_RECORD, attempt_no=attempt_no, + source_module=CreditRecordSourceModule.GENERATION_RECORD.value, ) diff --git a/video-gen-api/app/services/generation_prompt_service.py b/video-gen-api/app/services/generation_prompt_service.py index 25c7408f..f7719919 100644 --- a/video-gen-api/app/services/generation_prompt_service.py +++ b/video-gen-api/app/services/generation_prompt_service.py @@ -152,10 +152,13 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask if not content: raise RuntimeError("ChatAPI未返回有效prompt") + token_usage_id = generate_id() db.add(TokenUsage( - id=generate_id(), + id=token_usage_id, model_config_id=config.id, user_id=record.user_id, + owner_type="generation_record", + owner_id=record.id, input_tokens=input_tokens, output_tokens=output_tokens, total_tokens=total_tokens, @@ -178,6 +181,11 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask total_tokens=total_tokens, ) return content, { + "token_usage_id": token_usage_id, + "model_config_id": config.id, + "model_config_name": config.name, + "model_provider": config.provider, + "model_name": config.model_name, "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, diff --git a/video-gen-api/app/services/generation_refund_service.py b/video-gen-api/app/services/generation_refund_service.py index dcb0752a..7c937776 100644 --- a/video-gen-api/app/services/generation_refund_service.py +++ b/video-gen-api/app/services/generation_refund_service.py @@ -10,6 +10,7 @@ from app.models.chat_generation_task import ChatGenerationTask from app.models.credit_record import CreditRecord from app.models.generation_record import GenerationRecord from app.services.credits import refund_credits +from app.services.credit_record_meta_service import build_refund_meta_from_charge from app.services.generation_billing_service import ( CHARGE_MEDIA, OWNER_CHAT_GENERATION_TASK, @@ -113,6 +114,7 @@ async def refund_unrefunded_media_charges( related_id=owner_id, biz_key=refund_biz_key, refund_for_biz_key=charge.biz_key, + record_meta=build_refund_meta_from_charge(charge, attempt_no=attempt_no), ) total_refunded = round(total_refunded + amount, 2) return total_refunded diff --git a/video-gen-api/app/services/generation_task_factory_service.py b/video-gen-api/app/services/generation_task_factory_service.py index 21381157..6e4bbfab 100644 --- a/video-gen-api/app/services/generation_task_factory_service.py +++ b/video-gen-api/app/services/generation_task_factory_service.py @@ -63,6 +63,11 @@ async def create_chat_generation_task_for_module( resolution: str | None = None, billing_project_name: str = "模块生成任务", billing_description_prefix: str = "模块生成-", + billing_source_module: str | None = None, + billing_source_project_id: str | None = None, + billing_source_step_id: str | None = None, + billing_source_step_code: str | None = None, + billing_scene: str | None = None, ) -> ChatGenerationTask: """创建可复用的 ChatGenerationTask 子任务。 @@ -106,6 +111,11 @@ async def create_chat_generation_task_for_module( description_prefix=billing_description_prefix, owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=1, + source_module=billing_source_module, + source_project_id=billing_source_project_id, + source_step_id=billing_source_step_id, + source_step_code=billing_source_step_code, + billing_scene=billing_scene, ) snapshot = _build_image_snapshot(engine, size, proportion, px) task = ChatGenerationTask( @@ -155,6 +165,11 @@ async def create_chat_generation_task_for_module( description_prefix=billing_description_prefix, owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=1, + source_module=billing_source_module, + source_project_id=billing_source_project_id, + source_step_id=billing_source_step_id, + source_step_code=billing_source_step_code, + billing_scene=billing_scene, ) snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration) task = ChatGenerationTask( diff --git a/video-gen-api/app/services/hot_opening_replicate_service.py b/video-gen-api/app/services/hot_opening_replicate_service.py index 70bd5ddd..2a76bb99 100644 --- a/video-gen-api/app/services/hot_opening_replicate_service.py +++ b/video-gen-api/app/services/hot_opening_replicate_service.py @@ -951,6 +951,24 @@ async def generate_image_from_prompt( {"type": "image", "url": material.get("material_image_url"), "name": "新产品图片"}, ] + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + input_data={ + "engine_id": req.engine_id, + "params": { + "image_size": req.image_size, + "image_proportion": req.image_proportion, + "image_px": req.image_px, + }, + "prompt": optimized_prompt, + "media_references": refs, + }, + ) chat_task = await create_chat_generation_task_for_module( db, current_user=current_user, @@ -965,21 +983,30 @@ async def generate_image_from_prompt( image_px=req.image_px, billing_project_name=project.title or "爆款开头复刻", billing_description_prefix="爆款开头复刻图片生成", + billing_source_module=project.module, + billing_source_project_id=project.id, + billing_source_step_id=step.id, + billing_source_step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, ) - step = await _create_step( - db, - project=project, - step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, - status=ModuleStepStatusEnum.PROCESSING.value, - parent_step_id=prompt_step.id, - source_step_id=prompt_step.id, - chat_task_id=chat_task.id, - input_data={ - "engine_id": chat_task.engine_id, - "params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px}, - "prompt": optimized_prompt, - "media_references": refs, - }, + step.chat_task_id = chat_task.id + _force_set_json( + step, + "input_json", + _step_input( + step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value, + source_step_id=prompt_step.id, + parent_step_id=prompt_step.id, + payload={ + "engine_id": chat_task.engine_id, + "params": { + "image_size": chat_task.image_size, + "image_proportion": chat_task.image_proportion, + "image_px": chat_task.image_px, + }, + "prompt": optimized_prompt, + "media_references": refs, + }, + ), ) project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = HotOpeningStepCodeEnum.IMAGE_GENERATE.value @@ -1261,6 +1288,25 @@ async def generate_video_from_prompt( {"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"}, ] + step = await _create_step( + db, + project=project, + step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + input_data={ + "engine_id": req.engine_id or prompt_params.get("engine_id"), + "params": { + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + }, + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "media_references": refs, + }, + ) chat_task = await create_chat_generation_task_for_module( db, current_user=current_user, @@ -1275,29 +1321,34 @@ async def generate_video_from_prompt( resolution=resolution, billing_project_name=project.title or "爆款开头复刻", billing_description_prefix="爆款开头复刻视频生成", + billing_source_module=project.module, + billing_source_project_id=project.id, + billing_source_step_id=step.id, + billing_source_step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, ) - step = await _create_step( - db, - project=project, - step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, - status=ModuleStepStatusEnum.PROCESSING.value, - parent_step_id=prompt_step.id, - source_step_id=prompt_step.id, - chat_task_id=chat_task.id, - input_data={ - "engine_id": chat_task.engine_id, - "params": { - "duration": chat_task.duration, - "aspect_ratio": chat_task.aspect_ratio, - "resolution": chat_task.resolution, - "image_size": chat_task.image_size, - "image_proportion": chat_task.image_proportion, - "image_px": chat_task.image_px, + step.chat_task_id = chat_task.id + _force_set_json( + step, + "input_json", + _step_input( + step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value, + source_step_id=prompt_step.id, + parent_step_id=prompt_step.id, + payload={ + "engine_id": chat_task.engine_id, + "params": { + "duration": chat_task.duration, + "aspect_ratio": chat_task.aspect_ratio, + "resolution": chat_task.resolution, + "image_size": chat_task.image_size, + "image_proportion": chat_task.image_proportion, + "image_px": chat_task.image_px, + }, + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "media_references": refs, }, - "prompt_schema": prompt_schema, - "final_prompt": final_prompt, - "media_references": refs, - }, + ), ) project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = HotOpeningStepCodeEnum.VIDEO_GENERATE.value diff --git a/video-gen-api/app/services/hot_opening_video_prompt_service.py b/video-gen-api/app/services/hot_opening_video_prompt_service.py index a6077153..1a384a41 100644 --- a/video-gen-api/app/services/hot_opening_video_prompt_service.py +++ b/video-gen-api/app/services/hot_opening_video_prompt_service.py @@ -1488,9 +1488,10 @@ async def optimize_hot_opening_video_prompt( "total_tokens": int(usage.get("total_tokens") or 0), "log_user_message": log_user_message, } + token_usage_id = generate_id() db.add( TokenUsage( - id=generate_id(), + id=token_usage_id, model_config_id=config.id, user_id=user_id, input_tokens=token_usage["input_tokens"], @@ -1499,6 +1500,13 @@ async def optimize_hot_opening_video_prompt( ) ) await db.flush() + token_usage.update({ + "token_usage_id": token_usage_id, + "model_config_id": config.id, + "model_config_name": config.name, + "model_provider": config.provider, + "model_name": config.model_name, + }) result = parse_model_json(content) result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot) diff --git a/video-gen-api/app/services/llm.py b/video-gen-api/app/services/llm.py index fa70a529..8f1447bb 100644 --- a/video-gen-api/app/services/llm.py +++ b/video-gen-api/app/services/llm.py @@ -370,9 +370,11 @@ async def _call_openai_compatible( output_tokens = usage.get("completion_tokens", 0) total_tokens = usage.get("total_tokens", input_tokens + output_tokens) + token_usage_id = None if db is not None: + token_usage_id = generate_id() record = TokenUsage( - id=generate_id(), + id=token_usage_id, model_config_id=config.id, user_id=user_id, input_tokens=input_tokens, @@ -384,6 +386,11 @@ async def _call_openai_compatible( content = data["choices"][0]["message"]["content"].strip() token_usage = { + "token_usage_id": token_usage_id, + "model_config_id": config.id, + "model_config_name": config.name, + "model_provider": config.provider, + "model_name": config.model_name, "input_tokens": input_tokens, "output_tokens": output_tokens, "total_tokens": total_tokens, diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index b6c61ab3..bc269881 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -17,9 +17,45 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.payment_order import PaymentOrder from app.models.system_config import SystemConfig +from app.enums.credit_record import ( + CreditRecordAction, + CreditRecordBillingScene, + CreditRecordChargeKind, + CreditRecordOwnerType, + CreditRecordSourceModule, + CreditRecordSubject, +) +from app.services.credit_record_meta_service import CreditRecordMeta, build_recharge_meta from app.services.credits import add_credits, deduct_credits from app.utils.id_gen import generate_id, generate_order_no + + +def _payment_biz_key(order: PaymentOrder, *, charge_kind: str, action: str) -> str: + return ( + f"{CreditRecordOwnerType.PAYMENT_ORDER.value}:{order.id}:" + f"attempt:1:{charge_kind}:{action}" + ) + + +def _payment_recharge_meta(order: PaymentOrder) -> CreditRecordMeta: + meta = build_recharge_meta(owner_id=order.id) + meta.attempt_no = 1 + return meta + + +def _payment_refund_meta(order: PaymentOrder) -> CreditRecordMeta: + return CreditRecordMeta( + owner_type=CreditRecordOwnerType.PAYMENT_ORDER.value, + owner_id=order.id, + attempt_no=1, + charge_kind=CreditRecordChargeKind.REFUND.value, + charge_action=CreditRecordAction.REFUND.value, + credit_subject=CreditRecordSubject.REFUND.value, + billing_scene=CreditRecordBillingScene.REFUND.value, + source_module=CreditRecordSourceModule.PAYMENT.value, + ) + # --------------------------------------------------------------------------- # Payment logger → log/payment/YYYY-MM-DD.log (one file per day, no cleanup) # --------------------------------------------------------------------------- @@ -321,6 +357,12 @@ async def create_recharge_order( total_credits, desc, related_id=order.id, + biz_key=_payment_biz_key( + order, + charge_kind=CreditRecordChargeKind.RECHARGE.value, + action=CreditRecordAction.CHARGE.value, + ), + record_meta=_payment_recharge_meta(order), ) await db.flush() else: @@ -1094,6 +1136,12 @@ async def process_payment_success(db: AsyncSession, order_id: str): order.credits, f"充值成功({order.credits}积分)", related_id=order.id, + biz_key=_payment_biz_key( + order, + charge_kind=CreditRecordChargeKind.RECHARGE.value, + action=CreditRecordAction.CHARGE.value, + ), + record_meta=_payment_recharge_meta(order), ) await db.commit() @@ -1152,6 +1200,12 @@ async def process_payment_success_by_order_no( order.credits, f"充值成功({order.credits}积分), 订单号: {order_no}, 金额: {order.amount}", related_id=order.id, + biz_key=_payment_biz_key( + order, + charge_kind=CreditRecordChargeKind.RECHARGE.value, + action=CreditRecordAction.CHARGE.value, + ), + record_meta=_payment_recharge_meta(order), ) await db.commit() logger.info( @@ -1220,6 +1274,17 @@ async def process_refund( order.credits, refund_reason, related_id=order.id, + biz_key=_payment_biz_key( + order, + charge_kind=CreditRecordChargeKind.REFUND.value, + action=CreditRecordAction.REFUND.value, + ), + refund_for_biz_key=_payment_biz_key( + order, + charge_kind=CreditRecordChargeKind.RECHARGE.value, + action=CreditRecordAction.CHARGE.value, + ), + record_meta=_payment_refund_meta(order), ) except Exception as e: logger.exception(f"Failed to deduct credits for refund: {e}") diff --git a/video-gen-api/app/services/shot_replicate_flow_service.py b/video-gen-api/app/services/shot_replicate_flow_service.py index f0a2350a..3e0d0d68 100644 --- a/video-gen-api/app/services/shot_replicate_flow_service.py +++ b/video-gen-api/app/services/shot_replicate_flow_service.py @@ -903,6 +903,24 @@ async def generate_image_from_prompt( {"type": "image", "url": material.get("material_image_url"), "name": "新产品图片"}, ] + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + input_data={ + "engine_id": req.engine_id, + "params": { + "image_size": req.image_size, + "image_proportion": req.image_proportion, + "image_px": req.image_px, + }, + "prompt": optimized_prompt, + "media_references": refs, + }, + ) chat_task = await create_chat_generation_task_for_module( db, current_user=current_user, @@ -917,21 +935,30 @@ async def generate_image_from_prompt( image_px=req.image_px, billing_project_name=project.title or "拆镜复刻", billing_description_prefix="拆镜复刻图片生成", + billing_source_module=project.module, + billing_source_project_id=project.id, + billing_source_step_id=step.id, + billing_source_step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, ) - step = await _create_step( - db, - project=project, - step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, - status=ModuleStepStatusEnum.PROCESSING.value, - parent_step_id=prompt_step.id, - source_step_id=prompt_step.id, - chat_task_id=chat_task.id, - input_data={ - "engine_id": chat_task.engine_id, - "params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px}, - "prompt": optimized_prompt, - "media_references": refs, - }, + step.chat_task_id = chat_task.id + _force_set_json( + step, + "input_json", + _step_input( + step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value, + source_step_id=prompt_step.id, + parent_step_id=prompt_step.id, + payload={ + "engine_id": chat_task.engine_id, + "params": { + "image_size": chat_task.image_size, + "image_proportion": chat_task.image_proportion, + "image_px": chat_task.image_px, + }, + "prompt": optimized_prompt, + "media_references": refs, + }, + ), ) project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_GENERATE.value @@ -1223,6 +1250,25 @@ async def generate_video_from_prompt( {"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"}, ] + step = await _create_step( + db, + project=project, + step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, + status=ModuleStepStatusEnum.PROCESSING.value, + parent_step_id=prompt_step.id, + source_step_id=prompt_step.id, + input_data={ + "engine_id": req.engine_id or prompt_params.get("engine_id"), + "params": { + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + }, + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "media_references": refs, + }, + ) chat_task = await create_chat_generation_task_for_module( db, current_user=current_user, @@ -1237,29 +1283,34 @@ async def generate_video_from_prompt( resolution=resolution, billing_project_name=project.title or "拆镜复刻", billing_description_prefix="拆镜复刻视频生成", + billing_source_module=project.module, + billing_source_project_id=project.id, + billing_source_step_id=step.id, + billing_source_step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, ) - step = await _create_step( - db, - project=project, - step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, - status=ModuleStepStatusEnum.PROCESSING.value, - parent_step_id=prompt_step.id, - source_step_id=prompt_step.id, - chat_task_id=chat_task.id, - input_data={ - "engine_id": chat_task.engine_id, - "params": { - "duration": chat_task.duration, - "aspect_ratio": chat_task.aspect_ratio, - "resolution": chat_task.resolution, - "image_size": chat_task.image_size, - "image_proportion": chat_task.image_proportion, - "image_px": chat_task.image_px, + step.chat_task_id = chat_task.id + _force_set_json( + step, + "input_json", + _step_input( + step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value, + source_step_id=prompt_step.id, + parent_step_id=prompt_step.id, + payload={ + "engine_id": chat_task.engine_id, + "params": { + "duration": chat_task.duration, + "aspect_ratio": chat_task.aspect_ratio, + "resolution": chat_task.resolution, + "image_size": chat_task.image_size, + "image_proportion": chat_task.image_proportion, + "image_px": chat_task.image_px, + }, + "prompt_schema": prompt_schema, + "final_prompt": final_prompt, + "media_references": refs, }, - "prompt_schema": prompt_schema, - "final_prompt": final_prompt, - "media_references": refs, - }, + ), ) project.status = ModuleProjectStatusEnum.PROCESSING.value project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_GENERATE.value diff --git a/video-gen-api/app/services/shot_video_analysis_service.py b/video-gen-api/app/services/shot_video_analysis_service.py index 2da22640..643a5c2e 100644 --- a/video-gen-api/app/services/shot_video_analysis_service.py +++ b/video-gen-api/app/services/shot_video_analysis_service.py @@ -505,9 +505,10 @@ async def analyze_video_for_shot_split( if not token_usage["total_tokens"]: token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"] + token_usage_id = generate_id() db.add( TokenUsage( - id=generate_id(), + id=token_usage_id, model_config_id=config.id, user_id=user_id, input_tokens=token_usage["input_tokens"], @@ -516,6 +517,13 @@ async def analyze_video_for_shot_split( ) ) await db.flush() + token_usage.update({ + "token_usage_id": token_usage_id, + "model_config_id": config.id, + "model_config_name": config.name, + "model_provider": config.provider, + "model_name": config.model_name, + }) return ShotVideoAnalysisResult(result=result, raw_response=raw, usage=token_usage) diff --git a/video-gen-api/app/tasks/shot_replicate_tasks.py b/video-gen-api/app/tasks/shot_replicate_tasks.py index 2bf43753..8c5a5c73 100644 --- a/video-gen-api/app/tasks/shot_replicate_tasks.py +++ b/video-gen-api/app/tasks/shot_replicate_tasks.py @@ -7,6 +7,7 @@ from typing import Any from sqlalchemy import select from app.config import settings +from app.enums.credit_record import CreditRecordBillingScene, CreditRecordOwnerType from app.enums.shot_replicate import ( ModuleCodeEnum, ShotAnalysisStatusEnum, @@ -36,6 +37,7 @@ from app.services.module_async_recovery_service import ( ) from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary from app.services.shot_video_analysis_service import analyze_video_for_shot_split +from app.services.generation_billing_service import charge_shot_video_analysis_usage from app.services.shot_video_split_service import split_video_segment_async from app.services.upload_video_asset_service import validate_split_range from app.tasks.async_runner import run_async @@ -141,6 +143,16 @@ async def _run_analyze_original_video(task_set_id: str) -> None: task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value task_set.analysis_error_message = None + await charge_shot_video_analysis_usage( + db, + user_id=task_set.user_id, + owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value, + owner_id=task_set.id, + usage=analyzed.usage, + description="拆镜复刻-原视频分析", + billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value, + source_project_id=task_set.id, + ) await db.commit() await cleanup_active_if_terminal(db, object_type=OBJECT_SHOT_TASK_SET_ANALYSIS, object_id=task_set_id) @@ -260,6 +272,17 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None: segment.analysis_json = result_json segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value segment.analysis_error_message = None + await charge_shot_video_analysis_usage( + db, + user_id=segment.user_id, + owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value, + owner_id=segment.id, + usage=analyzed.usage, + description="拆镜复刻-片段视频分析", + billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value, + source_project_id=segment.task_set_id, + source_step_id=segment.id, + ) await db.commit() await cleanup_active_if_terminal(db, object_type=OBJECT_SHOT_SEGMENT_ANALYSIS, object_id=segment_id)