diff --git a/admin/src/app/admin/daily-report/page.tsx b/admin/src/app/admin/daily-report/page.tsx new file mode 100644 index 0000000..9bf979a --- /dev/null +++ b/admin/src/app/admin/daily-report/page.tsx @@ -0,0 +1,413 @@ +"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; + 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-..." /> +
+
+ + {/* 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/api/admin/daily-report/ai-config/route.ts b/admin/src/app/api/admin/daily-report/ai-config/route.ts new file mode 100644 index 0000000..3bff3da --- /dev/null +++ b/admin/src/app/api/admin/daily-report/ai-config/route.ts @@ -0,0 +1,101 @@ +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) { + console.error("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", + "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) { + console.error("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 new file mode 100644 index 0000000..94a20d1 --- /dev/null +++ b/admin/src/app/api/admin/daily-report/generate/route.ts @@ -0,0 +1,495 @@ +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; + 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 secret (optional, set in K8s CronJob annotation) + const cronSecret = req.headers.get("x-cron-secret"); + const expectedSecret = process.env.DAILY_REPORT_CRON_SECRET; + if (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); + } + + // ── 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) { + console.error("[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 user_id)::text as count + FROM room_message WHERE created_at >= $1`, + [todayStr] + ), + // Top room by message count today + query<{ room_id: string; room_name: string; message_count: string }>( + `SELECT rm.room_id, r.name as room_name, COUNT(*)::text as message_count + FROM room_message rm + JOIN room r ON r.id = rm.room_id + WHERE rm.created_at >= $1 + GROUP BY rm.room_id, 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_id, + 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_id = $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 +): 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}`; + + try { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + 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) { + console.error(`[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 new file mode 100644 index 0000000..b266fbd --- /dev/null +++ b/admin/src/app/api/admin/daily-report/recipients/[id]/route.ts @@ -0,0 +1,105 @@ +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 }); + } + console.error("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) { + console.error("Delete recipient 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 new file mode 100644 index 0000000..2a4b0a8 --- /dev/null +++ b/admin/src/app/api/admin/daily-report/route.ts @@ -0,0 +1,84 @@ +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) { + console.error("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 }); + } + console.error("Add recipient error:", e); + return NextResponse.json({ error: "服务器错误" }, { status: 500 }); + } +} diff --git a/admin/src/components/admin/Sidebar.tsx b/admin/src/components/admin/Sidebar.tsx index bb97d49..3d2a71d 100644 --- a/admin/src/components/admin/Sidebar.tsx +++ b/admin/src/components/admin/Sidebar.tsx @@ -52,6 +52,8 @@ export default function Sidebar({ user, loading, onLogout }: SidebarProps) { { href: "/admin/sessions", label: "Admin 会话", icon: "◐" }, { href: "/platform/sessions", label: "平台会话", icon: "☀" }, { href: "/admin/api-tokens", label: "API Token", icon: "⚿" }, + { href: "/admin/metrics", label: "指标监控", icon: "◉" }, + { href: "/admin/daily-report", label: "每日报告", icon: "📧" }, ], }, ]; diff --git a/admin/src/lib/admin-rpc.ts b/admin/src/lib/admin-rpc.ts new file mode 100644 index 0000000..a269427 --- /dev/null +++ b/admin/src/lib/admin-rpc.ts @@ -0,0 +1,130 @@ +/** + * adminrpc HTTP REST client + * + * Calls the adminrpc HTTP server (default: http://adminrpc.admin.svc.cluster.local:9091) + * which exposes the same session management and metrics APIs as the gRPC service. + * + * Usage: + * import { listWorkspaceSessions, kickUser } from "@/lib/admin-rpc"; + */ + +import { ADMIN_RPC_URL } from "./env"; + +// Default to k8s internal service address; override via ADMIN_RPC_URL env var +const BASE_URL = ADMIN_RPC_URL || "http://adminrpc.admin.svc.cluster.local:9091"; + +async function rpc(path: string, options?: RequestInit): Promise { + const url = `${BASE_URL}${path}`; + const res = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`adminrpc ${options?.method ?? "GET"} ${path} failed (${res.status}): ${body}`); + } + return res.json() as Promise; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface UserSession { + session_id: string; + user_id: string; + workspace_id: string; + ip_address: string; + user_agent: string; + connected_at: string; + last_heartbeat: string; +} + +export interface SessionInfo { + user_id: string; + session_count: number; + workspaces: string[]; + latest_session: UserSession | null; +} + +// ─── Sessions API ───────────────────────────────────────────────────────────── + +/** List all active sessions for a workspace. */ +export async function listWorkspaceSessions(workspaceId: string): Promise { + return rpc(`/api/admin/sessions/workspace/${encodeURIComponent(workspaceId)}`); +} + +/** List all active sessions for a specific user. */ +export async function listUserSessions(userId: string): Promise { + return rpc(`/api/admin/sessions/user/${encodeURIComponent(userId)}`); +} + +/** Get the online status of a specific user. */ +export async function getUserStatus(userId: string): Promise<{ status: string }> { + return rpc<{ status: string }>(`/api/admin/sessions/user/${encodeURIComponent(userId)}/status`); +} + +/** Get detailed info for a user (session count, workspaces, latest session). */ +export async function getUserInfo(userId: string): Promise { + return rpc(`/api/admin/sessions/user/${encodeURIComponent(userId)}/info`); +} + +/** List all online user IDs in a workspace. */ +export async function getWorkspaceOnlineUsers(workspaceId: string): Promise<{ user_ids: string[] }> { + return rpc<{ user_ids: string[] }>(`/api/admin/sessions/workspace/${encodeURIComponent(workspaceId)}/online-users`); +} + +/** Check if a user is currently online. */ +export async function isUserOnline(userId: string): Promise<{ online: boolean }> { + return rpc<{ online: boolean }>(`/api/admin/sessions/user/${encodeURIComponent(userId)}/online`); +} + +/** Kick all sessions for a user. Returns the number of sessions kicked. */ +export async function kickUser(userId: string): Promise<{ kicked_count: number }> { + return rpc<{ kicked_count: number }>("/api/admin/sessions/kick", { + method: "POST", + body: JSON.stringify({ user_id: userId }), + }); +} + +/** Kick all sessions for a user in a specific workspace. */ +export async function kickUserFromWorkspace( + userId: string, + workspaceId: string +): Promise<{ kicked_count: number }> { + return rpc<{ kicked_count: number }>("/api/admin/sessions/kick-workspace", { + method: "POST", + body: JSON.stringify({ user_id: userId, workspace_id: workspaceId }), + }); +} + +// ─── Metrics API ───────────────────────────────────────────────────────────── + +export interface InstanceMetrics { + instance_id: string; + timestamp_secs: number; + http: Record; + room: Record; +} + +/** Get metrics across all app instances. */ +export async function getMetrics(instanceFilter = ""): Promise { + const qs = instanceFilter ? `?instance_filter=${encodeURIComponent(instanceFilter)}` : ""; + return rpc(`/api/admin/metrics${qs}`); +} + +/** Export all metrics as CSV string. */ +export async function exportMetricsCsv(instanceFilter = ""): Promise { + const qs = instanceFilter ? `?instance_filter=${encodeURIComponent(instanceFilter)}` : ""; + const res = await fetch(`${BASE_URL}/api/admin/metrics/export${qs}`); + if (!res.ok) { + throw new Error(`adminrpc GET /metrics/export failed (${res.status})`); + } + return res.text(); +} + +/** Get adminrpc health status. */ +export async function adminRpcHealth(): Promise<{ ok: boolean }> { + return rpc<{ ok: boolean }>("/health"); +}