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';