feat: 客户视图(隐藏用户区+org过滤图表+API Key排行端点)

This commit is contained in:
yumoqing 2026-07-23 16:53:51 +08:00
parent 85afc1f023
commit 59072ef035
6 changed files with 84 additions and 25 deletions

View File

@ -212,18 +212,23 @@ async def get_avg_ttft(env, sor):
return 0 return 0
async def get_currency_breakdown(env, sor): async def get_currency_breakdown(env, sor, org_filter=None):
"""当天各币种分布""" """各币种分布(全量+可选org过滤"""
w = ""
ns = {}
if org_filter:
w = " AND userorgid = ${org}$"
ns['org'] = org_filter
sql = """SELECT sql = """SELECT
COALESCE(currency, 'CNY') as currency, COALESCE(currency, 'CNY') as currency,
COUNT(*) as call_count, COUNT(*) as call_count,
COALESCE(SUM(amount), 0) as amount, COALESCE(SUM(amount), 0) as amount,
COALESCE(SUM(amount_cny), 0) as amount_cny COALESCE(SUM(amount_cny), 0) as amount_cny
FROM dm_model_call_fact FROM dm_model_call_fact
WHERE 1=1 WHERE 1=1""" + w + """
GROUP BY dm_model_call_fact.currency GROUP BY dm_model_call_fact.currency
ORDER BY call_count DESC""" ORDER BY call_count DESC"""
recs = await sor.sqlExe(sql, {}) recs = await sor.sqlExe(sql, ns)
return [{ return [{
'currency': r.currency or 'CNY', 'currency': r.currency or 'CNY',
'call_count': int(r.call_count), 'call_count': int(r.call_count),
@ -234,37 +239,53 @@ async def get_currency_breakdown(env, sor):
# ── 排行榜 ── # ── 排行榜 ──
async def get_top_models(env, sor, limit=5): async def get_top_models(env, sor, limit=5, org_filter=None):
"""Top N 模型(按调用次数,全量)""" """Top N 模型(按调用次数,全量+可选org过滤"""
sql = """SELECT model, COUNT(*) as cnt, COALESCE(SUM(amount_cny), 0) as total_amount w = ""
FROM dm_model_call_fact ns = {'limit': limit}
GROUP BY model ORDER BY cnt DESC LIMIT ${limit}$""" if org_filter:
recs = await sor.sqlExe(sql, {'limit': limit}) w = " AND userorgid = ${org}$"
ns['org'] = org_filter
sql = ("SELECT model, COUNT(*) as cnt, COALESCE(SUM(amount_cny), 0) as total_amount "
"FROM dm_model_call_fact WHERE 1=1" + w +
" GROUP BY model ORDER BY cnt DESC LIMIT ${limit}$")
recs = await sor.sqlExe(sql, ns)
return [{'model_name': r.model or 'Unknown', 'cnt': int(r.cnt), return [{'model_name': r.model or 'Unknown', 'cnt': int(r.cnt),
'total_amount': round(float(r.total_amount), 2)} for r in recs] 'total_amount': round(float(r.total_amount), 2)} for r in recs]
async def get_top_providers(env, sor, limit=5): async def get_top_providers(env, sor, limit=5, org_filter=None):
"""Top N 供应商(按金额,全量)""" """Top N 供应商(按金额,全量+可选org过滤"""
w = ""
ns = {'limit': limit}
if org_filter:
w = " AND f.userorgid = ${org}$"
ns['org'] = org_filter
sql = """SELECT f.providerid, COUNT(*) as cnt, COALESCE(SUM(f.amount_cny), 0) as total_amount, sql = """SELECT f.providerid, COUNT(*) as cnt, COALESCE(SUM(f.amount_cny), 0) as total_amount,
COALESCE(o.orgname, f.providerid) as provider_name COALESCE(o.orgname, f.providerid) as provider_name
FROM dm_model_call_fact f FROM dm_model_call_fact f
LEFT JOIN organization o ON o.id = f.providerid LEFT JOIN organization o ON o.id = f.providerid
WHERE f.providerid IS NOT NULL WHERE f.providerid IS NOT NULL""" + w + """
GROUP BY f.providerid ORDER BY total_amount DESC LIMIT ${limit}$""" GROUP BY f.providerid ORDER BY total_amount DESC LIMIT ${limit}$"""
recs = await sor.sqlExe(sql, {'limit': limit}) recs = await sor.sqlExe(sql, ns)
return [{'provider_name': r.provider_name or r.providerid or 'Unknown', return [{'provider_name': r.provider_name or r.providerid or 'Unknown',
'cnt': int(r.cnt), 'total_amount': round(float(r.total_amount), 2)} for r in recs] 'cnt': int(r.cnt), 'total_amount': round(float(r.total_amount), 2)} for r in recs]
async def get_top_users(env, sor, limit=5): async def get_top_users(env, sor, limit=5, org_filter=None):
"""全量 Top N 用户(按金额)""" """全量 Top N 用户(按金额+可选org过滤"""
w = ""
ns = {'limit': limit}
if org_filter:
w = " AND f.userorgid = ${org}$"
ns['org'] = org_filter
sql = """SELECT f.userid, COUNT(*) as cnt, COALESCE(SUM(f.amount_cny), 0) as total_amount, sql = """SELECT f.userid, COUNT(*) as cnt, COALESCE(SUM(f.amount_cny), 0) as total_amount,
COALESCE(u.username, f.userid) as user_name COALESCE(u.username, f.userid) as user_name
FROM dm_model_call_fact f FROM dm_model_call_fact f
LEFT JOIN users u ON u.id = f.userid LEFT JOIN users u ON u.id = f.userid
WHERE 1=1""" + w + """
GROUP BY f.userid ORDER BY total_amount DESC LIMIT ${limit}$""" GROUP BY f.userid ORDER BY total_amount DESC LIMIT ${limit}$"""
recs = await sor.sqlExe(sql, {'limit': limit}) recs = await sor.sqlExe(sql, ns)
return [{'user_name': r.user_name or r.userid, 'cnt': int(r.cnt), return [{'user_name': r.user_name or r.userid, 'cnt': int(r.cnt),
'total_amount': round(float(r.total_amount), 2)} for r in recs] 'total_amount': round(float(r.total_amount), 2)} for r in recs]

View File

@ -123,8 +123,9 @@ async def api_stats(request):
async def api_top_models(request): async def api_top_models(request):
try: try:
env = request._run_ns env = request._run_ns
scope, org_filter = await _resolve_view(request)
async with get_sor_context(env, MODULE_NAME) as sor: async with get_sor_context(env, MODULE_NAME) as sor:
return await get_top_models(env, sor) return await get_top_models(env, sor, org_filter=org_filter)
except Exception as e: except Exception as e:
debug("sage_datamart API error: " + str(e)) debug("sage_datamart API error: " + str(e))
@ -134,8 +135,9 @@ async def api_top_models(request):
async def api_top_providers(request): async def api_top_providers(request):
try: try:
env = request._run_ns env = request._run_ns
scope, org_filter = await _resolve_view(request)
async with get_sor_context(env, MODULE_NAME) as sor: async with get_sor_context(env, MODULE_NAME) as sor:
return await get_top_providers(env, sor) return await get_top_providers(env, sor, org_filter=org_filter)
except Exception as e: except Exception as e:
debug("sage_datamart API error: " + str(e)) debug("sage_datamart API error: " + str(e))
@ -145,8 +147,9 @@ async def api_top_providers(request):
async def api_top_users(request): async def api_top_users(request):
try: try:
env = request._run_ns env = request._run_ns
scope, org_filter = await _resolve_view(request)
async with get_sor_context(env, MODULE_NAME) as sor: async with get_sor_context(env, MODULE_NAME) as sor:
return await get_top_users(env, sor) return await get_top_users(env, sor, org_filter=org_filter)
except Exception as e: except Exception as e:
debug("sage_datamart API error: " + str(e)) debug("sage_datamart API error: " + str(e))
@ -193,8 +196,9 @@ async def api_hourly_concurrency(request):
async def api_currency_stats(request): async def api_currency_stats(request):
try: try:
env = request._run_ns env = request._run_ns
scope, org_filter = await _resolve_view(request)
async with get_sor_context(env, MODULE_NAME) as sor: async with get_sor_context(env, MODULE_NAME) as sor:
return await get_currency_breakdown(env, sor) return await get_currency_breakdown(env, sor, org_filter=org_filter)
except Exception as e: except Exception as e:
debug("sage_datamart API error: " + str(e)) debug("sage_datamart API error: " + str(e))
@ -404,6 +408,26 @@ async def j2_avg_ttft(request):
return 0 return 0
async def api_user_apikeys(request):
"""当前用户每个 API Key 的使用排行"""
try:
env = request._run_ns
userid = await env.get_userid()
async with get_sor_context(env, MODULE_NAME) as sor:
sql = """SELECT uak.name as apikey_name, COUNT(*) as cnt, COALESCE(SUM(lu.amount), 0) as total_amount
FROM llmusage lu
JOIN userapikey uak ON uak.apikey = lu.apikey
WHERE lu.userid = ${userid}$
GROUP BY uak.name ORDER BY cnt DESC LIMIT 10"""
recs = await sor.sqlExe(sql, {'userid': userid})
return [{'apikey_name': r.apikey_name or 'Unknown',
'cnt': int(r.cnt or 0),
'total_amount': round(float(r.total_amount or 0), 2)} for r in recs]
except Exception as e:
debug("sage_datamart API error: " + str(e))
return []
# ── 监控组件 j2 函数,每段独立 .ui 通过 RefreshWidget 加载 ── # ── 监控组件 j2 函数,每段独立 .ui 通过 RefreshWidget 加载 ──
async def j2_model_perf_data(request): async def j2_model_perf_data(request):
@ -519,6 +543,7 @@ def load_sage_datamart():
env.j2_online_users = j2_online_users env.j2_online_users = j2_online_users
env.j2_scoped_success_rate = j2_scoped_success_rate env.j2_scoped_success_rate = j2_scoped_success_rate
env.j2_scoped_fail_count = j2_scoped_fail_count env.j2_scoped_fail_count = j2_scoped_fail_count
env.api_user_apikeys = api_user_apikeys
env.j2_model_perf_data = j2_model_perf_data env.j2_model_perf_data = j2_model_perf_data
env.j2_currency_data = j2_currency_data env.j2_currency_data = j2_currency_data
env.j2_provider_roi_data = j2_provider_roi_data env.j2_provider_roi_data = j2_provider_roi_data

View File

@ -7,12 +7,10 @@ SAGE_ROOT = os.path.expanduser("~/sage")
PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python") PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python")
SET_PERM = os.path.join(SAGE_ROOT, "set_role_perm.py") SET_PERM = os.path.join(SAGE_ROOT, "set_role_perm.py")
# cron endpoints — called via curl from localhost crontab, IP-checked in dspy
PATHS_ANY = [ PATHS_ANY = [
f"/{MOD}/cron/etl_sync.dspy", f"/{MOD}/cron/etl_sync.dspy",
f"/{MOD}/cron/etl_aggregate.dspy", f"/{MOD}/cron/etl_aggregate.dspy",
f"/{MOD}/cron/etl_provider_cost.dspy", f"/{MOD}/cron/etl_provider_cost.dspy",
] ]
PATHS_LOGINED = [ PATHS_LOGINED = [
@ -41,6 +39,7 @@ PATHS_LOGINED = [
f"/{MOD}/sect_top_models.ui", f"/{MOD}/sect_top_models.ui",
f"/{MOD}/sect_top_users.ui", f"/{MOD}/sect_top_users.ui",
f"/{MOD}/sect_top_providers.ui", f"/{MOD}/sect_top_providers.ui",
f"/{MOD}/sect_user_apikeys.ui",
] ]
def run(role, path): def run(role, path):

View File

@ -0,0 +1,5 @@
# API Key 使用排行
import json
env = request._run_ns
data = await env.api_user_apikeys(request)
return json.dumps(data, ensure_ascii=False)

View File

@ -56,7 +56,7 @@
"options": {"css": "card", "width": "50%", "padding": "16px"}, "options": {"css": "card", "width": "50%", "padding": "16px"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Title4", "options": {"fontWeight": "600", "otext": "我的模型(按金额Top5)", "i18n": true, "marginBottom": "8px"}}, {"widgettype": "Title4", "options": {"fontWeight": "600", "otext": "我的模型(按金额Top5)", "i18n": true, "marginBottom": "8px"}},
{"widgettype": "RefreshWidget", "options": {"period_seconds": 60, "url": "{{entire_url('sect_top_models.ui')}}", "width": "100%"}} {"widgettype": "RefreshWidget", "options": {"period_seconds": 60, "url": "{{entire_url('sect_user_apikeys.ui')}}", "width": "100%"}}
] ]
}, },
{ {
@ -64,7 +64,7 @@
"options": {"css": "card", "width": "50%", "padding": "16px"}, "options": {"css": "card", "width": "50%", "padding": "16px"},
"subwidgets": [ "subwidgets": [
{"widgettype": "Title4", "options": {"fontWeight": "600", "otext": "API Key排行", "i18n": true, "marginBottom": "8px"}}, {"widgettype": "Title4", "options": {"fontWeight": "600", "otext": "API Key排行", "i18n": true, "marginBottom": "8px"}},
{"widgettype": "RefreshWidget", "options": {"period_seconds": 60, "url": "{{entire_url('sect_top_models.ui')}}", "width": "100%"}} {"widgettype": "RefreshWidget", "options": {"period_seconds": 60, "url": "{{entire_url('sect_user_apikeys.ui')}}", "width": "100%"}}
] ]
} }
] ]

View File

@ -0,0 +1,9 @@
{
"widgettype": "ChartBar",
"options": {
"height": "200px",
"data_url": "/sage_datamart/api/user_apikeys.dspy",
"nameField": "apikey_name",
"valueFields": ["cnt"]
}
}