feat:修改压测

This commit is contained in:
zerosaturation 2026-06-16 22:19:44 +08:00
parent 7074546959
commit 792c891158
14 changed files with 1353 additions and 1066 deletions

View File

@ -1,7 +1,7 @@
# TopFans Backend Makefile
# 用于简化开发流程
.PHONY: help install-swagger gen-swagger update-swagger start-swagger start-all stop-all clean build run all loadgen-build loadgen-test loadgen-vet loadgen-ci
.PHONY: help install-swagger gen-swagger update-swagger start-swagger start-all stop-all clean build run all loadgen-build loadgen-test loadgen-vet loadgen-ci loadgen-seed-local loadgen-seed-prod-tunnel loadgen-cleanup-local loadgen-cleanup-prod-tunnel
# 默认目标
help:
@ -24,9 +24,15 @@ help:
@echo " make all - 安装依赖 + 生成文档 + 构建"
@echo ""
@echo "压测工具:"
@echo " make loadgen-build - 编译 seed + loadgen 到 bin/"
@echo " make loadgen-test - 运行 loadgen 单元测试"
@echo " make loadgen-vet - go vet 静态检查"
@echo " make loadgen-build - 编译 seed + loadgen 到 bin/"
@echo " make loadgen-test - 运行 loadgen 单元测试"
@echo " make loadgen-vet - go vet 静态检查"
@echo ""
@echo "压测 seed 便捷入口 (免去手敲 flag):"
@echo " make loadgen-seed-local - seed 写入本地 docker dev (top-fans:15432)"
@echo " make loadgen-seed-prod-tunnel - seed 通过 SSH 端口转发写生产 (127.0.0.1:25432)"
@echo " make loadgen-cleanup-local - 清理本地 docker 压测数据"
@echo " make loadgen-cleanup-prod-tunnel - 清理生产 docker 压测数据 (走 SSH 转发)"
@echo ""
@echo "清理:"
@echo " make clean - 清理生成的文件"
@ -43,9 +49,15 @@ help:
@echo " make all - 安装依赖 + 生成文档 + 构建"
@echo ""
@echo "压测工具:"
@echo " make loadgen-build - 编译 seed + loadgen 到 bin/"
@echo " make loadgen-test - 运行 loadgen 单元测试"
@echo " make loadgen-vet - go vet 静态检查"
@echo " make loadgen-build - 编译 seed + loadgen 到 bin/"
@echo " make loadgen-test - 运行 loadgen 单元测试"
@echo " make loadgen-vet - go vet 静态检查"
@echo ""
@echo "压测 seed 便捷入口 (免去手敲 flag):"
@echo " make loadgen-seed-local - seed 写入本地 docker dev (top-fans:15432)"
@echo " make loadgen-seed-prod-tunnel - seed 通过 SSH 端口转发写生产 (127.0.0.1:25432)"
@echo " make loadgen-cleanup-local - 清理本地 docker 压测数据"
@echo " make loadgen-cleanup-prod-tunnel - 清理生产 docker 压测数据 (走 SSH 转发)"
@echo ""
@echo "清理:"
@echo " make clean - 清理生成的文件"
@ -128,6 +140,54 @@ loadgen-vet:
# loadgen 完整 CI 入口: vet + test + build
loadgen-ci: loadgen-vet loadgen-test loadgen-build
# ============================================================
# 压测 seed 便捷入口 (按部署环境封装 DB 参数)
# ============================================================
#
# 三种部署对应的 DB 参数 (详见 backend/scripts/loadgen/seed/README.md):
# 1. 生产机直跑 : localhost:5432/topfans (seed 默认值, ssh 进去直接跑)
# 2. 本地联调 : localhost:15432/top-fans (宿主机 postgresql-database-1)
# 3. 本地连生产 : 127.0.0.1:25432/topfans (ssh -L 端口转发)
#
# 密码统一从对应 .env 文件读 (.env.prod / .env.local),避免在命令行明文泄露。
# --- 本地 docker dev (top-fans:15432, 密码 123456) ---
loadgen-seed-local: loadgen-build
@echo ">>> seed 写入本地 docker dev (top-fans:15432)"
@DB_PASSWORD=$$(grep '^DB_PASSWORD=' ../docker/.env.local | cut -d= -f2) \
JWT_SECRET=$$(grep '^JWT_SECRET=' ../docker/.env.local | cut -d= -f2) \
./bin/seed \
--db-host=localhost --db-port=15432 --db-name=top-fans --db-user=postgres
loadgen-cleanup-local: loadgen-build
@echo ">>> 清理本地 docker 压测数据 (top-fans:15432)"
@DB_PASSWORD=$$(grep '^DB_PASSWORD=' ../docker/.env.local | cut -d= -f2) \
./bin/seed --cleanup \
--db-host=localhost --db-port=15432 --db-name=top-fans --db-user=postgres
# --- 本地连生产 (ssh -L 25432 → 生产 docker 5432) ---
# 调用前请确保已建立转发: ssh -L 25432:127.0.0.1:5432 -N -f root@101.132.250.62
loadgen-seed-prod-tunnel: loadgen-build
@echo ">>> seed 通过 SSH 隧道写生产 (127.0.0.1:25432 → docker 5432)"
@if ! lsof -iTCP:25432 -sTCP:LISTEN >/dev/null 2>&1; then \
echo "❌ 25432 端口未监听,请先: ssh -L 25432:127.0.0.1:5432 -N -f root@101.132.250.62"; \
exit 1; \
fi
@DB_PASSWORD=$$(grep '^DB_PASSWORD=' ../docker/.env.prod | cut -d= -f2) \
JWT_SECRET=$$(grep '^JWT_SECRET=' ../docker/.env.prod | cut -d= -f2) \
./bin/seed \
--db-host=127.0.0.1 --db-port=25432 --db-name=topfans --db-user=postgres
loadgen-cleanup-prod-tunnel: loadgen-build
@echo ">>> 清理生产 docker 压测数据 (走 SSH 隧道)"
@if ! lsof -iTCP:25432 -sTCP:LISTEN >/dev/null 2>&1; then \
echo "❌ 25432 端口未监听,请先: ssh -L 25432:127.0.0.1:5432 -N -f root@101.132.250.62"; \
exit 1; \
fi
@DB_PASSWORD=$$(grep '^DB_PASSWORD=' ../docker/.env.prod | cut -d= -f2) \
./bin/seed --cleanup \
--db-host=127.0.0.1 --db-port=25432 --db-name=topfans --db-user=postgres
# 全部:安装依赖 + 生成文档 + 构建
all: install-swagger gen-swagger build
@echo ""

