解决冲突

This commit is contained in:
Lrd
2026-06-25 17:39:23 +08:00
44 changed files with 4596 additions and 1493 deletions
+2
View File
@@ -17,6 +17,8 @@ bak/
*.tmp
.DS_Store
.playwright-mcp
*.zip
*.bat
# 否定规则(不忽略)
# !important.log
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-wN5bY_f7.js"></script>
<script type="module" crossorigin src="/assets/index-CxLKoDUI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head>
<body>
+169
View File
@@ -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",
+1
View File
@@ -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": {
+35 -14
View File
@@ -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);
}
+312 -156
View File
@@ -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 variant="outlined" 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>
</div>
</Card>
<Card variant="outlined" 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 variant="outlined" 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 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>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<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>
+47 -6
View File
@@ -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" />
+118 -32
View File
@@ -18,6 +18,26 @@ interface OpenType {
updatedAt: string;
}
const normalizeUploadError = (e: unknown): Error => {
if (e instanceof Error) {
return e;
}
if (typeof e === 'string') {
return new Error(e);
}
return new Error('上传失败');
};
const assertUploadFile = (file: unknown): File => {
if (file instanceof File) {
return file;
}
throw new Error('请选择有效图片文件');
};
const AdminPlatform: React.FC = () => {
const [openTypes, setOpenTypes] = useState<OpenType[]>([]);
const [total, setTotal] = useState(0);
@@ -49,7 +69,9 @@ const AdminPlatform: React.FC = () => {
}
};
useEffect(() => { load(); }, []);
useEffect(() => {
load();
}, []);
const handleCreate = async () => {
try {
@@ -137,53 +159,89 @@ const AdminPlatform: React.FC = () => {
const columns = [
{
title: 'ID', dataIndex: 'id', width: 100,
title: 'ID',
dataIndex: 'id',
width: 100,
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
},
{
title: '开户类型', dataIndex: 'open_type', width: 100,
title: '开户类型',
dataIndex: 'open_type',
width: 100,
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
},
{
title: '类型名称', dataIndex: 'type_name', width: 150,
title: '类型名称',
dataIndex: 'type_name',
width: 150,
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
},
{
title: '描述', dataIndex: 'description', width: 250, ellipsis: true,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{v}</Typography.Text>,
title: '描述',
dataIndex: 'description',
width: 250,
ellipsis: true,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{v}
</Typography.Text>
),
},
{
title: '缩略图', dataIndex: 'thumb', width: 120,
render: (v: string) => v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-',
title: '缩略图',
dataIndex: 'thumb',
width: 120,
render: (v: string) => (
v ? <img src={v} alt="thumb" style={{ width: 80, height: 60, objectFit: 'cover' }} /> : '-'
),
},
{
title: '创建时间', dataIndex: 'createdAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
title: '创建时间',
dataIndex: 'createdAt',
width: 160,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(v)}
</Typography.Text>
),
},
{
title: '更新时间', dataIndex: 'updatedAt', width: 160,
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
title: '更新时间',
dataIndex: 'updatedAt',
width: 160,
render: (v: string) => (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(v)}
</Typography.Text>
),
},
{
title: '操作', width: 200,
title: '操作',
width: 200,
render: (_: any, record: OpenType) => (
<Space>
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleDetail(record.id)}
></Button>
>
</Button>
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleUpdate(record.id)}
></Button>
>
</Button>
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
></Button>
>
</Button>
</Space>
),
},
@@ -198,8 +256,12 @@ const AdminPlatform: React.FC = () => {
<Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
</Space>
<Space>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}></Button>
<Button icon={<ReloadOutlined />} onClick={() => load()}></Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => load()}>
</Button>
</Space>
</div>
<Table
@@ -212,7 +274,11 @@ const AdminPlatform: React.FC = () => {
pageSize,
total,
showTotal: (t) => `${t} 条记录`,
onChange: (p, ps) => { setPage(p); setPageSize(ps); load(p, ps); },
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
load(p, ps);
},
}}
scroll={{ x: 1000 }}
/>
@@ -231,7 +297,6 @@ const AdminPlatform: React.FC = () => {
width={600}
>
<Form form={createForm} layout="vertical">
<div style={{ display: 'flex', gap: 16 }}>
<Form.Item
name="open_type"
@@ -245,7 +310,10 @@ const AdminPlatform: React.FC = () => {
name="type_name"
label="类型名称"
style={{ flex: 1 }}
rules={[{ required: true, message: '请输入类型名称' }, { max: 100, message: '类型名称不能超过100个字符' }]}
rules={[
{ required: true, message: '请输入类型名称' },
{ max: 100, message: '类型名称不能超过100个字符' },
]}
>
<Input style={{ width: '100%' }} placeholder="请输入类型名称" />
</Form.Item>
@@ -266,12 +334,13 @@ const AdminPlatform: React.FC = () => {
listType="picture-card"
customRequest={async ({ file, onSuccess, onError }) => {
try {
const res = await uploadImage(file as File);
const uploadFile = assertUploadFile(file);
const res = await uploadImage(uploadFile);
console.log(res);
createForm.setFieldsValue({ thumb: res.url });
onSuccess(res);
} catch (e: any) {
onError(e);
onSuccess?.(res);
} catch (e) {
onError?.(normalizeUploadError(e));
}
}}
>
@@ -301,7 +370,16 @@ const AdminPlatform: React.FC = () => {
<p><strong>:</strong> {currentOpenType.open_type}</p>
<p><strong>:</strong> {currentOpenType.type_name}</p>
<p><strong>:</strong> {currentOpenType.description}</p>
<p><strong>:</strong> {currentOpenType.thumb ? <img src={currentOpenType.thumb} alt="thumb" style={{ width: 120, height: 80, objectFit: 'cover' }} /> : '-'}</p>
<p>
<strong>:</strong>{' '}
{currentOpenType.thumb ? (
<img
src={currentOpenType.thumb}
alt="thumb"
style={{ width: 120, height: 80, objectFit: 'cover' }}
/>
) : '-'}
</p>
<p><strong>:</strong> {formatDate(currentOpenType.createdAt)}</p>
<p><strong>:</strong> {formatDate(currentOpenType.updatedAt)}</p>
</div>
@@ -332,7 +410,10 @@ const AdminPlatform: React.FC = () => {
<Form.Item
name="type_name"
label="类型名称"
rules={[{ required: true, message: '请输入类型名称' }, { max: 100, message: '类型名称不能超过100个字符' }]}
rules={[
{ required: true, message: '请输入类型名称' },
{ max: 100, message: '类型名称不能超过100个字符' },
]}
>
<Input placeholder="请输入类型名称" />
</Form.Item>
@@ -349,14 +430,19 @@ const AdminPlatform: React.FC = () => {
>
<Upload
listType="picture-card"
defaultFileList={currentOpenType?.thumb ? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }] : []}
defaultFileList={
currentOpenType?.thumb
? [{ uid: '1', name: 'thumb', status: 'done', url: currentOpenType.thumb }]
: []
}
customRequest={async ({ file, onSuccess, onError }) => {
try {
const res = await uploadImage(file);
const uploadFile = assertUploadFile(file);
const res = await uploadImage(uploadFile);
updateForm.setFieldsValue({ thumb: res.url });
onSuccess(res);
} catch (e: any) {
onError(e);
onSuccess?.(res);
} catch (e) {
onError?.(normalizeUploadError(e));
}
}}
>
+97
View File
@@ -104,6 +104,7 @@ export interface AdminUser {
isActive: boolean;
isAdmin: boolean;
userType: string;
frontendUserKind: 'internal' | 'external';
createdAt: string;
lastLoginAt?: string;
allowedMenus?: string[] | null;
@@ -278,6 +279,7 @@ export interface GenerationAIEngineSnapshot {
name?: string;
provider?: string;
modelName?: string;
model?: string;
supportedModels?: string[];
defaultSize?: string;
selectedSize?: string;
@@ -673,3 +675,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;
}
+4
View File
@@ -0,0 +1,4 @@
declare module 'xlsx-js-style' {
export const utils: any;
export function writeFile(workbook: any, filename: string, options?: any): void;
}
+212
View File
@@ -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<number>((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<number>((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);
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminplatform.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
@@ -0,0 +1,935 @@
"""add credit record billing snapshots and frontend user kind
Revision ID: 0a8da2d3c091
Revises: 5e2c124e5484
Create Date: 2026-06-25 13:25:21.246025
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0a8da2d3c091"
down_revision: Union[str, None] = "5e2c124e5484"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1) 表结构变更。
# 注意:autogenerate 生成的 upload_task.other_info 删除语句与本次需求无关,已移除。
op.add_column("users", sa.Column("frontend_user_kind", sa.String(length=16), server_default="external", nullable=False))
op.add_column("credit_records", sa.Column("owner_type", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("owner_id", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("attempt_no", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("charge_kind", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("charge_action", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("credit_subject", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("media_type", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("billing_scene", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_module", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_project_id", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_step_id", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("source_step_code", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("token_usage_id", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("input_tokens", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("output_tokens", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("total_tokens", sa.Integer(), nullable=True))
op.add_column("credit_records", sa.Column("engine_type", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("engine_id", sa.String(length=32), nullable=True))
op.add_column("credit_records", sa.Column("engine_name", sa.String(length=128), nullable=True))
op.add_column("credit_records", sa.Column("engine_provider", sa.String(length=64), nullable=True))
op.add_column("credit_records", sa.Column("engine_model_name", sa.String(length=128), nullable=True))
op.add_column("credit_records", sa.Column("user_type_snapshot", sa.String(length=16), nullable=True))
op.add_column("credit_records", sa.Column("frontend_user_kind_snapshot", sa.String(length=16), nullable=True))
op.add_column("module_generation_steps", sa.Column("token_usage_id", sa.String(length=32), nullable=True))
op.add_column("module_generation_steps", sa.Column("model_config_id", sa.String(length=32), nullable=True))
op.add_column("module_generation_steps", sa.Column("input_tokens", sa.Integer(), nullable=True))
op.add_column("module_generation_steps", sa.Column("output_tokens", sa.Integer(), nullable=True))
op.add_column("module_generation_steps", sa.Column("total_tokens", sa.Integer(), nullable=True))
op.add_column("module_generation_steps", sa.Column("text_credits_cost", sa.Float(), nullable=True))
op.add_column("token_usage", sa.Column("owner_type", sa.String(length=64), nullable=True))
op.add_column("token_usage", sa.Column("owner_id", sa.String(length=64), nullable=True))
op.add_column("token_usage", sa.Column("biz_key", sa.String(length=160), nullable=True))
op.add_column("token_usage", sa.Column("source_module", sa.String(length=64), nullable=True))
op.add_column("token_usage", sa.Column("source_step_code", sa.String(length=64), nullable=True))
# 2) 历史数据补齐。
_backfill_legacy_data()
# 3) 索引。放在历史补齐之后,避免大批量 UPDATE 时维护过多新索引。
op.create_index(op.f("ix_users_frontend_user_kind"), "users", ["frontend_user_kind"], unique=False)
op.create_index(op.f("ix_users_user_type"), "users", ["user_type"], unique=False)
op.create_index(op.f("ix_credit_records_billing_scene"), "credit_records", ["billing_scene"], unique=False)
op.create_index(op.f("ix_credit_records_charge_kind"), "credit_records", ["charge_kind"], unique=False)
op.create_index(op.f("ix_credit_records_credit_subject"), "credit_records", ["credit_subject"], unique=False)
op.create_index(op.f("ix_credit_records_engine_id"), "credit_records", ["engine_id"], unique=False)
op.create_index(op.f("ix_credit_records_engine_type"), "credit_records", ["engine_type"], unique=False)
op.create_index(op.f("ix_credit_records_frontend_user_kind_snapshot"), "credit_records", ["frontend_user_kind_snapshot"], unique=False)
op.create_index(op.f("ix_credit_records_media_type"), "credit_records", ["media_type"], unique=False)
op.create_index("ix_credit_records_owner", "credit_records", ["owner_type", "owner_id"], unique=False)
op.create_index(op.f("ix_credit_records_owner_id"), "credit_records", ["owner_id"], unique=False)
op.create_index(op.f("ix_credit_records_owner_type"), "credit_records", ["owner_type"], unique=False)
op.create_index(op.f("ix_credit_records_source_module"), "credit_records", ["source_module"], unique=False)
op.create_index("ix_credit_records_source_module_scene", "credit_records", ["source_module", "billing_scene"], unique=False)
op.create_index(op.f("ix_credit_records_source_project_id"), "credit_records", ["source_project_id"], unique=False)
op.create_index(op.f("ix_credit_records_source_step_code"), "credit_records", ["source_step_code"], unique=False)
op.create_index(op.f("ix_credit_records_source_step_id"), "credit_records", ["source_step_id"], unique=False)
op.create_index("ix_credit_records_subject_media", "credit_records", ["credit_subject", "media_type"], unique=False)
op.create_index(op.f("ix_credit_records_token_usage_id"), "credit_records", ["token_usage_id"], unique=False)
op.create_index(op.f("ix_credit_records_type"), "credit_records", ["type"], unique=False)
op.create_index("ix_credit_records_user_kind_time", "credit_records", ["user_type_snapshot", "frontend_user_kind_snapshot", "created_at"], unique=False)
op.create_index(op.f("ix_credit_records_user_type_snapshot"), "credit_records", ["user_type_snapshot"], unique=False)
op.create_index(op.f("ix_module_generation_steps_model_config_id"), "module_generation_steps", ["model_config_id"], unique=False)
op.create_index(op.f("ix_module_generation_steps_token_usage_id"), "module_generation_steps", ["token_usage_id"], unique=False)
op.create_index("ix_token_usage_biz_key", "token_usage", ["biz_key"], unique=False)
op.create_index("ix_token_usage_owner", "token_usage", ["owner_type", "owner_id"], unique=False)
op.create_index(op.f("ix_token_usage_owner_id"), "token_usage", ["owner_id"], unique=False)
op.create_index(op.f("ix_token_usage_owner_type"), "token_usage", ["owner_type"], unique=False)
op.create_index(op.f("ix_token_usage_source_module"), "token_usage", ["source_module"], unique=False)
op.create_index(op.f("ix_token_usage_source_step_code"), "token_usage", ["source_step_code"], unique=False)
def _backfill_legacy_data() -> None:
"""修复历史账务流水的归类、模块、步骤、token、模型和引擎快照。
设计原则:
- 只补能确定的数据,不用“同用户 + 时间接近 + token 相等”这类不可靠方式强行匹配。
- ModuleGenerationStep 包含 deleted_at 不为空的软删数据,保证历史流水仍可展示来源。
- CreditRecord 是账务事实表,历史业务数据软删不影响流水展示。
- CreditRecord 统一使用 engine_* 承载执行配置快照:model/image/video。
"""
# users.frontend_user_kind 默认外部用户。
op.execute(
"""
UPDATE users
SET frontend_user_kind = 'external'
WHERE frontend_user_kind IS NULL OR frontend_user_kind NOT IN ('internal', 'external')
"""
)
# 用户类型快照:按迁移时 users 当前值兜底。
op.execute(
"""
UPDATE credit_records cr
SET
user_type_snapshot = COALESCE(NULLIF(u.user_type, ''), CASE WHEN COALESCE(u.is_admin, false) THEN 'admin' ELSE 'frontend' END),
frontend_user_kind_snapshot = CASE
WHEN COALESCE(NULLIF(u.user_type, ''), CASE WHEN COALESCE(u.is_admin, false) THEN 'admin' ELSE 'frontend' END) = 'frontend'
THEN COALESCE(NULLIF(u.frontend_user_kind, ''), 'external')
ELSE COALESCE(NULLIF(u.frontend_user_kind, ''), 'external')
END
FROM users u
WHERE cr.user_id = u.id
"""
)
# 解析标准 biz_keyowner_type:owner_id:attempt:{n}:charge_kind:charge/refund。
op.execute(
"""
UPDATE credit_records
SET
owner_type = split_part(biz_key, ':', 1),
owner_id = split_part(biz_key, ':', 2),
attempt_no = split_part(biz_key, ':', 4)::integer,
charge_kind = split_part(biz_key, ':', 5),
charge_action = split_part(biz_key, ':', 6)
WHERE biz_key IS NOT NULL
AND biz_key ~ '^[^:]+:[^:]+:attempt:[0-9]+:[^:]+:(charge|refund)$'
"""
)
# 少数退款记录如果自身 biz_key 不完整,用 refund_for_biz_key 兜底解析业务归属。
op.execute(
"""
UPDATE credit_records
SET
owner_type = split_part(refund_for_biz_key, ':', 1),
owner_id = split_part(refund_for_biz_key, ':', 2),
attempt_no = split_part(refund_for_biz_key, ':', 4)::integer,
charge_kind = split_part(refund_for_biz_key, ':', 5),
charge_action = 'refund'
WHERE type = 'refund'
AND (owner_type IS NULL OR owner_type = 'unknown' OR owner_id IS NULL)
AND refund_for_biz_key IS NOT NULL
AND refund_for_biz_key ~ '^[^:]+:[^:]+:attempt:[0-9]+:[^:]+:(charge|refund)$'
"""
)
# 支付充值流水。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'payment_order'),
owner_id = COALESCE(cr.owner_id, po.id),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'recharge'),
charge_action = COALESCE(cr.charge_action, 'charge'),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'recharge'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'recharge'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'payment')
FROM payment_orders po
WHERE cr.related_id = po.id
AND cr.type = 'recharge'
AND (cr.description IS NULL OR cr.description NOT LIKE '管理员调整:%')
"""
)
# 支付退款扣回积分。历史上它通常是 type=consume,但属于支付退款,不应该混入生成消费。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'payment_order'),
owner_id = COALESCE(cr.owner_id, po.id),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'refund'),
charge_action = COALESCE(cr.charge_action, 'refund'),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'refund'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'refund'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'payment')
FROM payment_orders po
WHERE cr.related_id = po.id
AND cr.type = 'consume'
AND (cr.description ILIKE '%退款%' OR po.status = 'refunded')
"""
)
# 管理员手动调整,不混入普通充值/消费。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'admin_adjust'),
owner_id = COALESCE(cr.owner_id, cr.related_id),
charge_kind = 'admin_adjust',
charge_action = CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END,
credit_subject = 'admin_adjust',
billing_scene = 'admin_adjust',
source_module = 'admin'
WHERE cr.description LIKE '管理员调整:%'
"""
)
# GenerationRecord 旧版提示词优化裸扣费:generation.py /optimize 历史上未传 related_id / biz_key / record_meta。
# 只做严格唯一匹配:同用户、同项目名称描述、同扣费金额,并且 credit_record 与 generation_record 均唯一时才回填。
op.execute(
"""
WITH candidates AS (
SELECT
cr.id AS credit_record_id,
gr.id AS generation_record_id,
gr.text_tokens_used,
gr.text_credits_cost
FROM credit_records cr
JOIN generation_records gr
ON gr.user_id = cr.user_id
JOIN projects p
ON p.id = gr.project_id
WHERE cr.type = 'consume'
AND cr.biz_key IS NULL
AND cr.refund_for_biz_key IS NULL
AND cr.related_id IS NULL
AND (cr.owner_type IS NULL OR cr.owner_type = 'unknown')
AND (cr.source_module IS NULL OR cr.source_module = 'unknown')
AND (cr.billing_scene IS NULL OR cr.billing_scene = 'unknown')
AND COALESCE(gr.text_credits_cost, 0) > 0
AND COALESCE(gr.text_tokens_used, 0) > 0
AND cr.description = '提示词优化 - ' || p.name
AND ABS(ABS(cr.amount)::numeric - ROUND(COALESCE(gr.text_credits_cost, 0)::numeric, 2)) < 0.01
),
unique_credit AS (
SELECT credit_record_id
FROM candidates
GROUP BY credit_record_id
HAVING COUNT(*) = 1
),
unique_generation AS (
SELECT generation_record_id
FROM candidates
GROUP BY generation_record_id
HAVING COUNT(*) = 1
),
matched AS (
SELECT c.*
FROM candidates c
JOIN unique_credit uc ON uc.credit_record_id = c.credit_record_id
JOIN unique_generation ug ON ug.generation_record_id = c.generation_record_id
)
UPDATE credit_records cr
SET
related_id = COALESCE(cr.related_id, matched.generation_record_id),
owner_type = 'generation_record',
owner_id = matched.generation_record_id,
attempt_no = COALESCE(cr.attempt_no, 1),
charge_kind = 'text_prompt',
charge_action = 'charge',
credit_subject = 'text',
billing_scene = 'generation_record_text_prompt_optimize',
source_module = 'generation_record',
total_tokens = COALESCE(cr.total_tokens, NULLIF(matched.text_tokens_used, 0)),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
FROM matched
WHERE cr.id = matched.credit_record_id
"""
)
# GenerationRecord:项目记录提词/文件解析/图片理解/图片生成/视频生成。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'generation_record'),
owner_id = COALESCE(cr.owner_id, gr.id),
related_id = COALESCE(cr.related_id, gr.id),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'generation_record'),
media_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) IN ('image', 'video') THEN lower(gr.gen_type)
ELSE cr.media_type
END,
total_tokens = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'text_prompt' THEN COALESCE(cr.total_tokens, NULLIF(gr.text_tokens_used, 0))
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'image' THEN COALESCE(cr.total_tokens, NULLIF(gr.image_tokens_used, 0))
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'video' THEN COALESCE(cr.total_tokens, NULLIF(gr.video_tokens_used, 0))
ELSE cr.total_tokens
END,
credit_subject = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' THEN 'media'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input') THEN 'text'
ELSE cr.credit_subject
END,
billing_scene = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'text_prompt' THEN 'generation_record_text_prompt_optimize'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'file_parse' THEN 'generation_record_file_parse'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'vision_input' THEN 'generation_record_vision_input'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'image' THEN 'generation_record_image_generate'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'video' THEN 'generation_record_video_generate'
ELSE cr.billing_scene
END,
engine_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input') THEN COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
ELSE cr.engine_type
END
FROM generation_records gr
WHERE (cr.owner_type = 'generation_record' AND cr.owner_id = gr.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = gr.id)
"""
)
# ModuleGenerationStep 提词优化:从 output_json.usage.credit_biz_key 精准匹配 CreditRecord.biz_key。
# 包含软删步骤,不加 deleted_at 过滤。
op.execute(
"""
WITH step_usage AS (
SELECT
s.id AS step_id,
s.project_id,
s.module,
s.step_code,
(s.output_json::jsonb -> 'usage') AS usage,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'tokenUsageId', ''),
NULLIF(s.output_json::jsonb ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb ->> 'tokenUsageId', '')
) AS token_usage_id,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'id', ''),
NULLIF(s.output_json::jsonb ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'id', '')
) AS model_config_id
FROM module_generation_steps s
WHERE s.output_json IS NOT NULL
AND jsonb_typeof(s.output_json::jsonb) = 'object'
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
)
UPDATE module_generation_steps s
SET
token_usage_id = COALESCE(s.token_usage_id, step_usage.token_usage_id),
model_config_id = COALESCE(s.model_config_id, step_usage.model_config_id),
input_tokens = COALESCE(
s.input_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'input_tokens')::integer ELSE NULL END
),
output_tokens = COALESCE(
s.output_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'output_tokens')::integer ELSE NULL END
),
total_tokens = COALESCE(
s.total_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'total_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'total_tokens')::integer ELSE NULL END,
CASE
WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$'
AND COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$'
THEN (step_usage.usage ->> 'input_tokens')::integer + (step_usage.usage ->> 'output_tokens')::integer
ELSE NULL
END
),
text_credits_cost = COALESCE(
s.text_credits_cost,
CASE WHEN COALESCE(step_usage.usage ->> 'text_credits_cost', '') ~ '^[0-9]+(\\.[0-9]+)?$' THEN (step_usage.usage ->> 'text_credits_cost')::double precision ELSE NULL END
)
FROM step_usage
WHERE s.id = step_usage.step_id
"""
)
op.execute(
"""
WITH step_usage AS (
SELECT
s.id AS step_id,
s.project_id,
s.module,
s.step_code,
(s.output_json::jsonb -> 'usage') AS usage,
COALESCE(
NULLIF(s.token_usage_id, ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'tokenUsageId', ''),
NULLIF(s.output_json::jsonb ->> 'token_usage_id', ''),
NULLIF(s.output_json::jsonb ->> 'tokenUsageId', '')
) AS step_token_usage_id,
COALESCE(
NULLIF(s.model_config_id, ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'id', ''),
NULLIF(s.output_json::jsonb ->> 'model_config_id', ''),
NULLIF(s.output_json::jsonb ->> 'modelConfigId', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'id', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'id', '')
) AS step_model_config_id,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'name', ''),
NULLIF(s.output_json::jsonb ->> 'model_config_name', ''),
NULLIF(s.output_json::jsonb ->> 'modelConfigName', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'name', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'name', '')
) AS step_model_config_name,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelProvider', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'provider', ''),
NULLIF(s.output_json::jsonb ->> 'model_provider', ''),
NULLIF(s.output_json::jsonb ->> 'modelProvider', ''),
NULLIF(s.output_json::jsonb ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'provider', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'provider', '')
) AS step_model_provider,
COALESCE(
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb ->> 'model_name', ''),
NULLIF(s.output_json::jsonb ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'model_name', ''),
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'modelName', ''),
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'modelName', '')
) AS step_model_name
FROM module_generation_steps s
WHERE s.output_json IS NOT NULL
AND jsonb_typeof(s.output_json::jsonb) = 'object'
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
)
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'module_generation_step'),
owner_id = COALESCE(cr.owner_id, step_usage.step_id),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'text_prompt'),
charge_action = COALESCE(cr.charge_action, 'charge'),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'text'),
billing_scene = COALESCE(
NULLIF(cr.billing_scene, 'unknown'),
CASE
WHEN step_usage.module = 'hot_opening_replicate' AND step_usage.step_code = 'image_prompt_optimize' THEN 'hot_opening_image_prompt_optimize'
WHEN step_usage.module = 'hot_opening_replicate' AND step_usage.step_code = 'video_prompt_optimize' THEN 'hot_opening_video_prompt_optimize'
WHEN step_usage.module = 'shot_replicate' AND step_usage.step_code = 'image_prompt_optimize' THEN 'shot_image_prompt_optimize'
WHEN step_usage.module = 'shot_replicate' AND step_usage.step_code = 'video_prompt_optimize' THEN 'shot_video_prompt_optimize'
ELSE 'unknown'
END
),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), step_usage.module),
source_project_id = COALESCE(cr.source_project_id, step_usage.project_id),
source_step_id = COALESCE(cr.source_step_id, step_usage.step_id),
source_step_code = COALESCE(cr.source_step_code, step_usage.step_code),
token_usage_id = COALESCE(cr.token_usage_id, step_usage.step_token_usage_id),
input_tokens = COALESCE(
cr.input_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'input_tokens')::integer ELSE NULL END
),
output_tokens = COALESCE(
cr.output_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'output_tokens')::integer ELSE NULL END
),
total_tokens = COALESCE(
cr.total_tokens,
CASE WHEN COALESCE(step_usage.usage ->> 'total_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'total_tokens')::integer ELSE NULL END,
CASE
WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$'
AND COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$'
THEN (step_usage.usage ->> 'input_tokens')::integer + (step_usage.usage ->> 'output_tokens')::integer
ELSE NULL
END
),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model'),
engine_id = COALESCE(cr.engine_id, step_usage.step_model_config_id),
engine_name = COALESCE(cr.engine_name, step_usage.step_model_config_name),
engine_provider = COALESCE(cr.engine_provider, step_usage.step_model_provider),
engine_model_name = COALESCE(cr.engine_model_name, step_usage.step_model_name)
FROM step_usage
WHERE cr.biz_key = step_usage.usage ->> 'credit_biz_key'
"""
)
# TokenUsage 如果 usage 中已经记录 token_usage_id,则可以安全补 owner;否则不强行按时间/token 猜。
op.execute(
"""
WITH step_usage AS (
SELECT
s.id AS step_id,
s.module,
s.step_code,
(s.output_json::jsonb -> 'usage') AS usage
FROM module_generation_steps s
WHERE s.output_json IS NOT NULL
AND jsonb_typeof(s.output_json::jsonb) = 'object'
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
AND NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', '') IS NOT NULL
)
UPDATE token_usage tu
SET
owner_type = COALESCE(NULLIF(tu.owner_type, 'unknown'), 'module_generation_step'),
owner_id = COALESCE(tu.owner_id, step_usage.step_id),
biz_key = COALESCE(tu.biz_key, NULLIF(step_usage.usage ->> 'credit_biz_key', '')),
source_module = COALESCE(NULLIF(tu.source_module, 'unknown'), step_usage.module),
source_step_code = COALESCE(tu.source_step_code, step_usage.step_code)
FROM step_usage
WHERE tu.id = step_usage.usage ->> 'token_usage_id'
"""
)
# 拆镜复刻原视频整体分析流水:归属到任务集。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'shot_replicate_task_set'),
owner_id = COALESCE(cr.owner_id, ts.id),
attempt_no = COALESCE(cr.attempt_no, 1),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'video_analysis'),
charge_action = COALESCE(cr.charge_action, CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'analysis'),
media_type = COALESCE(cr.media_type, 'video'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'shot_original_video_analysis'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'shot_replicate'),
source_project_id = COALESCE(cr.source_project_id, ts.id),
source_step_code = COALESCE(cr.source_step_code, 'video_analysis'),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
FROM shot_replicate_task_sets ts
WHERE (
(cr.owner_type = 'shot_replicate_task_set' AND cr.owner_id = ts.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = ts.id)
)
AND cr.type IN ('consume', 'refund')
AND (
cr.charge_kind = 'video_analysis'
OR cr.billing_scene IN ('shot_video_analysis', 'shot_original_video_analysis')
OR cr.description ILIKE '%视频分析%'
OR cr.description ILIKE '%原视频分析%'
)
"""
)
# 拆镜复刻手动片段分析流水:归属到片段。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'shot_replicate_segment'),
owner_id = COALESCE(cr.owner_id, sg.id),
attempt_no = COALESCE(cr.attempt_no, 1),
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'video_analysis'),
charge_action = COALESCE(cr.charge_action, CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END),
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'analysis'),
media_type = COALESCE(cr.media_type, 'video'),
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'shot_segment_video_analysis'),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'shot_replicate'),
source_project_id = COALESCE(cr.source_project_id, sg.task_set_id),
source_step_id = COALESCE(cr.source_step_id, sg.id),
source_step_code = COALESCE(cr.source_step_code, 'video_analysis'),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
FROM shot_replicate_segments sg
WHERE (
(cr.owner_type = 'shot_replicate_segment' AND cr.owner_id = sg.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = sg.id)
)
AND cr.type IN ('consume', 'refund')
AND (
cr.charge_kind = 'video_analysis'
OR cr.billing_scene IN ('shot_video_analysis', 'shot_segment_video_analysis')
OR cr.description ILIKE '%片段分析%'
OR cr.description ILIKE '%手动%分析%'
)
"""
)
# Module media generationChatGenerationTask 被模块步骤引用时,优先归到具体模块/步骤,而不是 AI 创作。
op.execute(
"""
WITH step_task AS (
SELECT DISTINCT ON (s.chat_task_id)
s.chat_task_id,
s.id AS step_id,
s.project_id,
s.module,
s.step_code
FROM module_generation_steps s
WHERE s.chat_task_id IS NOT NULL
ORDER BY s.chat_task_id, s.is_current DESC, s.updated_at DESC
)
UPDATE credit_records cr
SET
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), step_task.module),
source_project_id = COALESCE(cr.source_project_id, step_task.project_id),
source_step_id = COALESCE(cr.source_step_id, step_task.step_id),
source_step_code = COALESCE(cr.source_step_code, step_task.step_code),
billing_scene = COALESCE(
NULLIF(cr.billing_scene, 'unknown'),
CASE
WHEN step_task.module = 'hot_opening_replicate' AND step_task.step_code = 'image_generate' THEN 'hot_opening_image_generate'
WHEN step_task.module = 'hot_opening_replicate' AND step_task.step_code = 'video_generate' THEN 'hot_opening_video_generate'
WHEN step_task.module = 'shot_replicate' AND step_task.step_code = 'image_generate' THEN 'shot_image_generate'
WHEN step_task.module = 'shot_replicate' AND step_task.step_code = 'video_generate' THEN 'shot_video_generate'
ELSE cr.billing_scene
END
)
FROM step_task
WHERE cr.owner_type = 'chat_generation_task'
AND cr.owner_id = step_task.chat_task_id
"""
)
# ChatGenerationTaskAI 创作图片/视频生成,以及媒体 token/引擎快照。
op.execute(
"""
UPDATE credit_records cr
SET
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'chat_generation_task'),
owner_id = COALESCE(cr.owner_id, t.id),
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'ai_creation'),
credit_subject = CASE WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' THEN 'media' ELSE cr.credit_subject END,
media_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) IN ('image', 'video') THEN lower(t.gen_type)
ELSE cr.media_type
END,
billing_scene = COALESCE(
NULLIF(cr.billing_scene, 'unknown'),
CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'image' THEN 'ai_creation_image_generate'
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'video' THEN 'ai_creation_video_generate'
ELSE cr.billing_scene
END
),
engine_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) IN ('image', 'video') THEN lower(t.gen_type)
ELSE cr.engine_type
END,
engine_id = COALESCE(cr.engine_id, t.engine_id),
total_tokens = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'image' THEN COALESCE(cr.total_tokens, NULLIF(t.image_tokens_used, 0))
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'video' THEN COALESCE(cr.total_tokens, NULLIF(t.video_tokens_used, 0))
ELSE cr.total_tokens
END
FROM chat_generation_tasks t
WHERE (cr.owner_type = 'chat_generation_task' AND cr.owner_id = t.id)
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = t.id)
"""
)
# 从 TokenUsage 补 CreditRecord 的 token 和 ModelConfig 执行快照,前提是已经能安全匹配 token_usage_id。
op.execute(
"""
UPDATE credit_records cr
SET
input_tokens = COALESCE(cr.input_tokens, NULLIF(tu.input_tokens, 0)),
output_tokens = COALESCE(cr.output_tokens, NULLIF(tu.output_tokens, 0)),
total_tokens = COALESCE(cr.total_tokens, NULLIF(tu.total_tokens, 0)),
engine_type = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR COALESCE(NULLIF(cr.credit_subject, 'unknown'), '') IN ('text', 'analysis')
THEN COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
ELSE cr.engine_type
END,
engine_id = CASE
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR COALESCE(NULLIF(cr.credit_subject, 'unknown'), '') IN ('text', 'analysis')
THEN COALESCE(cr.engine_id, tu.model_config_id)
ELSE cr.engine_id
END
FROM token_usage tu
WHERE cr.token_usage_id = tu.id
"""
)
# ModelConfig 快照统一冷备到 engine_*engine_type=model。
op.execute(
"""
UPDATE credit_records cr
SET
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model'),
engine_name = COALESCE(cr.engine_name, mc.name),
engine_provider = COALESCE(cr.engine_provider, mc.provider),
engine_model_name = COALESCE(cr.engine_model_name, mc.model_name)
FROM model_configs mc
WHERE cr.engine_id = mc.id
AND (
cr.engine_type = 'model'
OR cr.charge_kind IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR cr.credit_subject IN ('text', 'analysis')
)
"""
)
# 图片引擎快照。
op.execute(
"""
UPDATE credit_records cr
SET
engine_name = COALESCE(cr.engine_name, ie.name),
engine_provider = COALESCE(cr.engine_provider, ie.provider),
engine_model_name = COALESCE(cr.engine_model_name, ie.model_name),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'image')
FROM image_engines ie
WHERE cr.engine_id = ie.id
AND (cr.engine_type = 'image' OR cr.media_type = 'image' OR cr.engine_type IS NULL OR cr.engine_type = 'unknown')
"""
)
# 视频引擎快照。
op.execute(
"""
UPDATE credit_records cr
SET
engine_name = COALESCE(cr.engine_name, ve.name),
engine_provider = COALESCE(cr.engine_provider, ve.provider),
engine_model_name = COALESCE(cr.engine_model_name, ve.model_name),
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'video')
FROM video_engines ve
WHERE cr.engine_id = ve.id
AND (cr.engine_type = 'video' OR cr.media_type = 'video' OR cr.engine_type IS NULL OR cr.engine_type = 'unknown')
"""
)
# 退款流水复制原消费流水快照,但 billing_scene 固定为 refund,方便筛选回退流水。
op.execute(
"""
UPDATE credit_records r
SET
owner_type = COALESCE(NULLIF(r.owner_type, 'unknown'), c.owner_type),
owner_id = COALESCE(r.owner_id, c.owner_id),
attempt_no = COALESCE(r.attempt_no, c.attempt_no),
charge_kind = COALESCE(NULLIF(r.charge_kind, 'unknown'), c.charge_kind),
charge_action = 'refund',
credit_subject = COALESCE(NULLIF(r.credit_subject, 'unknown'), c.credit_subject, 'refund'),
media_type = COALESCE(r.media_type, c.media_type),
billing_scene = 'refund',
source_module = COALESCE(NULLIF(r.source_module, 'unknown'), c.source_module),
source_project_id = COALESCE(r.source_project_id, c.source_project_id),
source_step_id = COALESCE(r.source_step_id, c.source_step_id),
source_step_code = COALESCE(r.source_step_code, c.source_step_code),
token_usage_id = COALESCE(r.token_usage_id, c.token_usage_id),
input_tokens = COALESCE(r.input_tokens, c.input_tokens),
output_tokens = COALESCE(r.output_tokens, c.output_tokens),
total_tokens = COALESCE(r.total_tokens, c.total_tokens),
engine_type = COALESCE(NULLIF(r.engine_type, 'unknown'), c.engine_type),
engine_id = COALESCE(r.engine_id, c.engine_id),
engine_name = COALESCE(r.engine_name, c.engine_name),
engine_provider = COALESCE(r.engine_provider, c.engine_provider),
engine_model_name = COALESCE(r.engine_model_name, c.engine_model_name),
user_type_snapshot = COALESCE(r.user_type_snapshot, c.user_type_snapshot),
frontend_user_kind_snapshot = COALESCE(r.frontend_user_kind_snapshot, c.frontend_user_kind_snapshot)
FROM credit_records c
WHERE r.type = 'refund'
AND r.refund_for_biz_key = c.biz_key
"""
)
# 基础分类兜底。
op.execute(
"""
UPDATE credit_records
SET
charge_action = COALESCE(charge_action, CASE WHEN type = 'refund' THEN 'refund' ELSE 'charge' END),
charge_kind = COALESCE(
NULLIF(charge_kind, 'unknown'),
CASE
WHEN type = 'recharge' THEN 'recharge'
WHEN type = 'refund' THEN 'refund'
ELSE 'unknown'
END
),
credit_subject = COALESCE(
NULLIF(credit_subject, 'unknown'),
CASE
WHEN type = 'recharge' THEN 'recharge'
WHEN type = 'refund' THEN 'refund'
WHEN charge_kind = 'media' THEN 'media'
WHEN charge_kind IN ('text_prompt', 'file_parse', 'vision_input') THEN 'text'
WHEN charge_kind = 'module_create' THEN 'module'
WHEN charge_kind = 'video_analysis' THEN 'analysis'
WHEN charge_kind = 'video_split' THEN 'split'
WHEN charge_kind = 'admin_adjust' THEN 'admin_adjust'
ELSE 'unknown'
END
),
source_module = COALESCE(NULLIF(source_module, 'unknown'), CASE WHEN type = 'recharge' THEN 'payment' ELSE 'unknown' END),
billing_scene = COALESCE(
NULLIF(billing_scene, 'unknown'),
CASE
WHEN type = 'recharge' THEN 'recharge'
WHEN type = 'refund' THEN 'refund'
ELSE 'unknown'
END
),
owner_type = COALESCE(NULLIF(owner_type, 'unknown'), 'unknown'),
engine_type = CASE
WHEN charge_kind IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
OR credit_subject IN ('text', 'analysis')
THEN COALESCE(NULLIF(engine_type, 'unknown'), 'model')
ELSE engine_type
END
WHERE charge_kind IS NULL
OR charge_kind = 'unknown'
OR charge_action IS NULL
OR credit_subject IS NULL
OR credit_subject = 'unknown'
OR source_module IS NULL
OR source_module = 'unknown'
OR billing_scene IS NULL
OR billing_scene = 'unknown'
OR owner_type IS NULL
OR owner_type = 'unknown'
"""
)
# 用户快照兜底,避免后台筛选 NULL。
op.execute(
"""
UPDATE credit_records
SET
user_type_snapshot = COALESCE(user_type_snapshot, 'frontend'),
frontend_user_kind_snapshot = COALESCE(frontend_user_kind_snapshot, 'external')
WHERE user_type_snapshot IS NULL OR frontend_user_kind_snapshot IS NULL
"""
)
def downgrade() -> None:
op.drop_index(op.f("ix_users_user_type"), table_name="users")
op.drop_index(op.f("ix_users_frontend_user_kind"), table_name="users")
op.drop_index(op.f("ix_token_usage_source_step_code"), table_name="token_usage")
op.drop_index(op.f("ix_token_usage_source_module"), table_name="token_usage")
op.drop_index(op.f("ix_token_usage_owner_type"), table_name="token_usage")
op.drop_index(op.f("ix_token_usage_owner_id"), table_name="token_usage")
op.drop_index("ix_token_usage_owner", table_name="token_usage")
op.drop_index("ix_token_usage_biz_key", table_name="token_usage")
op.drop_index(op.f("ix_module_generation_steps_token_usage_id"), table_name="module_generation_steps")
op.drop_index(op.f("ix_module_generation_steps_model_config_id"), table_name="module_generation_steps")
op.drop_index(op.f("ix_credit_records_user_type_snapshot"), table_name="credit_records")
op.drop_index("ix_credit_records_user_kind_time", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_token_usage_id"), table_name="credit_records")
op.drop_index("ix_credit_records_subject_media", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_step_id"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_step_code"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_project_id"), table_name="credit_records")
op.drop_index("ix_credit_records_source_module_scene", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_source_module"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_owner_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_owner_id"), table_name="credit_records")
op.drop_index("ix_credit_records_owner", table_name="credit_records")
op.drop_index(op.f("ix_credit_records_media_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_frontend_user_kind_snapshot"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_engine_type"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_engine_id"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_credit_subject"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_charge_kind"), table_name="credit_records")
op.drop_index(op.f("ix_credit_records_billing_scene"), table_name="credit_records")
op.drop_column("token_usage", "source_step_code")
op.drop_column("token_usage", "source_module")
op.drop_column("token_usage", "biz_key")
op.drop_column("token_usage", "owner_id")
op.drop_column("token_usage", "owner_type")
op.drop_column("module_generation_steps", "text_credits_cost")
op.drop_column("module_generation_steps", "total_tokens")
op.drop_column("module_generation_steps", "output_tokens")
op.drop_column("module_generation_steps", "input_tokens")
op.drop_column("module_generation_steps", "model_config_id")
op.drop_column("module_generation_steps", "token_usage_id")
op.drop_column("credit_records", "frontend_user_kind_snapshot")
op.drop_column("credit_records", "user_type_snapshot")
op.drop_column("credit_records", "engine_model_name")
op.drop_column("credit_records", "engine_provider")
op.drop_column("credit_records", "engine_name")
op.drop_column("credit_records", "engine_id")
op.drop_column("credit_records", "engine_type")
op.drop_column("credit_records", "total_tokens")
op.drop_column("credit_records", "output_tokens")
op.drop_column("credit_records", "input_tokens")
op.drop_column("credit_records", "token_usage_id")
op.drop_column("credit_records", "source_step_code")
op.drop_column("credit_records", "source_step_id")
op.drop_column("credit_records", "source_project_id")
op.drop_column("credit_records", "source_module")
op.drop_column("credit_records", "billing_scene")
op.drop_column("credit_records", "media_type")
op.drop_column("credit_records", "credit_subject")
op.drop_column("credit_records", "charge_action")
op.drop_column("credit_records", "charge_kind")
op.drop_column("credit_records", "attempt_no")
op.drop_column("credit_records", "owner_id")
op.drop_column("credit_records", "owner_type")
op.drop_column("users", "frontend_user_kind")
@@ -0,0 +1,25 @@
"""merge changes from remote add credit record and upload_task add other_info
Revision ID: c72a6f69e641
Revises: 0a8da2d3c091, a65cf38abbb8
Create Date: 2026-06-25 17:02:01.095460
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c72a6f69e641'
down_revision: Union[str, None] = ('0a8da2d3c091', 'a65cf38abbb8')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+65 -55
View File
@@ -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 ───────────────────────────────────
+21
View File
@@ -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 阶段只处理提示词优化扣费。
+5 -1
View File
@@ -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 *
+193
View File
@@ -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: "历史未知",
}
+10
View File
@@ -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"
+22
View File
@@ -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: "前台外部用户",
}
+37 -2
View File
@@ -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)
+11 -3
View File
@@ -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)
+11 -2
View File
@@ -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
)
+81
View File
@@ -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),
)
+21
View File
@@ -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_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
"engine_id": chat_task.engine_id,
"params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px},
"prompt": optimized_prompt,
"media_references": refs,
},
step.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value,
source_step_id=prompt_step.id,
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
},
"prompt": optimized_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_GENERATE.value
@@ -1261,6 +1288,25 @@ async def generate_video_from_prompt(
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"},
]
step = await _create_step(
db,
project=project,
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
input_data={
"engine_id": req.engine_id or prompt_params.get("engine_id"),
"params": {
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
)
chat_task = await create_chat_generation_task_for_module(
db,
current_user=current_user,
@@ -1275,29 +1321,34 @@ async def generate_video_from_prompt(
resolution=resolution,
billing_project_name=project.title or "爆款开头复刻",
billing_description_prefix="爆款开头复刻视频生成",
billing_source_module=project.module,
billing_source_project_id=project.id,
billing_source_step_id=step.id,
billing_source_step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
)
step = await _create_step(
db,
project=project,
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
"engine_id": chat_task.engine_id,
"params": {
"duration": chat_task.duration,
"aspect_ratio": chat_task.aspect_ratio,
"resolution": chat_task.resolution,
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
step.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
source_step_id=prompt_step.id,
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {
"duration": chat_task.duration,
"aspect_ratio": chat_task.aspect_ratio,
"resolution": chat_task.resolution,
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_GENERATE.value
@@ -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)
+8 -1
View File
@@ -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,
+65
View File
@@ -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_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
"engine_id": chat_task.engine_id,
"params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px},
"prompt": optimized_prompt,
"media_references": refs,
},
step.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value,
source_step_id=prompt_step.id,
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
},
"prompt": optimized_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_GENERATE.value
@@ -1223,6 +1250,25 @@ async def generate_video_from_prompt(
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"},
]
step = await _create_step(
db,
project=project,
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
input_data={
"engine_id": req.engine_id or prompt_params.get("engine_id"),
"params": {
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
)
chat_task = await create_chat_generation_task_for_module(
db,
current_user=current_user,
@@ -1237,29 +1283,34 @@ async def generate_video_from_prompt(
resolution=resolution,
billing_project_name=project.title or "拆镜复刻",
billing_description_prefix="拆镜复刻视频生成",
billing_source_module=project.module,
billing_source_project_id=project.id,
billing_source_step_id=step.id,
billing_source_step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
)
step = await _create_step(
db,
project=project,
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
"engine_id": chat_task.engine_id,
"params": {
"duration": chat_task.duration,
"aspect_ratio": chat_task.aspect_ratio,
"resolution": chat_task.resolution,
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
step.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
source_step_id=prompt_step.id,
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {
"duration": chat_task.duration,
"aspect_ratio": chat_task.aspect_ratio,
"resolution": chat_task.resolution,
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_GENERATE.value
@@ -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)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
}
})();
</script>
<script type="module" crossorigin src="/assets/index-DH3LzPJl.js"></script>
<script type="module" crossorigin src="/assets/index-CeDXrP7R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
</head>
<body>