Add admin usage audit for generation records

This commit is contained in:
24024 2026-05-02 11:45:44 +08:00
parent c8d9a7b51c
commit c0a0db4142
4 changed files with 395 additions and 1 deletions

View File

@ -0,0 +1,14 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "google"
[setup]
script = ""
[[actions]]
name = "运行"
icon = "run"
command = '''
.venv\Scripts\Activate.ps1
python app.py
'''

View File

@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify, g
from datetime import timedelta
import json
from extensions import db
from models import User, Role, Permission, SystemDict, SystemNotification, Order, PointsGrant, InviteReward, to_bj_time, get_bj_now
from models import User, Role, Permission, SystemDict, SystemNotification, Order, PointsGrant, InviteReward, GenerationRecord, to_bj_time, get_bj_now
from middlewares.auth import permission_required, login_required
from services.logger import system_logger
@ -343,6 +343,143 @@ def get_order_detail(order_id):
"current_is_admin": g.user.has_permission('manage_system')
})
# --- 用户积分消耗与生成内容管理 ---
def _fix_minio_url(url):
if url and ":9000/images/" in url:
return "/files/" + url.split(":9000/images/")[-1]
return url
def _normalize_generation_outputs(raw_image_urls):
"""兼容历史字符串 URL、新对象格式、文本与视频生成记录。"""
if not raw_image_urls:
return []
try:
raw_outputs = json.loads(raw_image_urls)
except (TypeError, ValueError):
return []
if not isinstance(raw_outputs, list):
raw_outputs = [raw_outputs]
outputs = []
for item in raw_outputs:
if isinstance(item, str):
outputs.append({
"type": "image",
"url": _fix_minio_url(item),
"thumb": _fix_minio_url(item)
})
continue
if not isinstance(item, dict):
continue
output_type = item.get("type") or "image"
output = {"type": output_type}
if output_type == "text":
output["content"] = item.get("content", "")
else:
url = _fix_minio_url(item.get("url"))
thumb = _fix_minio_url(item.get("thumb") or item.get("thumbnail") or url)
output["url"] = url
output["thumb"] = thumb
if output_type == "video" and not thumb:
output["thumb"] = "https://img.icons8.com/flat-round/64/000000/play--v1.png"
outputs.append(output)
return outputs
def _detect_generation_type(outputs):
output_types = {item.get("type") for item in outputs}
if "video" in output_types:
return "video"
if "text" in output_types:
return "text"
return "image"
@admin_bp.route('/usage/stats', methods=['GET'])
@permission_required('manage_system')
def get_usage_stats():
"""获取全站积分消耗与生成内容统计。"""
today = get_bj_now().date()
total_cost = db.session.query(db.func.sum(GenerationRecord.cost)).scalar() or 0
today_cost = db.session.query(db.func.sum(GenerationRecord.cost)).filter(
db.func.date(GenerationRecord.created_at) == today
).scalar() or 0
return jsonify({
"total_records": GenerationRecord.query.count(),
"total_cost": int(total_cost),
"today_records": GenerationRecord.query.filter(
db.func.date(GenerationRecord.created_at) == today
).count(),
"today_cost": int(today_cost),
"active_users": db.session.query(db.func.count(db.func.distinct(GenerationRecord.user_id))).scalar() or 0
})
@admin_bp.route('/usage/records', methods=['GET'])
@permission_required('manage_system')
def get_usage_records():
"""分页获取用户积分消耗和生成内容。"""
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 20, type=int), 100)
search = request.args.get('q', '').strip()
content_type = request.args.get('type', 'all')
query = db.session.query(GenerationRecord).outerjoin(User, GenerationRecord.user_id == User.id)
if search:
keyword = f"%{search}%"
query = query.filter(db.or_(
User.phone.like(keyword),
GenerationRecord.prompt.like(keyword),
GenerationRecord.model.like(keyword)
))
if content_type == 'video':
query = query.filter(GenerationRecord.image_urls.like('%"type": "video"%'))
elif content_type == 'text':
query = query.filter(GenerationRecord.image_urls.like('%"type": "text"%'))
elif content_type == 'image':
query = query.filter(
~GenerationRecord.image_urls.like('%"type": "video"%'),
~GenerationRecord.image_urls.like('%"type": "text"%')
)
pagination = query.order_by(GenerationRecord.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
records = []
for record in pagination.items:
outputs = _normalize_generation_outputs(record.image_urls)
records.append({
"id": record.id,
"user_id": record.user_id,
"user_phone": record.user.phone if record.user else "未知",
"prompt": record.prompt or "",
"model": record.model or "Default",
"cost": record.cost or 0,
"type": _detect_generation_type(outputs),
"outputs": outputs,
"created_at": record.created_at_bj.strftime('%Y-%m-%d %H:%M:%S')
})
return jsonify({
"records": records,
"total": pagination.total,
"pages": pagination.pages,
"current_page": pagination.page
})
# --- 积分发放管理 ---
@admin_bp.route('/points/grant', methods=['POST'])
@permission_required('manage_system')

View File

@ -53,6 +53,12 @@ def admin_points_page():
"""积分发放管理页面"""
return render_template('points.html')
@auth_bp.route('/admin/usage')
@admin_required
def admin_usage_page():
"""全站积分消耗与生成内容审计页面"""
return render_template('usage.html')
@auth_bp.route('/buy')
def buy_page():
"""购买积分页面"""
@ -617,6 +623,7 @@ def get_user_menu(user):
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
{"name": "积分发放管理", "icon": "gift", "url": "/admin/points", "perm": "manage_system"},
{"name": "生成内容审计", "icon": "shield-alert", "url": "/admin/usage", "perm": "manage_system"},
{"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
{"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"},

236
templates/usage.html Normal file
View File

@ -0,0 +1,236 @@
{% extends "base.html" %}
{% block title %}生成内容审计 - AI 视界{% endblock %}
{% block content %}
<div class="w-full h-full overflow-hidden flex flex-col p-6 lg:p-10 bg-slate-50/60">
<div class="max-w-7xl w-full mx-auto flex flex-col h-full space-y-6">
<div class="flex items-center justify-between shrink-0">
<div class="flex items-center gap-4">
<div class="w-12 h-12 bg-rose-600 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-rose-200">
<i data-lucide="shield-alert" class="w-7 h-7"></i>
</div>
<div>
<h1 class="text-3xl font-black text-slate-900 tracking-tight">生成内容审计</h1>
<p class="text-slate-400 text-sm font-medium">全站用户积分消耗与生成内容留痕</p>
</div>
</div>
<button onclick="refreshUsage()"
class="w-11 h-11 bg-white border border-slate-200 rounded-2xl flex items-center justify-center text-slate-500 hover:text-rose-600 transition-colors shadow-sm">
<i data-lucide="refresh-cw" class="w-5 h-5"></i>
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-5 shrink-0">
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">生成记录总数</p>
<h3 id="statTotalRecords" class="text-3xl font-black text-slate-900 mt-2">0</h3>
</div>
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">累计消耗积分</p>
<h3 id="statTotalCost" class="text-3xl font-black text-amber-600 mt-2">0</h3>
</div>
<div class="bg-white p-5 rounded-2xl border border-slate-100 shadow-sm">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">今日生成 / 消耗</p>
<h3 class="text-3xl font-black text-slate-900 mt-2"><span id="statTodayRecords">0</span><span class="text-base text-slate-300 mx-1">/</span><span id="statTodayCost">0</span></h3>
</div>
<div class="bg-slate-900 p-5 rounded-2xl shadow-sm text-white">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-widest">生成用户数</p>
<h3 id="statActiveUsers" class="text-3xl font-black mt-2">0</h3>
</div>
</div>
<div class="flex items-center justify-between gap-4 bg-white p-4 rounded-2xl border border-slate-100 shadow-sm shrink-0">
<div class="relative w-full max-w-xl">
<i data-lucide="search" class="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"></i>
<input type="text" id="usageSearch" onkeyup="if(event.key === 'Enter') loadRecords(1)"
placeholder="搜索手机号、提示词或模型"
class="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-xl text-sm font-bold outline-none focus:border-rose-500 transition-all">
</div>
<div class="bg-slate-100 p-1 rounded-xl flex text-xs font-black shrink-0">
<button id="filter-all" onclick="setFilter('all')" class="px-4 py-2 rounded-lg transition-all">全部</button>
<button id="filter-image" onclick="setFilter('image')" class="px-4 py-2 rounded-lg transition-all">图片</button>
<button id="filter-video" onclick="setFilter('video')" class="px-4 py-2 rounded-lg transition-all">视频</button>
<button id="filter-text" onclick="setFilter('text')" class="px-4 py-2 rounded-lg transition-all">文本</button>
</div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 flex-1 flex flex-col min-h-0 overflow-hidden">
<div class="flex-1 overflow-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead class="sticky top-0 bg-white z-10">
<tr>
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">用户</th>
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">内容</th>
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">输出预览</th>
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100">消耗</th>
<th class="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest border-b border-slate-100 text-right">时间</th>
</tr>
</thead>
<tbody id="recordTable" class="divide-y divide-slate-100">
<tr>
<td colspan="5" class="px-8 py-20 text-center text-slate-400 font-bold">正在加载审计记录...</td>
</tr>
</tbody>
</table>
</div>
<div class="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50 shrink-0">
<span class="text-xs font-bold text-slate-400" id="pageInfo">共 0 条记录</span>
<div class="flex items-center gap-2">
<button onclick="changePage(-1)" class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-all">
<i data-lucide="chevron-left" class="w-4 h-4 text-slate-600"></i>
</button>
<button onclick="changePage(1)" class="p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-all">
<i data-lucide="chevron-right" class="w-4 h-4 text-slate-600"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<div id="previewModal" class="fixed inset-0 z-50 hidden items-center justify-center bg-slate-900/80 backdrop-blur-md p-6">
<button onclick="closePreview()" class="absolute top-6 right-6 w-11 h-11 rounded-full bg-white/10 text-white hover:bg-white/20 flex items-center justify-center">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
<div id="previewContent" class="max-w-[92vw] max-h-[88vh]"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentPage = 1;
let totalPages = 1;
let currentFilter = 'all';
function escapeHtml(value) {
return String(value || '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
}[char]));
}
async function loadStats() {
const res = await fetch('/api/admin/usage/stats');
const data = await res.json();
document.getElementById('statTotalRecords').innerText = data.total_records || 0;
document.getElementById('statTotalCost').innerText = data.total_cost || 0;
document.getElementById('statTodayRecords').innerText = data.today_records || 0;
document.getElementById('statTodayCost').innerText = data.today_cost || 0;
document.getElementById('statActiveUsers').innerText = data.active_users || 0;
}
async function loadRecords(page = currentPage) {
currentPage = page;
const q = encodeURIComponent(document.getElementById('usageSearch').value.trim());
const res = await fetch(`/api/admin/usage/records?page=${currentPage}&per_page=20&type=${currentFilter}&q=${q}`);
const data = await res.json();
totalPages = data.pages || 1;
renderRecords(data.records || []);
document.getElementById('pageInfo').innerText = `第 ${data.current_page || 1} / ${totalPages} 页,共 ${data.total || 0} 条记录`;
}
function setFilter(type) {
currentFilter = type;
['all', 'image', 'video', 'text'].forEach(name => {
const btn = document.getElementById(`filter-${name}`);
btn.classList.toggle('bg-white', name === type);
btn.classList.toggle('text-slate-900', name === type);
btn.classList.toggle('shadow-sm', name === type);
btn.classList.toggle('text-slate-500', name !== type);
});
loadRecords(1);
}
function renderRecords(records) {
const tbody = document.getElementById('recordTable');
if (!records.length) {
tbody.innerHTML = `<tr><td colspan="5" class="px-8 py-20 text-center text-slate-400 font-bold">暂无生成记录</td></tr>`;
return;
}
tbody.innerHTML = records.map(record => `
<tr class="hover:bg-slate-50/80 transition-colors align-top">
<td class="px-6 py-5">
<div class="font-black text-slate-800">${escapeHtml(record.user_phone)}</div>
<div class="text-[10px] text-slate-400 font-mono mt-1">UID: ${record.user_id}</div>
<span class="inline-flex mt-3 px-2 py-1 rounded-lg bg-slate-100 text-slate-500 text-[10px] font-black uppercase">${record.type}</span>
</td>
<td class="px-6 py-5 max-w-md">
<div class="font-black text-slate-900 text-sm">${escapeHtml(record.model)}</div>
<div class="mt-2 text-xs font-medium text-slate-500 leading-relaxed max-h-24 overflow-auto whitespace-pre-wrap">${escapeHtml(record.prompt || '无提示词')}</div>
</td>
<td class="px-6 py-5 min-w-64">${renderOutputs(record.outputs || [])}</td>
<td class="px-6 py-5">
<span class="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-amber-50 text-amber-700 text-xs font-black">
<i data-lucide="zap" class="w-3.5 h-3.5"></i>
-${record.cost}
</span>
</td>
<td class="px-6 py-5 text-right text-[10px] font-black text-slate-400 whitespace-nowrap">${record.created_at}</td>
</tr>
`).join('');
lucide.createIcons();
}
function renderOutputs(outputs) {
if (!outputs.length) return '<span class="text-xs font-bold text-slate-300">无输出记录</span>';
return `<div class="flex flex-wrap gap-3">${outputs.map(output => {
if (output.type === 'text') {
return `<button onclick="openTextPreview('${encodeURIComponent(output.content || '')}')" class="w-28 h-20 rounded-xl border border-slate-200 bg-slate-50 p-3 text-left text-[10px] font-bold text-slate-500 overflow-hidden hover:border-rose-300 transition-colors">${escapeHtml(output.content || '文本内容')}</button>`;
}
if (output.type === 'video') {
return `<button onclick="openMediaPreview('${encodeURIComponent(output.url || '')}', 'video')" class="w-28 h-20 rounded-xl bg-slate-900 text-white flex items-center justify-center hover:scale-[1.02] transition-transform"><i data-lucide="play" class="w-7 h-7"></i></button>`;
}
return `<button onclick="openMediaPreview('${encodeURIComponent(output.url || output.thumb || '')}', 'image')" class="w-20 h-20 rounded-xl overflow-hidden border border-slate-200 bg-slate-100 hover:scale-[1.02] transition-transform"><img src="${escapeHtml(output.thumb || output.url || '')}" class="w-full h-full object-cover" loading="lazy"></button>`;
}).join('')}</div>`;
}
function openMediaPreview(encodedUrl, type) {
const url = decodeURIComponent(encodedUrl || '');
if (!url) return;
const modal = document.getElementById('previewModal');
const content = document.getElementById('previewContent');
content.innerHTML = type === 'video'
? `<video src="${url}" controls autoplay class="max-w-[92vw] max-h-[88vh] rounded-2xl bg-black"></video>`
: `<img src="${url}" class="max-w-[92vw] max-h-[88vh] rounded-2xl object-contain bg-white">`;
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function openTextPreview(encodedText) {
const text = decodeURIComponent(encodedText || '');
const modal = document.getElementById('previewModal');
const content = document.getElementById('previewContent');
content.innerHTML = `<div class="w-[min(760px,92vw)] max-h-[80vh] overflow-auto rounded-2xl bg-white p-8 text-slate-700 text-sm leading-7 whitespace-pre-wrap">${escapeHtml(text)}</div>`;
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closePreview() {
const modal = document.getElementById('previewModal');
document.getElementById('previewContent').innerHTML = '';
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function changePage(delta) {
const next = Math.min(Math.max(currentPage + delta, 1), totalPages);
if (next !== currentPage) loadRecords(next);
}
function refreshUsage() {
loadStats();
loadRecords(currentPage);
}
document.getElementById('previewModal').onclick = (event) => {
if (event.target.id === 'previewModal') closePreview();
};
document.addEventListener('DOMContentLoaded', () => {
setFilter('all');
loadStats();
});
</script>
{% endblock %}