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:
wangjia
2026-06-28 08:11:32 +08:00
parent 77ce809d48
commit 3527537c96
2 changed files with 102 additions and 2 deletions
+31 -2
View File
@@ -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
}