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 from datetime import timedelta
import json import json
from extensions import db 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 middlewares.auth import permission_required, login_required
from services.logger import system_logger 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') "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']) @admin_bp.route('/points/grant', methods=['POST'])
@permission_required('manage_system') @permission_required('manage_system')

View File

@ -53,6 +53,12 @@ def admin_points_page():
"""积分发放管理页面""" """积分发放管理页面"""
return render_template('points.html') 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') @auth_bp.route('/buy')
def buy_page(): def buy_page():
"""购买积分页面""" """购买积分页面"""
@ -617,6 +623,7 @@ def get_user_menu(user):
{"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None}, {"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None},
{"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None}, {"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None},
{"name": "积分发放管理", "icon": "gift", "url": "/admin/points", "perm": "manage_system"}, {"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": "shield-check", "url": "/rbac", "perm": "manage_rbac"},
{"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"}, {"name": "系统字典管理", "icon": "book-open", "url": "/dicts", "perm": "manage_dicts"},
{"name": "系统通知管理", "icon": "megaphone", "url": "/notifications", "perm": "manage_notifications"}, {"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 %}