From fb27918285100f046bb4a181fe541bc1f2ddc61a Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sun, 26 Apr 2026 14:44:21 +0800 Subject: [PATCH] feat(admin): remove daily report, add platform metrics endpoint Remove daily report system (page, API routes, cron scheduler) as it is no longer needed. Add /api/metrics endpoint exposing total and time- windowed counts (27h, 7d, 30d) for users, workspaces, projects, repos, rooms, and skills. Also clean up dead code: - Remove OpenRouter sync and alerts check routes - Remove syncModels/checkAlerts from adminrpc client - Remove unused adminRpcAvailable state from platform sessions page - Fix handleEdit displayName comparison bug in platform users page - Simplify pricing sync to create 0-price defaults --- admin/src/app/admin/ai/page.tsx | 34 +- admin/src/app/admin/daily-report/page.tsx | 423 --------------- admin/src/app/admin/metrics/page.tsx | 163 ++++++ admin/src/app/admin/workspaces/[id]/page.tsx | 34 -- .../api/admin/daily-report/ai-config/route.ts | 102 ---- .../api/admin/daily-report/generate/route.ts | 507 ------------------ .../daily-report/recipients/[id]/route.ts | 106 ---- .../admin/daily-report/recipients/route.ts | 60 --- admin/src/app/api/admin/daily-report/route.ts | 85 --- admin/src/app/api/cron-initialize/route.ts | 17 - admin/src/app/api/metrics/route.ts | 65 +++ admin/src/app/api/platform/ai/sync/route.ts | 19 - .../app/api/platform/alerts/check/route.ts | 19 - admin/src/app/platform/sessions/page.tsx | 115 +--- admin/src/app/platform/users/page.tsx | 11 +- admin/src/components/admin/Sidebar.tsx | 2 +- admin/src/lib/adminrpc/client.ts | 32 -- admin/src/lib/daily-report-cron.ts | 75 --- admin/src/middleware.ts | 4 + libs/service/agent/sync.rs | 59 +- 20 files changed, 276 insertions(+), 1656 deletions(-) delete mode 100644 admin/src/app/admin/daily-report/page.tsx create mode 100644 admin/src/app/admin/metrics/page.tsx delete mode 100644 admin/src/app/api/admin/daily-report/ai-config/route.ts delete mode 100644 admin/src/app/api/admin/daily-report/generate/route.ts delete mode 100644 admin/src/app/api/admin/daily-report/recipients/[id]/route.ts delete mode 100644 admin/src/app/api/admin/daily-report/recipients/route.ts delete mode 100644 admin/src/app/api/admin/daily-report/route.ts delete mode 100644 admin/src/app/api/cron-initialize/route.ts create mode 100644 admin/src/app/api/metrics/route.ts delete mode 100644 admin/src/app/api/platform/ai/sync/route.ts delete mode 100644 admin/src/app/api/platform/alerts/check/route.ts delete mode 100644 admin/src/lib/daily-report-cron.ts diff --git a/admin/src/app/admin/ai/page.tsx b/admin/src/app/admin/ai/page.tsx index e2f457a..44af942 100644 --- a/admin/src/app/admin/ai/page.tsx +++ b/admin/src/app/admin/ai/page.tsx @@ -83,8 +83,6 @@ export default function AiPage() { const [pricing, setPricing] = useState([]); const [loading, setLoading] = useState(true); const [tab, setTab] = useState("providers"); - const [syncing, setSyncing] = useState(false); - const [syncMsg, setSyncMsg] = useState(null); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); @@ -127,27 +125,6 @@ export default function AiPage() { useEffect(() => { loadData(); }, []); - async function triggerSync() { - if (!confirm("确定从 OpenRouter 同步最新模型?")) return; - setSyncing(true); - setSyncMsg(null); - try { - const res = await fetch("/api/platform/ai/sync", { method: "POST" }); - const data = await res.json(); - if (res.ok && data.data) { - const d = data.data; - setSyncMsg(`同步完成:${d.models_created} 新增 / ${d.models_updated} 更新`); - loadData(); - } else { - setSyncMsg(`同步失败: ${data.error}`); - } - } catch (e) { - setSyncMsg(`同步失败: ${e instanceof Error ? e.message : String(e)}`); - } finally { - setSyncing(false); - } - } - // Provider CRUD function openNewProvider() { setEditingProviderId(null); @@ -298,17 +275,8 @@ export default function AiPage() { -
-
- {syncMsg && ( -
- {syncMsg} -
- )} {msg && (
{msg.text} @@ -402,7 +370,7 @@ export default function AiPage() { ); })} - {versions.length === 0 && 暂无版本(从 OpenRouter 同步后自动创建)} + {versions.length === 0 && 暂无版本,点击上方「新建版本」添加}
diff --git a/admin/src/app/admin/daily-report/page.tsx b/admin/src/app/admin/daily-report/page.tsx deleted file mode 100644 index c220c71..0000000 --- a/admin/src/app/admin/daily-report/page.tsx +++ /dev/null @@ -1,423 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { format } from "date-fns"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -interface Recipient { - id: number; - email: string; - name: string; - is_active: boolean; - created_at: string; - created_by: number; -} - -interface AiConfig { - ai_model?: string; - ai_api_key?: string; - ai_enabled?: string; - basic_api_url?: string; - smtp_host?: string; - smtp_port?: string; - smtp_username?: string; - smtp_password?: string; - smtp_from?: string; - smtp_tls?: string; - report_enabled?: string; -} - -// ─── Page ──────────────────────────────────────────────────────────────────── - -export default function DailyReportPage() { - const [tab, setTab] = useState<"recipients" | "ai-config">("recipients"); - const [loading, setLoading] = useState(true); - const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null); - - // Recipients state - const [recipients, setRecipients] = useState([]); - const [showAdd, setShowAdd] = useState(false); - const [addEmail, setAddEmail] = useState(""); - const [addName, setAddName] = useState(""); - const [addLoading, setAddLoading] = useState(false); - const [editId, setEditId] = useState(null); - const [editName, setEditName] = useState(""); - const [editActive, setEditActive] = useState(true); - - // AI Config state - const [config, setConfig] = useState({}); - const [savingAi, setSavingAi] = useState(false); - const [aiForm, setAiForm] = useState({}); - const [testLoading, setTestLoading] = useState(false); - - // Generate state - const [generating, setGenerating] = useState(false); - const [generateResult, setGenerateResult] = useState | null>(null); - - useEffect(() => { loadRecipients(); loadConfig(); }, []); - - async function loadRecipients() { - try { - const res = await fetch("/api/admin/daily-report/recipients"); - const data = await res.json(); - setRecipients(data.recipients || []); - } catch { setMsg({ type: "error", text: "加载收件人失败" }); } - finally { setLoading(false); } - } - - async function loadConfig() { - try { - const res = await fetch("/api/admin/daily-report/ai-config"); - const data = await res.json(); - setConfig(data.config || {}); - setAiForm(data.config || {}); - } catch { setMsg({ type: "error", text: "加载配置失败" }); } - } - - // ── Recipients ─────────────────────────────────────────────────────────────── - - async function handleAddRecipient() { - if (!addEmail) return; - setAddLoading(true); - try { - const res = await fetch("/api/admin/daily-report/recipients", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email: addEmail, name: addName }), - }); - const data = await res.json(); - if (!res.ok) { setMsg({ type: "error", text: data.error || "添加失败" }); return; } - setMsg({ type: "success", text: "收件人已添加" }); - setShowAdd(false); setAddEmail(""); setAddName(""); - loadRecipients(); - } catch { setMsg({ type: "error", text: "添加失败" }); } - finally { setAddLoading(false); } - } - - async function handleUpdateRecipient(id: number) { - const res = await fetch(`/api/admin/daily-report/recipients/${id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: editName, is_active: editActive }), - }); - const data = await res.json(); - if (!res.ok) { setMsg({ type: "error", text: data.error || "更新失败" }); return; } - setMsg({ type: "success", text: "已更新" }); - setEditId(null); - loadRecipients(); - } - - async function handleDeleteRecipient(id: number) { - if (!confirm("确定删除该收件人?")) return; - const res = await fetch(`/api/admin/daily-report/recipients/${id}`, { method: "DELETE" }); - const data = await res.json(); - if (!res.ok) { setMsg({ type: "error", text: data.error || "删除失败" }); return; } - setMsg({ type: "success", text: "已删除" }); - loadRecipients(); - } - - // ── AI Config ────────────────────────────────────────────────────────────── - - async function handleSaveAiConfig() { - setSavingAi(true); - try { - const res = await fetch("/api/admin/daily-report/ai-config", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(aiForm), - }); - const data = await res.json(); - if (!res.ok) { setMsg({ type: "error", text: data.error || "保存失败" }); return; } - setMsg({ type: "success", text: "配置已保存" }); - loadConfig(); - } catch { setMsg({ type: "error", text: "保存失败" }); } - finally { setSavingAi(false); } - } - - async function handleTriggerReport() { - if (!confirm("立即触发每日报告生成?")) return; - setGenerating(true); - setGenerateResult(null); - try { - const res = await fetch("/api/admin/daily-report/generate", { method: "POST" }); - const data = await res.json(); - if (!res.ok) { setMsg({ type: "error", text: data.error || "生成失败" }); return; } - setGenerateResult(data); - setMsg({ type: "success", text: `报告已发送至 ${data.sent || 0} 个收件人` }); - } catch { setMsg({ type: "error", text: "触发失败" }); } - finally { setGenerating(false); } - } - - const isReportEnabled = aiForm.report_enabled === "true"; - - return ( -
-
-
-

