From 59072ef035184b83bbcef8e98e158f77d040e52d Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 23 Jul 2026 16:53:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=E8=A7=86=E5=9B=BE(?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E7=94=A8=E6=88=B7=E5=8C=BA+org=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=9B=BE=E8=A1=A8+API=20Key=E6=8E=92=E8=A1=8C?= =?UTF-8?q?=E7=AB=AF=E7=82=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sage_datamart/dashboards.py | 55 ++++++++++++++++++++++++----------- sage_datamart/init.py | 33 ++++++++++++++++++--- scripts/load_path.py | 3 +- wwwroot/api/user_apikeys.dspy | 5 ++++ wwwroot/index.ui | 4 +-- wwwroot/sect_user_apikeys.ui | 9 ++++++ 6 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 wwwroot/api/user_apikeys.dspy create mode 100644 wwwroot/sect_user_apikeys.ui diff --git a/sage_datamart/dashboards.py b/sage_datamart/dashboards.py index ed0503c..a56f1bd 100644 --- a/sage_datamart/dashboards.py +++ b/sage_datamart/dashboards.py @@ -212,18 +212,23 @@ async def get_avg_ttft(env, sor): 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 COALESCE(currency, 'CNY') as currency, COUNT(*) as call_count, COALESCE(SUM(amount), 0) as amount, COALESCE(SUM(amount_cny), 0) as amount_cny FROM dm_model_call_fact - WHERE 1=1 + WHERE 1=1""" + w + """ GROUP BY dm_model_call_fact.currency ORDER BY call_count DESC""" - recs = await sor.sqlExe(sql, {}) + recs = await sor.sqlExe(sql, ns) return [{ 'currency': r.currency or 'CNY', '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): - """Top N 模型(按调用次数,全量)""" - sql = """SELECT model, COUNT(*) as cnt, COALESCE(SUM(amount_cny), 0) as total_amount - FROM dm_model_call_fact - GROUP BY model ORDER BY cnt DESC LIMIT ${limit}$""" - recs = await sor.sqlExe(sql, {'limit': limit}) +async def get_top_models(env, sor, limit=5, org_filter=None): + """Top N 模型(按调用次数,全量+可选org过滤)""" + w = "" + ns = {'limit': limit} + if org_filter: + 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), 'total_amount': round(float(r.total_amount), 2)} for r in recs] -async def get_top_providers(env, sor, limit=5): - """Top N 供应商(按金额,全量)""" +async def get_top_providers(env, sor, limit=5, org_filter=None): + """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, COALESCE(o.orgname, f.providerid) as provider_name FROM dm_model_call_fact f 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}$""" - recs = await sor.sqlExe(sql, {'limit': limit}) + recs = await sor.sqlExe(sql, ns) 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] -async def get_top_users(env, sor, limit=5): - """全量 Top N 用户(按金额)""" +async def get_top_users(env, sor, limit=5, org_filter=None): + """全量 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, COALESCE(u.username, f.userid) as user_name FROM dm_model_call_fact f LEFT JOIN users u ON u.id = f.userid + WHERE 1=1""" + w + """ 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), 'total_amount': round(float(r.total_amount), 2)} for r in recs] diff --git a/sage_datamart/init.py b/sage_datamart/init.py index e59e559..9b74a07 100644 --- a/sage_datamart/init.py +++ b/sage_datamart/init.py @@ -123,8 +123,9 @@ async def api_stats(request): async def api_top_models(request): try: env = request._run_ns + scope, org_filter = await _resolve_view(request) 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: debug("sage_datamart API error: " + str(e)) @@ -134,8 +135,9 @@ async def api_top_models(request): async def api_top_providers(request): try: env = request._run_ns + scope, org_filter = await _resolve_view(request) 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: debug("sage_datamart API error: " + str(e)) @@ -145,8 +147,9 @@ async def api_top_providers(request): async def api_top_users(request): try: env = request._run_ns + scope, org_filter = await _resolve_view(request) 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: debug("sage_datamart API error: " + str(e)) @@ -193,8 +196,9 @@ async def api_hourly_concurrency(request): async def api_currency_stats(request): try: env = request._run_ns + scope, org_filter = await _resolve_view(request) 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: debug("sage_datamart API error: " + str(e)) @@ -404,6 +408,26 @@ async def j2_avg_ttft(request): 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 加载 ── async def j2_model_perf_data(request): @@ -519,6 +543,7 @@ def load_sage_datamart(): env.j2_online_users = j2_online_users env.j2_scoped_success_rate = j2_scoped_success_rate 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_currency_data = j2_currency_data env.j2_provider_roi_data = j2_provider_roi_data diff --git a/scripts/load_path.py b/scripts/load_path.py index 79265b4..de3cb26 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -7,12 +7,10 @@ SAGE_ROOT = os.path.expanduser("~/sage") PYTHON = os.path.join(SAGE_ROOT, "py3", "bin", "python") 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 = [ f"/{MOD}/cron/etl_sync.dspy", f"/{MOD}/cron/etl_aggregate.dspy", f"/{MOD}/cron/etl_provider_cost.dspy", - ] PATHS_LOGINED = [ @@ -41,6 +39,7 @@ PATHS_LOGINED = [ f"/{MOD}/sect_top_models.ui", f"/{MOD}/sect_top_users.ui", f"/{MOD}/sect_top_providers.ui", + f"/{MOD}/sect_user_apikeys.ui", ] def run(role, path): diff --git a/wwwroot/api/user_apikeys.dspy b/wwwroot/api/user_apikeys.dspy new file mode 100644 index 0000000..2dba753 --- /dev/null +++ b/wwwroot/api/user_apikeys.dspy @@ -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) diff --git a/wwwroot/index.ui b/wwwroot/index.ui index 24b54fd..b3e12fc 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -56,7 +56,7 @@ "options": {"css": "card", "width": "50%", "padding": "16px"}, "subwidgets": [ {"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"}, "subwidgets": [ {"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%"}} ] } ] diff --git a/wwwroot/sect_user_apikeys.ui b/wwwroot/sect_user_apikeys.ui new file mode 100644 index 0000000..8a710f5 --- /dev/null +++ b/wwwroot/sect_user_apikeys.ui @@ -0,0 +1,9 @@ +{ + "widgettype": "ChartBar", + "options": { + "height": "200px", + "data_url": "/sage_datamart/api/user_apikeys.dspy", + "nameField": "apikey_name", + "valueFields": ["cnt"] + } +}