diff --git a/design/server/openapi.yaml b/design/server/openapi.yaml index df56034..0eed1b8 100644 --- a/design/server/openapi.yaml +++ b/design/server/openapi.yaml @@ -217,6 +217,83 @@ components: minutes_used: type: integer + RoutingRule: + type: object + required: [type, value, action, enabled] + properties: + type: + type: string + enum: [domain, domain_suffix, domain_keyword, ip_cidr, geoip, geosite] + value: + type: string + description: geoip/geosite 目前仅白名单 cn(自托管规则集);ip_cidr 需合法 CIDR + example: x.com + action: + type: string + enum: [direct, proxy, reject] + note: + type: string + enabled: + type: boolean + + RoutingBuiltin: + type: object + properties: + china_direct: + type: boolean + description: 国内域名/IP 直连 + lan_direct: + type: boolean + description: 局域网直连 + private_via_tunnel: + type: boolean + description: 私有服务域名强制走隧道(家庭内网穿透场景) + + RoutingProfile: + type: object + required: [mode, builtin, rules, final] + properties: + mode: + type: string + enum: [rule, global, direct] + builtin: + $ref: '#/components/schemas/RoutingBuiltin' + rules: + type: array + maxItems: 200 + items: + $ref: '#/components/schemas/RoutingRule' + final: + type: string + enum: [proxy, direct] + + RoutingValidationError: + type: object + required: [code, message_zh, message_en, errors] + properties: + code: + type: string + example: routing_invalid + message_zh: + type: string + example: 规则校验未通过 + message_en: + type: string + example: Rule validation failed + errors: + type: array + items: + type: object + required: [index, field, reason] + properties: + index: + type: integer + description: -1 表示档案级字段(mode/final/rules 数量),否则为 rules 下标 + field: + type: string + reason: + type: string + paths: # ── Auth ────────────────────────────────────────────────────────────────── @@ -418,6 +495,50 @@ paths: schema: $ref: '#/components/schemas/Error' + /me/routing: + get: + summary: 获取当前用户的分流配置(可配置分流;未自定义时返回默认档案) + responses: + '200': + description: 分流档案 + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingProfile' + '401': + description: 未认证 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + summary: 保存当前用户的分流配置(整份覆盖;校验失败不部分保存) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingProfile' + responses: + '200': + description: 保存成功,返回规整化后的档案 + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingProfile' + '400': + description: 校验失败(逐条错误,不部分保存) + content: + application/json: + schema: + $ref: '#/components/schemas/RoutingValidationError' + '401': + description: 未认证 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # ── Plans & Subscriptions ───────────────────────────────────────────────── /plans: diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index 6bec7fa..ff0b689 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -249,6 +249,55 @@ paths: "500": $ref: "#/components/responses/Internal" + /me/routing: + get: + operationId: getRoutingProfile + summary: 获取当前用户的分流配置 + description: | + 可配置分流(#5 之上的用户自定义规则)。用户未自定义时返回默认档案 + (等价于原硬编码行为:智能分流 + 国内直连 + 无自定义规则)。 + tags: [Account] + responses: + "200": + description: 分流档案 + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingProfile" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/Internal" + post: + operationId: saveRoutingProfile + summary: 保存当前用户的分流配置 + description: | + 整份覆盖式保存(非增量 patch)。校验失败时不部分保存,一次性返回全部违规字段。 + tags: [Account] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingProfile" + responses: + "200": + description: 保存成功,返回规整化(去重/裁剪)后的档案 + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingProfile" + "400": + description: 校验失败(逐条错误,不部分保存) + content: + application/json: + schema: + $ref: "#/components/schemas/RoutingValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/Internal" + # ── 商业闭环 ───────────────────────────────────────────── /redeem: @@ -1131,6 +1180,85 @@ components: format: date-time description: 最后活跃时间(UTC ISO-8601) + # ── 可配置分流 ─────────────────────────────────────────── + + RoutingRule: + type: object + required: [type, value, action, enabled] + properties: + type: + type: string + enum: [domain, domain_suffix, domain_keyword, ip_cidr, geoip, geosite] + value: + type: string + description: geoip/geosite 目前仅白名单 cn(自托管规则集);ip_cidr 需合法 CIDR + example: x.com + action: + type: string + enum: [direct, proxy, reject] + note: + type: string + enabled: + type: boolean + + RoutingBuiltin: + type: object + properties: + china_direct: + type: boolean + description: 国内域名/IP 直连 + lan_direct: + type: boolean + description: 局域网直连 + private_via_tunnel: + type: boolean + description: 私有服务域名强制走隧道(家庭内网穿透场景) + + RoutingProfile: + type: object + required: [mode, builtin, rules, final] + properties: + mode: + type: string + enum: [rule, global, direct] + builtin: + $ref: "#/components/schemas/RoutingBuiltin" + rules: + type: array + maxItems: 200 + items: + $ref: "#/components/schemas/RoutingRule" + final: + type: string + enum: [proxy, direct] + + RoutingValidationError: + type: object + required: [code, message_zh, message_en, errors] + properties: + code: + type: string + example: routing_invalid + message_zh: + type: string + example: 规则校验未通过 + message_en: + type: string + example: Rule validation failed + errors: + type: array + items: + type: object + required: [index, field, reason] + properties: + index: + type: integer + description: -1 表示档案级字段(mode/final/rules 数量),否则为 rules 下标 + field: + type: string + reason: + type: string + # ── 商业 ─────────────────────────────────────────────── Plan: diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 1415b63..36e45f2 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -40,6 +40,7 @@ import ( "github.com/wangjia/pangolin/server/internal/provision/providers" "github.com/wangjia/pangolin/server/internal/redisutil" "github.com/wangjia/pangolin/server/internal/reward" + "github.com/wangjia/pangolin/server/internal/routing" "github.com/wangjia/pangolin/server/internal/scheduler" "github.com/wangjia/pangolin/server/internal/scheduler/probe" "github.com/wangjia/pangolin/server/internal/sessions" @@ -406,6 +407,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv // ── Account / Plans / Notices ───────────────────────────────────────────── accountAPI := httpapi.NewAccountAPI(sqlDB) + // ── Routing profile (可配置分流) ─────────────────────────────────────────── + routingAPI := httpapi.NewRoutingAPI(routing.NewStore(sqlDB)) + // ── Nodes + Connect ─────────────────────────────────────────────────────── var nodeAPI *httpapi.NodeAPI var nodeStore nodes.NodeStore @@ -486,6 +490,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv me.Post("/redeem", redeemHandler.ServeHTTP) me.Get("/subscription", subAPI.GetSubscription) me.Post("/subscription/reset", subAPI.ResetSubscription) + me.Get("/routing", routingAPI.GetProfile) + me.Post("/routing", routingAPI.SaveProfile) if totpHandler != nil { me.Post("/totp/setup", totpHandler.Setup) me.Post("/totp/verify", totpHandler.Verify) diff --git a/server/internal/httpapi/routing.go b/server/internal/httpapi/routing.go new file mode 100644 index 0000000..743125c --- /dev/null +++ b/server/internal/httpapi/routing.go @@ -0,0 +1,70 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/wangjia/pangolin/server/internal/apierr" + "github.com/wangjia/pangolin/server/internal/auth" + "github.com/wangjia/pangolin/server/internal/routing" +) + +// RoutingAPI serves /v1/me/routing: the user's configurable routing profile +// (可配置分流). GET returns the stored profile or routing.Default() when the +// user hasn't customized one yet; POST validates and upserts. +type RoutingAPI struct { + store *routing.Store +} + +// NewRoutingAPI creates a RoutingAPI backed by the given routing.Store. +func NewRoutingAPI(store *routing.Store) *RoutingAPI { return &RoutingAPI{store: store} } + +// GetProfile handles GET /v1/me/routing. +func (a *RoutingAPI) GetProfile(w http.ResponseWriter, r *http.Request) { + uid, ok := auth.UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + p, err := a.store.Get(r.Context(), uid) + if err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + if p == nil { + p = routing.Default() + } + writeJSON(w, http.StatusOK, p) +} + +// SaveProfile handles POST /v1/me/routing. On validation failure it returns +// 400 with every offending field reported at once (no partial save). +func (a *RoutingAPI) SaveProfile(w http.ResponseWriter, r *http.Request) { + uid, ok := auth.UserIDFromContext(r.Context()) + if !ok { + apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) + return + } + var p routing.Profile + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&p); err != nil { + apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) + return + } + p.Normalize() + if errs := p.Validate(); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "routing_invalid", + "message_zh": "规则校验未通过", + "message_en": "Rule validation failed", + "errors": errs, + }) + return + } + if err := a.store.Upsert(r.Context(), uid, &p); err != nil { + apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) + return + } + writeJSON(w, http.StatusOK, &p) +} diff --git a/server/internal/httpapi/routing_test.go b/server/internal/httpapi/routing_test.go new file mode 100644 index 0000000..c01d6da --- /dev/null +++ b/server/internal/httpapi/routing_test.go @@ -0,0 +1,130 @@ +package httpapi + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/wangjia/pangolin/server/internal/codes" + "github.com/wangjia/pangolin/server/internal/config" + "github.com/wangjia/pangolin/server/internal/routing" + "github.com/wangjia/pangolin/server/internal/store" +) + +// openRoutingTestDB opens an in-memory SQLite DB with migrations applied, +// mirroring internal/routing/store_sqlite_test.go's helper (no shared helper +// exists in this package yet). +func openRoutingTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + if err := store.MigrateUp(db, "sqlite"); err != nil { + t.Fatal(err) + } + if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil { + t.Fatal(err) + } + return db +} + +func seedRoutingUser(t *testing.T, db *sql.DB, id int64) { + t.Helper() + uuid := "u-routing" + if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at) + VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil { + t.Fatal(err) + } +} + +// doAuthReq builds an httptest request with an authenticated context (numeric +// user id injected under codes.CtxKeyUserID, matching auth.RequireAuth) and +// invokes the handler directly (no router needed for a single route). +func doAuthReq(t *testing.T, method, target string, body *strings.Reader, uid int64, h http.HandlerFunc) *httptest.ResponseRecorder { + t.Helper() + var req *http.Request + if body == nil { + req = httptest.NewRequest(method, target, nil) + } else { + req = httptest.NewRequest(method, target, body) + } + ctx := context.WithValue(req.Context(), codes.CtxKeyUserID, uid) + req = req.WithContext(ctx) + rr := httptest.NewRecorder() + h(rr, req) + return rr +} + +func TestRoutingGetDefaultThenSave(t *testing.T) { + db := openRoutingTestDB(t) + seedRoutingUser(t, db, 7) + api := NewRoutingAPI(routing.NewStore(db)) + + // GET 无档案 → 200 + Default + rr := doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile) + if rr.Code != 200 { + t.Fatalf("GET code %d", rr.Code) + } + var p routing.Profile + if err := json.Unmarshal(rr.Body.Bytes(), &p); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if p.Mode != "rule" { + t.Fatalf("default mode %s", p.Mode) + } + + // POST 合法 → 200 + body := `{"mode":"rule","builtin":{"china_direct":true,"lan_direct":true,"private_via_tunnel":true},"rules":[{"type":"domain_suffix","value":"x.com","action":"direct","enabled":true}],"final":"proxy"}` + rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(body), 7, api.SaveProfile) + if rr.Code != 200 { + t.Fatalf("POST code %d body %s", rr.Code, rr.Body) + } + + // GET 后应能取回刚保存的档案 + rr = doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile) + if rr.Code != 200 { + t.Fatalf("GET-after-save code %d", rr.Code) + } + var p2 routing.Profile + if err := json.Unmarshal(rr.Body.Bytes(), &p2); err != nil { + t.Fatalf("unmarshal2: %v", err) + } + if len(p2.Rules) != 1 || p2.Rules[0].Value != "x.com" { + t.Fatalf("saved profile not persisted: %+v", p2) + } + + // POST 非法 → 400 + errors + rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(`{"mode":"x","final":"y","rules":[]}`), 7, api.SaveProfile) + if rr.Code != 400 { + t.Fatalf("bad POST code %d", rr.Code) + } + var errBody map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil { + t.Fatalf("unmarshal err body: %v", err) + } + if errBody["code"] != "routing_invalid" { + t.Fatalf("bad POST body code = %v", errBody["code"]) + } + errs, ok := errBody["errors"].([]any) + if !ok || len(errs) == 0 { + t.Fatalf("expected non-empty errors, got %v", errBody["errors"]) + } +} + +func TestRoutingGetUnauthorized(t *testing.T) { + db := openRoutingTestDB(t) + api := NewRoutingAPI(routing.NewStore(db)) + req := httptest.NewRequest(http.MethodGet, "/v1/me/routing", nil) + rr := httptest.NewRecorder() + api.GetProfile(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", rr.Code) + } +}