From 3527537c960a52e54bdbb54ad5701b26924a314f Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sun, 28 Jun 2026 08:11:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(connect):=204=20=E5=A4=84=20500=20=E8=AE=B0?= =?UTF-8?q?=20slog.Error;=E5=8A=A0=E8=BF=9E=E6=8E=A5=E8=B7=AF=E5=BE=84=20s?= =?UTF-8?q?chema=20=E7=BB=91=E5=AE=9A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/internal/httpapi/nodes.go | 33 ++++++++- server/internal/store/connect_schema_test.go | 71 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 server/internal/store/connect_schema_test.go diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index 4e1c942..00e0c85 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -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 } diff --git a/server/internal/store/connect_schema_test.go b/server/internal/store/connect_schema_test.go new file mode 100644 index 0000000..bc95066 --- /dev/null +++ b/server/internal/store/connect_schema_test.go @@ -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) + } +}