交易流水对账明细导出完成
This commit is contained in:
@@ -17,6 +17,8 @@ bak/
|
||||
*.tmp
|
||||
.DS_Store
|
||||
.playwright-mcp
|
||||
*.zip
|
||||
*.bat
|
||||
|
||||
# 否定规则(不忽略)
|
||||
# !important.log
|
||||
|
||||
Generated
+169
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<AdminUser> {
|
||||
return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind });
|
||||
}
|
||||
|
||||
export async function adjustCredits(userId: string, amount: number, description: string): Promise<void> {
|
||||
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<any> {
|
||||
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<AdminCreditRecordListResponse> {
|
||||
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<void> {
|
||||
|
||||
// ── 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<any> {
|
||||
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<any> {
|
||||
return api.post('/admin/users', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '退回', color: 'blue', icon: <RollbackOutlined /> },
|
||||
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<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
|
||||
};
|
||||
|
||||
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<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
||||
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<CreditRecord[]>([]);
|
||||
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
||||
const [summary, setSummary] = useState<AdminCreditRecordSummary>(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<string>('');
|
||||
const [userNameFilter, setUserNameFilter] = useState<string>('');
|
||||
|
||||
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<AdminCreditRecordQueryParams>(() => ({
|
||||
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<AdminCreditRecord>[] = [
|
||||
{ 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<AdminCreditRecord>({
|
||||
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) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
|
||||
return (
|
||||
<Tag color={cfg.color} icon={cfg.icon}>
|
||||
{cfg.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{v > 0 ? '+' : ''}{v.toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
|
||||
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '说明', dataIndex: 'description', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
||||
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
||||
{ 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 <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
||||
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
|
||||
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
||||
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
|
||||
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
|
||||
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}>入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}</div></div> },
|
||||
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
|
||||
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
||||
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#10b981',
|
||||
}}><ArrowUpOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总充值</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#ef4444',
|
||||
}}><ArrowDownOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总消费</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}><WalletOutlined /></div>
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>交易笔数</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{total}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card size="small" bordered={false}>图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>提词消费:{n(summary.textConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>视频分析:{n(summary.analysisConsume)} 积分</Card>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(value) => setTypeFilter(value)}
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '退回' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户名搜索"
|
||||
value={userNameFilter}
|
||||
onChange={(e) => setUserNameFilter(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||||
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
||||
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
|
||||
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
|
||||
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
|
||||
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
|
||||
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}>下载 Excel</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
@@ -204,13 +360,13 @@ const AdminCreditRecords: React.FC = () => {
|
||||
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 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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<string>('frontend');
|
||||
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
|
||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
const [createType, setCreateType] = useState<string>('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) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
|
||||
},
|
||||
...(!isAdminTab ? [{
|
||||
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
|
||||
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
|
||||
}] : []),
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => (
|
||||
@@ -238,6 +253,12 @@ const AdminUsers: React.FC = () => {
|
||||
调整积分
|
||||
</Button>
|
||||
)}
|
||||
{!isAdminTab && r.frontendUserKind !== 'internal' && (
|
||||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}>设为内部</Button>
|
||||
)}
|
||||
{!isAdminTab && r.frontendUserKind === 'internal' && (
|
||||
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}>取消内部</Button>
|
||||
)}
|
||||
<Button type="link" size="small" icon={<MenuOutlined />}
|
||||
onClick={() => openMenuModal(r)}>
|
||||
菜单权限
|
||||
@@ -329,6 +350,18 @@ const AdminUsers: React.FC = () => {
|
||||
style={{ width: 280, borderRadius: 8 }}
|
||||
allowClear
|
||||
/>
|
||||
{!isAdminTab && (
|
||||
<Select
|
||||
value={frontendKindFilter}
|
||||
onChange={(v) => { setPage(1); setFrontendKindFilter(v); }}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: '', label: '全部前台用户' },
|
||||
{ value: 'internal', label: '内部用户' },
|
||||
{ value: 'external', label: '外部用户' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />}
|
||||
@@ -340,7 +373,7 @@ const AdminUsers: React.FC = () => {
|
||||
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setPage(1); }}
|
||||
items={[
|
||||
{ key: 'frontend', label: '前台用户' },
|
||||
{ key: 'admin', label: '后台用户' },
|
||||
@@ -349,7 +382,7 @@ const AdminUsers: React.FC = () => {
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredUsers}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
@@ -412,6 +445,14 @@ const AdminUsers: React.FC = () => {
|
||||
{ value: 'admin', label: '后台管理员' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
{createType === 'frontend' && (
|
||||
<Form.Item name="frontend_user_kind" label="前台归类" initialValue="external">
|
||||
<Select size="large" options={[
|
||||
{ value: 'external', label: '外部用户' },
|
||||
{ value: 'internal', label: '内部用户' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
)}
|
||||
{createType === 'frontend' ? (
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||
<Input placeholder="请输入手机号" maxLength={11} size="large" />
|
||||
|
||||
@@ -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<string, any>;
|
||||
timePlan: Record<string, any>[];
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
declare module 'xlsx-js-style' {
|
||||
export const utils: any;
|
||||
export function writeFile(workbook: any, filename: string, options?: any): void;
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as XLSX from 'xlsx-js-style';
|
||||
|
||||
export type ExcelCellValue = string | number | boolean | Date | null | undefined;
|
||||
|
||||
export interface StyledExcelColumn<T = any> {
|
||||
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<T = any> {
|
||||
filename: string;
|
||||
sheetName: string;
|
||||
title?: string;
|
||||
metadataRows?: ExcelCellValue[][];
|
||||
summaryRows?: ExcelCellValue[][];
|
||||
columns: StyledExcelColumn<T>[];
|
||||
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<T>(options: StyledExcelExportOptions<T>): 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);
|
||||
}
|
||||
@@ -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 ───────────────────────────────────
|
||||
|
||||
@@ -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 阶段只处理提示词优化扣费。
|
||||
|
||||
@@ -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 *
|
||||
|
||||
@@ -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: "历史未知",
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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: "前台外部用户",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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<owner_type>[^:]+):(?P<owner_id>[^:]+):attempt:(?P<attempt_no>\d+):(?P<charge_kind>[^:]+):(?P<action>charge|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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.chat_task_id = chat_task.id
|
||||
_force_set_json(
|
||||
step,
|
||||
"input_json",
|
||||
_step_input(
|
||||
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={
|
||||
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},
|
||||
"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,16 +1321,20 @@ 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.chat_task_id = chat_task.id
|
||||
_force_set_json(
|
||||
step,
|
||||
"input_json",
|
||||
_step_input(
|
||||
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={
|
||||
parent_step_id=prompt_step.id,
|
||||
payload={
|
||||
"engine_id": chat_task.engine_id,
|
||||
"params": {
|
||||
"duration": chat_task.duration,
|
||||
@@ -1298,6 +1348,7 @@ async def generate_video_from_prompt(
|
||||
"final_prompt": final_prompt,
|
||||
"media_references": refs,
|
||||
},
|
||||
),
|
||||
)
|
||||
project.status = ModuleProjectStatusEnum.PROCESSING.value
|
||||
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_GENERATE.value
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.chat_task_id = chat_task.id
|
||||
_force_set_json(
|
||||
step,
|
||||
"input_json",
|
||||
_step_input(
|
||||
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={
|
||||
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},
|
||||
"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,16 +1283,20 @@ 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.chat_task_id = chat_task.id
|
||||
_force_set_json(
|
||||
step,
|
||||
"input_json",
|
||||
_step_input(
|
||||
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={
|
||||
parent_step_id=prompt_step.id,
|
||||
payload={
|
||||
"engine_id": chat_task.engine_id,
|
||||
"params": {
|
||||
"duration": chat_task.duration,
|
||||
@@ -1260,6 +1310,7 @@ async def generate_video_from_prompt(
|
||||
"final_prompt": final_prompt,
|
||||
"media_references": refs,
|
||||
},
|
||||
),
|
||||
)
|
||||
project.status = ModuleProjectStatusEnum.PROCESSING.value
|
||||
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_GENERATE.value
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user