交易流水对账明细导出完成

This commit is contained in:
2026-06-25 16:53:04 +08:00
parent 8aafd7de32
commit 30db7d3bf5
34 changed files with 2491 additions and 327 deletions
+2
View File
@@ -17,6 +17,8 @@ bak/
*.tmp
.DS_Store
.playwright-mcp
*.zip
*.bat
# 否定规则(不忽略)
# !important.log
+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);
}
+310 -154
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 bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(16,185,129,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#10b981',
}}><ArrowUpOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}></div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}> / </div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(239,68,68,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#ef4444',
}}><ArrowDownOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
</div>
</div>
</Card>
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 44, height: 44, borderRadius: 10,
background: 'rgba(99,102,241,0.08)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 20, color: '#6366f1',
}}><WalletOutlined /></div>
<div>
<div style={{ color: '#94a3b8', fontSize: 12 }}></div>
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{total}</div>
</div>
</div>
</Card>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
<Card size="small" bordered={false}>{n(summary.imageGenerationCount)} / {n(summary.imageConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.videoGenerationCount)} / {n(summary.videoConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.textConsume)} </Card>
<Card size="small" bordered={false}>{n(summary.analysisConsume)} </Card>
</div>
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space wrap>
<Select
value={typeFilter}
onChange={(value) => setTypeFilter(value)}
style={{ width: 120 }}
options={[
{ value: '', label: '全部类型' },
{ value: 'recharge', label: '充值' },
{ value: 'consume', label: '消费' },
{ value: 'refund', label: '退回' },
]}
/>
<Input
placeholder="用户名搜索"
value={userNameFilter}
onChange={(e) => setUserNameFilter(e.target.value)}
style={{ width: 180 }}
allowClear
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
placeholder={['开始日期', '结束日期']}
style={{ width: 250 }}
/>
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
</Space>
<Space>
<Button type="primary" onClick={handleSearch}></Button>
<Button onClick={handleReset}></Button>
<Button icon={<ReloadOutlined />} onClick={load}></Button>
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}> Excel</Button>
</Space>
</div>
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
<Table
columns={columns}
dataSource={records}
@@ -204,13 +360,13 @@ const AdminCreditRecords: React.FC = () => {
loading={loading}
pagination={{
current: page,
pageSize: pageSize,
total: total,
onChange: handlePageChange,
pageSize,
total,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
showSizeChanger: true,
showTotal: (t) => `${t} 条记录`,
}}
scroll={{ x: 800 }}
scroll={{ x: 1900 }}
/>
</Card>
</div>
+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" />
+96 -1
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;
@@ -277,7 +278,6 @@ export interface GenerationAIEngineSnapshot {
id?: string;
name?: string;
provider?: string;
modelName?: string;
supportedModels?: string[];
defaultSize?: string;
selectedSize?: string;
@@ -673,3 +673,98 @@ export interface VideoPromptSchemaPreviewOut {
runtimeSchema: Record<string, any>;
timePlan: Record<string, any>[];
}
export interface AdminCreditRecordSummary {
totalRecharge: number;
totalConsume: number;
totalRefund: number;
transactionCount: number;
generationCount: number;
generationAttemptCount: number;
imageGenerationCount: number;
videoGenerationCount: number;
imageConsume: number;
videoConsume: number;
textConsume: number;
analysisConsume: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
}
export interface AdminCreditRecord {
id: string;
userId: string;
username?: string;
phone?: string;
email?: string;
userType?: string;
userTypeLabel?: string;
frontendUserKind?: string;
frontendUserKindLabel?: string;
type: string;
recordType: string;
recordTypeLabel?: string;
amount: number;
balanceAfter: number;
description?: string;
relatedId?: string;
bizKey?: string;
refundForBizKey?: string;
ownerType?: string;
ownerId?: string;
ownerDeleted?: boolean;
ownerDeletedAt?: string;
attemptNo?: number;
chargeKind?: string;
chargeKindLabel?: string;
chargeAction?: string;
creditSubject?: string;
creditSubjectLabel?: string;
mediaType?: string;
mediaTypeLabel?: string;
billingScene?: string;
billingSceneLabel?: string;
sourceModule?: string;
sourceModuleLabel?: string;
sourceProjectId?: string;
sourceStepId?: string;
sourceStepCode?: string;
sourceStepCodeLabel?: string;
tokenUsageId?: string;
inputTokens: number;
outputTokens: number;
totalTokens: number;
engineType?: string;
engineId?: string;
engineName?: string;
engineProvider?: string;
engineModelName?: string;
createdAt?: string;
}
export interface AdminCreditRecordListResponse {
items: AdminCreditRecord[];
total: number;
summary: AdminCreditRecordSummary;
}
export interface AdminCreditRecordQueryParams {
page?: number;
pageSize?: number;
userId?: string;
userName?: string;
userType?: string;
frontendUserKind?: string;
recordType?: string;
type?: string;
creditSubject?: string;
mediaType?: string;
chargeKind?: string;
sourceModule?: string;
sourceStepCode?: string;
billingScene?: string;
startDate?: string;
endDate?: string;
}
+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((max, row) => Math.max(max, visualLength(row[index])), visualLength(config?.title));
const minWidth = config?.minWidth ?? 10;
const maxWidth = config?.maxWidth ?? 42;
return { wch: clamp(Math.ceil(maxLength * 1.15) + 2, minWidth, maxWidth) };
});
}
function estimateRowHeight(row: ExcelCellValue[], colWidths: { wch: number }[], baseHeight = 20): number {
const maxLines = row.reduce((max, cell, index) => {
const width = Math.max(8, colWidths[index]?.wch || 12);
const text = cell === null || cell === undefined ? '' : String(cell);
const explicitLines = text.split(/\r?\n/);
const wrappedLines = explicitLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visualLength(line) / Math.max(8, width - 2))), 0);
return Math.max(max, wrappedLines);
}, 1);
return clamp(baseHeight + (maxLines - 1) * 16, baseHeight, 120);
}
function applyCellStyle(cell: any, style: any, numFmt?: string): void {
cell.s = {
...style,
alignment: { ...style.alignment },
border: BLACK_BORDER,
};
if (numFmt) cell.z = numFmt;
}
function isNumericCell(value: ExcelCellValue): boolean {
return typeof value === 'number' && Number.isFinite(value);
}
export function exportStyledExcel<T>(options: StyledExcelExportOptions<T>): void {
const { filename, sheetName, title, metadataRows = [], summaryRows = [], columns, rows } = options;
const totalColumns = Math.max(1, columns.length);
const aoa: ExcelCellValue[][] = [];
let titleRowIndex = -1;
let headerRowIndex = -1;
if (title) {
titleRowIndex = aoa.length;
aoa.push([title, ...Array.from({ length: totalColumns - 1 }, () => '')]);
}
metadataRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
summaryRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
if (metadataRows.length || summaryRows.length) {
aoa.push(Array.from({ length: totalColumns }, () => ''));
}
headerRowIndex = aoa.length;
aoa.push(columns.map((column) => column.title));
rows.forEach((row, rowIndex) => {
aoa.push(columns.map((column) => normalizeCellValue(column.render(row, rowIndex))));
});
const worksheet = XLSX.utils.aoa_to_sheet(aoa);
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
const colWidths = estimateColumnWidths(aoa, columns);
worksheet['!cols'] = colWidths;
worksheet['!rows'] = aoa.map((row, index) => {
if (index === titleRowIndex) return { hpt: 34 };
if (index === headerRowIndex) return { hpt: 28 };
if (row.every((cell) => cell === '')) return { hpt: 10 };
return { hpt: estimateRowHeight(row, colWidths) };
});
if (titleRowIndex >= 0 && totalColumns > 1) {
worksheet['!merges'] = worksheet['!merges'] || [];
worksheet['!merges'].push({
s: { r: titleRowIndex, c: 0 },
e: { r: titleRowIndex, c: totalColumns - 1 },
});
}
worksheet['!autofilter'] = {
ref: XLSX.utils.encode_range({
s: { r: headerRowIndex, c: 0 },
e: { r: Math.max(headerRowIndex, aoa.length - 1), c: totalColumns - 1 },
}),
};
for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) {
for (let colIndex = range.s.c; colIndex <= range.e.c; colIndex += 1) {
const address = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex });
const cell = worksheet[address] || { t: 's', v: '' };
worksheet[address] = cell;
if (rowIndex === titleRowIndex) {
applyCellStyle(cell, TITLE_STYLE);
continue;
}
if (rowIndex === headerRowIndex) {
applyCellStyle(cell, HEADER_STYLE);
continue;
}
if (rowIndex < headerRowIndex) {
applyCellStyle(cell, colIndex === 0 ? SECTION_LABEL_STYLE : SECTION_VALUE_STYLE, isNumericCell(cell.v) ? '#,##0.00' : undefined);
continue;
}
const column = columns[colIndex];
const align = column?.align || (isNumericCell(cell.v) ? 'right' : 'left');
applyCellStyle(cell, {
...BODY_STYLE,
alignment: { ...BASE_ALIGNMENT, horizontal: align },
}, column?.numFmt || (isNumericCell(cell.v) ? '#,##0.00' : undefined));
}
}
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
XLSX.writeFile(workbook, filename);
}
+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.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=HotOpeningStepCodeEnum.IMAGE_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px},
"params": {
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
},
"prompt": optimized_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.IMAGE_GENERATE.value
@@ -1261,6 +1288,25 @@ async def generate_video_from_prompt(
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"},
]
step = await _create_step(
db,
project=project,
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
input_data={
"engine_id": req.engine_id or prompt_params.get("engine_id"),
"params": {
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
)
chat_task = await create_chat_generation_task_for_module(
db,
current_user=current_user,
@@ -1275,16 +1321,20 @@ async def generate_video_from_prompt(
resolution=resolution,
billing_project_name=project.title or "爆款开头复刻",
billing_description_prefix="爆款开头复刻视频生成",
billing_source_module=project.module,
billing_source_project_id=project.id,
billing_source_step_id=step.id,
billing_source_step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
)
step = await _create_step(
db,
project=project,
step.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=HotOpeningStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {
"duration": chat_task.duration,
@@ -1298,6 +1348,7 @@ async def generate_video_from_prompt(
"final_prompt": final_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = HotOpeningStepCodeEnum.VIDEO_GENERATE.value
@@ -1488,9 +1488,10 @@ async def optimize_hot_opening_video_prompt(
"total_tokens": int(usage.get("total_tokens") or 0),
"log_user_message": log_user_message,
}
token_usage_id = generate_id()
db.add(
TokenUsage(
id=generate_id(),
id=token_usage_id,
model_config_id=config.id,
user_id=user_id,
input_tokens=token_usage["input_tokens"],
@@ -1499,6 +1500,13 @@ async def optimize_hot_opening_video_prompt(
)
)
await db.flush()
token_usage.update({
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
})
result = parse_model_json(content)
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
+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.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=ShotReplicateStepCodeEnum.IMAGE_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {"image_size": chat_task.image_size, "image_proportion": chat_task.image_proportion, "image_px": chat_task.image_px},
"params": {
"image_size": chat_task.image_size,
"image_proportion": chat_task.image_proportion,
"image_px": chat_task.image_px,
},
"prompt": optimized_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.IMAGE_GENERATE.value
@@ -1223,6 +1250,25 @@ async def generate_video_from_prompt(
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url), "name": "新项目图片"},
]
step = await _create_step(
db,
project=project,
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
input_data={
"engine_id": req.engine_id or prompt_params.get("engine_id"),
"params": {
"duration": duration,
"aspect_ratio": aspect_ratio,
"resolution": resolution,
},
"prompt_schema": prompt_schema,
"final_prompt": final_prompt,
"media_references": refs,
},
)
chat_task = await create_chat_generation_task_for_module(
db,
current_user=current_user,
@@ -1237,16 +1283,20 @@ async def generate_video_from_prompt(
resolution=resolution,
billing_project_name=project.title or "拆镜复刻",
billing_description_prefix="拆镜复刻视频生成",
billing_source_module=project.module,
billing_source_project_id=project.id,
billing_source_step_id=step.id,
billing_source_step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
)
step = await _create_step(
db,
project=project,
step.chat_task_id = chat_task.id
_force_set_json(
step,
"input_json",
_step_input(
step_code=ShotReplicateStepCodeEnum.VIDEO_GENERATE.value,
status=ModuleStepStatusEnum.PROCESSING.value,
parent_step_id=prompt_step.id,
source_step_id=prompt_step.id,
chat_task_id=chat_task.id,
input_data={
parent_step_id=prompt_step.id,
payload={
"engine_id": chat_task.engine_id,
"params": {
"duration": chat_task.duration,
@@ -1260,6 +1310,7 @@ async def generate_video_from_prompt(
"final_prompt": final_prompt,
"media_references": refs,
},
),
)
project.status = ModuleProjectStatusEnum.PROCESSING.value
project.current_step_code = ShotReplicateStepCodeEnum.VIDEO_GENERATE.value
@@ -505,9 +505,10 @@ async def analyze_video_for_shot_split(
if not token_usage["total_tokens"]:
token_usage["total_tokens"] = token_usage["input_tokens"] + token_usage["output_tokens"]
token_usage_id = generate_id()
db.add(
TokenUsage(
id=generate_id(),
id=token_usage_id,
model_config_id=config.id,
user_id=user_id,
input_tokens=token_usage["input_tokens"],
@@ -516,6 +517,13 @@ async def analyze_video_for_shot_split(
)
)
await db.flush()
token_usage.update({
"token_usage_id": token_usage_id,
"model_config_id": config.id,
"model_config_name": config.name,
"model_provider": config.provider,
"model_name": config.model_name,
})
return ShotVideoAnalysisResult(result=result, raw_response=raw, usage=token_usage)
@@ -7,6 +7,7 @@ from typing import Any
from sqlalchemy import select
from app.config import settings
from app.enums.credit_record import CreditRecordBillingScene, CreditRecordOwnerType
from app.enums.shot_replicate import (
ModuleCodeEnum,
ShotAnalysisStatusEnum,
@@ -36,6 +37,7 @@ from app.services.module_async_recovery_service import (
)
from app.services.shot_replicate_taskset_service import refresh_task_set_split_summary
from app.services.shot_video_analysis_service import analyze_video_for_shot_split
from app.services.generation_billing_service import charge_shot_video_analysis_usage
from app.services.shot_video_split_service import split_video_segment_async
from app.services.upload_video_asset_service import validate_split_range
from app.tasks.async_runner import run_async
@@ -141,6 +143,16 @@ async def _run_analyze_original_video(task_set_id: str) -> None:
task_set.analysis_status = ShotAnalysisStatusEnum.COMPLETED.value
task_set.status = ShotTaskSetStatusEnum.ANALYSIS_COMPLETED.value
task_set.analysis_error_message = None
await charge_shot_video_analysis_usage(
db,
user_id=task_set.user_id,
owner_type=CreditRecordOwnerType.SHOT_REPLICATE_TASK_SET.value,
owner_id=task_set.id,
usage=analyzed.usage,
description="拆镜复刻-原视频分析",
billing_scene=CreditRecordBillingScene.SHOT_ORIGINAL_VIDEO_ANALYSIS.value,
source_project_id=task_set.id,
)
await db.commit()
await cleanup_active_if_terminal(db, object_type=OBJECT_SHOT_TASK_SET_ANALYSIS, object_id=task_set_id)
@@ -260,6 +272,17 @@ async def _run_analyze_custom_segment_video(segment_id: str) -> None:
segment.analysis_json = result_json
segment.analysis_status = ShotSegmentAnalysisStatusEnum.COMPLETED.value
segment.analysis_error_message = None
await charge_shot_video_analysis_usage(
db,
user_id=segment.user_id,
owner_type=CreditRecordOwnerType.SHOT_REPLICATE_SEGMENT.value,
owner_id=segment.id,
usage=analyzed.usage,
description="拆镜复刻-片段视频分析",
billing_scene=CreditRecordBillingScene.SHOT_SEGMENT_VIDEO_ANALYSIS.value,
source_project_id=segment.task_set_id,
source_step_id=segment.id,
)
await db.commit()
await cleanup_active_if_terminal(db, object_type=OBJECT_SHOT_SEGMENT_ANALYSIS, object_id=segment_id)