Compare commits
No commits in common. "27cd4ea83c7ced7671f212559872a2cb4beb803b" and "99ebfc14a78d5df5a800b8f40436b126a054ff76" have entirely different histories.
27cd4ea83c
...
99ebfc14a7
278
admin/metrics.md
278
admin/metrics.md
@ -1,278 +0,0 @@
|
||||
# Admin 平台指标 — Grafana / Prometheus 配置指南
|
||||
|
||||
## 概述
|
||||
|
||||
Admin 服务暴露两个指标端点:
|
||||
|
||||
| 端点 | 格式 | 用途 |
|
||||
|------|------|------|
|
||||
| `GET /api/metrics` | JSON | 前端页面 / 人工查看 / API 消费 |
|
||||
| `GET /api/metrics/prometheus` | Prometheus Text | Prometheus 采集 |
|
||||
|
||||
Prometheus 端点 **无需认证**,可直接 scrape。
|
||||
|
||||
## 采集的指标
|
||||
|
||||
所有指标通过 `platform_entity_count` Gauge 暴露,带 `entity` 和 `window` 两个 label:
|
||||
|
||||
```
|
||||
# HELP platform_entity_count Platform entity counts by time window
|
||||
# TYPE platform_entity_count gauge
|
||||
platform_entity_count{entity="users",window="total"} 1000
|
||||
platform_entity_count{entity="users",window="27h"} 5
|
||||
platform_entity_count{entity="users",window="7d"} 32
|
||||
platform_entity_count{entity="users",window="30d"} 150
|
||||
platform_entity_count{entity="workspaces",window="total"} 50
|
||||
platform_entity_count{entity="workspaces",window="27h"} 1
|
||||
...
|
||||
platform_entity_count{entity="skills",window="30d"} 45
|
||||
```
|
||||
|
||||
Entity 列表:`users`、`workspaces`、`projects`、`repos`、`rooms`、`skills`
|
||||
|
||||
Window 列表:`total`(累计)、`27h`(近27小时)、`7d`(近7天)、`30d`(近30天)
|
||||
|
||||
## Prometheus 配置
|
||||
|
||||
### prometheus.yml
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'admin-metrics'
|
||||
scrape_interval: 60s
|
||||
metrics_path: '/api/metrics/prometheus'
|
||||
static_configs:
|
||||
- targets: ['<admin-host>:<port>']
|
||||
labels:
|
||||
env: 'production'
|
||||
service: 'admin'
|
||||
```
|
||||
|
||||
### K8s ServiceMonitor(如果用 prometheus-operator)
|
||||
|
||||
```yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: admin-metrics
|
||||
namespace: monitoring
|
||||
labels:
|
||||
release: prometheus
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: admin
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /api/metrics/prometheus
|
||||
interval: 60s
|
||||
```
|
||||
|
||||
## Grafana Dashboard
|
||||
|
||||
### 推荐 Panel 配置
|
||||
|
||||
#### Panel 1: 实体总量(Stat Panel)
|
||||
|
||||
```
|
||||
Query:
|
||||
platform_entity_count{window="total"}
|
||||
|
||||
Visualization: Stat
|
||||
- Show: Value
|
||||
- Color mode: Background
|
||||
- Thresholds: 按实际业务设定
|
||||
```
|
||||
|
||||
#### Panel 2: 27 小时增长趋势(Time Series / Bar Gauge)
|
||||
|
||||
```
|
||||
Query:
|
||||
platform_entity_count{window="27h"}
|
||||
|
||||
Visualization: Bar Gauge
|
||||
- Display: Basic
|
||||
- Show: Value
|
||||
```
|
||||
|
||||
#### Panel 3: 7 天 / 30 天对比(Bar Chart)
|
||||
|
||||
```
|
||||
Query:
|
||||
platform_entity_count{window=~"7d|30d"}
|
||||
|
||||
Visualization: Bar Chart
|
||||
- Group by: entity
|
||||
- Bar mode: grouped
|
||||
```
|
||||
|
||||
#### Panel 4: 总量汇总表(Table Panel)
|
||||
|
||||
```
|
||||
Query:
|
||||
platform_entity_count
|
||||
|
||||
Transform:
|
||||
1. Labels to fields
|
||||
2. Pivot by entity
|
||||
3. Organize fields
|
||||
|
||||
Visualization: Table
|
||||
```
|
||||
|
||||
### Dashboard JSON 模板
|
||||
|
||||
将以下 JSON 导入 Grafana(Dashboard → Import → Paste JSON):
|
||||
|
||||
> 注:`uid` 和 `datasource` 需要根据实际 Prometheus 数据源修改。
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Admin 平台指标",
|
||||
"tags": ["admin", "platform"],
|
||||
"timezone": "browser",
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "实体总量",
|
||||
"type": "stat",
|
||||
"gridPos": { "h": 4, "w": 24, "x": 0, "y": 0 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "platform_entity_count{window=\"total\"}",
|
||||
"legendFormat": "{{entity}}"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"colorMode": "background",
|
||||
"graphMode": "none",
|
||||
"justifyMode": "auto"
|
||||
},
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{ "color": "green", "value": null },
|
||||
{ "color": "yellow", "value": 100 },
|
||||
{ "color": "red", "value": 1000 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "近 27 小时新增",
|
||||
"type": "bargauge",
|
||||
"gridPos": { "h": 6, "w": 12, "x": 0, "y": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "platform_entity_count{window=\"27h\"}",
|
||||
"legendFormat": "{{entity}}"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"displayMode": "gradient",
|
||||
"orientation": "horizontal"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "近 7 天 / 30 天对比",
|
||||
"type": "barchart",
|
||||
"gridPos": { "h": 6, "w": 12, "x": 12, "y": 4 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "platform_entity_count{window=~\"7d|30d\"}",
|
||||
"legendFormat": "{{entity}} ({{window}})"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"barRadius": 0.05,
|
||||
"groupWidth": 0.7,
|
||||
"orientation": "auto"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "指标汇总表",
|
||||
"type": "table",
|
||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 10 },
|
||||
"targets": [
|
||||
{
|
||||
"expr": "platform_entity_count",
|
||||
"format": "table",
|
||||
"instant": true
|
||||
}
|
||||
],
|
||||
"transformations": [
|
||||
{ "id": "labelsToFields", "options": {} },
|
||||
{
|
||||
"id": "organize",
|
||||
"options": {
|
||||
"excludeByName": { "Time": true, "__name__": true },
|
||||
"indexByName": { "entity": 0, "window": 1, "Value": 2 }
|
||||
}
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
"showHeader": true,
|
||||
"sortBy": [{ "desc": false, "displayName": "entity" }]
|
||||
}
|
||||
}
|
||||
],
|
||||
"time": { "from": "now-24h", "to": "now" },
|
||||
"refresh": "1m"
|
||||
},
|
||||
"overwrite": true
|
||||
}
|
||||
```
|
||||
|
||||
## 告警规则(可选)
|
||||
|
||||
### prometheus rules
|
||||
|
||||
```yaml
|
||||
groups:
|
||||
- name: admin-entity-growth
|
||||
rules:
|
||||
# 27 小时内用户增长超过 100 告警
|
||||
- alert: HighUserGrowth27h
|
||||
expr: platform_entity_count{entity="users", window="27h"} > 100
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "27 小时内新增用户 {{ $value }} 超过阈值"
|
||||
|
||||
# 仓库 7 天零增长告警
|
||||
- alert: NoRepoGrowth7d
|
||||
expr: platform_entity_count{entity="repos", window="7d"} == 0
|
||||
and on() platform_entity_count{entity="repos", window="total"} > 0
|
||||
for: 1h
|
||||
labels:
|
||||
severity: info
|
||||
annotations:
|
||||
summary: "近 7 天无新增仓库"
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 1. JSON 格式
|
||||
curl http://localhost:3000/api/metrics | jq .
|
||||
|
||||
# 2. Prometheus 格式
|
||||
curl http://localhost:3000/api/metrics/prometheus
|
||||
|
||||
# 预期输出:
|
||||
# HELP platform_entity_count Platform entity counts by time window
|
||||
# TYPE platform_entity_count gauge
|
||||
platform_entity_count{entity="users",window="27h"} 0
|
||||
platform_entity_count{entity="users",window="30d"} 0
|
||||
platform_entity_count{entity="users",window="7d"} 0
|
||||
platform_entity_count{entity="users",window="total"} 5
|
||||
...
|
||||
```
|
||||
38
admin/package-lock.json
generated
38
admin/package-lock.json
generated
@ -21,7 +21,6 @@
|
||||
"next": "16.2.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
@ -1678,15 +1677,6 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmmirror.com/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@phc/format": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@phc/format/-/format-1.0.0.tgz",
|
||||
@ -2974,12 +2964,6 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bintrees": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/bintrees/-/bintrees-1.0.2.tgz",
|
||||
"integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"license": "MIT",
|
||||
@ -6098,19 +6082,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prom-client": {
|
||||
"version": "15.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/prom-client/-/prom-client-15.1.3.tgz",
|
||||
"integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"tdigest": "^0.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16 || ^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"dev": true,
|
||||
@ -6864,15 +6835,6 @@
|
||||
"version": "4.0.0",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tdigest": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/tdigest/-/tdigest-0.1.2.tgz",
|
||||
"integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bintrees": "1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"dev": true,
|
||||
|
||||
@ -26,7 +26,6 @@
|
||||
"next": "16.2.4",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg": "^8.11.3",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
|
||||
@ -83,6 +83,8 @@ 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);
|
||||
|
||||
@ -125,6 +127,27 @@ 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);
|
||||
@ -275,8 +298,17 @@ 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}
|
||||
@ -370,7 +402,7 @@ export default function AiPage() {
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{versions.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无版本,点击上方「新建版本」添加</td></tr>}
|
||||
{versions.length === 0 && <tr><td colSpan={6} style={{ textAlign: "center", padding: "24px", color: "#737373" }}>暂无版本(从 OpenRouter 同步后自动创建)</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
423
admin/src/app/admin/daily-report/page.tsx
Normal file
423
admin/src/app/admin/daily-report/page.tsx
Normal file
@ -0,0 +1,423 @@
|
||||
"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 Gateway、OneAPI 等
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -1,163 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@ -60,6 +60,8 @@ 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);
|
||||
@ -433,7 +435,39 @@ 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>
|
||||
|
||||
102
admin/src/app/api/admin/daily-report/ai-config/route.ts
Normal file
102
admin/src/app/api/admin/daily-report/ai-config/route.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
507
admin/src/app/api/admin/daily-report/generate/route.ts
Normal file
507
admin/src/app/api/admin/daily-report/generate/route.ts
Normal file
@ -0,0 +1,507 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
106
admin/src/app/api/admin/daily-report/recipients/[id]/route.ts
Normal file
106
admin/src/app/api/admin/daily-report/recipients/[id]/route.ts
Normal file
@ -0,0 +1,106 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
60
admin/src/app/api/admin/daily-report/recipients/route.ts
Normal file
60
admin/src/app/api/admin/daily-report/recipients/route.ts
Normal file
@ -0,0 +1,60 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
85
admin/src/app/api/admin/daily-report/route.ts
Normal file
85
admin/src/app/api/admin/daily-report/route.ts
Normal file
@ -0,0 +1,85 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
17
admin/src/app/api/cron-initialize/route.ts
Normal file
17
admin/src/app/api/cron-initialize/route.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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" });
|
||||
}
|
||||
@ -1,74 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Registry, Gauge } from "prom-client";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
/**
|
||||
* Prometheus-compatible metrics endpoint.
|
||||
*
|
||||
* Usage in prometheus.yml:
|
||||
* - job_name: 'admin-metrics'
|
||||
* scrape_interval: 60s
|
||||
* static_configs:
|
||||
* - targets: ['admin:3000']
|
||||
* metrics_path: '/api/metrics/prometheus'
|
||||
*/
|
||||
export async function GET() {
|
||||
const register = new Registry();
|
||||
|
||||
const gauge = new Gauge({
|
||||
name: "platform_entity_count",
|
||||
help: "Platform entity counts by time window",
|
||||
labelNames: ["entity", "window"] as const,
|
||||
registers: [register],
|
||||
});
|
||||
|
||||
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),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
for (const r of results) {
|
||||
gauge.set({ entity: r.name, window: "total" }, r.total);
|
||||
gauge.set({ entity: r.name, window: "27h" }, r.new_27h);
|
||||
gauge.set({ entity: r.name, window: "7d" }, r.new_7d);
|
||||
gauge.set({ entity: r.name, window: "30d" }, r.new_30d);
|
||||
}
|
||||
|
||||
const metrics = await register.metrics();
|
||||
|
||||
return new NextResponse(metrics, {
|
||||
headers: { "Content-Type": register.contentType },
|
||||
});
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
19
admin/src/app/api/platform/ai/sync/route.ts
Normal file
19
admin/src/app/api/platform/ai/sync/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
19
admin/src/app/api/platform/alerts/check/route.ts
Normal file
19
admin/src/app/api/platform/alerts/check/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
listUserSessions,
|
||||
kickUser,
|
||||
getUserStatus,
|
||||
type UserSession,
|
||||
} from "@/lib/admin-rpc";
|
||||
|
||||
interface PlatformSessionInfo {
|
||||
sessionId: string;
|
||||
@ -17,12 +23,14 @@ 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([]);
|
||||
@ -38,6 +46,31 @@ 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);
|
||||
@ -53,26 +86,6 @@ 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";
|
||||
@ -88,9 +101,30 @@ 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>
|
||||
<button className="btn btn-secondary" onClick={loadSessions}>刷新</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@ -114,14 +148,19 @@ export default function PlatformSessionsPage() {
|
||||
<span className="badge badge-neutral" style={{ marginLeft: "8px" }}>
|
||||
{userSessions.length} 个会话
|
||||
</span>
|
||||
{adminRpcAvailable && (
|
||||
<AdminRpcUserStatus userId={userId} />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={kicking === userId}
|
||||
onClick={() => handleKickAllSessions(userId)}
|
||||
>
|
||||
{kicking === userId ? "处理中..." : "全部下线"}
|
||||
</button>
|
||||
{adminRpcAvailable && (
|
||||
<button
|
||||
className="btn btn-danger btn-sm"
|
||||
disabled={kicking === userId}
|
||||
onClick={() => handleKickAllSessions(userId)}
|
||||
>
|
||||
{kicking === userId ? "处理中..." : "全部下线 (adminrpc)"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="table-container">
|
||||
@ -177,3 +216,23 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ 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: "" });
|
||||
@ -105,15 +106,11 @@ export default function PlatformUsersPage() {
|
||||
setEditLoading(true);
|
||||
setEditMsg(null);
|
||||
try {
|
||||
const body: Record<string, string | undefined> = {};
|
||||
const body: Record<string, string> = {};
|
||||
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 || undefined;
|
||||
}
|
||||
if (editForm.organization !== (editUser.organization ?? "")) {
|
||||
body.organization = editForm.organization || undefined;
|
||||
}
|
||||
if (editForm.displayName !== (editUser.display_name || "")) body.displayName = editForm.displayName;
|
||||
if (editForm.organization !== (editUser.organization || "")) body.organization = editForm.organization;
|
||||
const res = await fetch(`/api/platform/users/${editUser.uid}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@ -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: "📧" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@ -89,6 +89,38 @@ 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> {
|
||||
|
||||
75
admin/src/lib/daily-report-cron.ts
Normal file
75
admin/src/lib/daily-report-cron.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ import { createHash } from "crypto";
|
||||
import { parseSessionCookie, loadAdminSession, canAccess } from "@/lib/auth";
|
||||
import { query } from "@/lib/db";
|
||||
|
||||
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/oidc", "/api/health", "/api/metrics/prometheus"];
|
||||
const PUBLIC_PATHS = ["/login", "/api/auth/login", "/api/auth/oidc", "/api/health"];
|
||||
const PROTECTED_PATHS = ["/dashboard", "/admin", "/platform"];
|
||||
|
||||
function getRequiredPermission(path: string, method: string): string | null {
|
||||
@ -48,10 +48,6 @@ 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 访问自己
|
||||
}
|
||||
|
||||
409
libs/api/admin/ai_models.rs
Normal file
409
libs/api/admin/ai_models.rs
Normal file
@ -0,0 +1,409 @@
|
||||
//! Admin CRUD endpoints for AI Providers, Models, Versions, and Pricing.
|
||||
//!
|
||||
//! All write operations use `Session::no_op()` (system caller) which passes
|
||||
//! the `require_system_caller` check in the service layer.
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use service::agent::provider::{CreateProviderRequest as SvcCreateProvider, UpdateProviderRequest as SvcUpdateProvider};
|
||||
use service::agent::model::{CreateModelRequest as SvcCreateModel, UpdateModelRequest as SvcUpdateModel};
|
||||
use service::agent::model_version::{CreateModelVersionRequest as SvcCreateVersion, UpdateModelVersionRequest as SvcUpdateVersion};
|
||||
use service::agent::model_pricing::UpdateModelPricingRequest as SvcUpdatePricing;
|
||||
use service::AppService;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::ApiResponse;
|
||||
use service::error::AppError;
|
||||
|
||||
/// Validate the `x-admin-api-key` header against ADMIN_API_SHARED_KEY env var.
|
||||
fn validate_admin_key(req: &HttpRequest) -> Result<(), ApiError> {
|
||||
let expected = std::env::var("ADMIN_API_SHARED_KEY").ok();
|
||||
let Some(expected) = expected else {
|
||||
return Err(ApiError(AppError::InternalServerError(
|
||||
"ADMIN_API_SHARED_KEY not configured".into(),
|
||||
)));
|
||||
};
|
||||
let provided = req
|
||||
.headers()
|
||||
.get("x-admin-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(ApiError(AppError::Unauthorized))?;
|
||||
if provided != expected {
|
||||
return Err(ApiError(AppError::Unauthorized));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── Provider CRUD ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminCreateProvider {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub website: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminUpdateProvider {
|
||||
pub display_name: Option<String>,
|
||||
pub website: Option<String>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/ai/providers",
|
||||
request_body = AdminCreateProvider,
|
||||
responses(
|
||||
(status = 200, description = "Provider created"),
|
||||
(status = 401),
|
||||
(status = 400),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_provider_create(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
body: web::Json<AdminCreateProvider>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcCreateProvider {
|
||||
name: body.name.clone(),
|
||||
display_name: body.display_name.clone(),
|
||||
website: body.website.clone(),
|
||||
};
|
||||
let resp = service.agent_provider_create(svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/admin/ai/providers/{id}",
|
||||
params(("id" = Uuid, Path)),
|
||||
request_body = AdminUpdateProvider,
|
||||
responses(
|
||||
(status = 200, description = "Provider updated"),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_provider_update(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<Uuid>,
|
||||
body: web::Json<AdminUpdateProvider>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcUpdateProvider {
|
||||
display_name: body.display_name.clone(),
|
||||
website: body.website.clone(),
|
||||
status: body.status.clone(),
|
||||
};
|
||||
let resp = service.agent_provider_update(id, svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/admin/ai/providers/{id}",
|
||||
params(("id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_provider_delete(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
service.agent_provider_delete(id, &session).await?;
|
||||
Ok(ApiResponse::ok(serde_json::json!({ "deleted": true })).to_response())
|
||||
}
|
||||
|
||||
// ─── Model CRUD ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminCreateModel {
|
||||
pub provider_id: Uuid,
|
||||
pub name: String,
|
||||
pub modality: String,
|
||||
pub capability: String,
|
||||
pub context_length: i64,
|
||||
#[serde(default)]
|
||||
pub max_output_tokens: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub training_cutoff: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(default)]
|
||||
pub is_open_source: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminUpdateModel {
|
||||
pub display_name: Option<String>,
|
||||
pub modality: Option<String>,
|
||||
pub capability: Option<String>,
|
||||
pub context_length: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub training_cutoff: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(default)]
|
||||
pub is_open_source: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/ai/models",
|
||||
request_body = AdminCreateModel,
|
||||
responses(
|
||||
(status = 200, description = "Model created"),
|
||||
(status = 401),
|
||||
(status = 400),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_model_create(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
body: web::Json<AdminCreateModel>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcCreateModel {
|
||||
provider_id: body.provider_id,
|
||||
name: body.name.clone(),
|
||||
modality: body.modality.clone(),
|
||||
capability: body.capability.clone(),
|
||||
context_length: body.context_length,
|
||||
max_output_tokens: body.max_output_tokens,
|
||||
training_cutoff: body.training_cutoff,
|
||||
is_open_source: body.is_open_source,
|
||||
};
|
||||
let resp = service.agent_model_create(svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/admin/ai/models/{id}",
|
||||
params(("id" = Uuid, Path)),
|
||||
request_body = AdminUpdateModel,
|
||||
responses(
|
||||
(status = 200, description = "Model updated"),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_model_update(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<Uuid>,
|
||||
body: web::Json<AdminUpdateModel>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcUpdateModel {
|
||||
display_name: body.display_name.clone(),
|
||||
modality: body.modality.clone(),
|
||||
capability: body.capability.clone(),
|
||||
context_length: body.context_length,
|
||||
max_output_tokens: body.max_output_tokens,
|
||||
training_cutoff: body.training_cutoff,
|
||||
is_open_source: body.is_open_source,
|
||||
status: body.status.clone(),
|
||||
};
|
||||
let resp = service.agent_model_update(id, svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/admin/ai/models/{id}",
|
||||
params(("id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_model_delete(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
service.agent_model_delete(id, &session).await?;
|
||||
Ok(ApiResponse::ok(serde_json::json!({ "deleted": true })).to_response())
|
||||
}
|
||||
|
||||
// ─── Model Version CRUD ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminCreateVersion {
|
||||
pub model_id: Uuid,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub release_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
#[serde(default)]
|
||||
pub change_log: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminUpdateVersion {
|
||||
pub version: Option<String>,
|
||||
pub release_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub change_log: Option<String>,
|
||||
#[serde(default)]
|
||||
pub is_default: Option<bool>,
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/ai/versions",
|
||||
request_body = AdminCreateVersion,
|
||||
responses(
|
||||
(status = 200, description = "Version created"),
|
||||
(status = 401),
|
||||
(status = 400),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_version_create(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
body: web::Json<AdminCreateVersion>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcCreateVersion {
|
||||
model_id: body.model_id,
|
||||
version: body.version.clone(),
|
||||
release_date: body.release_date,
|
||||
change_log: body.change_log.clone(),
|
||||
is_default: body.is_default,
|
||||
};
|
||||
let resp = service.agent_model_version_create(svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/admin/ai/versions/{id}",
|
||||
params(("id" = Uuid, Path)),
|
||||
request_body = AdminUpdateVersion,
|
||||
responses(
|
||||
(status = 200),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_version_update(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<Uuid>,
|
||||
body: web::Json<AdminUpdateVersion>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcUpdateVersion {
|
||||
version: body.version.clone(),
|
||||
release_date: body.release_date,
|
||||
change_log: body.change_log.clone(),
|
||||
is_default: body.is_default,
|
||||
status: body.status.clone(),
|
||||
};
|
||||
let resp = service.agent_model_version_update(id, svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/admin/ai/versions/{id}",
|
||||
params(("id" = Uuid, Path)),
|
||||
responses(
|
||||
(status = 200),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_version_delete(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<Uuid>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
service.agent_model_version_delete(id, &session).await?;
|
||||
Ok(ApiResponse::ok(serde_json::json!({ "deleted": true })).to_response())
|
||||
}
|
||||
|
||||
// ─── Model Pricing CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminCreatePricing {
|
||||
pub model_version_id: Uuid,
|
||||
pub input_price_per_1k_tokens: String,
|
||||
pub output_price_per_1k_tokens: String,
|
||||
pub currency: String,
|
||||
pub effective_from: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct AdminUpdatePricing {
|
||||
pub input_price_per_1k_tokens: Option<String>,
|
||||
pub output_price_per_1k_tokens: Option<String>,
|
||||
pub currency: Option<String>,
|
||||
pub effective_from: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
patch,
|
||||
path = "/api/admin/ai/pricing/{id}",
|
||||
params(("id" = i64, Path)),
|
||||
request_body = AdminUpdatePricing,
|
||||
responses(
|
||||
(status = 200),
|
||||
(status = 401),
|
||||
(status = 404),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_pricing_update(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<AdminUpdatePricing>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let id = path.into_inner();
|
||||
let session = session::Session::no_op();
|
||||
let svc_body = SvcUpdatePricing {
|
||||
input_price_per_1k_tokens: body.input_price_per_1k_tokens.clone(),
|
||||
output_price_per_1k_tokens: body.output_price_per_1k_tokens.clone(),
|
||||
currency: body.currency.clone(),
|
||||
effective_from: body.effective_from,
|
||||
};
|
||||
let resp = service.agent_model_pricing_update(id, svc_body, &session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
45
libs/api/admin/alerts.rs
Normal file
45
libs/api/admin/alerts.rs
Normal file
@ -0,0 +1,45 @@
|
||||
//! Alert trigger endpoint for admin.
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::ApiResponse;
|
||||
use service::error::AppError;
|
||||
|
||||
/// Validate the `x-admin-api-key` header against ADMIN_API_SHARED_KEY env var.
|
||||
fn validate_admin_key(req: &HttpRequest) -> Result<(), ApiError> {
|
||||
let expected = std::env::var("ADMIN_API_SHARED_KEY").ok();
|
||||
let Some(expected) = expected else {
|
||||
return Err(ApiError(AppError::InternalServerError(
|
||||
"ADMIN_API_SHARED_KEY not configured".into(),
|
||||
)));
|
||||
};
|
||||
let provided = req
|
||||
.headers()
|
||||
.get("x-admin-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(ApiError(AppError::Unauthorized))?;
|
||||
if provided != expected {
|
||||
return Err(ApiError(AppError::Unauthorized));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/alerts/check",
|
||||
responses(
|
||||
(status = 200, description = "Alert check result", body = ApiResponse<service::workspace::alert::CheckAlertsResponse>),
|
||||
(status = 401, description = "Invalid or missing admin API key"),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_check_alerts(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let resp = service.check_billing_alerts().await;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
103
libs/api/admin/billing.rs
Normal file
103
libs/api/admin/billing.rs
Normal file
@ -0,0 +1,103 @@
|
||||
//! Workspace billing admin endpoints (add-credit without membership requirement).
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use chrono::Utc;
|
||||
use models::*;
|
||||
use service::workspace::billing::WorkspaceBillingAddCreditParams;
|
||||
use service::AppService;
|
||||
use uuid::Uuid;
|
||||
use sea_orm::sea_query::prelude::rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::ApiResponse;
|
||||
use service::error::AppError;
|
||||
|
||||
/// Validate the `x-admin-api-key` header against ADMIN_API_SHARED_KEY env var.
|
||||
fn validate_admin_key(req: &HttpRequest) -> Result<(), ApiError> {
|
||||
let expected = std::env::var("ADMIN_API_SHARED_KEY").ok();
|
||||
let Some(expected) = expected else {
|
||||
return Err(ApiError(AppError::InternalServerError(
|
||||
"ADMIN_API_SHARED_KEY not configured".into(),
|
||||
)));
|
||||
};
|
||||
let provided = req
|
||||
.headers()
|
||||
.get("x-admin-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(ApiError(AppError::Unauthorized))?;
|
||||
if provided != expected {
|
||||
return Err(ApiError(AppError::Unauthorized));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/workspaces/{slug}/add-credit",
|
||||
params(
|
||||
("slug" = String, Path, description = "Workspace slug")
|
||||
),
|
||||
request_body = WorkspaceBillingAddCreditParams,
|
||||
responses(
|
||||
(status = 200, description = "Credit added", body = ApiResponse<service::workspace::billing::WorkspaceBillingCurrentResponse>),
|
||||
(status = 401, description = "Invalid or missing admin API key"),
|
||||
(status = 404, description = "Workspace not found"),
|
||||
(status = 400, description = "Invalid amount"),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_workspace_add_credit(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
path: web::Path<String>,
|
||||
body: web::Json<WorkspaceBillingAddCreditParams>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let slug = path.into_inner();
|
||||
|
||||
if body.amount <= 0.0 {
|
||||
return Err(ApiError(AppError::BadRequest("Amount must be positive".to_string())));
|
||||
}
|
||||
|
||||
let ws = service.utils_find_workspace_by_slug(slug.clone()).await?;
|
||||
let billing = service.ensure_workspace_billing(ws.id, None).await?;
|
||||
let now_utc = Utc::now();
|
||||
|
||||
let new_balance = rust_decimal::Decimal::from_f64_retain(
|
||||
billing.balance.to_f64().unwrap_or_default() + body.amount,
|
||||
)
|
||||
.unwrap_or(rust_decimal::Decimal::ZERO);
|
||||
|
||||
// Update billing balance (pk is workspace_id, mark unchanged)
|
||||
let _ = models::workspaces::workspace_billing::ActiveModel {
|
||||
workspace_id: sea_orm::ActiveValue::Unchanged(ws.id),
|
||||
balance: sea_orm::ActiveValue::Set(new_balance),
|
||||
updated_at: sea_orm::ActiveValue::Set(now_utc),
|
||||
..Default::default()
|
||||
}
|
||||
.update(&service.db)
|
||||
.await;
|
||||
|
||||
// Insert history record with user_id = NULL (admin action)
|
||||
let reason = body.reason.clone().unwrap_or_else(|| "admin_credit".to_string());
|
||||
let extra = serde_json::json!({ "description": format!("Admin 手动充值: {}", reason) });
|
||||
let _ = models::workspaces::workspace_billing_history::ActiveModel {
|
||||
uid: sea_orm::ActiveValue::Set(Uuid::now_v7()),
|
||||
workspace_id: sea_orm::ActiveValue::Set(ws.id),
|
||||
user_id: sea_orm::ActiveValue::Set(None),
|
||||
amount: sea_orm::ActiveValue::Set(
|
||||
rust_decimal::Decimal::from_f64_retain(body.amount)
|
||||
.unwrap_or(rust_decimal::Decimal::ZERO),
|
||||
),
|
||||
currency: sea_orm::ActiveValue::Set(billing.currency.clone()),
|
||||
reason: sea_orm::ActiveValue::Set(reason),
|
||||
extra: sea_orm::ActiveValue::Set(Some(extra)),
|
||||
created_at: sea_orm::ActiveValue::Set(now_utc),
|
||||
}
|
||||
.insert(&service.db)
|
||||
.await;
|
||||
|
||||
let session = session::Session::no_op();
|
||||
let resp = service.workspace_billing_current(&session, slug).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
50
libs/api/admin/mod.rs
Normal file
50
libs/api/admin/mod.rs
Normal file
@ -0,0 +1,50 @@
|
||||
//! Admin API endpoints — protected by `x-admin-api-key` header.
|
||||
//!
|
||||
//! These endpoints are called by the Admin Dashboard (Next.js) to perform
|
||||
//! platform-wide operations that bypass normal user-session authorization.
|
||||
|
||||
use actix_web::web;
|
||||
|
||||
pub mod alerts;
|
||||
pub mod ai_models;
|
||||
pub mod billing;
|
||||
pub mod sync;
|
||||
|
||||
pub fn init_admin_routes(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
web::scope("/api/admin")
|
||||
.route("/health", web::get().to(health_check))
|
||||
.route("/ai/sync", web::post().to(sync::admin_sync_models))
|
||||
.route("/alerts/check", web::post().to(alerts::admin_check_alerts))
|
||||
.route(
|
||||
"/workspaces/{slug}/add-credit",
|
||||
web::post().to(billing::admin_workspace_add_credit),
|
||||
)
|
||||
// Provider CRUD
|
||||
.route("/ai/providers", web::post().to(ai_models::admin_provider_create))
|
||||
.route("/ai/providers/{id}", web::patch().to(ai_models::admin_provider_update))
|
||||
.route("/ai/providers/{id}", web::delete().to(ai_models::admin_provider_delete))
|
||||
// Model CRUD
|
||||
.route("/ai/models", web::post().to(ai_models::admin_model_create))
|
||||
.route("/ai/models/{id}", web::patch().to(ai_models::admin_model_update))
|
||||
.route("/ai/models/{id}", web::delete().to(ai_models::admin_model_delete))
|
||||
// Version CRUD
|
||||
.route("/ai/versions", web::post().to(ai_models::admin_version_create))
|
||||
.route("/ai/versions/{id}", web::patch().to(ai_models::admin_version_update))
|
||||
.route("/ai/versions/{id}", web::delete().to(ai_models::admin_version_delete))
|
||||
// Pricing update
|
||||
.route("/ai/pricing/{id}", web::patch().to(ai_models::admin_pricing_update)),
|
||||
);
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/admin/health",
|
||||
responses(
|
||||
(status = 200, description = "Admin API is healthy"),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
async fn health_check() -> impl actix_web::Responder {
|
||||
actix_web::HttpResponse::Ok().json(serde_json::json!({ "ok": true }))
|
||||
}
|
||||
47
libs/api/admin/sync.rs
Normal file
47
libs/api/admin/sync.rs
Normal file
@ -0,0 +1,47 @@
|
||||
//! AI model sync endpoint for admin.
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use service::AppService;
|
||||
|
||||
use crate::error::ApiError;
|
||||
use crate::ApiResponse;
|
||||
use service::error::AppError;
|
||||
|
||||
/// Validate the `x-admin-api-key` header against ADMIN_API_SHARED_KEY env var.
|
||||
fn validate_admin_key(req: &HttpRequest) -> Result<(), ApiError> {
|
||||
let expected = std::env::var("ADMIN_API_SHARED_KEY").ok();
|
||||
let Some(expected) = expected else {
|
||||
return Err(ApiError(AppError::InternalServerError(
|
||||
"ADMIN_API_SHARED_KEY not configured".into(),
|
||||
)));
|
||||
};
|
||||
let provided = req
|
||||
.headers()
|
||||
.get("x-admin-api-key")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or(ApiError(AppError::Unauthorized))?;
|
||||
if provided != expected {
|
||||
return Err(ApiError(AppError::Unauthorized));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/ai/sync",
|
||||
responses(
|
||||
(status = 200, description = "Sync result", body = ApiResponse<service::agent::sync::SyncModelsResponse>),
|
||||
(status = 401, description = "Invalid or missing admin API key"),
|
||||
(status = 500, description = "Sync failed"),
|
||||
),
|
||||
tag = "Admin"
|
||||
)]
|
||||
pub async fn admin_sync_models(
|
||||
req: HttpRequest,
|
||||
service: web::Data<AppService>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
validate_admin_key(&req)?;
|
||||
let session = session::Session::no_op();
|
||||
let resp = service.sync_upstream_models(&session).await?;
|
||||
Ok(ApiResponse::ok(resp).to_response())
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod admin;
|
||||
pub mod agent;
|
||||
pub mod auth;
|
||||
pub mod error;
|
||||
|
||||
@ -31,6 +31,9 @@ use utoipa::OpenApi;
|
||||
crate::auth::email::api_email_change,
|
||||
crate::auth::email::api_email_verify,
|
||||
// Agent
|
||||
crate::admin::sync::admin_sync_models,
|
||||
crate::admin::billing::admin_workspace_add_credit,
|
||||
crate::admin::alerts::admin_check_alerts,
|
||||
// Agent (CRUD)
|
||||
crate::agent::code_review::trigger_code_review,
|
||||
crate::agent::issue_triage::triage_issue,
|
||||
|
||||
@ -13,6 +13,7 @@ pub fn init_routes(cfg: &mut web::ServiceConfig) {
|
||||
|
||||
cfg.service(
|
||||
web::scope("/api")
|
||||
// .configure(crate::admin::init_admin_routes)
|
||||
.configure(crate::auth::init_auth_routes)
|
||||
.configure(crate::git::init_git_routes)
|
||||
.configure(crate::git::init_git_toplevel_routes)
|
||||
|
||||
@ -23,6 +23,7 @@ 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;
|
||||
@ -51,6 +52,7 @@ struct OpenRouterModel {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
pricing: Option<OpenRouterPricing>,
|
||||
#[serde(default)]
|
||||
context_length: Option<u64>,
|
||||
#[serde(default)]
|
||||
@ -59,6 +61,25 @@ 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 {
|
||||
@ -379,13 +400,20 @@ async fn upsert_version(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create default pricing record with 0 price for admin-side modification.
|
||||
/// 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(),
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@ -394,11 +422,17 @@ 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("0.00".to_string()),
|
||||
output_price_per_1k_tokens: Set("0.00".to_string()),
|
||||
input_price_per_1k_tokens: Set(input_str),
|
||||
output_price_per_1k_tokens: Set(output_str),
|
||||
currency: Set("USD".to_string()),
|
||||
effective_from: Set(Utc::now()),
|
||||
};
|
||||
@ -550,7 +584,7 @@ async fn sync_models_direct(
|
||||
versions_created += 1;
|
||||
}
|
||||
|
||||
if upsert_pricing(db, version_record.id).await.unwrap_or(false) {
|
||||
if upsert_pricing(db, version_record.id, None).await.unwrap_or(false) {
|
||||
pricing_created += 1;
|
||||
}
|
||||
|
||||
@ -802,7 +836,15 @@ impl AppService {
|
||||
versions_created += 1;
|
||||
}
|
||||
|
||||
if upsert_pricing(&self.db, version_record.id).await.unwrap_or(false) {
|
||||
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 {
|
||||
pricing_created += 1;
|
||||
}
|
||||
|
||||
@ -968,7 +1010,10 @@ impl AppService {
|
||||
versions_created += 1;
|
||||
}
|
||||
|
||||
if upsert_pricing(db, version_record.id).await.unwrap_or(false) {
|
||||
if upsert_pricing(db, version_record.id, or_model.pricing.as_ref())
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
pricing_created += 1;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user