From b430fb9301d62e8afef0c9ec841d7b65598594f7 Mon Sep 17 00:00:00 2001 From: xingxing Date: Thu, 21 May 2026 15:30:24 +0800 Subject: [PATCH] fix deployment and frontend build regressions --- DEPLOYMENT.md | 113 ++++++++++++++++++++++++------------- RUNBOOK.md | 51 +++++++++-------- cmd/server/main.go | 63 ++++++++++++++++++++- cmd/server/main_test.go | 92 +++++++++++++++++++++++++++++- docker-compose.yml | 10 ---- frontend/src/lib/models.ts | 46 ++++++++------- 6 files changed, 276 insertions(+), 99 deletions(-) diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 39c85ad..66cfc41 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -1,27 +1,27 @@ # LLM Intelligence Hub - 部署指南 -> 版本: v1.0 -> 日期: 2026-05-10 -> 适用版本: Phase 1 +> 版本: v1.1 +> 日期: 2026-05-21 +> 适用版本: Phase 1 / Phase 2 基础部署 --- ## 环境要求 ### 硬件 -- CPU: 1核+ -- 内存: 512MB+ -- 磁盘: 5GB+ +- CPU: 1 核+ +- 内存: 512 MB+ +- 磁盘: 5 GB+ ### 软件 - Go 1.22+ - Node.js 20+ - PostgreSQL 16+ -- Docker 或 Podman (可选) +- Docker / Docker Compose --- -## 快速开始 +## 本地开发启动 ### 1. 克隆仓库 ```bash @@ -29,13 +29,14 @@ git clone llm-intelligence cd llm-intelligence ``` -### 2. 配置数据库 +### 2. 初始化数据库 ```bash -# 创建数据库 createdb llm_intelligence - -# 运行迁移 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. 配置环境变量 @@ -50,29 +51,40 @@ export FEISHU_WEBHOOK="your-webhook-url" # 可选 go run cmd/server/main.go ``` -### 5. 启动前端 (开发) +### 5. 启动前端开发服务 ```bash cd frontend npm install npm run dev ``` -### 6. 配置定时任务 -```bash -crontab -e -# 添加: 0 8 * * * cd /path/to/llm-intelligence && bash scripts/run_daily.sh -``` - --- ## Docker 部署 -```bash -# 构建 -docker build -t llm-hub . +当前容器镜像已经内置前端静态资源,`app` 服务会同时提供页面和 API。 -# 或 docker-compose -docker-compose up -d +### 使用 compose 启动完整环境 +```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 连接串 | -| OPENROUTER_API_KEY | ✅ | OpenRouter API Key | -| FEISHU_WEBHOOK | ❌ | 飞书告警 Webhook | -| API_PORT | ❌ | 默认 8080 | +| `DATABASE_URL` | 是 | PostgreSQL 连接串 | +| `OPENROUTER_API_KEY` | 是 | OpenRouter API Key | +| `FEISHU_WEBHOOK` | 否 | 飞书告警 Webhook | +| `PORT` | 否 | 服务端监听端口,默认 `8080` | +| `FRONTEND_DIST_DIR` | 否 | 自定义静态资源目录,默认自动查找 `frontend/dist` | --- ## 验证安装 ```bash -# 数据库连接 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 run scripts/generate_daily_report.go +Go 测试校验: +```bash +go test ./... ``` --- ## 常见问题 -### Q: 数据库迁移失败? -确保 PostgreSQL 已启动,且用户有创建表的权限。 +### Q: 前端构建失败? +确认: +- Node.js >= 20 +- `frontend/package-lock.json` 与 `npm ci` 一致 +- 本地没有依赖已删除的 `frontend/src/data/latest_models.json` -### Q: 前端构建失败? -检查 Node.js 版本 >= 20,npm 版本 >= 10。 +### Q: `docker-compose up -d` 后页面空白? +先执行: +```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 3: 多数据源 / 自动发现 / ELO评分 +- Phase 3: 多数据源 / 自动发现 / ELO 评分 diff --git a/RUNBOOK.md b/RUNBOOK.md index b37ec16..2a59a27 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -1,8 +1,7 @@ # LLM Intelligence Hub - 运维手册 -> 版本: v1.0 -> 日期: 2026-05-10 -> 适用版本: Phase 1 +> 版本: v1.1 +> 日期: 2026-05-21 --- @@ -10,7 +9,7 @@ ### 启动全部服务 ```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 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` -2. 手动运行: `bash scripts/run_daily.sh` -3. 检查降级报告: `ls reports/daily/*.md | tail -1` +2. 手动执行: `bash scripts/run_daily.sh` +3. 检查最近日报: `ls reports/daily/*.md | tail -1` ### 前端无法访问 -1. 检查 Nginx: `docker-compose ps nginx` -2. 检查 dist: `ls frontend/dist/` -3. 检查端口: `netstat -tlnp | grep 80` +1. 检查应用容器: `docker-compose ps app` +2. 检查首页响应: `curl -I http://localhost:8080/` +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" ``` -### 定时备份 (cron) +### 定时备份 ```bash 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` | -| 采集成功率 | < 95% | `SELECT success_rate FROM collector_stats` | +| 模型数 | `< 300` | `SELECT COUNT(*) FROM models` | +| 采集成功率 | `< 95%` | `SELECT success_rate FROM collector_stats` | | 数据库连接 | 失败 | `pg_isready` | -| 磁盘空间 | > 80% | `df -h` | - ---- - -## 扩容指南 - -### 垂直扩容 -增加 PostgreSQL 内存和 CPU。 - -### 水平扩容 -使用读写分离或分片(Phase 2+)。 +| 磁盘空间 | `> 80%` | `df -h` | --- ## 联系信息 -- 维护者: 宰相 -- 项目路径: /home/long/project/llm-intelligence +- 维护者: 宅相 +- 项目路径: `D:\project\llm-intelligence` diff --git a/cmd/server/main.go b/cmd/server/main.go index 298b546..746a776 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -7,6 +7,9 @@ import ( "log" "net/http" "os" + "path" + "path/filepath" + "strings" "time" _ "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) 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.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if db == nil { @@ -122,9 +125,65 @@ func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPla } writeJSON(w, http.StatusOK, apiEnvelope{Data: plans}) }) + if frontendDistDir != "" { + mux.Handle("/", frontendHandler(frontendDistDir)) + } 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) { rows, err := db.QueryContext(ctx, ` WITH latest_prices AS ( diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index dba91bb..5c495c9 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -6,6 +6,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" ) @@ -20,7 +23,7 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { { PlanFamily: "token_plan", PlanCode: "token-plan-lite", - PlanName: "通用 Token Plan Lite", + PlanName: "General Token Plan Lite", Tier: "Lite", Provider: "Tencent", ProviderCN: "腾讯", @@ -38,6 +41,7 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { }, }, 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)) } } + +func TestFrontendHandlerServesIndexAssetsAndSpaFallback(t *testing.T) { + distDir := t.TempDir() + writeTestFile(t, filepath.Join(distDir, "index.html"), "dashboard") + 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) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 7d2b35e..407200b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,15 +27,5 @@ services: ports: - "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: postgres_data: diff --git a/frontend/src/lib/models.ts b/frontend/src/lib/models.ts index 455d948..c9ac1cf 100644 --- a/frontend/src/lib/models.ts +++ b/frontend/src/lib/models.ts @@ -110,31 +110,35 @@ export function normalizeModel(raw: any): Model | null { } } -export async function loadFallbackModels() { - // latest_models.json is a local runtime snapshot when present. - // models.json is the committed fixture fallback kept in the repo. - const sources = [ - () => import('../data/latest_models.json'), - () => import('../data/models.json'), - ] +function normalizeModelList(raw: any) { + const arr: any[] = Array.isArray(raw) ? raw : (raw?.models || []) + return arr + .map(normalizeModel) + .filter((model: Model | null): model is Model => model !== null) +} - for (const load of sources) { - try { - const module = await load() - const raw = module.default as any - const arr: any[] = Array.isArray(raw) ? raw : (raw.models || []) - const normalized = arr - .map(normalizeModel) - .filter((model: Model | null): model is Model => model !== null) - if (normalized.length > 0) { - return normalized - } - } catch { - // 继续尝试下一个回退源 +async function loadRuntimeSnapshot() { + try { + const response = await fetch('/latest_models.json', { cache: 'no-store' }) + if (!response.ok) { + return [] } + + 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') {