fix deployment and frontend build regressions

This commit is contained in:
2026-05-21 15:30:24 +08:00
parent 31f1b510c3
commit b430fb9301
6 changed files with 276 additions and 99 deletions

View File

@@ -1,27 +1,27 @@
# LLM Intelligence Hub - 部署指南 # LLM Intelligence Hub - 部署指南
> 版本: v1.0 > 版本: v1.1
> 日期: 2026-05-10 > 日期: 2026-05-21
> 适用版本: Phase 1 > 适用版本: Phase 1 / Phase 2 基础部署
--- ---
## 环境要求 ## 环境要求
### 硬件 ### 硬件
- CPU: 1核+ - CPU: 1 核+
- 内存: 512MB+ - 内存: 512 MB+
- 磁盘: 5GB+ - 磁盘: 5 GB+
### 软件 ### 软件
- Go 1.22+ - Go 1.22+
- Node.js 20+ - Node.js 20+
- PostgreSQL 16+ - PostgreSQL 16+
- Docker 或 Podman (可选) - Docker / Docker Compose
--- ---
## 快速开始 ## 本地开发启动
### 1. 克隆仓库 ### 1. 克隆仓库
```bash ```bash
@@ -29,13 +29,14 @@ git clone <repo-url> llm-intelligence
cd llm-intelligence cd llm-intelligence
``` ```
### 2. 配置数据库 ### 2. 初始化数据库
```bash ```bash
# 创建数据库
createdb llm_intelligence createdb llm_intelligence
# 运行迁移
psql llm_intelligence < db/migrations/001_phase1_core_tables.sql psql llm_intelligence < db/migrations/001_phase1_core_tables.sql
psql llm_intelligence < db/migrations/002_sprint1_complete_schema.sql
psql llm_intelligence < db/migrations/003_phase2_region_pricing_metadata.sql
psql llm_intelligence < db/migrations/004_backfill_models_batch_id.sql
psql llm_intelligence < db/migrations/005_subscription_plan.sql
``` ```
### 3. 配置环境变量 ### 3. 配置环境变量
@@ -50,29 +51,40 @@ export FEISHU_WEBHOOK="your-webhook-url" # 可选
go run cmd/server/main.go go run cmd/server/main.go
``` ```
### 5. 启动前端 (开发) ### 5. 启动前端开发服务
```bash ```bash
cd frontend cd frontend
npm install npm install
npm run dev npm run dev
``` ```
### 6. 配置定时任务
```bash
crontab -e
# 添加: 0 8 * * * cd /path/to/llm-intelligence && bash scripts/run_daily.sh
```
--- ---
## Docker 部署 ## Docker 部署
```bash 当前容器镜像已经内置前端静态资源,`app` 服务会同时提供页面和 API。
# 构建
docker build -t llm-hub .
# 或 docker-compose ### 使用 compose 启动完整环境
docker-compose up -d ```bash
docker-compose up -d --build
```
启动后访问:
- Web UI: `http://localhost:8080/`
- Health: `http://localhost:8080/health`
- API: `http://localhost:8080/api/v1/models`
### 只构建镜像
```bash
docker build -t llm-hub .
```
运行示例:
```bash
docker run --rm -p 8080:8080 \
-e DATABASE_URL="postgres://llm_hub:changeme@host.docker.internal:5432/llm_intelligence?sslmode=disable" \
-e OPENROUTER_API_KEY="your-api-key" \
llm-hub
``` ```
--- ---
@@ -81,42 +93,63 @@ docker-compose up -d
| 变量 | 必填 | 说明 | | 变量 | 必填 | 说明 |
|------|------|------| |------|------|------|
| DATABASE_URL | | PostgreSQL 连接串 | | `DATABASE_URL` | | PostgreSQL 连接串 |
| OPENROUTER_API_KEY | | OpenRouter API Key | | `OPENROUTER_API_KEY` | | OpenRouter API Key |
| FEISHU_WEBHOOK | | 飞书告警 Webhook | | `FEISHU_WEBHOOK` | | 飞书告警 Webhook |
| API_PORT | | 默认 8080 | | `PORT` | | 服务端监听端口,默认 `8080` |
| `FRONTEND_DIST_DIR` | 否 | 自定义静态资源目录,默认自动查找 `frontend/dist` |
--- ---
## 验证安装 ## 验证安装
```bash ```bash
# 数据库连接
curl http://localhost:8080/health curl http://localhost:8080/health
curl http://localhost:8080/api/v1/models
```
# 采集器测试 前端构建校验:
go run scripts/fetch_openrouter.go ```bash
cd frontend
npm run build
```
# 日报生成 Go 测试校验:
go run scripts/generate_daily_report.go ```bash
go test ./...
``` ```
--- ---
## 常见问题 ## 常见问题
### Q: 数据库迁移失败 ### Q: 前端构建失败?
保 PostgreSQL 已启动,且用户有创建表的权限。 认:
- Node.js >= 20
- `frontend/package-lock.json``npm ci` 一致
- 本地没有依赖已删除的 `frontend/src/data/latest_models.json`
### Q: 前端构建失败? ### Q: `docker-compose up -d` 后页面空白?
检查 Node.js 版本 >= 20npm 版本 >= 10。 先执行:
```bash
docker-compose up -d --build
```
### Q: 采集器返回模拟数据? 然后检查:
未提供 OPENROUTER_API_KEY 时使用模拟数据,提供 Key 后获取真实数据。 ```bash
docker-compose logs -f app
curl http://localhost:8080/
```
### Q: API 返回 `database not configured`?
说明 `DATABASE_URL` 未注入或格式不正确,先执行:
```bash
echo "$DATABASE_URL"
```
--- ---
## 升级路径 ## 升级路径
- Phase 2: 告警订阅 / 用户系统 / 付费分析 - Phase 2: 告警订阅 / 用户系统 / 付费分析
- Phase 3: 多数据源 / 自动发现 / ELO评分 - Phase 3: 多数据源 / 自动发现 / ELO 评分

View File

@@ -1,8 +1,7 @@
# LLM Intelligence Hub - 运维手册 # LLM Intelligence Hub - 运维手册
> 版本: v1.0 > 版本: v1.1
> 日期: 2026-05-10 > 日期: 2026-05-21
> 适用版本: Phase 1
--- ---
@@ -10,7 +9,7 @@
### 启动全部服务 ### 启动全部服务
```bash ```bash
docker-compose up -d docker-compose up -d --build
``` ```
### 停止服务 ### 停止服务
@@ -28,6 +27,12 @@ docker-compose logs -f db
## 日常巡检 ## 日常巡检
### 应用健康
```bash
curl http://localhost:8080/health
curl http://localhost:8080/api/v1/models
```
### 数据库健康 ### 数据库健康
```bash ```bash
psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM models WHERE deleted_at IS NULL" psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM models WHERE deleted_at IS NULL"
@@ -61,13 +66,19 @@ df -h /tmp
### 日报未生成 ### 日报未生成
1. 检查 cron: `crontab -l | grep llm-intelligence` 1. 检查 cron: `crontab -l | grep llm-intelligence`
2. 手动行: `bash scripts/run_daily.sh` 2. 手动行: `bash scripts/run_daily.sh`
3. 检查降级报告: `ls reports/daily/*.md | tail -1` 3. 检查最近日报: `ls reports/daily/*.md | tail -1`
### 前端无法访问 ### 前端无法访问
1. 检查 Nginx: `docker-compose ps nginx` 1. 检查应用容器: `docker-compose ps app`
2. 检查 dist: `ls frontend/dist/` 2. 检查首页响应: `curl -I http://localhost:8080/`
3. 检查端口: `netstat -tlnp | grep 80` 3. 检查 API 响应: `curl http://localhost:8080/api/v1/models`
4. 查看应用日志: `docker-compose logs -f app`
### 静态资源 404
1. 重新构建镜像: `docker-compose up -d --build`
2. 本地校验前端构建: `cd frontend && npm run build`
3. 确认容器内含有前端产物: `docker-compose exec app ls /app/frontend/dist`
--- ---
@@ -83,7 +94,7 @@ bash scripts/backup.sh
gunzip < backup_file.sql.gz | psql "$DATABASE_URL" gunzip < backup_file.sql.gz | psql "$DATABASE_URL"
``` ```
### 定时备份 (cron) ### 定时备份
```bash ```bash
0 2 * * * cd /path/to/llm-intelligence && bash scripts/backup.sh >> /tmp/backup.log 2>&1 0 2 * * * cd /path/to/llm-intelligence && bash scripts/backup.sh >> /tmp/backup.log 2>&1
``` ```
@@ -94,24 +105,14 @@ gunzip < backup_file.sql.gz | psql "$DATABASE_URL"
| 指标 | 告警阈值 | 检查命令 | | 指标 | 告警阈值 | 检查命令 |
|------|----------|----------| |------|----------|----------|
| 模型数 | < 300 | `SELECT COUNT(*) FROM models` | | 模型数 | `< 300` | `SELECT COUNT(*) FROM models` |
| 采集成功率 | < 95% | `SELECT success_rate FROM collector_stats` | | 采集成功率 | `< 95%` | `SELECT success_rate FROM collector_stats` |
| 数据库连接 | 失败 | `pg_isready` | | 数据库连接 | 失败 | `pg_isready` |
| 磁盘空间 | > 80% | `df -h` | | 磁盘空间 | `> 80%` | `df -h` |
---
## 扩容指南
### 垂直扩容
增加 PostgreSQL 内存和 CPU。
### 水平扩容
使用读写分离或分片Phase 2+)。
--- ---
## 联系信息 ## 联系信息
- 维护者: - 维护者:
- 项目路径: /home/long/project/llm-intelligence - 项目路径: `D:\project\llm-intelligence`

View File

@@ -7,6 +7,9 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"path"
"path/filepath"
"strings"
"time" "time"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -75,7 +78,7 @@ func main() {
} }
} }
mux := newMux(db, fetchModels, fetchSubscriptionPlans) mux := newMux(db, fetchModels, fetchSubscriptionPlans, resolveFrontendDistDir())
log.Printf("server listening on :%s", addr) log.Printf("server listening on :%s", addr)
if err := http.ListenAndServe(":"+addr, mux); err != nil { if err := http.ListenAndServe(":"+addr, mux); err != nil {
@@ -83,7 +86,7 @@ func main() {
} }
} }
func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher) *http.ServeMux { func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher, frontendDistDir string) *http.ServeMux {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if db == nil { if db == nil {
@@ -122,9 +125,65 @@ func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPla
} }
writeJSON(w, http.StatusOK, apiEnvelope{Data: plans}) writeJSON(w, http.StatusOK, apiEnvelope{Data: plans})
}) })
if frontendDistDir != "" {
mux.Handle("/", frontendHandler(frontendDistDir))
}
return mux return mux
} }
func resolveFrontendDistDir() string {
candidates := []string{}
if custom := os.Getenv("FRONTEND_DIST_DIR"); custom != "" {
candidates = append(candidates, custom)
}
candidates = append(candidates,
filepath.Join("frontend", "dist"),
filepath.Join(filepath.Dir(os.Args[0]), "frontend", "dist"),
)
for _, candidate := range candidates {
indexPath := filepath.Join(candidate, "index.html")
info, err := os.Stat(indexPath)
if err == nil && !info.IsDir() {
return candidate
}
}
return ""
}
func frontendHandler(frontendDistDir string) http.Handler {
indexPath := filepath.Join(frontendDistDir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
cleanPath := path.Clean("/" + r.URL.Path)
if cleanPath == "/" {
http.ServeFile(w, r, indexPath)
return
}
relativePath := strings.TrimPrefix(cleanPath, "/")
assetPath := filepath.Join(frontendDistDir, filepath.FromSlash(relativePath))
if info, err := os.Stat(assetPath); err == nil && !info.IsDir() {
http.ServeFile(w, r, assetPath)
return
}
if filepath.Ext(relativePath) != "" {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, indexPath)
})
}
func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) {
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
WITH latest_prices AS ( WITH latest_prices AS (

View File

@@ -6,6 +6,9 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath"
"strings"
"testing" "testing"
) )
@@ -20,7 +23,7 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) {
{ {
PlanFamily: "token_plan", PlanFamily: "token_plan",
PlanCode: "token-plan-lite", PlanCode: "token-plan-lite",
PlanName: "通用 Token Plan Lite", PlanName: "General Token Plan Lite",
Tier: "Lite", Tier: "Lite",
Provider: "Tencent", Provider: "Tencent",
ProviderCN: "腾讯", ProviderCN: "腾讯",
@@ -38,6 +41,7 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) {
}, },
}, nil }, nil
}, },
"",
) )
req := httptest.NewRequest(http.MethodGet, "/api/v1/subscription-plans", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/subscription-plans", nil)
@@ -76,3 +80,89 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) {
t.Fatalf("unexpected model scope length: %d", len(got.ModelScope)) t.Fatalf("unexpected model scope length: %d", len(got.ModelScope))
} }
} }
func TestFrontendHandlerServesIndexAssetsAndSpaFallback(t *testing.T) {
distDir := t.TempDir()
writeTestFile(t, filepath.Join(distDir, "index.html"), "<html>dashboard</html>")
writeTestFile(t, filepath.Join(distDir, "assets", "app.js"), "console.log('ok');")
mux := newMux(&sql.DB{}, noOpModelsFetcher, noOpPlansFetcher, distDir)
t.Run("root serves index", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "dashboard") {
t.Fatalf("expected index response, got %q", rec.Body.String())
}
})
t.Run("asset serves file", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/app.js", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "console.log") {
t.Fatalf("expected asset response, got %q", rec.Body.String())
}
})
t.Run("spa route falls back to index", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/explorer/detail", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "dashboard") {
t.Fatalf("expected SPA fallback, got %q", rec.Body.String())
}
})
t.Run("missing asset returns not found", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/assets/missing.js", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d", rec.Code)
}
})
t.Run("api routes keep precedence", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/models", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
})
}
func noOpModelsFetcher(context.Context, *sql.DB) ([]modelResponse, error) {
return []modelResponse{}, nil
}
func noOpPlansFetcher(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) {
return []subscriptionPlanResponse{}, nil
}
func writeTestFile(t *testing.T, path string, contents string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

View File

@@ -27,15 +27,5 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
nginx:
image: nginx:alpine
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./frontend/dist:/usr/share/nginx/html:ro
ports:
- "80:80"
depends_on:
- app
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -110,31 +110,35 @@ export function normalizeModel(raw: any): Model | null {
} }
} }
export async function loadFallbackModels() { function normalizeModelList(raw: any) {
// latest_models.json is a local runtime snapshot when present. const arr: any[] = Array.isArray(raw) ? raw : (raw?.models || [])
// models.json is the committed fixture fallback kept in the repo. return arr
const sources = [ .map(normalizeModel)
() => import('../data/latest_models.json'), .filter((model: Model | null): model is Model => model !== null)
() => import('../data/models.json'), }
]
for (const load of sources) { async function loadRuntimeSnapshot() {
try { try {
const module = await load() const response = await fetch('/latest_models.json', { cache: 'no-store' })
const raw = module.default as any if (!response.ok) {
const arr: any[] = Array.isArray(raw) ? raw : (raw.models || []) return []
const normalized = arr
.map(normalizeModel)
.filter((model: Model | null): model is Model => model !== null)
if (normalized.length > 0) {
return normalized
}
} catch {
// 继续尝试下一个回退源
} }
const raw = await response.json()
return normalizeModelList(raw)
} catch {
return []
}
}
export async function loadFallbackModels() {
const snapshot = await loadRuntimeSnapshot()
if (snapshot.length > 0) {
return snapshot
} }
return [] const module = await import('../data/models.json')
return normalizeModelList(module.default)
} }
export function formatPrice(model: Model, kind: 'input' | 'output') { export function formatPrice(model: Model, kind: 'input' | 'output') {