View File

@ -0,0 +1 @@
$2b$10$h0UgR1Ls1DgBQnYTs0msTuBahtonqzPVu06txjyt./iNxUJsreFdO

View File

@ -1,45 +1,45 @@
{
"scenario": "S1",
"total_requests": 18,
"total_requests": 1072,
"errors": 0,
"five_xx": 0,
"p50_us": 86143,
"p95_us": 95743,
"p99_us": 95743,
"max_us": 95743,
"p50_us": 173823,
"p95_us": 182015,
"p99_us": 210175,
"max_us": 223999,
"stages": [
{
"stage_idx": 1,
"target_rps": 1,
"total_requests": 3,
"target_rps": 5,
"total_requests": 300,
"errors": 0,
"five_xx": 0,
"p50_us": 93951,
"p95_us": 98495,
"p99_us": 98495,
"max_us": 98495
"p50_us": 112063,
"p95_us": 122815,
"p99_us": 138751,
"max_us": 214527
},
{
"stage_idx": 2,
"target_rps": 2,
"total_requests": 6,
"target_rps": 10,
"total_requests": 387,
"errors": 0,
"five_xx": 0,
"p50_us": 87295,
"p95_us": 89215,
"p99_us": 89215,
"max_us": 89215
"p50_us": 174335,
"p95_us": 182015,
"p99_us": 203519,
"max_us": 259199
},
{
"stage_idx": 3,
"target_rps": 3,
"total_requests": 9,
"target_rps": 20,
"total_requests": 385,
"errors": 0,
"five_xx": 0,
"p50_us": 86143,
"p95_us": 95743,
"p99_us": 95743,
"max_us": 95743
"p50_us": 173823,
"p95_us": 182015,
"p99_us": 210175,
"max_us": 223999
}
]
}