每日平台报告

-

配置 AI 分析并发送给内部员工邮箱,每日零点自动运行

-
-
- -
-
- - {msg && ( -
- {msg.text} - -
- )} - - {generateResult && ( -
-

📊 上次报告结果

-
-
📧 已发送: {`${(generateResult.sent as number) ?? 0}`}
-
👥 收件人: {`${(generateResult.recipients as number) ?? 0}`}
-
🤖 AI分析: {`${(generateResult.aiSummaryUsed as boolean) ? "已启用" : "未启用"}`}
- {!!generateResult.stats && ( - <> -
👥 新增用户: {`${(generateResult.stats as Record).newUsers ?? 0}`}
-
💬 新增消息: {`${(generateResult.stats as Record).newMessages ?? 0}`}
-
🔧 新增提交: {`${(generateResult.stats as Record).newCommits ?? 0}`}
- - )} -
-
- )} - - {/* Tabs */} -
- - -
- -
- {tab === "recipients" && ( - <> -
- -
- - {showAdd && ( -
-
-
- - setAddEmail(e.target.value)} placeholder="employee@company.com" /> -
-
- - setAddName(e.target.value)} placeholder="张三" /> -
- - -
-
- )} - -
- - - - - - - - {recipients.map(r => ( - - - - - - - - ))} - {recipients.length === 0 && ( - - )} - -
邮箱姓名状态添加时间操作
{r.email}{editId === r.id ? ( - setEditName(e.target.value)} /> - ) : r.name || "—"}{editId === r.id ? ( - - ) : ( - - {r.is_active ? "启用" : "禁用"} - - )}{r.created_at ? format(new Date(r.created_at), "yyyy-MM-dd HH:mm") : "—"} - {editId === r.id ? ( - <> - - - - ) : ( - <> - - - - )} -
- 暂无收件人,点击上方「添加收件人」开始配置 -
-
- - )} - - {tab === "ai-config" && ( -
- {/* Report enable */} -
- -
- -
- {/* AI Settings */} -
-

🤖 AI 分析设置

-
- - -
-
- - setAiForm(f => ({ ...f, ai_model: e.target.value }))} - placeholder="gpt-4o-mini" /> - OpenAI 模型,支持 gpt-4o, gpt-4o-mini 等 -
-
- - setAiForm(f => ({ ...f, ai_api_key: e.target.value }))} - placeholder="sk-..." /> -
-
- - setAiForm(f => ({ ...f, basic_api_url: e.target.value }))} - placeholder="https://api.openai.com(留空使用默认地址)" /> - - 支持 OpenAI 兼容接口,如 Cloudflare AI Gateway、OneAPI 等 - -
-
- - {/* SMTP Settings */} -
-

📧 SMTP 设置

-
- - setAiForm(f => ({ ...f, smtp_host: e.target.value }))} - placeholder="smtp.example.com" /> -
-
- - setAiForm(f => ({ ...f, smtp_port: e.target.value }))} - placeholder="587" /> -
-
- - setAiForm(f => ({ ...f, smtp_username: e.target.value }))} - placeholder="notifications@example.com" /> -
-
- - setAiForm(f => ({ ...f, smtp_password: e.target.value }))} - placeholder="••••••••" /> -
-
- - setAiForm(f => ({ ...f, smtp_from: e.target.value }))} - placeholder="no-reply@example.com" /> -
-
- -
-
-
- -
- - - 配置保存在数据库中,不会泄露到前端 - -
-
- )} -
- - {/* Cron 说明 */} -
-

⏰ 自动运行配置

-

- 使用 npm start 启动服务时,定时调度自动启用(每日零点 UTC)。 - 生产环境(多实例)推荐使用 K8s CronJob: -

-
-{`# admin-daily-report-cronjob.yaml
-apiVersion: batch/v1
-kind: CronJob
-metadata:
-  name: daily-report
-  namespace: admin
-spec:
-  schedule: "0 0 * * *"  # 每日零点 (UTC)
-  jobTemplate:
-    spec:
-      template:
-        spec:
-          containers:
-          - name: curl
-            image: curlimages/curl:latest
-            command: ["curl", "-X", "POST",
-              "-H", "x-cron-secret: $DAILY_REPORT_CRON_SECRET",
-              "http://admin-admin.$NAMESPACE.svc.cluster.local:3000/api/admin/daily-report/generate"]
-          restartPolicy: OnFailure`}
-        
-

- 生产提示:多实例部署时,K8s CronJob 可确保只触发一次;单实例部署时使用内置调度器即可。 - 可通过设置 DAILY_REPORT_CRON_SECRET 环境变量保护 CronJob 调用。 -

