fix(connect): 4 处 500 记 slog.Error;加连接路径 schema 绑定测试
ConnectNode 四条 ErrInternal 路径(entitlement/配额/节点/配置渲染)此前只写 500 不记 err,导致「no such column: p.daily_mb」这种 SQL 错全静默 → 难排查。 改为各记 slog.Error(含 user/node/err)。 新增 store 层测试 TestSQLite_ConnectPathSchema:在跑过完整迁移(含 000015)的 真 SQLite 库上,跑连接路径依赖 015 schema 的三条查询——EntitlementForUser (断言 DailyMB 从 plans.daily_mb 取到 102400)、EnsureDeviceDpUUID(devices.dp_uuid)、 AccountDayBytes——任一引用了没有迁移建的列即 SQL 报错、测试变红,守住本次 「查询引用了未迁移列」一类回归。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,7 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Load user entitlement (dp_uuid + plan).
|
||||
ent, err := a.store.EntitlementForUser(r.Context(), uid)
|
||||
if err != nil {
|
||||
slog.Error("connect: entitlement load failed", "user", uid, "err", err)
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
@@ -124,6 +125,21 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1.5 GB 综合配额卡控(todo #5 Phase 2):按账户当日综合流量卡,超 plan.daily_mb 即拒。
|
||||
// 对免费(与分钟门双卡)与付费(高上限防滥用)统一生效;daily_mb NULL = 不限。
|
||||
if ent.DailyMB.Valid {
|
||||
usedBytes, qerr := a.store.AccountDayBytes(r.Context(), uid, time.Now().UTC())
|
||||
if qerr != nil {
|
||||
slog.Error("connect: account day bytes failed", "user", uid, "err", qerr)
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
if usedBytes >= ent.DailyMB.Int64*(1<<20) {
|
||||
apierr.WriteJSON(w, http.StatusForbidden, apierr.ErrQuotaExhausted)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Determine TTL from plan.
|
||||
var ttl time.Duration
|
||||
if ent.AdGate {
|
||||
@@ -142,9 +158,21 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
expiresAt := time.Now().UTC().Add(ttl)
|
||||
|
||||
// 2.5 Per-device dp_uuid (todo #5 Phase 2): each device gets its own data-plane
|
||||
// credential so the node reports per-device traffic counters. Falls back to the
|
||||
// account-level dp_uuid when the device isn't registered yet (legacy clients).
|
||||
dpUUID := ent.DpUUID
|
||||
if devDp, _, derr := a.store.EnsureDeviceDpUUID(r.Context(), uid, req.DeviceID); derr == nil && devDp != "" {
|
||||
dpUUID = devDp
|
||||
} else if derr != nil {
|
||||
slog.Info("connect: per-device dp_uuid unavailable, using account credential",
|
||||
"user", uid, "device", req.DeviceID, "err", derr.Error())
|
||||
}
|
||||
|
||||
// 3. Resolve node.
|
||||
node, err := a.store.NodeByUUID(r.Context(), nodeUUID)
|
||||
if err != nil {
|
||||
slog.Error("connect: node load failed", "node", nodeUUID, "err", err)
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
@@ -155,7 +183,7 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// 4. Build the agentv1.Credential.
|
||||
cred := &agentv1.Credential{
|
||||
DpUUID: ent.DpUUID,
|
||||
DpUUID: dpUUID,
|
||||
Protocol: agentv1.ProtocolBoth,
|
||||
Flow: "xtls-rprx-vision",
|
||||
ExpiresAtUnix: expiresAt.Unix(),
|
||||
@@ -181,9 +209,10 @@ func (a *NodeAPI) ConnectNode(w http.ResponseWriter, r *http.Request) {
|
||||
// 7. Render and return the full sing-box CLIENT config JSON.
|
||||
// split_cn=1/true → 国内 IP/域名直连(#5);客户端按 smartRoute 偏好传。
|
||||
splitCN := r.URL.Query().Get("split_cn") == "1" || r.URL.Query().Get("split_cn") == "true"
|
||||
cfgJSON, renderErr := BuildClientConfig(node, ent.DpUUID, a.deriveKey,
|
||||
cfgJSON, renderErr := BuildClientConfig(node, dpUUID, a.deriveKey,
|
||||
ClientConfigOpts{SplitCN: splitCN, RulesBaseURL: a.rulesBaseURL})
|
||||
if renderErr != nil {
|
||||
slog.Error("connect: build client config failed", "node", nodeUUID, "err", renderErr)
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/nodes"
|
||||
)
|
||||
|
||||
// TestSQLite_ConnectPathSchema guards the "no such column: p.daily_mb" class of
|
||||
// regression that 500'd /v1/nodes/{id}/connect: the connect handler's store
|
||||
// queries must bind to the schema the migrations actually produce. Each
|
||||
// 000015-dependent query runs against a fully-migrated real SQLite DB — if any
|
||||
// references a column/table no migration creates, the call fails with a SQL
|
||||
// error and this test goes red. (The live outage was operational — binary
|
||||
// deployed ahead of the migration — but this also catches a query that names a
|
||||
// column no migration adds.)
|
||||
func TestSQLite_ConnectPathSchema(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := openSQLite(t)
|
||||
ns := nodes.NewSQLNodeStore(db)
|
||||
seedUser(t, db, 1)
|
||||
|
||||
// EntitlementForUser: the exact query that broke (SELECT ... p.daily_mb ...).
|
||||
// With a PRO subscription, DailyMB must come back from plans.daily_mb (102400,
|
||||
// seeded by migration 000015).
|
||||
var proID int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code='pro'`).Scan(&proID); err != nil {
|
||||
t.Fatalf("lookup pro plan: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source)
|
||||
VALUES (1, ?, ?, 'trial')`,
|
||||
proID, time.Now().UTC().Add(24*time.Hour)); err != nil {
|
||||
t.Fatalf("seed subscription: %v", err)
|
||||
}
|
||||
ent, err := ns.EntitlementForUser(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("EntitlementForUser: %v", err) // a missing daily_mb column lands here
|
||||
}
|
||||
if ent == nil {
|
||||
t.Fatal("EntitlementForUser returned nil entitlement")
|
||||
}
|
||||
if ent.PlanCode != "pro" {
|
||||
t.Errorf("PlanCode = %q, want pro", ent.PlanCode)
|
||||
}
|
||||
if !ent.DailyMB.Valid || ent.DailyMB.Int64 != 102400 {
|
||||
t.Errorf("DailyMB = %+v, want valid 102400", ent.DailyMB)
|
||||
}
|
||||
|
||||
// EnsureDeviceDpUUID: exercises devices.dp_uuid (000015 ADD COLUMN). Mints a
|
||||
// per-device data-plane credential into the new column.
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO devices (id, uuid, user_id, name, platform)
|
||||
VALUES (1, 'dev-uuid', 1, 'dev', 'windows')`); err != nil {
|
||||
t.Fatalf("seed device: %v", err)
|
||||
}
|
||||
dp, devID, err := ns.EnsureDeviceDpUUID(ctx, 1, "dev-uuid")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDeviceDpUUID: %v", err) // missing devices.dp_uuid lands here
|
||||
}
|
||||
if dp == "" || devID != 1 {
|
||||
t.Errorf("EnsureDeviceDpUUID = (%q, %d), want non-empty dp + devID 1", dp, devID)
|
||||
}
|
||||
|
||||
// AccountDayBytes: GB-quota query run right after entitlement; must bind cleanly.
|
||||
if _, err := ns.AccountDayBytes(ctx, 1, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("AccountDayBytes: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user