View File

@ -1,14 +1,15 @@
{
"start_time": "2026-06-15T21:05:10.831978+08:00",
"end_time": "2026-06-15T21:05:38.174693+08:00",
"target": "http://localhost:8080",
"start_time": "2026-06-16T22:10:55.266986+08:00",
"end_time": "2026-06-16T22:13:55.674244+08:00",
"target": "http://101.132.250.62:8080",
"scenarios": [
"S1",
"S2",
"S4"
],
"step_schedule": "1,2,3",
"step_schedule": "5,10,20",
"jwt_secret_hint": "topfans-",
"monitor_mode": "off",
"prod_ssh": "root@101.132.250.62",
"monitor_mode": "full",
"stage_mode": "step"
}

View File

@ -97,8 +97,15 @@ cd /opt/topfans/loadtest
bash scripts/prod_seed.sh
```
**脚本会先做这些预检查** (任一失败立即退出,不会写数据):
1. `/opt/topfans/docker/.env.prod` 存在 + `DB_PASSWORD` / `JWT_SECRET` 非空
2. `seed` 二进制 + `loadtest_bcrypt.txt` 都存在
3. 如果 `psql` 可用,会先 `SELECT 1` 验证能连到 `localhost:5432/topfans`
**然后**打印连接信息 + 删除/重建摘要,要求输入 `y` 确认才执行。
**这一步骤会做什么**:
- 读 `/opt/topfans/docker/.env.prod` 拿 DB_PASSWORD + JWT_SECRET
- 显式传所有 DB 参数 (`--db-host=localhost --db-port=5432 --db-name=topfans --db-user=postgres`),不依赖 seed 默认值
- 插入 star_id=999900 测试明星 (1 行)
- 插入 1000 个测试用户 (mobile 19900000001 - 19900001000)
- 插入 1000 个 fan_profile + crystal

View File

@ -60,6 +60,26 @@ func (cb *CircuitBreaker) Check(client ClientMetrics, server ServerMetrics, now
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.checkClientLocked(client, now) || cb.checkServerLocked(server, now) {
return true
}
return false
}
// CheckClient 仅检查 client-side 指标 (R1/R2/R3: ErrorRate/P99/FiveXXRate),
// 不触发任何 server-side 检查 (R4/R5/R6: PGConn/Disk/OOM)。
//
// 用途:scenarios/common.go 在每个 HTTP 请求后调用,避免 server 指标还没从
// metrics-feed.jsonl 喂进来时(零值 ServerMetrics{} 会误触发 DiskGB < 5 规则)
// 导致 circuit breaker 误 trip。Server-side 检查统一交给 main.go 的
// consumeServerMetrics goroutine,每 5 秒基于真实 server metrics 做一次。
func (cb *CircuitBreaker) CheckClient(client ClientMetrics, now time.Time) bool {
cb.mu.Lock()
defer cb.mu.Unlock()
return cb.checkClientLocked(client, now)
}
func (cb *CircuitBreaker) checkClientLocked(client ClientMetrics, now time.Time) bool {
if client.ErrorRate > cb.ErrRate {
if cb.errRateStart.IsZero() {
cb.errRateStart = now
@ -96,6 +116,10 @@ func (cb *CircuitBreaker) Check(client ClientMetrics, server ServerMetrics, now
cb.fiveXXStart = time.Time{}
}
return false
}
func (cb *CircuitBreaker) checkServerLocked(server ServerMetrics, now time.Time) bool {
if server.PGActive > cb.PGConnMax {
if cb.pgConnStart.IsZero() {
cb.pgConnStart = now

View File

@ -61,3 +61,48 @@ func TestCircuitBreaker_Recovers(t *testing.T) {
t.Error("should remain OK")
}
}
// TestCheckClient_IgnoresServerMetrics 验证 CheckClient 只看 client 指标,
// 不会因为 ServerMetrics{} 零值 (DiskGB=0) 触发 R5 disk < 5GB 误 trip。
// 回归测试:之前 scenarios/common.go 每次 HTTP 请求都传 ServerMetrics{},
// 导致 breaker 每 30 秒误 trip 一次,屏蔽掉 R1/R2/R3 的真实告警。
func TestCheckClient_IgnoresServerMetrics(t *testing.T) {
cb := NewCircuitBreaker()
now := time.Now()
// 模拟连续 2 分钟的请求(远超 SustainTime=30s)
for i := range 24 {
ts := now.Add(time.Duration(i*5) * time.Second)
if cb.CheckClient(ClientMetrics{ErrorRate: 0.01}, ts) {
t.Fatalf("CheckClient should never trip on healthy client metrics, "+
"iter=%d ts=%v", i, ts)
}
}
if cb.State() != CircuitOK {
t.Error("breaker should remain OK; CheckClient must not trigger R4/R5/R6")
}
}
// TestCheckClient_StillTripsOnErrorRate 验证 CheckClient 仍能正确触发
// R1 (ErrorRate > 0.05 持续 30s) — 不能因为拆分就把 client 检查也漏掉。
func TestCheckClient_StillTripsOnErrorRate(t *testing.T) {
cb := NewCircuitBreaker()
now := time.Now()
if cb.CheckClient(ClientMetrics{ErrorRate: 0.06}, now) {
t.Error("R1 should not trip on first check")
}
if !cb.CheckClient(ClientMetrics{ErrorRate: 0.06}, now.Add(31*time.Second)) {
t.Error("R1 should trip after 30s sustain")
}
}
// TestCheckClient_StillTripsOnP99 验证 R2 (P99Ms > 3000 持续 30s) 仍生效。
func TestCheckClient_StillTripsOnP99(t *testing.T) {
cb := NewCircuitBreaker()
now := time.Now()
cb.CheckClient(ClientMetrics{P99Ms: 4000}, now)
if !cb.CheckClient(ClientMetrics{P99Ms: 4000}, now.Add(31*time.Second)) {
t.Error("R2 P99>3000 sustained should trip")
}
}

View File

@ -218,13 +218,17 @@ func runLoadgen(target, scenarioIDs, stage, stepSchedule string, rps, vus int, d
func consumeServerMetrics(ctx context.Context, feed <-chan lib.MetricsLine, breaker *lib.CircuitBreaker, rec *lib.LatencyRecorder, errCount, totalCount, fiveXXCount *atomic.Int64) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
var latestServer lib.ServerMetrics
// 用指针,nil 表示"还没从 metrics feed 拿到任何一行"。
// 修前 bug: 用值类型 var latestServer lib.ServerMetrics,
// 零值 DiskGB=0 永远 < 5,30 秒后 R5 disk 规则误 trip。
var latestServer *lib.ServerMetrics
for {
select {
case <-ctx.Done():
return
case ml := <-feed:
latestServer = ml.ToServerMetrics()
sm := ml.ToServerMetrics()
latestServer = &sm
case <-ticker.C:
tot := totalCount.Load()
if tot == 0 {
@ -238,7 +242,14 @@ func consumeServerMetrics(ctx context.Context, feed <-chan lib.MetricsLine, brea
if snap.TotalCount() > 0 {
clientMetrics.P99Ms = snap.ValueAtPercentile(99) / 1000
}
if breaker.Check(clientMetrics, latestServer, time.Now()) {
var tripped bool
if latestServer == nil {
// metrics feed 还没数据,只做 client 检查 (R1/R2/R3)
tripped = breaker.CheckClient(clientMetrics, time.Now())
} else {
tripped = breaker.Check(clientMetrics, *latestServer, time.Now())
}
if tripped {
log.Printf("🚨 circuit breaker tripped!")
}
}

View File

@ -42,16 +42,26 @@ func runPreflight(target, prodSSH string) error {
Detail: strings.TrimSpace(string(out)),
})
// ③ pg_dump backup file exists
// ③ pg_dump backup file exists (远程路径,需用 SSH 拿大小,不能用 os.Stat)
// 修复前 bug: 用 os.Stat 去查远程路径,本地必然不存在导致 info=nil,
// Sprintf 里的 info.Size() 无 nil 保护 → panic。
// 修复: 用 `ssh prodSSH stat -c%s <file>` 拿远程大小,避免走本地 fs。
cmd = exec.Command("ssh", prodSSH, "ls -t /opt/topfans/backups/pre-loadtest-*.sql 2>/dev/null | head -1")
out, _ = cmd.Output()
backupFile := strings.TrimSpace(string(out))
info, statErr := os.Stat(backupFile)
sizeOK := statErr == nil && info.Size() > 50*1024*1024
var backupSize int64 = -1
if backupFile != "" {
cmd = exec.Command("ssh", prodSSH, fmt.Sprintf("stat -c%%s %s", backupFile))
sizeOut, sizeErr := cmd.Output()
if sizeErr == nil {
fmt.Sscanf(strings.TrimSpace(string(sizeOut)), "%d", &backupSize)
}
}
sizeOK := backupSize > 50*1024*1024
checks = append(checks, CheckResult{
Name: "③ pg_dump backup exists (>50MB)",
Passed: sizeOK,
Detail: fmt.Sprintf("file=%s size=%d", backupFile, ifZero64(info.Size())),
Detail: fmt.Sprintf("file=%s size=%d", backupFile, backupSize),
})
// ⑤ prod 磁盘空闲 > 10GB
@ -119,13 +129,6 @@ func ifZero(v int) int {
return v
}
func ifZero64(v int64) int64 {
if v == 0 {
return -1
}
return v
}
func ifEmpty(empty bool, s string) string {
if empty {
return s

View File

@ -21,7 +21,7 @@ func doRequest(client *http.Client, req *http.Request, rec *lib.LatencyRecorder,
if err != nil {
errCount.Add(1)
rec.RecordResult(true, false)
checkBreaker(client, rec, errCount, totalCount, fiveXXCount, breaker)
checkBreaker(rec, errCount, totalCount, fiveXXCount, breaker)
return
}
defer resp.Body.Close()
@ -34,10 +34,10 @@ func doRequest(client *http.Client, req *http.Request, rec *lib.LatencyRecorder,
errCount.Add(1)
}
rec.RecordResult(isErr, is5xx)
checkBreaker(client, rec, errCount, totalCount, fiveXXCount, breaker)
checkBreaker(rec, errCount, totalCount, fiveXXCount, breaker)
}
func checkBreaker(client *http.Client, rec *lib.LatencyRecorder, errCount, totalCount, fiveXXCount *atomic.Int64, breaker *lib.CircuitBreaker) {
func checkBreaker(rec *lib.LatencyRecorder, errCount, totalCount, fiveXXCount *atomic.Int64, breaker *lib.CircuitBreaker) {
if breaker == nil {
return
}
@ -53,5 +53,9 @@ func checkBreaker(client *http.Client, rec *lib.LatencyRecorder, errCount, total
if snap.TotalCount() > 0 {
clientMetrics.P99Ms = snap.ValueAtPercentile(99) / 1000
}
breaker.Check(clientMetrics, lib.ServerMetrics{}, time.Now())
// 只检查 client 指标 (R1/R2/R3);server 指标 (R4/R5/R6) 由
// main.go consumeServerMetrics goroutine 每 5 秒基于真实 metrics
// feed 检查,避免在 metrics feed 还没数据时(零值 ServerMetrics{}
// 会让 DiskGB=0 < 5 触发误 trip)
breaker.CheckClient(clientMetrics, time.Now())
}

View File

@ -3,30 +3,99 @@
# prod seed 一键运行脚本
# 用途:从 /opt/topfans/docker/.env.prod 读 DB/JWT 凭据,跑 seed 工具
# 使用:ssh root@101.132.250.62 "bash /opt/topfans/loadtest/scripts/prod_seed.sh"
#
# 设计原则(与 backend/scripts/loadgen/seed/README.md 同步):
# - 显式传所有 DB 参数,不依赖 seed 二进制的默认值
# (避免 seed 默认值被改时突然连错地方)
# - 前置检查 .env.prod / loadtest_bcrypt.txt / DB 连通性
# - 所有写操作指向生产 docker 暴露的 5432 端口 (宿主机 localhost:5432
# = docker 端口映射 `5432:5432`,不是容器内)
# ===================================================================
set -euo pipefail
ENV_FILE="/opt/topfans/docker/.env.prod"
LOADTEST_DIR="/opt/topfans/loadtest"
SEED_BIN="$LOADTEST_DIR/seed"
BCRYPT_FILE="$LOADTEST_DIR/loadtest_bcrypt.txt"
# 这些值必须跟 docker/.env.prod + docker-compose.prod.yml 保持一致
EXPECTED_DB_HOST="localhost"
EXPECTED_DB_PORT="5432"
EXPECTED_DB_NAME="topfans"
EXPECTED_DB_USER="postgres"
# ===== 1. 预检查:env 文件 =====
if [[ ! -f "$ENV_FILE" ]]; then
echo "$ENV_FILE 不存在"
exit 1
fi
export DB_PASSWORD=$(grep '^DB_PASSWORD=' "$ENV_FILE" | cut -d= -f2)
export JWT_SECRET=$(grep '^JWT_SECRET=' "$ENV_FILE" | cut -d= -f2)
DB_PASSWORD=$(grep '^DB_PASSWORD=' "$ENV_FILE" | cut -d= -f2)
JWT_SECRET=$(grep '^JWT_SECRET=' "$ENV_FILE" | cut -d= -f2)
if [[ -z "$DB_PASSWORD" ]]; then
echo "$ENV_FILE 里 DB_PASSWORD 为空"
exit 1
fi
if [[ -z "$JWT_SECRET" ]]; then
echo "$ENV_FILE 里 JWT_SECRET 为空"
exit 1
fi
# ===== 2. 预检查:seed 二进制 + bcrypt 文件 =====
if [[ ! -x "$SEED_BIN" ]]; then
echo "$SEED_BIN 不存在或不可执行,先按 RUNBOOK.md §1 上传:"
echo " scp bin/seed root@101.132.250.62:$LOADTEST_DIR/"
exit 1
fi
if [[ ! -f "$BCRYPT_FILE" ]]; then
echo "$BCRYPT_FILE 不存在,seed 会读不到密码哈希"
echo " 按 RUNBOOK.md §1.2 重新生成并上传"
exit 1
fi
# ===== 3. 预检查:DB 连通性 (避免 seed 跑到一半才发现连不上) =====
if command -v psql >/dev/null 2>&1; then
if ! PGPASSWORD="$DB_PASSWORD" psql \
-h "$EXPECTED_DB_HOST" -p "$EXPECTED_DB_PORT" \
-U "$EXPECTED_DB_USER" -d "$EXPECTED_DB_NAME" \
-c 'SELECT 1' >/dev/null 2>&1; then
echo "❌ 连不上 $EXPECTED_DB_HOST:$EXPECTED_DB_PORT/$EXPECTED_DB_NAME"
echo " 检查 docker ps | grep topfans-postgres 确认容器在跑"
exit 1
fi
fi
# ===== 4. 打印信息并要求确认 =====
cd "$LOADTEST_DIR"
echo "=========================================="
echo "prod seed - 准备 loadtest 数据"
echo "DB host: localhost (容器内)"
echo "DB name: topfans"
echo "JWT secret: ${JWT_SECRET:0:10}..."
echo "DB host : $EXPECTED_DB_HOST:$EXPECTED_DB_PORT"
echo " (生产 docker 端口映射 5432:5432,从宿主机 localhost 访问)"
echo "DB name : $EXPECTED_DB_NAME"
echo "DB user : $EXPECTED_DB_USER"
echo "JWT secret : ${JWT_SECRET:0:10}..."
echo "bcrypt 文件: $BCRYPT_FILE ($(wc -c <"$BCRYPT_FILE") bytes)"
echo "=========================================="
echo ""
echo "⚠️ 即将:删除 star_id=999900 的旧测试数据 → 重新灌 23k 行"
echo " 真实业务数据 (star_id 87/88/91/93/94/95) 不受影响"
echo ""
read -r -p "确认继续? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "已取消"
exit 0
fi
./seed --db-name=topfans --jwt-secret="$JWT_SECRET"
# ===== 5. 跑 seed (显式传所有 DB 参数,排除默认值被改的风险) =====
./seed \
--db-host="$EXPECTED_DB_HOST" \
--db-port="$EXPECTED_DB_PORT" \
--db-name="$EXPECTED_DB_NAME" \
--db-user="$EXPECTED_DB_USER" \
--db-password="$DB_PASSWORD" \
--jwt-secret="$JWT_SECRET"
echo ""
echo "✅ seed 完成。生成的文件:"

View File

@ -21,7 +21,7 @@ make loadgen-build
---
## 在 prod 上跑 (凌晨 T0 = 02:00)
## 在 prod 上跑 (凌晨 T0 = 02:00,推荐)
```bash
ssh root@101.132.250.62
@ -31,12 +31,58 @@ bash scripts/prod_seed.sh
这个脚本会自动:
1. 读 `/opt/topfans/docker/.env.prod` 拿 DB_PASSWORD + JWT_SECRET
2. 跑 seed (插入 23k 行测试数据)
2. 跑 seed (插入 23k 行测试数据,直接写入 docker 里的 `topfans-postgres`)
3. 自动重置 PG 序列 (CLAUDE.md 规范)
4. 写 `users.csv` (含 1000 个 JWT)
**预计耗时**:30-60 秒
> ⚠️ **为什么必须在生产机上跑**:`seed` 默认值是 `localhost:5432/topfans`,
> 这正好等于生产机上 docker 暴露的 `5432:5432` + `POSTGRES_DB=topfans`
> 从本地 Mac 跑默认值会**连到你本机的 Postgres**(可能根本不是 topfans 库),
> 后续 `users.csv` 拿去打生产网关会全部 401。详见下一节。
---
## 从本地 Mac 连到生产 DB (不 ssh 进生产机)
只适合本地紧急补 seed、或者压测脚本调试。**首选仍是上一节 ssh 进生产机**。
### 方式 A:SSH 端口转发 (推荐,改动最小)
```bash
# 1. 建立转发:本地 25432 → 生产机 docker 5432
ssh -L 25432:127.0.0.1:5432 -N -f root@101.132.250.62
# 2. 跑 seed (从 docker/.env.prod 拿密码)
cd /Users/liulujian/Documents/code/TopFansByGithub
DB_PASSWORD=$(grep '^DB_PASSWORD=' docker/.env.prod | cut -d= -f2) \
JWT_SECRET=$(grep '^JWT_SECRET=' docker/.env.prod | cut -d= -f2) \
go run ./backend/scripts/loadgen/seed \
--db-host=127.0.0.1 \
--db-port=25432 \
--db-name=topfans \
--db-user=postgres
# 3. 用完记得关转发
pkill -f 'ssh -L 25432'
```
### 方式 B:从本地直连 docker 容器 (生产机已开 5432 端口时)
如果生产机 `topfans-postgres` 已经通过 `ports: - "5432:5432"` 暴露到外网,
也可以直接走公网,但**强烈不建议** (生产 PG 暴露公网本身就有风险):
```bash
DB_PASSWORD=$(grep '^DB_PASSWORD=' docker/.env.prod | cut -d= -f2) \
JWT_SECRET=$(grep '^JWT_SECRET=' docker/.env.prod | cut -d= -f2) \
go run ./backend/scripts/loadgen/seed \
--db-host=101.132.250.62 \
--db-port=5432 \
--db-name=topfans \
--db-user=postgres
```
---
## 在本地 docker 跑 (开发联调)
@ -77,11 +123,24 @@ Usage of ./bin/seed:
-jwt-secret string # JWT 密钥 (默认 $JWT_SECRET)
-db-host string # PG host (默认 localhost)
-db-port int # PG port (默认 5432)
-db-name string # PG 数据库 (prod=topfans, 本地=top-fans)
-db-name string # PG 数据库 (默认 topfans,即 prod 默认;
# 本地 docker dev 用 top-fans,需显式 --db-name=top-fans)
-db-user string # PG user (默认 postgres)
-db-password string # PG 密码 (默认 $DB_PASSWORD)
```
### 三种部署对应的 DB 参数速查表
| 部署 | host | port | dbname | user | password |
|------|------|------|--------|------|----------|
| **生产机直跑** (ssh 进去) | `localhost` | `5432` | `topfans` | `postgres` | `$DB_PASSWORD` (从 `docker/.env.prod` 读) |
| **本地连生产** (SSH 端口转发) | `127.0.0.1` | `25432` | `topfans` | `postgres` | `$DB_PASSWORD` (从 `docker/.env.prod` 读) |
| **本地 docker 联调** (宿主机 postgresql-database-1) | `localhost` | `15432` | `top-fans` | `postgres` | `123456` (从 `docker/.env.local` 读) |
> **默认值设计意图**:`localhost:5432/topfans` 是给"在生产机上直接跑"设计的,
> 因为生产 docker 把 5432 暴露到宿主机 5432。从本地 Mac 跑时**必须显式覆盖
> `--db-host` / `--db-port` / `--db-name`**,否则会连到本机 Postgres。
---
## 三种"清理"模式对比

View File

@ -79,9 +79,12 @@ func main() {
func parseFlags() *Config {
cfg := &Config{}
flag.StringVar(&cfg.JWTSecret, "jwt-secret", os.Getenv("JWT_SECRET"), "JWT secret (或 $JWT_SECRET)")
flag.StringVar(&cfg.DBHost, "db-host", "localhost", "PG host")
flag.IntVar(&cfg.DBPort, "db-port", 5432, "PG port")
flag.StringVar(&cfg.DBName, "db-name", "topfans", "PG database name (本地为 'top-fans' 带横线)")
flag.StringVar(&cfg.DBHost, "db-host", "localhost",
"PG host (默认 localhost — 生产机直跑用; 本地联调要覆盖,详见 README)")
flag.IntVar(&cfg.DBPort, "db-port", 5432,
"PG port (默认 5432 — 生产机直跑用; 本地 docker dev 用 15432)")
flag.StringVar(&cfg.DBName, "db-name", "topfans",
"PG database name (默认 topfans=生产; 本地 docker dev 需显式传 'top-fans')")
flag.StringVar(&cfg.DBUser, "db-user", "postgres", "PG user")
flag.StringVar(&cfg.DBPass, "db-password", os.Getenv("DB_PASSWORD"), "PG password (或 $DB_PASSWORD)")
flag.BoolVar(&cfg.Reset, "reset", false, "delete existing test data before seed")

File diff suppressed because it is too large Load Diff