-
-
- ); -} diff --git a/admin/src/app/admin/metrics/page.tsx b/admin/src/app/admin/metrics/page.tsx new file mode 100644 index 0000000..c9c2a22 --- /dev/null +++ b/admin/src/app/admin/metrics/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { format } from "date-fns"; + +interface MetricData { + total: number; + last_27h: number; + last_7d: number; + last_30d: number; +} + +interface MetricsResponse { + metrics: Record; + timestamp: string; +} + +const ENTITY_LABELS: Record = { + users: { label: "用户", icon: "👤" }, + workspaces: { label: "Workspace", icon: "◎" }, + projects: { label: "项目", icon: "◻" }, + repos: { label: "仓库", icon: "◈" }, + rooms: { label: "房间", icon: "◫" }, + skills: { label: "Skills", icon: "⚡" }, +}; + +export default function MetricsPage() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [lastRefresh, setLastRefresh] = useState(null); + + useEffect(() => { loadMetrics(); }, []); + + function loadMetrics() { + setLoading(true); + fetch("/api/metrics") + .then((r) => r.json()) + .then((d) => { + setData(d); + setLastRefresh(new Date().toISOString()); + }) + .catch(console.error) + .finally(() => setLoading(false)); + } + + return ( +
+
+
+

平台指标

+

+ 各实体数量统计 + {lastRefresh && ( + + 更新于 {format(new Date(lastRefresh), "HH:mm:ss")} + + )} +

+
+ +
+ + {loading ? ( +
加载中...
+ ) : data ? ( + <> + {/* Summary cards */} +
+ {Object.entries(data.metrics).map(([key, val]) => { + const meta = ENTITY_LABELS[key]; + if (!meta) return null; + return ( +
+
+ {meta.icon} {meta.label} +
+
+ {val.total.toLocaleString()} +
+
总计
+
+ ); + })} +
+ + {/* Detail table */} +
+
+ + + + + + + + + + + + {Object.entries(data.metrics).map(([key, val]) => { + const meta = ENTITY_LABELS[key]; + if (!meta) return null; + return ( + + + + + + + + ); + })} + +
实体总计过去 27 小时新增过去 7 天新增过去 30 天新增
+ {meta.icon} {meta.label} + {val.total.toLocaleString()} + + +{val.last_27h.toLocaleString()} + + {val.last_27h > 0 && ( + + ({((val.last_27h / Math.max(val.total, 1)) * 100).toFixed(1)}%) + + )} + + + +{val.last_7d.toLocaleString()} + + {val.last_7d > 0 && ( + + ({((val.last_7d / Math.max(val.total, 1)) * 100).toFixed(1)}%) + + )} + + + +{val.last_30d.toLocaleString()} + + {val.last_30d > 0 && ( + + ({((val.last_30d / Math.max(val.total, 1)) * 100).toFixed(1)}%) + + )} +
+
+
+ + {/* Raw JSON for API consumers */} +
+

API 端点

+

+ 通过 GET /api/metrics 获取 JSON 格式指标数据,可用于 Prometheus 或其他监控系统采集。 +

+
+              {JSON.stringify(data, null, 2)}
+            
+
+ + ) : ( +
无法加载指标数据
+ )} +
+ ); +} diff --git a/admin/src/app/admin/workspaces/[id]/page.tsx b/admin/src/app/admin/workspaces/[id]/page.tsx index bb99cea..5af8237 100644 --- a/admin/src/app/admin/workspaces/[id]/page.tsx +++ b/admin/src/app/admin/workspaces/[id]/page.tsx @@ -60,8 +60,6 @@ export default function WorkspaceDetailPage() { const [tab, setTab] = useState("members"); const [alertConfigs, setAlertConfigs] = useState([]); const [alertSaving, setAlertSaving] = useState(false); - const [alertCheckMsg, setAlertCheckMsg] = useState(null); - const [alertChecking, setAlertChecking] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showCredit, setShowCredit] = useState(false); @@ -435,39 +433,7 @@ export default function WorkspaceDetailPage() { - - {alertCheckMsg && ( -
- {alertCheckMsg} -
- )} )} diff --git a/admin/src/app/api/admin/daily-report/ai-config/route.ts b/admin/src/app/api/admin/daily-report/ai-config/route.ts deleted file mode 100644 index 292f146..0000000 --- a/admin/src/app/api/admin/daily-report/ai-config/route.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; -import { query } from "@/lib/db"; -import { createAuditLog } from "@/lib/log"; - -export const runtime = "nodejs"; - -async function ensureTablesExist() { - try { - await query(` - CREATE TABLE IF NOT EXISTS internal_email_recipients ( - id BIGSERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL DEFAULT '', is_active BOOLEAN NOT NULL DEFAULT true, - created_by BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - await query(`CREATE INDEX IF NOT EXISTS idx_internal_email_recipients_email ON internal_email_recipients (LOWER(email))`); - await query(` - CREATE TABLE IF NOT EXISTS admin_ai_config ( - id BIGSERIAL PRIMARY KEY, config_key VARCHAR(100) NOT NULL UNIQUE, - config_value TEXT NOT NULL DEFAULT '', updated_by BIGINT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - } catch { /* ignore */ } -} - -// GET /api/admin/daily-report/ai-config — get AI config (values, not secrets) -export async function GET() { - try { - await ensureTablesExist(); - const result = await query<{ config_key: string; config_value: string }>( - `SELECT config_key, config_value, updated_at::text as updated_at - FROM admin_ai_config - WHERE config_key IN ('ai_model', 'ai_api_key', 'ai_enabled', 'smtp_host', 'smtp_port', 'smtp_username', 'smtp_password', 'smtp_from', 'smtp_tls', 'report_enabled')` - ); - - const config: Record = {}; - for (const row of result.rows) { - // Mask secrets - if (row.config_key === "ai_api_key" || row.config_key === "smtp_password") { - config[row.config_key] = row.config_value ? "***" : ""; - } else { - config[row.config_key] = row.config_value; - } - } - - return NextResponse.json({ config }); - } catch (e) { - logError("Get AI config error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} - -// PUT /api/admin/daily-report/ai-config — upsert AI config -export async function PUT(req: NextRequest) { - try { - const body = await req.json() as Record; - const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10); - const allowedKeys = [ - "ai_model", "ai_api_key", "ai_enabled", "basic_api_url", - "smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_from", "smtp_tls", - "report_enabled", - ]; - - const updates: string[] = []; - const vals: unknown[] = []; - let idx = 1; - - for (const [key, value] of Object.entries(body)) { - if (!allowedKeys.includes(key)) continue; - updates.push(`($${idx++}, $${idx++}, $${idx++})`); - vals.push(key, value, adminUserId); - } - - if (updates.length === 0) { - return NextResponse.json({ error: "没有需要保存的配置" }, { status: 400 }); - } - - await query( - `INSERT INTO admin_ai_config (config_key, config_value, updated_by) - VALUES ${updates.join(", ")} - ON CONFLICT (config_key) DO UPDATE SET - config_value = EXCLUDED.config_value, - updated_by = EXCLUDED.updated_by, - updated_at = NOW()`, - vals - ); - - await createAuditLog({ - userId: adminUserId, - username: req.headers.get("x-admin-username") || "unknown", - action: "update", - resource: "admin_ai_config", - resourceId: "bulk-update", - }); - - return NextResponse.json({ success: true }); - } catch (e) { - logError("Update AI config error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} diff --git a/admin/src/app/api/admin/daily-report/generate/route.ts b/admin/src/app/api/admin/daily-report/generate/route.ts deleted file mode 100644 index b4c57d8..0000000 --- a/admin/src/app/api/admin/daily-report/generate/route.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; -import { query } from "@/lib/db"; - -export const runtime = "nodejs"; - -// ─── Ensure tables exist ─────────────────────────────────────────────────────── - -async function ensureTablesExist() { - try { - await query(` - CREATE TABLE IF NOT EXISTS internal_email_recipients ( - id BIGSERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL DEFAULT '', - is_active BOOLEAN NOT NULL DEFAULT true, - created_by BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - await query(` - CREATE INDEX IF NOT EXISTS idx_internal_email_recipients_email - ON internal_email_recipients (LOWER(email)) - `); - await query(` - CREATE TABLE IF NOT EXISTS admin_ai_config ( - id BIGSERIAL PRIMARY KEY, - config_key VARCHAR(100) NOT NULL UNIQUE, - config_value TEXT NOT NULL DEFAULT '', - updated_by BIGINT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - } catch (e) { - console.warn("[daily-report] Table creation warning (may already exist):", e); - } -} - -interface DailyStats { - newUsers: number; - newRooms: number; - newCommits: number; - newMessages: number; - activeUsers: number; - topRoom: { id: string; name: string; messageCount: number } | null; - topRoomMessages: { content: string; created_at: string }[]; -} - -interface AiConfig { - ai_model: string; - ai_api_key: string; - ai_enabled: string; - basic_api_url: string; - smtp_host: string; - smtp_port: string; - smtp_username: string; - smtp_password: string; - smtp_from: string; - smtp_tls: string; - report_enabled: string; -} - -// ─── Main handler ───────────────────────────────────────────────────────────── - -export async function POST(req: NextRequest) { - // Verify cron — accept internal marker OR secret from K8s CronJob - const cronInternal = req.headers.get("x-cron-internal"); - const cronSecret = req.headers.get("x-cron-secret"); - const expectedSecret = process.env.DAILY_REPORT_CRON_SECRET; - if (!cronInternal && (!expectedSecret || cronSecret !== expectedSecret)) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } - - try { - // ── Ensure DB tables exist ───────────────────────────────────────────────── - await ensureTablesExist(); - - // ── Load config ──────────────────────────────────────────────────────────── - const configRows = await query<{ config_key: string; config_value: string }>( - `SELECT config_key, config_value FROM admin_ai_config` - ); - const config: Record = {}; - for (const row of configRows.rows) { - config[row.config_key] = row.config_value; - } - const cfg = config as unknown as AiConfig; - - if (cfg.report_enabled !== "true") { - return NextResponse.json({ message: "report disabled, skipping" }); - } - - // ── Check recipients ──────────────────────────────────────────────────── - const recipients = await query( - `SELECT email, name FROM internal_email_recipients WHERE is_active = true` - ); - if (!recipients.rows.length) { - return NextResponse.json({ message: "no recipients, skipping" }); - } - - // ── Collect daily stats ───────────────────────────────────────────────── - const stats = await collectDailyStats(); - - // ── Generate AI summary ────────────────────────────────────────────────── - let aiSummary = ""; - if (cfg.ai_enabled === "true" && cfg.ai_api_key) { - aiSummary = await generateAiSummary( - stats, - cfg.ai_model || "gpt-4o-mini", - cfg.ai_api_key, - cfg.basic_api_url || "" - ); - } - - // ── Build email content ───────────────────────────────────────────────── - const dateStr = new Date().toLocaleDateString("zh-CN", { - year: "numeric", month: "2-digit", day: "2-digit", - }); - const subject = `📊 每日平台报告 — ${dateStr}`; - const htmlBody = buildHtmlReport(dateStr, stats, aiSummary); - - // ── Send emails ───────────────────────────────────────────────────────── - const smtpConfigured = - cfg.smtp_host && cfg.smtp_port && cfg.smtp_username && cfg.smtp_from; - - if (!smtpConfigured) { - console.warn("[daily-report] SMTP not configured, email skipped"); - return NextResponse.json({ - message: "SMTP not configured, email skipped", - stats: summarizeStats(stats), - }); - } - - const sent = await sendEmail({ - host: cfg.smtp_host, - port: parseInt(cfg.smtp_port || "587", 10), - user: cfg.smtp_username, - pass: cfg.smtp_password, - from: cfg.smtp_from, - tls: cfg.smtp_tls === "true", - to: (recipients.rows as { email: string }[]).map(r => r.email), - subject, - html: htmlBody, - }); - - return NextResponse.json({ - sent, - recipients: recipients.rows.length, - stats: summarizeStats(stats), - aiSummaryUsed: !!aiSummary, - }); - } catch (e) { - logError("[daily-report] Error:", e); - return NextResponse.json({ error: String(e) }, { status: 500 }); - } -} - -// ─── Stats collection ───────────────────────────────────────────────────────── - -async function collectDailyStats(): Promise { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const todayStr = today.toISOString(); - - const [userRow, roomRow, messageRow, activeUserRow, topRoomRow] = await Promise.all([ - // New users today - query<{ count: string }>( - `SELECT COUNT(*)::text as count FROM "user" WHERE created_at >= $1`, - [todayStr] - ), - // New rooms today - query<{ count: string }>( - `SELECT COUNT(*)::text as count FROM room WHERE created_at >= $1`, - [todayStr] - ), - // New messages today - query<{ count: string }>( - `SELECT COUNT(*)::text as count FROM room_message WHERE created_at >= $1`, - [todayStr] - ), - // Active users today (users who sent messages) - query<{ count: string }>( - `SELECT COUNT(DISTINCT sender_id)::text as count - FROM room_message WHERE created_at >= $1`, - [todayStr] - ), - // Top room by message count today - query<{ room: string; room_name: string; message_count: string }>( - `SELECT rm.room, r.name as room_name, COUNT(*)::text as message_count - FROM room_message rm - JOIN room r ON r.id = rm.room - WHERE rm.created_at >= $1 - GROUP BY rm.room, r.name - ORDER BY COUNT(*) DESC - LIMIT 1`, - [todayStr] - ), - ]); - - const stats: DailyStats = { - newUsers: parseInt(userRow.rows[0]?.count || "0", 10), - newRooms: parseInt(roomRow.rows[0]?.count || "0", 10), - newMessages: parseInt(messageRow.rows[0]?.count || "0", 10), - activeUsers: parseInt(activeUserRow.rows[0]?.count || "0", 10), - newCommits: 0, // filled below - topRoom: topRoomRow.rows[0] ? { - id: topRoomRow.rows[0].room, - name: topRoomRow.rows[0].room_name, - messageCount: parseInt(topRoomRow.rows[0].message_count || "0", 10), - } : null, - topRoomMessages: [], - }; - - // New commits today (git_commits table) - try { - const commitRow = await query<{ count: string }>( - `SELECT COUNT(*)::text as count FROM git_commit WHERE committed_at >= $1`, - [todayStr] - ); - stats.newCommits = parseInt(commitRow.rows[0]?.count || "0", 10); - } catch { - // git_commit table might not exist yet - stats.newCommits = 0; - } - - // Fetch top room's recent messages for AI summary - if (stats.topRoom) { - try { - const messages = await query<{ content: string; created_at: string }>( - `SELECT content, created_at::text - FROM room_message - WHERE room = $1 AND created_at >= $2 - ORDER BY created_at DESC - LIMIT 20`, - [stats.topRoom.id, todayStr] - ); - stats.topRoomMessages = messages.rows; - } catch { - stats.topRoomMessages = []; - } - } - - return stats; -} - -// ─── AI Summary ────────────────────────────────────────────────────────────── - -async function generateAiSummary( - stats: DailyStats, - model: string, - apiKey: string, - basicApiUrl: string -): Promise { - const systemPrompt = `你是一名平台运营分析师。请根据以下每日平台数据,生成一段简洁的中文总结(100-200字),分析今日平台的关键变化和亮点。注意: -1. 用专业但易懂的语言 -2. 突出增长或异常数据 -3. 如果有最活跃房间,给出简短评价 -4. 结尾给出简短建议`; - - const topRoomContext = stats.topRoom - ? `最活跃聊天室「${stats.topRoom.name}」今日发送 ${stats.topRoom.messageCount} 条消息。` - : "今日无聊天室活动。"; - - const userContext = stats.newUsers > 0 - ? `新增用户 ${stats.newUsers} 人,活跃用户 ${stats.activeUsers} 人。` - : `今日新增用户较少(${stats.newUsers} 人),活跃用户 ${stats.activeUsers} 人。`; - - const userMessages = stats.topRoomMessages - .slice(0, 5) - .map((m, i) => `${i + 1}. ${m.content.slice(0, 150)}`) - .join("\n"); - - const userMessagesSection = stats.topRoomMessages.length > 0 - ? `\n\n最活跃房间「${stats.topRoom!.name}」最新消息样例:\n${userMessages}` - : ""; - - const userContent = `日期:${new Date().toLocaleDateString("zh-CN")} -平台数据: -- 新增用户:${stats.newUsers} 人 -- 活跃用户:${stats.activeUsers} 人 -- 新增聊天室:${stats.newRooms} 个 -- 新增消息:${stats.newMessages} 条 -- 新增代码提交:${stats.newCommits} 次 -${topRoomContext} -${userMessagesSection}`; - - const baseUrl = basicApiUrl || "https://api.openai.com"; - const chatEndpoint = `${baseUrl.replace(/\/$/, "")}/v1/chat/completions`; - - try { - const response = await fetch(chatEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userContent }, - ], - max_tokens: 500, - temperature: 0.7, - }), - }); - - if (!response.ok) { - const err = await response.text(); - console.warn("[daily-report] AI call failed:", response.status, err); - return ""; - } - - const data = await response.json() as { - choices?: { message?: { content?: string } }[]; - }; - return data.choices?.[0]?.message?.content?.trim() || ""; - } catch (e) { - console.warn("[daily-report] AI call error:", e); - return ""; - } -} - -// ─── HTML Report Builder ────────────────────────────────────────────────────── - -function buildHtmlReport(date: string, stats: DailyStats, aiSummary: string): string { - const aiSection = aiSummary - ? `
-
🤖 AI 分析总结
-

${aiSummary}

-
` - : `
- ⚠️ AI 分析未启用(请在设置中配置 AI 以获取每日自动分析) -
`; - - const topRoomSection = stats.topRoom - ? ` - 💬 最活跃聊天室 - - ${stats.topRoom.name} — ${stats.topRoom.messageCount} 条消息 - - ` - : ` - 💬 最活跃聊天室 - 今日无聊天室活动 - `; - - return ` - - - -
-
-
每日平台报告
-

${date}

-
- - ${aiSection} - -
-

📈 今日数据

- - - - - - - - - - - - - - - - - - - - - - ${topRoomSection} -
👥 新增用户${stats.newUsers} 人
🟢 活跃用户${stats.activeUsers} 人
🏠 新增聊天室${stats.newRooms} 个
💬 聊天室消息${stats.newMessages} 条
🔧 代码提交${stats.newCommits} 次
- -
- 此报告由系统自动生成,如需修改收件人或 AI 设置,请联系平台管理员。 -
-
-
- -`; -} - -// ─── Email Sender (direct SMTP) ─────────────────────────────────────────────── - -async function sendEmail(opts: { - host: string; port: number; user: string; pass: string; - from: string; tls: boolean; - to: string[]; subject: string; html: string; -}): Promise { - // Use nodemailer-style SMTP (via built-in tls net module) - const { host, port, user, pass, from, tls, to, subject, html } = opts; - - // Simple SMTP client using Node.js built-ins - // eslint-disable-next-line @typescript-eslint/no-require-imports - const net = await import("node:net") as any; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const tlsMod = await import("node:tls") as any; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const cryptoMod = await import("node:crypto") as any; - - function b64(str: string): string { - return Buffer.from(str).toString("base64"); - } - - let sentCount = 0; - - for (const recipient of to) { - try { - const socket = tls - ? net.createSecureSocket({ host, port, servername: host }) - : net.createSocket({ host, port }); - - await new Promise((res, rej) => { - const timeout = setTimeout(() => { socket.destroy(); rej(new Error("timeout")); }, 30000); - const commands: string[] = []; - let step = 0; - - socket.on("data", (chunk: Buffer) => { - const lines = chunk.toString().split("\r\n"); - for (const line of lines) { - commands.push(line); - const code = line.slice(0, 3); - if (code === "220" && step === 0) { step = 1; socket.write(`EHLO localhost\r\n`); } - else if (code === "250" && step === 1) { - step = 2; - socket.write(`AUTH LOGIN\r\n`); - } else if (code === "334" && step === 2) { - step = 3; - socket.write(b64(user) + "\r\n"); - } else if (code === "334" && step === 3) { - step = 4; - socket.write(b64(pass) + "\r\n"); - } else if (code === "235" && step === 4) { - step = 5; - socket.write(`MAIL FROM:<${from}>\r\n`); - } else if (code === "250" && step === 5) { - step = 6; - socket.write(`RCPT TO:<${recipient}>\r\n`); - } else if (code === "250" && step === 6) { - step = 7; - socket.write(`DATA\r\n`); - } else if (code === "354" && step === 7) { - step = 8; - const msgId = `<${cryptoMod.randomBytes(8).toString("hex")}@daily-report>`; - const msg = [ - `From: ${from}`, - `To: ${recipient}`, - `Subject: =?utf-8?B?${b64(subject)}?=`, - "MIME-Version: 1.0", - "Content-Type: text/html; charset=utf-8", - `Message-ID: ${msgId}`, - `Date: ${new Date().toUTCString()}`, - "", - html, - ".", - ].join("\r\n"); - socket.write(msg + "\r\n"); - } else if (code === "250" && step === 8) { - step = 9; - socket.write("QUIT\r\n"); - clearTimeout(timeout); - res(); - } - } - }); - - socket.on("error", (e: Error) => { clearTimeout(timeout); rej(e); }); - socket.on("close", () => { clearTimeout(timeout); if (step < 9) rej(new Error("connection closed")); }); - - socket.write(`HELO localhost\r\n`); - }); - - sentCount++; - console.log(`[daily-report] Sent to ${recipient}`); - } catch (e) { - logError(`[daily-report] Failed to send to ${recipient}:`, e); - } - } - - return sentCount; -} - -// ─── Helpers ───────────────────────────────────────────────────────────────── - -function summarizeStats(s: DailyStats) { - return { - newUsers: s.newUsers, - newRooms: s.newRooms, - newMessages: s.newMessages, - newCommits: s.newCommits, - activeUsers: s.activeUsers, - topRoom: s.topRoom?.name || null, - topRoomMessages: s.topRoomMessages.length, - }; -} diff --git a/admin/src/app/api/admin/daily-report/recipients/[id]/route.ts b/admin/src/app/api/admin/daily-report/recipients/[id]/route.ts deleted file mode 100644 index 5daa304..0000000 --- a/admin/src/app/api/admin/daily-report/recipients/[id]/route.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; -import { query } from "@/lib/db"; -import { createAuditLog } from "@/lib/log"; - -export const runtime = "nodejs"; - -// PATCH /api/admin/daily-report/recipients/[id] — update recipient -export async function PATCH( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - const body = await req.json() as { email?: string; name?: string; is_active?: boolean }; - const { email, name, is_active } = body; - - const updates: string[] = []; - const vals: unknown[] = []; - let idx = 1; - - if (email !== undefined) { - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: "无效的邮箱地址" }, { status: 400 }); - } - updates.push(`email = $${idx++}`); - vals.push(email.toLowerCase()); - } - if (name !== undefined) { - updates.push(`name = $${idx++}`); - vals.push(name); - } - if (is_active !== undefined) { - updates.push(`is_active = $${idx++}`); - vals.push(is_active); - } - - if (updates.length === 0) { - return NextResponse.json({ error: "没有需要更新的字段" }, { status: 400 }); - } - - updates.push(`updated_at = NOW()`); - vals.push(id); - - const result = await query( - `UPDATE internal_email_recipients SET ${updates.join(", ")} WHERE id = $${idx} RETURNING *`, - vals - ); - - if (!result.rows.length) { - return NextResponse.json({ error: "收件人不存在" }, { status: 404 }); - } - - const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10); - await createAuditLog({ - userId: adminUserId, - username: req.headers.get("x-admin-username") || "unknown", - action: "update", - resource: "internal_email_recipient", - resourceId: id, - requestParams: body, - }); - - return NextResponse.json(result.rows[0]); - } catch (e: unknown) { - const err = e as { code?: string }; - if (err?.code === "23505") { - return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 }); - } - logError("Update recipient error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} - -// DELETE /api/admin/daily-report/recipients/[id] — remove recipient -export async function DELETE( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - - const result = await query( - `DELETE FROM internal_email_recipients WHERE id = $1 RETURNING id`, - [id] - ); - - if (!result.rows.length) { - return NextResponse.json({ error: "收件人不存在" }, { status: 404 }); - } - - const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10); - await createAuditLog({ - userId: adminUserId, - username: req.headers.get("x-admin-username") || "unknown", - action: "delete", - resource: "internal_email_recipient", - resourceId: id, - }); - - return NextResponse.json({ success: true }); - } catch (e) { - logError("Delete recipient error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} diff --git a/admin/src/app/api/admin/daily-report/recipients/route.ts b/admin/src/app/api/admin/daily-report/recipients/route.ts deleted file mode 100644 index 4120cf2..0000000 --- a/admin/src/app/api/admin/daily-report/recipients/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; -import { query } from "@/lib/db"; -import { createAuditLog } from "@/lib/log"; - -export const runtime = "nodejs"; - -// GET /api/admin/daily-report/recipients — list all recipients -export async function GET() { - try { - const result = await query( - `SELECT id, email, name, is_active, created_at::text as created_at, created_by - FROM internal_email_recipients - ORDER BY created_at DESC` - ); - return NextResponse.json({ recipients: result.rows }); - } catch (e) { - logError("[recipients] List error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} - -// POST /api/admin/daily-report/recipients — add recipient -export async function POST(req: NextRequest) { - try { - const body = await req.json() as { email?: string; name?: string }; - const { email, name = "" } = body; - - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: "无效的邮箱地址" }, { status: 400 }); - } - - const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10); - - const result = await query( - `INSERT INTO internal_email_recipients (email, name, created_by) - VALUES ($1, $2, $3) - RETURNING id, email, name, is_active, created_at::text as created_at`, - [email.toLowerCase(), name, adminUserId] - ); - - await createAuditLog({ - userId: adminUserId, - username: req.headers.get("x-admin-username") || "unknown", - action: "create", - resource: "internal_email_recipient", - resourceId: String(result.rows[0]?.id), - requestParams: { email }, - }); - - return NextResponse.json(result.rows[0], { status: 201 }); - } catch (e: unknown) { - const err = e as { code?: string }; - if (err?.code === "23505") { - return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 }); - } - logError("[recipients] Add error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} diff --git a/admin/src/app/api/admin/daily-report/route.ts b/admin/src/app/api/admin/daily-report/route.ts deleted file mode 100644 index b4a7bc6..0000000 --- a/admin/src/app/api/admin/daily-report/route.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextRequest, NextResponse } from "next/server"; -import { query } from "@/lib/db"; -import { createAuditLog } from "@/lib/log"; - -export const runtime = "nodejs"; - -async function ensureTablesExist() { - try { - await query(` - CREATE TABLE IF NOT EXISTS internal_email_recipients ( - id BIGSERIAL PRIMARY KEY, - email VARCHAR(255) NOT NULL, - name VARCHAR(255) NOT NULL DEFAULT '', - is_active BOOLEAN NOT NULL DEFAULT true, - created_by BIGINT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - await query(`CREATE INDEX IF NOT EXISTS idx_internal_email_recipients_email ON internal_email_recipients (LOWER(email))`); - await query(` - CREATE TABLE IF NOT EXISTS admin_ai_config ( - id BIGSERIAL PRIMARY KEY, config_key VARCHAR(100) NOT NULL UNIQUE, - config_value TEXT NOT NULL DEFAULT '', updated_by BIGINT, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - `); - } catch { /* ignore */ } -} - -// GET /api/admin/daily-report/recipients — list all recipients -export async function GET() { - await ensureTablesExist(); - try { - const result = await query( - `SELECT id, email, name, is_active, created_at::text as created_at, created_by - FROM internal_email_recipients - ORDER BY created_at DESC` - ); - return NextResponse.json({ recipients: result.rows }); - } catch (e) { - logError("List recipients error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} - -// POST /api/admin/daily-report/recipients — add recipient -export async function POST(req: NextRequest) { - await ensureTablesExist(); - try { - const body = await req.json() as { email?: string; name?: string }; - const { email, name = "" } = body; - - if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: "无效的邮箱地址" }, { status: 400 }); - } - - const adminUserId = parseInt(req.headers.get("x-admin-user-id") || "0", 10); - - const result = await query( - `INSERT INTO internal_email_recipients (email, name, created_by) - VALUES ($1, $2, $3) - RETURNING id, email, name, is_active, created_at::text as created_at`, - [email.toLowerCase(), name, adminUserId] - ); - - await createAuditLog({ - userId: adminUserId, - username: req.headers.get("x-admin-username") || "unknown", - action: "create", - resource: "internal_email_recipient", - resourceId: String(result.rows[0]?.id), - requestParams: { email }, - }); - - return NextResponse.json(result.rows[0], { status: 201 }); - } catch (e: unknown) { - const err = e as { code?: string }; - if (err?.code === "23505") { - return NextResponse.json({ error: "该邮箱已存在" }, { status: 409 }); - } - logError("Add recipient error:", e); - return NextResponse.json({ error: "服务器错误" }, { status: 500 }); - } -} diff --git a/admin/src/app/api/cron-initialize/route.ts b/admin/src/app/api/cron-initialize/route.ts deleted file mode 100644 index 8902804..0000000 --- a/admin/src/app/api/cron-initialize/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextResponse } from "next/server"; -import { startDailyReportCron } from "@/lib/daily-report-cron"; - -export const runtime = "nodejs"; - -// Guard: only start cron once per server instance -let started = false; - -export async function GET() { - if (started) { - return NextResponse.json({ status: "already_started" }); - } - started = true; - - startDailyReportCron(); - return NextResponse.json({ status: "started" }); -} diff --git a/admin/src/app/api/metrics/route.ts b/admin/src/app/api/metrics/route.ts new file mode 100644 index 0000000..b569581 --- /dev/null +++ b/admin/src/app/api/metrics/route.ts @@ -0,0 +1,65 @@ +import { logError } from "@/lib/logger"; +import { NextResponse } from "next/server"; +import { query } from "@/lib/db"; + +export const runtime = "nodejs"; + +/** + * Platform metrics endpoint. + * Returns total / 27h / 7d / 30d new counts for all platform entities. + */ +export async function GET() { + try { + // Query all entity counts in parallel + // Tables: user, workspace, project, repo, room, project_skill + const entities = [ + { name: "users", table: '"user"' }, + { name: "workspaces", table: "workspace" }, + { name: "projects", table: "project" }, + { name: "repos", table: "repo" }, + { name: "rooms", table: "room" }, + { name: "skills", table: "project_skill" }, + ]; + + const results = await Promise.all( + entities.map(async ({ name, table }) => { + const res = await query<{ + total: string; + new_27h: string; + new_7d: string; + new_30d: string; + }>( + `SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '27 hours') AS new_27h, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '7 days') AS new_7d, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '30 days') AS new_30d + FROM ${table}` + ); + const row = res.rows[0]; + return { + name, + total: parseInt(row.total, 10), + new_27h: parseInt(row.new_27h, 10), + new_7d: parseInt(row.new_7d, 10), + new_30d: parseInt(row.new_30d, 10), + }; + }) + ); + + const metrics: Record = {}; + for (const r of results) { + metrics[r.name] = { + total: r.total, + last_27h: r.new_27h, + last_7d: r.new_7d, + last_30d: r.new_30d, + }; + } + + return NextResponse.json({ metrics, timestamp: new Date().toISOString() }); + } catch (e) { + logError("Metrics error:", e); + return NextResponse.json({ error: "服务器错误" }, { status: 500 }); + } +} diff --git a/admin/src/app/api/platform/ai/sync/route.ts b/admin/src/app/api/platform/ai/sync/route.ts deleted file mode 100644 index f537abb..0000000 --- a/admin/src/app/api/platform/ai/sync/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextResponse } from "next/server"; -import { syncModels } from "@/lib/adminrpc/client"; - -export const runtime = "nodejs"; - -/** - * Trigger AI model sync via adminrpc gRPC. - */ -export async function POST() { - try { - const data = await syncModels(); - return NextResponse.json(data); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - logError("AI sync error:", e); - return NextResponse.json({ error: `同步失败: ${msg}` }, { status: 500 }); - } -} diff --git a/admin/src/app/api/platform/alerts/check/route.ts b/admin/src/app/api/platform/alerts/check/route.ts deleted file mode 100644 index 30e9ae3..0000000 --- a/admin/src/app/api/platform/alerts/check/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { logError } from "@/lib/logger"; -import { NextResponse } from "next/server"; -import { checkAlerts } from "@/lib/adminrpc/client"; - -export const runtime = "nodejs"; - -/** - * Trigger workspace billing alert check via adminrpc gRPC. - */ -export async function POST() { - try { - const data = await checkAlerts(); - return NextResponse.json(data); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - logError("Alert check error:", e); - return NextResponse.json({ error: `检查失败: ${msg}` }, { status: 500 }); - } -} diff --git a/admin/src/app/platform/sessions/page.tsx b/admin/src/app/platform/sessions/page.tsx index 2041965..06eeded 100644 --- a/admin/src/app/platform/sessions/page.tsx +++ b/admin/src/app/platform/sessions/page.tsx @@ -2,12 +2,6 @@ import { useEffect, useState } from "react"; import { format } from "date-fns"; -import { - listUserSessions, - kickUser, - getUserStatus, - type UserSession, -} from "@/lib/admin-rpc"; interface PlatformSessionInfo { sessionId: string; @@ -23,14 +17,12 @@ export default function PlatformSessionsPage() { const [sessions, setSessions] = useState([]); const [loading, setLoading] = useState(true); const [kicking, setKicking] = useState(null); - const [adminRpcAvailable, setAdminRpcAvailable] = useState(false); useEffect(() => { loadSessions(); }, []); async function loadSessions() { setLoading(true); try { - // Load platform sessions from the app's REST API (Redis-based) const res = await fetch("/api/platform/sessions"); if (!res.ok) { setSessions([]); @@ -46,31 +38,6 @@ export default function PlatformSessionsPage() { } } - async function handleKickAllSessions(userId: string) { - if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return; - setKicking(userId); - try { - if (adminRpcAvailable) { - await kickUser(userId); - } else { - // Fallback: kick sessions via Redis (app REST API) - const userSessions = sessions.filter((s) => s.userId === userId); - await Promise.all( - userSessions.map((s) => - fetch("/api/platform/sessions", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ sessionId: s.sessionId }), - }) - ) - ); - } - loadSessions(); - } finally { - setKicking(null); - } - } - async function handleKickSession(sessionId: string) { if (!confirm("确定强制下线该会话吗?")) return; setKicking(sessionId); @@ -86,6 +53,26 @@ export default function PlatformSessionsPage() { } } + async function handleKickAllSessions(userId: string) { + if (!confirm(`强制下线用户 ${userId} 的所有会话?`)) return; + setKicking(userId); + try { + const userSessions = sessions.filter((s) => s.userId === userId); + await Promise.all( + userSessions.map((s) => + fetch("/api/platform/sessions", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId: s.sessionId }), + }) + ) + ); + loadSessions(); + } finally { + setKicking(null); + } + } + // Group by user const byUser = sessions.reduce>((acc, s) => { const key = s.userId || "unknown"; @@ -101,30 +88,9 @@ export default function PlatformSessionsPage() {

平台用户在线会话

共 {sessions.length} 个活跃会话,涉及 {Object.keys(byUser).length} 个用户 - {adminRpcAvailable && ( - - ● adminrpc 已连接 - - )}

-
- - -
+ {loading ? ( @@ -148,19 +114,14 @@ export default function PlatformSessionsPage() { {userSessions.length} 个会话 - {adminRpcAvailable && ( - - )} - {adminRpcAvailable && ( - - )} +
@@ -216,23 +177,3 @@ export default function PlatformSessionsPage() {
); } - -// ─── AdminRpc Status Badge ──────────────────────────────────────────────────── - -function AdminRpcUserStatus({ userId }: { userId: string }) { - const [status, setStatus] = useState(null); - - useEffect(() => { - getUserStatus(userId) - .then((r) => setStatus(r.status)) - .catch(() => setStatus(null)); - }, [userId]); - - if (!status) return null; - const color = status === "Online" ? "#22c55e" : "#f59e0b"; - return ( - - [{status}] - - ); -} diff --git a/admin/src/app/platform/users/page.tsx b/admin/src/app/platform/users/page.tsx index 9a4dde1..c249820 100644 --- a/admin/src/app/platform/users/page.tsx +++ b/admin/src/app/platform/users/page.tsx @@ -23,7 +23,6 @@ export default function PlatformUsersPage() { const [search, setSearch] = useState(""); const [loading, setLoading] = useState(true); const [selected, setSelected] = useState>(new Set()); - const [batchAction, setBatchAction] = useState<"enable" | "disable" | "">(""); const [batchLoading, setBatchLoading] = useState(false); const [editUser, setEditUser] = useState(null); const [editForm, setEditForm] = useState({ email: "", password: "", displayName: "", organization: "" }); @@ -106,11 +105,15 @@ export default function PlatformUsersPage() { setEditLoading(true); setEditMsg(null); try { - const body: Record = {}; + const body: Record = {}; if (editForm.email !== undefined) body.email = editForm.email; if (editForm.password) body.password = editForm.password; - if (editForm.displayName !== (editUser.display_name || "")) body.displayName = editForm.displayName; - if (editForm.organization !== (editUser.organization || "")) body.organization = editForm.organization; + if (editForm.displayName !== (editUser.display_name ?? "")) { + body.displayName = editForm.displayName || undefined; + } + if (editForm.organization !== (editUser.organization ?? "")) { + body.organization = editForm.organization || undefined; + } const res = await fetch(`/api/platform/users/${editUser.uid}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, diff --git a/admin/src/components/admin/Sidebar.tsx b/admin/src/components/admin/Sidebar.tsx index ba8dfd0..20bc6fc 100644 --- a/admin/src/components/admin/Sidebar.tsx +++ b/admin/src/components/admin/Sidebar.tsx @@ -47,12 +47,12 @@ export default function Sidebar({ user, loading, onLogout }: SidebarProps) { { section: "系统", links: [ + { href: "/admin/metrics", label: "平台指标", icon: "◉" }, { href: "/admin/logs", label: "审计日志", icon: "▣" }, { href: "/platform/audit", label: "平台审计", icon: "☰" }, { href: "/admin/sessions", label: "Admin 会话", icon: "◐" }, { href: "/platform/sessions", label: "平台会话", icon: "☀" }, { href: "/admin/api-tokens", label: "API Token", icon: "⚿" }, - { href: "/admin/daily-report", label: "每日报告", icon: "📧" }, ], }, ]; diff --git a/admin/src/lib/adminrpc/client.ts b/admin/src/lib/adminrpc/client.ts index 77f834c..a35324d 100644 --- a/admin/src/lib/adminrpc/client.ts +++ b/admin/src/lib/adminrpc/client.ts @@ -89,38 +89,6 @@ function decode(schema: any, bytes: Uint8Array): any { return fromBinary(schema as any, bytes) as any; } -// ─── AI: Sync Models ────────────────────────────────────────────────────────── - -export async function syncModels(): Promise { - const body = encode(admin.SyncModelsRequestSchema, {}); - const bytes = await grpcRequest( - ADMIN_RPC_URL, - "/admin.SessionAdmin/SyncModels", - admin.SyncModelsRequestSchema, - admin.SyncModelsResponseSchema, - body, - ); - if (bytes.length === 0) return {}; - const resp = decode(admin.SyncModelsResponseSchema, bytes); - return JSON.parse(resp.bodyJson || "{}"); -} - -// ─── AI: Check Alerts ───────────────────────────────────────────────────────── - -export async function checkAlerts(): Promise { - const body = encode(admin.CheckAlertsRequestSchema, {}); - const bytes = await grpcRequest( - ADMIN_RPC_URL, - "/admin.SessionAdmin/CheckAlerts", - admin.CheckAlertsRequestSchema, - admin.CheckAlertsResponseSchema, - body, - ); - if (bytes.length === 0) return {}; - const resp = decode(admin.CheckAlertsResponseSchema, bytes); - return JSON.parse(resp.bodyJson || "{}"); -} - // ─── AI: Provider CRUD ──────────────────────────────────────────────────────── export async function createProvider(body: unknown): Promise { diff --git a/admin/src/lib/daily-report-cron.ts b/admin/src/lib/daily-report-cron.ts deleted file mode 100644 index a01222d..0000000 --- a/admin/src/lib/daily-report-cron.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Daily report cron scheduler. - * Uses node-cron to trigger report generation at midnight UTC every day. - * Started once per server instance via /api/cron-initialize. - */ -import cron from "node-cron"; -import { query } from "@/lib/db"; - -// Whether report is currently running (prevents overlap) -let isRunning = false; - -async function runReport() { - if (isRunning) { - console.log("[daily-report-cron] Previous run still in progress, skipping"); - return; - } - isRunning = true; - console.log("[daily-report-cron] Starting daily report generation at", new Date().toISOString()); - - try { - // Verify report is enabled - const configRows = await query<{ config_key: string; config_value: string }>( - `SELECT config_key, config_value FROM admin_ai_config WHERE config_key = 'report_enabled'` - ); - const enabled = configRows.rows[0]?.config_value === "true"; - if (!enabled) { - console.log("[daily-report-cron] Report disabled, skipping"); - return; - } - - // Call generate endpoint internally (server-side fetch, no auth needed for cron) - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`; - const headers: Record = { - "Content-Type": "application/json", - "x-cron-internal": "true", - }; - if (process.env.DAILY_REPORT_CRON_SECRET) { - headers["x-cron-secret"] = process.env.DAILY_REPORT_CRON_SECRET; - } - const res = await fetch(`${baseUrl}/api/admin/daily-report/generate`, { - method: "POST", - headers, - }); - const data = await res.json().catch(() => ({})); - console.log("[daily-report-cron] Result:", res.status, data); - } catch (e) { - console.error("[daily-report-cron] Error:", e); - } finally { - isRunning = false; - } -} - -let task: ReturnType | null = null; - -export function startDailyReportCron() { - if (task) { - console.log("[daily-report-cron] Already started"); - return; - } - - // Run at midnight UTC every day - task = cron.schedule("0 0 * * *", runReport, { - timezone: "UTC", - }); - - console.log("[daily-report-cron] Scheduled: daily at 00:00 UTC"); -} - -export function stopDailyReportCron() { - if (task) { - task.stop(); - task = null; - console.log("[daily-report-cron] Stopped"); - } -} diff --git a/admin/src/middleware.ts b/admin/src/middleware.ts index cb0380a..3aab6a6 100644 --- a/admin/src/middleware.ts +++ b/admin/src/middleware.ts @@ -48,6 +48,10 @@ function getRequiredPermission(path: string, method: string): string | null { if (method === "GET") return "platform:read"; return "platform:manage"; } + if (path.startsWith("/api/metrics")) { + if (method === "GET") return "platform:read"; + return "platform:manage"; + } if (path.startsWith("/api/api-tokens")) { return null; // Token 管理需要 session,不允许 token 访问自己 } diff --git a/libs/service/agent/sync.rs b/libs/service/agent/sync.rs index c662fd3..d96434a 100644 --- a/libs/service/agent/sync.rs +++ b/libs/service/agent/sync.rs @@ -23,7 +23,6 @@ use db::database::AppDatabase; use models::agents::model::Entity as ModelEntity; use models::agents::model_capability::Entity as CapabilityEntity; use models::agents::model_parameter_profile::Entity as ProfileEntity; -use models::agents::model_pricing::Entity as PricingEntity; use models::agents::model_provider::Entity as ProviderEntity; use models::agents::model_provider::Model as ProviderModel; use models::agents::model_version::Entity as VersionEntity; @@ -52,7 +51,6 @@ struct OpenRouterModel { name: Option, #[serde(default)] description: Option, - pricing: Option, #[serde(default)] context_length: Option, #[serde(default)] @@ -61,25 +59,6 @@ struct OpenRouterModel { top_provider: Option, } -#[derive(Debug, Clone, Deserialize)] -#[allow(dead_code)] -struct OpenRouterPricing { - prompt: String, - completion: String, - #[serde(default)] - request: Option, - #[serde(default)] - image: Option, - #[serde(default)] - input_cache_read: Option, - #[serde(default)] - input_cache_write: Option, - #[serde(default)] - web_search: Option, - #[serde(default)] - internal_reasoning: Option, -} - #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] struct OpenRouterArchitecture { @@ -400,20 +379,13 @@ async fn upsert_version( } } -/// OpenRouter prices are per-million-tokens strings; convert to per-1k-tokens. -fn parse_price(s: &str) -> String { - match s.parse::() { - Ok(v) => format!("{:.6}", v / 1_000.0), - Err(_) => "0.00".to_string(), - } -} - +/// Create default pricing record with 0 price for admin-side modification. async fn upsert_pricing( db: &AppDatabase, version_uuid: Uuid, - pricing: Option<&OpenRouterPricing>, ) -> Result { use models::agents::model_pricing::Column as PCol; + use models::agents::model_pricing::Entity as PricingEntity; let existing = PricingEntity::find() .filter(PCol::ModelVersionId.eq(version_uuid)) .one(db) @@ -422,17 +394,11 @@ async fn upsert_pricing( return Ok(false); } - let (input_str, output_str) = if let Some(p) = pricing { - (parse_price(&p.prompt), parse_price(&p.completion)) - } else { - ("0.00".to_string(), "0.00".to_string()) - }; - let active = models::agents::model_pricing::ActiveModel { id: Set(Uuid::now_v7().as_u128() as i64), model_version_id: Set(version_uuid), - input_price_per_1k_tokens: Set(input_str), - output_price_per_1k_tokens: Set(output_str), + input_price_per_1k_tokens: Set("0.00".to_string()), + output_price_per_1k_tokens: Set("0.00".to_string()), currency: Set("USD".to_string()), effective_from: Set(Utc::now()), }; @@ -584,7 +550,7 @@ async fn sync_models_direct( versions_created += 1; } - if upsert_pricing(db, version_record.id, None).await.unwrap_or(false) { + if upsert_pricing(db, version_record.id).await.unwrap_or(false) { pricing_created += 1; } @@ -836,15 +802,7 @@ impl AppService { versions_created += 1; } - if let Err(e) = - upsert_pricing(&self.db, version_record.id, or_model.pricing.as_ref()).await - { - tracing::warn!( - model = %or_model.id, - error = ?e, - "sync_upstream_models: upsert_pricing error" - ); - } else { + if upsert_pricing(&self.db, version_record.id).await.unwrap_or(false) { pricing_created += 1; } @@ -1010,10 +968,7 @@ impl AppService { versions_created += 1; } - if upsert_pricing(db, version_record.id, or_model.pricing.as_ref()) - .await - .unwrap_or(false) - { + if upsert_pricing(db, version_record.id).await.unwrap_or(false) { pricing_created += 1; }