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
This commit is contained in:
ZhenYi 2026-04-26 14:44:21 +08:00
parent 660ffd6acb
commit fb27918285
20 changed files with 276 additions and 1656 deletions

View File

@ -83,8 +83,6 @@ export default function AiPage() {
const [pricing, setPricing] = useState<Pricing[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>("providers");
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState<string | null>(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() {
<button className={`btn btn-sm ${tab === "models" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("models")}> ({models.length})</button>
<button className={`btn btn-sm ${tab === "versions" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("versions")}></button>
<button className={`btn btn-sm ${tab === "pricing" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("pricing")}></button>
<div style={{ flex: 1 }} />
<button className="btn btn-sm btn-primary" disabled={syncing} onClick={triggerSync}>
{syncing ? "同步中..." : "同步 OpenRouter 模型"}
</button>
</div>
{syncMsg && (
<div className={`alert ${syncMsg.includes("失败") ? "alert-error" : "alert-success"}`} style={{ marginBottom: "12px" }}>
{syncMsg}
</div>
)}
{msg && (
<div className={`alert ${msg.type === "error" ? "alert-error" : "alert-success"}`} style={{ marginBottom: "12px" }}>
{msg.text}
@ -402,7 +370,7 @@ export default function AiPage() {
</tr>
);
})}
{versions.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}> OpenRouter </td></tr>}
{versions.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}></td></tr>}
</tbody>
</table>
</div>

View File

@ -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<Recipient[]>([]);
const [showAdd, setShowAdd] = useState(false);
const [addEmail, setAddEmail] = useState("");
const [addName, setAddName] = useState("");
const [addLoading, setAddLoading] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [editName, setEditName] = useState("");
const [editActive, setEditActive] = useState(true);
// AI Config state
const [config, setConfig] = useState<AiConfig>({});
const [savingAi, setSavingAi] = useState(false);
const [aiForm, setAiForm] = useState<AiConfig>({});
const [testLoading, setTestLoading] = useState(false);
// Generate state
const [generating, setGenerating] = useState(false);
const [generateResult, setGenerateResult] = useState<Record<string, unknown> | 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 (
<div className="admin-content">
<div className="page-header">
<div>
<h1 className="page-title"></h1>
<p className="page-subtitle"> AI </p>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
className="btn btn-secondary"
onClick={handleTriggerReport}
disabled={generating || !isReportEnabled}
>
{generating ? "生成中..." : "🔄 立即触发"}
</button>
</div>
</div>
{msg && (
<div className={`alert ${msg.type === "error" ? "alert-error" : "alert-success"}`} style={{ marginBottom: "16px" }}>
{msg.text}
<button onClick={() => setMsg(null)} style={{ float: "right", background: "none", border: "none", cursor: "pointer" }}>×</button>
</div>
)}
{generateResult && (
<div className="card" style={{ marginBottom: "16px", background: "#f0fdf4" }}>
<h3 style={{ margin: "0 0 12px 0", fontSize: "14px" }}>📊 </h3>
<div style={{ fontSize: "13px", color: "#166534", display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "8px" }}>
<div>📧 : <strong>{`${(generateResult.sent as number) ?? 0}`}</strong></div>
<div>👥 : <strong>{`${(generateResult.recipients as number) ?? 0}`}</strong></div>
<div>🤖 AI分析: <strong>{`${(generateResult.aiSummaryUsed as boolean) ? "已启用" : "未启用"}`}</strong></div>
{!!generateResult.stats && (
<>
<div>👥 : <strong>{`${(generateResult.stats as Record<string, unknown>).newUsers ?? 0}`}</strong></div>
<div>💬 : <strong>{`${(generateResult.stats as Record<string, unknown>).newMessages ?? 0}`}</strong></div>
<div>🔧 : <strong>{`${(generateResult.stats as Record<string, unknown>).newCommits ?? 0}`}</strong></div>
</>
)}
</div>
</div>
)}
{/* Tabs */}
<div className="toolbar" style={{ marginBottom: "0" }}>
<button className={`btn btn-sm ${tab === "recipients" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("recipients")}>
📧
</button>
<button className={`btn btn-sm ${tab === "ai-config" ? "btn-primary" : "btn-secondary"}`} onClick={() => setTab("ai-config")}>
🤖 AI & SMTP
</button>
</div>
<div className="card">
{tab === "recipients" && (
<>
<div style={{ padding: "12px", borderBottom: "1px solid #e5e7eb", display: "flex", justifyContent: "flex-end" }}>
<button className="btn btn-sm btn-primary" onClick={() => setShowAdd(true)}>+ </button>
</div>
{showAdd && (
<div style={{ padding: "16px", background: "#f9fafb", borderBottom: "1px solid #e5e7eb" }}>
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
<div className="form-group" style={{ flex: 1, margin: 0 }}>
<label className="form-label"></label>
<input className="form-input" type="email" value={addEmail} onChange={e => setAddEmail(e.target.value)} placeholder="employee@company.com" />
</div>
<div className="form-group" style={{ flex: 1, margin: 0 }}>
<label className="form-label"></label>
<input className="form-input" value={addName} onChange={e => setAddName(e.target.value)} placeholder="张三" />
</div>
<button className="btn btn-primary btn-sm" disabled={addLoading || !addEmail} onClick={handleAddRecipient}>
{addLoading ? "添加中..." : "添加"}
</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowAdd(false); setAddEmail(""); setAddName(""); }}></button>
</div>
</div>
)}
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th></th><th></th><th></th><th></th><th></th>
</tr>
</thead>
<tbody>
{recipients.map(r => (
<tr key={r.id}>
<td>{r.email}</td>
<td>{editId === r.id ? (
<input className="form-input" style={{ padding: "2px 8px", fontSize: "13px", width: "120px" }}
value={editName} onChange={e => setEditName(e.target.value)} />
) : r.name || "—"}</td>
<td>{editId === r.id ? (
<label style={{ display: "flex", alignItems: "center", gap: "4px", fontSize: "13px" }}>
<input type="checkbox" checked={editActive} onChange={e => setEditActive(e.target.checked)} />
</label>
) : (
<span className={`badge ${r.is_active ? "badge-success" : "badge-neutral"}`}>
{r.is_active ? "启用" : "禁用"}
</span>
)}</td>
<td style={{ fontSize: "13px", color: "#737373" }}>{r.created_at ? format(new Date(r.created_at), "yyyy-MM-dd HH:mm") : "—"}</td>
<td>
{editId === r.id ? (
<>
<button className="btn btn-primary btn-sm" style={{ marginRight: "4px" }} onClick={() => handleUpdateRecipient(r.id)}></button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditId(null)}></button>
</>
) : (
<>
<button className="btn btn-secondary btn-sm" style={{ marginRight: "4px" }}
onClick={() => { setEditId(r.id); setEditName(r.name); setEditActive(r.is_active); }}></button>
<button className="btn btn-danger btn-sm" onClick={() => handleDeleteRecipient(r.id)}></button>
</>
)}
</td>
</tr>
))}
{recipients.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>
</td></tr>
)}
</tbody>
</table>
</div>
</>
)}
{tab === "ai-config" && (
<div style={{ padding: "24px" }}>
{/* Report enable */}
<div className="form-group">
<label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer", fontWeight: 600 }}>
<input type="checkbox" checked={aiForm.report_enabled === "true"}
onChange={e => setAiForm(f => ({ ...f, report_enabled: e.target.checked ? "true" : "false" }))} />
</label>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "16px" }}>
{/* AI Settings */}
<div>
<h3 style={{ fontSize: "14px", fontWeight: 600, margin: "16px 0 12px", color: "#374151" }}>🤖 AI </h3>
<div className="form-group">
<label className="form-label"> AI </label>
<label style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="checkbox" checked={aiForm.ai_enabled === "true"}
onChange={e => setAiForm(f => ({ ...f, ai_enabled: e.target.checked ? "true" : "false" }))} />
</label>
</div>
<div className="form-group">
<label className="form-label">AI </label>
<input className="form-input" value={aiForm.ai_model || ""}
onChange={e => setAiForm(f => ({ ...f, ai_model: e.target.value }))}
placeholder="gpt-4o-mini" />
<span style={{ fontSize: "12px", color: "#737373" }}>OpenAI gpt-4o, gpt-4o-mini </span>
</div>
<div className="form-group">
<label className="form-label">OpenAI API Key</label>
<input className="form-input" type="password" value={aiForm.ai_api_key || ""}
onChange={e => setAiForm(f => ({ ...f, ai_api_key: e.target.value }))}
placeholder="sk-..." />
</div>
<div className="form-group">
<label className="form-label"> API </label>
<input className="form-input" value={aiForm.basic_api_url || ""}
onChange={e => setAiForm(f => ({ ...f, basic_api_url: e.target.value }))}
placeholder="https://api.openai.com留空使用默认地址" />
<span style={{ fontSize: "12px", color: "#737373" }}>
OpenAI Cloudflare AI GatewayOneAPI
</span>
</div>
</div>
{/* SMTP Settings */}
<div>
<h3 style={{ fontSize: "14px", fontWeight: 600, margin: "16px 0 12px", color: "#374151" }}>📧 SMTP </h3>
<div className="form-group">
<label className="form-label">SMTP </label>
<input className="form-input" value={aiForm.smtp_host || ""}
onChange={e => setAiForm(f => ({ ...f, smtp_host: e.target.value }))}
placeholder="smtp.example.com" />
</div>
<div className="form-group">
<label className="form-label">SMTP </label>
<input className="form-input" type="number" value={aiForm.smtp_port || "587"}
onChange={e => setAiForm(f => ({ ...f, smtp_port: e.target.value }))}
placeholder="587" />
</div>
<div className="form-group">
<label className="form-label"></label>
<input className="form-input" value={aiForm.smtp_username || ""}
onChange={e => setAiForm(f => ({ ...f, smtp_username: e.target.value }))}
placeholder="notifications@example.com" />
</div>
<div className="form-group">
<label className="form-label"> / API Key</label>
<input className="form-input" type="password" value={aiForm.smtp_password || ""}
onChange={e => setAiForm(f => ({ ...f, smtp_password: e.target.value }))}
placeholder="••••••••" />
</div>
<div className="form-group">
<label className="form-label"></label>
<input className="form-input" type="email" value={aiForm.smtp_from || ""}
onChange={e => setAiForm(f => ({ ...f, smtp_from: e.target.value }))}
placeholder="no-reply@example.com" />
</div>
<div className="form-group">
<label style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<input type="checkbox" checked={aiForm.smtp_tls === "true"}
onChange={e => setAiForm(f => ({ ...f, smtp_tls: e.target.checked ? "true" : "false" }))} />
使 TLS 587
</label>
</div>
</div>
</div>
<div style={{ marginTop: "20px", paddingTop: "16px", borderTop: "1px solid #e5e7eb" }}>
<button className="btn btn-primary" disabled={savingAi} onClick={handleSaveAiConfig}>
{savingAi ? "保存中..." : "💾 保存配置"}
</button>
<span style={{ marginLeft: "12px", fontSize: "13px", color: "#737373" }}>
</span>
</div>
</div>
)}
</div>
{/* Cron 说明 */}
<div className="card" style={{ marginTop: "16px" }}>
<h3 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: 600 }}> </h3>
<p style={{ margin: "0 0 8px 0", fontSize: "13px", color: "#737373" }}>
使 <code>npm start</code> UTC
使 K8s CronJob
</p>
<pre style={{ background: "#1e1e1e", color: "#d4d4d4", padding: "16px", borderRadius: "8px", fontSize: "12px", overflow: "auto" }}>
{`# 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`}
</pre>
<p style={{ margin: "8px 0 0 0", fontSize: "12px", color: "#9ca3af" }}>
<strong></strong>K8s CronJob 使
<code>DAILY_REPORT_CRON_SECRET</code> CronJob
</p>
</div>
</div>
);
}

