forked from niuniu/llm-intelligence
fix deployment and frontend build regressions
This commit is contained in:
105
DEPLOYMENT.md
105
DEPLOYMENT.md
@@ -1,8 +1,8 @@
|
||||
# LLM Intelligence Hub - 部署指南
|
||||
|
||||
> 版本: v1.0
|
||||
> 日期: 2026-05-10
|
||||
> 适用版本: Phase 1
|
||||
> 版本: v1.1
|
||||
> 日期: 2026-05-21
|
||||
> 适用版本: Phase 1 / Phase 2 基础部署
|
||||
|
||||
---
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
- Go 1.22+
|
||||
- Node.js 20+
|
||||
- PostgreSQL 16+
|
||||
- Docker 或 Podman (可选)
|
||||
- Docker / Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
## 本地开发启动
|
||||
|
||||
### 1. 克隆仓库
|
||||
```bash
|
||||
@@ -29,13 +29,14 @@ git clone <repo-url> 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,38 +93,59 @@ 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
51
RUNBOOK.md
51
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`
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"), "<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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
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
|
||||
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)
|
||||
if (normalized.length > 0) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
export async function loadFallbackModels() {
|
||||
const snapshot = await loadRuntimeSnapshot()
|
||||
if (snapshot.length > 0) {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const module = await import('../data/models.json')
|
||||
return normalizeModelList(module.default)
|
||||
}
|
||||
|
||||
export function formatPrice(model: Model, kind: 'input' | 'output') {
|
||||
|
||||
Reference in New Issue
Block a user