From 3d87bffbe956488fd612b29a30c51e3d404a6cbf Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 18 Jun 2026 23:42:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20P2=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E8=A1=A5=E5=AD=97=E6=AE=B5=20=E2=80=94=20=E5=A5=97=E9=A4=90?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=20+=20=E8=8A=82=E7=82=B9=20host(#6=206B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 000014_plan_pricing(mysql+sqlite 双套):plans 加 price_cents/currency/ period,seed pro=2500 team=9900(¥25/¥99);ListPlans 返回价格字段。 - /v1/nodes 暴露 host+port(取自 node.Endpoint,无 schema 变更),供客户端实测 真实 per-client 延迟(服务端无法代知)。 - 修复 account.go 的 GetMe/weeklyGB 残留 MySQL 专属 UTC_DATE()/INTERVAL (#1 漏网):改 Go 端算日期传 ?,否则 SQLite 节点今日/周流量恒 0。 - sqlite 迁移 up/down 测试期望版本 13→14。 go build/vet 干净;sqlite 迁移 up/down + httpapi 测试通过。节点 tag 暂不加 (无真实数据,不造空字段;客户端将不显示伪造 tag)。 Co-Authored-By: Claude Opus 4.8 --- server/internal/httpapi/account.go | 31 ++++++++++++------- server/internal/httpapi/clientconfig.go | 2 +- server/internal/httpapi/nodes.go | 11 +++++++ server/internal/store/sqlite_migrate_test.go | 4 +-- .../mysql/000014_plan_pricing.down.sql | 4 +++ .../mysql/000014_plan_pricing.up.sql | 7 +++++ .../sqlite/000014_plan_pricing.down.sql | 3 ++ .../sqlite/000014_plan_pricing.up.sql | 6 ++++ 8 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 server/migrations/mysql/000014_plan_pricing.down.sql create mode 100644 server/migrations/mysql/000014_plan_pricing.up.sql create mode 100644 server/migrations/sqlite/000014_plan_pricing.down.sql create mode 100644 server/migrations/sqlite/000014_plan_pricing.up.sql diff --git a/server/internal/httpapi/account.go b/server/internal/httpapi/account.go index 595b702..b476f35 100644 --- a/server/internal/httpapi/account.go +++ b/server/internal/httpapi/account.go @@ -25,7 +25,7 @@ type meResponse struct { UUID string `json:"uuid"` Email string `json:"email"` DpUUID string `json:"dp_uuid"` - Plan string `json:"plan"` // "free" | "pro" | "team" + Plan string `json:"plan"` // "free" | "pro" | "team" // expire_at kept for the existing app; expires_at is the same value for the // web user-center (both RFC3339 UTC, null = no active sub). ExpireAt *string `json:"expire_at"` @@ -98,12 +98,14 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { _ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices WHERE user_id = ?`, uid).Scan(&devicesUsed) // Today's usage (UTC). Missing row → zero. + // 日期在 Go 端算好传 ?,不用 MySQL 专属 UTC_DATE()(否则 SQLite 报错→恒 0)。 var todayBytes uint64 var todayMinutes int + today := time.Now().UTC().Format("2006-01-02") _ = a.db.QueryRowContext(ctx, ` SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0) - FROM usage_daily WHERE user_id = ? AND date = UTC_DATE() - `, uid).Scan(&todayBytes, &todayMinutes) + FROM usage_daily WHERE user_id = ? AND date = ? + `, uid, today).Scan(&todayBytes, &todayMinutes) resp := meResponse{ UUID: uuid, @@ -138,11 +140,13 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) { // filling days with no usage row as 0. func (a *AccountAPI) weeklyGB(ctx context.Context, uid int64) []float64 { out := make([]float64, 7) + // 起始日 Go 端算好传 ?(避免 MySQL 专属 UTC_DATE()/INTERVAL,SQLite 不支持)。 + weekFrom := time.Now().UTC().AddDate(0, 0, -6).Format("2006-01-02") rows, err := a.db.QueryContext(ctx, ` SELECT date, COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0) FROM usage_daily - WHERE user_id = ? AND date >= UTC_DATE() - INTERVAL 6 DAY - `, uid) + WHERE user_id = ? AND date >= ? + `, uid, weekFrom) if err != nil { return out } @@ -168,17 +172,20 @@ func (a *AccountAPI) weeklyGB(ctx context.Context, uid int64) []float64 { // ─── GET /v1/plans ─────────────────────────────────────────────────────────── type planResponse struct { - Code string `json:"code"` - NameZH string `json:"name_zh"` - NameEN string `json:"name_en"` - DailyMinutes *int64 `json:"daily_minutes"` // null = unlimited - AdGate bool `json:"ad_gate"` + Code string `json:"code"` + NameZH string `json:"name_zh"` + NameEN string `json:"name_en"` + DailyMinutes *int64 `json:"daily_minutes"` // null = unlimited + AdGate bool `json:"ad_gate"` + PriceCents int `json:"price_cents"` // 价格(分);0 = 免费 + Currency string `json:"currency"` // CNY / USD … + Period string `json:"period"` // month / year } // ListPlans handles GET /v1/plans. func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), - `SELECT code, name_zh, name_en, daily_minutes, ad_gate FROM plans ORDER BY id`) + `SELECT code, name_zh, name_en, daily_minutes, ad_gate, price_cents, currency, period FROM plans ORDER BY id`) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return @@ -189,7 +196,7 @@ func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) { for rows.Next() { var p planResponse var dm sql.NullInt64 - if err := rows.Scan(&p.Code, &p.NameZH, &p.NameEN, &dm, &p.AdGate); err != nil { + if err := rows.Scan(&p.Code, &p.NameZH, &p.NameEN, &dm, &p.AdGate, &p.PriceCents, &p.Currency, &p.Period); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } diff --git a/server/internal/httpapi/clientconfig.go b/server/internal/httpapi/clientconfig.go index 97cdb06..df20e86 100644 --- a/server/internal/httpapi/clientconfig.go +++ b/server/internal/httpapi/clientconfig.go @@ -145,7 +145,7 @@ func BuildClientConfig(node *nodes.NodeRow, dpUUID, deriveKey string) ([]byte, e cfg := map[string]any{ // timestamp=false:客户端日志出口(logLine)已统一加时间戳, // 关掉 sing-box 自带时间戳避免一行打印两个时间。 - "log": map[string]any{"level": "warn", "timestamp": false}, + "log": map[string]any{"level": "warn", "timestamp": false}, "inbounds": []any{tunIn}, "outbounds": append(proxyOutbounds, autoBest, diff --git a/server/internal/httpapi/nodes.go b/server/internal/httpapi/nodes.go index 1348cde..b54f153 100644 --- a/server/internal/httpapi/nodes.go +++ b/server/internal/httpapi/nodes.go @@ -3,6 +3,7 @@ package httpapi import ( "encoding/json" "net/http" + "strconv" "strings" "time" @@ -42,6 +43,9 @@ type nodeResponse struct { NameEN string `json:"name_en"` Tier string `json:"tier"` // "free" | "pro" Status string `json:"status"` // always "up" in this endpoint + // host/port 暴露节点入口,供客户端实测真实延迟(per-client ping 服务端无法代知)。 + Host string `json:"host"` + Port int `json:"port"` } // ListNodes handles GET /v1/nodes. @@ -54,6 +58,11 @@ func (a *NodeAPI) ListNodes(w http.ResponseWriter, r *http.Request) { resp := make([]nodeResponse, 0, len(nodeRows)) for _, n := range nodeRows { + host, portStr := splitHostPort(n.Endpoint) + if host == "" { + host = n.Endpoint + } + port, _ := strconv.Atoi(portStr) resp = append(resp, nodeResponse{ ID: n.UUID, Region: n.Region, @@ -61,6 +70,8 @@ func (a *NodeAPI) ListNodes(w http.ResponseWriter, r *http.Request) { NameEN: n.NameEN, Tier: n.Tier, Status: n.Status, + Host: host, + Port: port, }) } diff --git a/server/internal/store/sqlite_migrate_test.go b/server/internal/store/sqlite_migrate_test.go index 2f95768..62dc53b 100644 --- a/server/internal/store/sqlite_migrate_test.go +++ b/server/internal/store/sqlite_migrate_test.go @@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) { if dirty { t.Fatalf("schema dirty after MigrateUp") } - if v != 13 { - t.Errorf("version = %d, want 13", v) + if v != 14 { + t.Errorf("version = %d, want 14", v) } // 2. Core tables exist. diff --git a/server/migrations/mysql/000014_plan_pricing.down.sql b/server/migrations/mysql/000014_plan_pricing.down.sql new file mode 100644 index 0000000..dcd7591 --- /dev/null +++ b/server/migrations/mysql/000014_plan_pricing.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE plans + DROP COLUMN price_cents, + DROP COLUMN currency, + DROP COLUMN period; diff --git a/server/migrations/mysql/000014_plan_pricing.up.sql b/server/migrations/mysql/000014_plan_pricing.up.sql new file mode 100644 index 0000000..a7e5668 --- /dev/null +++ b/server/migrations/mysql/000014_plan_pricing.up.sql @@ -0,0 +1,7 @@ +-- 套餐定价(price_cents/currency/period);价格此前不入表,现补齐供客户端展示。 +ALTER TABLE plans + ADD COLUMN price_cents INT NOT NULL DEFAULT 0, + ADD COLUMN currency VARCHAR(8) NOT NULL DEFAULT 'CNY', + ADD COLUMN period VARCHAR(8) NOT NULL DEFAULT 'month'; +UPDATE plans SET price_cents = 2500 WHERE code = 'pro'; +UPDATE plans SET price_cents = 9900 WHERE code = 'team'; diff --git a/server/migrations/sqlite/000014_plan_pricing.down.sql b/server/migrations/sqlite/000014_plan_pricing.down.sql new file mode 100644 index 0000000..8f1e01e --- /dev/null +++ b/server/migrations/sqlite/000014_plan_pricing.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE plans DROP COLUMN period; +ALTER TABLE plans DROP COLUMN currency; +ALTER TABLE plans DROP COLUMN price_cents; diff --git a/server/migrations/sqlite/000014_plan_pricing.up.sql b/server/migrations/sqlite/000014_plan_pricing.up.sql new file mode 100644 index 0000000..14c291d --- /dev/null +++ b/server/migrations/sqlite/000014_plan_pricing.up.sql @@ -0,0 +1,6 @@ +-- 套餐定价(price_cents/currency/period);价格此前不入表,现补齐供客户端展示。 +ALTER TABLE plans ADD COLUMN price_cents INTEGER NOT NULL DEFAULT 0; +ALTER TABLE plans ADD COLUMN currency TEXT NOT NULL DEFAULT 'CNY'; +ALTER TABLE plans ADD COLUMN period TEXT NOT NULL DEFAULT 'month'; +UPDATE plans SET price_cents = 2500 WHERE code = 'pro'; +UPDATE plans SET price_cents = 9900 WHERE code = 'team';