View File

@ -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<string, MetricData>;
timestamp: string;
}
const ENTITY_LABELS: Record<string, { label: string; icon: string }> = {
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<MetricsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState<string | null>(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 (
<div className="admin-content">
<div className="page-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
<div>
<h1 className="page-title"></h1>
<p className="page-subtitle">
{lastRefresh && (
<span style={{ marginLeft: "8px", fontSize: "12px", color: "#737373" }}>
{format(new Date(lastRefresh), "HH:mm:ss")}
</span>
)}
</p>
</div>
<button className="btn btn-secondary" onClick={loadMetrics}></button>
</div>
{loading ? (
<div className="loading">...</div>
) : data ? (
<>
{/* Summary cards */}
<div className="stats-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))" }}>
{Object.entries(data.metrics).map(([key, val]) => {
const meta = ENTITY_LABELS[key];
if (!meta) return null;
return (
<div key={key} className="stat-card">
<div className="stat-label" style={{ fontSize: "14px", marginBottom: "4px" }}>
{meta.icon} {meta.label}
</div>
<div className="stat-value" style={{ fontSize: "28px" }}>
{val.total.toLocaleString()}
</div>
<div className="stat-label"></div>
</div>
);
})}
</div>
{/* Detail table */}
<div className="card" style={{ marginTop: "16px" }}>
<div className="table-container">
<table className="data-table">
<thead>
<tr>
<th></th>
<th></th>
<th> 27 </th>
<th> 7 </th>
<th> 30 </th>
</tr>
</thead>
<tbody>
{Object.entries(data.metrics).map(([key, val]) => {
const meta = ENTITY_LABELS[key];
if (!meta) return null;
return (
<tr key={key}>
<td>
<span style={{ fontWeight: 500 }}>{meta.icon} {meta.label}</span>
</td>
<td style={{ fontWeight: 600 }}>{val.total.toLocaleString()}</td>
<td>
<span className="badge badge-neutral">
+{val.last_27h.toLocaleString()}
</span>
{val.last_27h > 0 && (
<span style={{ marginLeft: "6px", fontSize: "12px", color: "#16a34a" }}>
({((val.last_27h / Math.max(val.total, 1)) * 100).toFixed(1)}%)
</span>
)}
</td>
<td>
<span className="badge badge-neutral">
+{val.last_7d.toLocaleString()}
</span>
{val.last_7d > 0 && (
<span style={{ marginLeft: "6px", fontSize: "12px", color: "#16a34a" }}>
({((val.last_7d / Math.max(val.total, 1)) * 100).toFixed(1)}%)
</span>
)}
</td>
<td>
<span className="badge badge-neutral">
+{val.last_30d.toLocaleString()}
</span>
{val.last_30d > 0 && (
<span style={{ marginLeft: "6px", fontSize: "12px", color: "#16a34a" }}>
({((val.last_30d / Math.max(val.total, 1)) * 100).toFixed(1)}%)
</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Raw JSON for API consumers */}
<div className="card" style={{ marginTop: "16px" }}>
<h3 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: 600 }}>API </h3>
<p style={{ margin: "0 0 8px 0", fontSize: "13px", color: "#737373" }}>
<code>GET /api/metrics</code> JSON Prometheus
</p>
<pre style={{ background: "#1e1e1e", color: "#d4d4d4", padding: "16px", borderRadius: "8px", fontSize: "12px", overflow: "auto" }}>
{JSON.stringify(data, null, 2)}
</pre>
</div>
</>
) : (
<div className="alert alert-error"></div>
)}
</div>
);
}

View File

@ -60,8 +60,6 @@ export default function WorkspaceDetailPage() {
const [tab, setTab] = useState<Tab>("members");
const [alertConfigs, setAlertConfigs] = useState<AlertConfig[]>([]);
const [alertSaving, setAlertSaving] = useState(false);
const [alertCheckMsg, setAlertCheckMsg] = useState<string | null>(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() {
<button className="btn btn-primary" disabled={alertSaving} onClick={handleSaveAlerts}>
{alertSaving ? "保存中..." : "保存配置"}
</button>
<button
className="btn btn-secondary"
style={{ marginLeft: "8px" }}
disabled={alertChecking}
onClick={async () => {
setAlertChecking(true);
setAlertCheckMsg(null);
try {
const res = await fetch("/api/platform/alerts/check", { method: "POST" });
const data = await res.json();
if (res.ok && data.data) {
const d = data.data;
setAlertCheckMsg(
`检查完成:扫描 ${d.workspaces_checked} 个 Workspace发送 ${d.alerts_sent} 封告警邮件`
);
} else {
setAlertCheckMsg(`检查失败: ${data.error || res.status}`);
}
} catch (e) {
setAlertCheckMsg(`检查失败: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setAlertChecking(false);
}
}}
>
{alertChecking ? "检查中..." : "立即检查告警"}
</button>
</div>
{alertCheckMsg && (
<div className={`alert ${alertCheckMsg.includes("失败") ? "alert-error" : "alert-success"}`} style={{ marginTop: "12px" }}>
{alertCheckMsg}
</div>
)}
</div>
)}
</div>

View File

@ -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<string, string> = {};
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<string, string>;
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 });
}
}

View File

@ -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<string, string> = {};
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<DailyStats> {
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<string> {
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
? `<div style="background:#f0f9ff;border:1px solid #bae6fd;border-radius:8px;padding:16px;margin:16px 0">
<div style="font-weight:600;color:#0369a1;margin-bottom:8px">🤖 AI </div>
<p style="margin:0;color:#0c4a6e;line-height:1.6">${aiSummary}</p>
</div>`
: `<div style="background:#fff7ed;border:1px solid #fed7aa;border-radius:8px;padding:16px;margin:16px 0;color:#9a3412">
AI AI
</div>`;
const topRoomSection = stats.topRoom
? `<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">💬 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">
<strong>${stats.topRoom.name}</strong> ${stats.topRoom.messageCount}
</td>
</tr>`
: `<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">💬 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;color:#9ca3af"></td>
</tr>`;
return `<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:640px;margin:0 auto;padding:20px;background:#f9fafb">
<div style="background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1)">
<div style="background:linear-gradient(135deg,#667eea,#764ba2);padding:24px 32px">
<div style="color:rgba(255,255,255,0.9);font-size:13px;margin-bottom:4px"></div>
<h1 style="color:#fff;margin:0;font-size:22px">${date}</h1>
</div>
${aiSection}
<div style="padding:0 24px 24px">
<h2 style="font-size:15px;color:#374151;margin:16px 0 8px;border-bottom:2px solid #e5e7eb;padding-bottom:8px">📈 </h2>
<table style="width:100%;border-collapse:collapse;font-size:14px">
<tr style="background:#f9fafb">
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">👥 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;text-align:right;font-weight:600;color:#059669">${stats.newUsers} </td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">🟢 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;text-align:right;font-weight:600">${stats.activeUsers} </td>
</tr>
<tr style="background:#f9fafb">
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">🏠 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;text-align:right;font-weight:600;color:#7c3aed">${stats.newRooms} </td>
</tr>
<tr>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">💬 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;text-align:right;font-weight:600;color:#2563eb">${stats.newMessages} </td>
</tr>
<tr style="background:#f9fafb">
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb">🔧 </td>
<td style="padding:10px 16px;border-bottom:1px solid #e5e7eb;text-align:right;font-weight:600">${stats.newCommits} </td>
</tr>
${topRoomSection}
</table>
<div style="margin-top:24px;padding:16px;background:#f9fafb;border-radius:8px;font-size:12px;color:#9ca3af">
AI
</div>
</div>
</div>
</body>
</html>`;
}
// ─── 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<number> {
// 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<void>((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,
};
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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" });
}

View File

@ -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<string, unknown> = {};
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 });
}
}

View File

@ -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 });
}
}

View File

@ -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 });
}
}

View File

@ -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<PlatformSessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [kicking, setKicking] = useState<string | null>(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<Record<string, PlatformSessionInfo[]>>((acc, s) => {
const key = s.userId || "unknown";
@ -101,30 +88,9 @@ export default function PlatformSessionsPage() {
<h1 className="page-title">线</h1>
<p className="page-subtitle">
{sessions.length} {Object.keys(byUser).length}
{adminRpcAvailable && (
<span style={{ marginLeft: "8px", color: "#22c55e", fontSize: "12px" }}>
adminrpc
</span>
)}
</p>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
className="btn btn-secondary"
onClick={async () => {
try {
const { adminRpcHealth } = await import("@/lib/admin-rpc");
await adminRpcHealth();
setAdminRpcAvailable(true);
} catch {
setAdminRpcAvailable(false);
}
}}
>
adminrpc
</button>
<button className="btn btn-secondary" onClick={loadSessions}></button>
</div>
<button className="btn btn-secondary" onClick={loadSessions}></button>
</div>
{loading ? (
@ -148,19 +114,14 @@ export default function PlatformSessionsPage() {
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>
{userSessions.length}
</span>
{adminRpcAvailable && (
<AdminRpcUserStatus userId={userId} />
)}
</div>
{adminRpcAvailable && (
<button
className="btn btn-danger btn-sm"
disabled={kicking === userId}
onClick={() => handleKickAllSessions(userId)}
>
{kicking === userId ? "处理中..." : "全部下线 (adminrpc)"}
</button>
)}
<button
className="btn btn-danger btn-sm"
disabled={kicking === userId}
onClick={() => handleKickAllSessions(userId)}
>
{kicking === userId ? "处理中..." : "全部下线"}
</button>
</div>
<div className="table-container">
@ -216,23 +177,3 @@ export default function PlatformSessionsPage() {
</div>
);
}
// ─── AdminRpc Status Badge ────────────────────────────────────────────────────
function AdminRpcUserStatus({ userId }: { userId: string }) {
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
getUserStatus(userId)
.then((r) => setStatus(r.status))
.catch(() => setStatus(null));
}, [userId]);
if (!status) return null;
const color = status === "Online" ? "#22c55e" : "#f59e0b";
return (
<span style={{ marginLeft: "8px", color, fontSize: "12px", fontWeight: 500 }}>
[{status}]
</span>
);
}

View File

@ -23,7 +23,6 @@ export default function PlatformUsersPage() {
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [batchAction, setBatchAction] = useState<"enable" | "disable" | "">("");
const [batchLoading, setBatchLoading] = useState(false);
const [editUser, setEditUser] = useState<PlatformUser | null>(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<string, string> = {};
const body: Record<string, string | undefined> = {};
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" },

View File

@ -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: "📧" },
],
},
];

View File

@ -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<unknown> {
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<unknown> {
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<unknown> {

View File

@ -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<string, string> = {
"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<typeof cron.schedule> | 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");
}
}

View File

@ -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 访问自己
}

View File

@ -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<String>,
#[serde(default)]
description: Option<String>,
pricing: Option<OpenRouterPricing>,
#[serde(default)]
context_length: Option<u64>,
#[serde(default)]
@ -61,25 +59,6 @@ struct OpenRouterModel {
top_provider: Option<OpenRouterTopProvider>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(dead_code)]
struct OpenRouterPricing {
prompt: String,
completion: String,
#[serde(default)]
request: Option<String>,
#[serde(default)]
image: Option<String>,
#[serde(default)]
input_cache_read: Option<String>,
#[serde(default)]
input_cache_write: Option<String>,
#[serde(default)]
web_search: Option<String>,
#[serde(default)]
internal_reasoning: Option<String>,
}
#[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::<f64>() {
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<bool, AppError> {
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;
}