diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 95a2003..ea5fbe9 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -1,6 +1,7 @@ package router import ( + goautoclientapi "go-admin/app/goauto/clientapi" "os" "github.com/gin-gonic/gin" @@ -53,6 +54,7 @@ func InitRouter() { // 注册 GoAuto Agent 与设备管理路由。 goautodevice.InitRouter(r, authMiddleware) + goautoclientapi.InitRouter(r, authMiddleware) goautoapprelease.InitRouter(r, authMiddleware) goautoaimatching.InitRouter(r, authMiddleware) goautotask.InitRouter(r, authMiddleware) diff --git a/server/app/goauto/clientapi/gateway.go b/server/app/goauto/clientapi/gateway.go new file mode 100644 index 0000000..8a786e3 --- /dev/null +++ b/server/app/goauto/clientapi/gateway.go @@ -0,0 +1,180 @@ +package clientapi + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + "go-admin/app/goauto/clientkey" + "go-admin/common/clientprincipal" + "gorm.io/gorm" +) + +// Only TLS or explicitly trusted loopback reverse proxies may carry a key. +// The Agent's insecure-HTTP exception does not apply to client credentials. +func secure(r *http.Request) bool { + if r.TLS != nil { + return true + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return false + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() && os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO") == "true" && r.Header.Get("X-Forwarded-Proto") == "https" +} + +func RequireHTTPS(c *gin.Context) { + c.Header("Cache-Control", "no-store") + if !secure(c.Request) { + c.AbortWithStatusJSON(426, gin.H{"code": 426, "message": "客户端密钥管理仅允许 HTTPS"}) + return + } + c.Next() +} +func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store") + deny := func(status int, message string) { + c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message}) + } + if !secure(c.Request) { + deny(426, "客户端密钥仅允许 HTTPS") + return + } + // Never accept a credential supplied through a URL or cookie fallback. + for k := range c.Request.URL.Query() { + if sensitive(k) || strings.EqualFold(k, "token") { + deny(400, "密钥只能通过 Authorization 请求头发送") + return + } + } + header := strings.Fields(c.GetHeader("Authorization")) + if len(header) != 2 || !strings.EqualFold(header[0], "Bearer") { + deny(401, "需要客户端密钥") + return + } + connection := db + var err error + if connection == nil { + connection, err = pkg.GetOrm(c) + } + if err != nil || connection == nil { + deny(503, "客户端认证暂不可用") + return + } + key, err := (clientkey.Service{DB: connection}).Authenticate(c.Request.Context(), header[1]) + if err != nil { + if errors.Is(err, clientkey.ErrCredential) { + deny(401, "客户端密钥无效或已停用") + } else { + deny(503, "客户端认证暂不可用") + } + return + } + random := make([]byte, 16) + if _, err = rand.Read(random); err != nil { + deny(503, "审计暂不可用") + return + } + requestID := hex.EncodeToString(random) + c.Header("X-Client-Request-Id", requestID) + record := clientkey.Audit{KeyID: key.ID, Event: "request", RequestID: requestID, Method: e.Method, Route: "/api/client/v1" + e.Path} + if !key.Allows(e.Module, e.Capability) { + record.Status = 403 + _ = connection.Create(&record).Error + deny(403, "密钥未授权此模块或执行动作") + return + } + // The intent must be durable before any business handler can run. + if err = connection.WithContext(c.Request.Context()).Create(&record).Error; err != nil { + deny(503, "审计暂不可用,未执行操作") + return + } + clientprincipal.Set(c, clientprincipal.Identity{KeyID: key.ID, RequestID: requestID, AuthorizedBy: key.UpdatedBy}) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20) + original := c.Writer + buffer := &responseBuffer{ResponseWriter: original, status: 200} + c.Writer = buffer + defer func() { c.Writer = original }() + c.Next() + c.Writer = original + status := buffer.status + var value any + if buffer.overflow || json.Unmarshal(buffer.body.Bytes(), &value) != nil { + status = 502 + value = gin.H{"code": 502, "message": "无法生成客户端响应,请先核对操作结果,不要自动重试"} + } else { + value = redact(value) + } + auditCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + _ = connection.WithContext(auditCtx).Model(&clientkey.Audit{}).Where("id = ?", record.ID).Update("status", status).Error + now := time.Now().UTC() + _ = connection.WithContext(auditCtx).Model(&clientkey.Key{}).Where("id = ?", key.ID).UpdateColumn("last_used_at", now).Error + c.JSON(status, value) + } +} + +type responseBuffer struct { + gin.ResponseWriter + body bytes.Buffer + status int + overflow bool +} + +func (w *responseBuffer) WriteHeader(status int) { w.status = status } +func (w *responseBuffer) WriteHeaderNow() {} +func (w *responseBuffer) Status() int { return w.status } +func (w *responseBuffer) Size() int { return w.body.Len() } +func (w *responseBuffer) Written() bool { return w.body.Len() > 0 } +func (w *responseBuffer) Write(b []byte) (int, error) { + if w.body.Len()+len(b) > 32<<20 { + w.overflow = true + return len(b), nil + } + return w.body.Write(b) +} +func (w *responseBuffer) WriteString(s string) (int, error) { return w.Write([]byte(s)) } +func (w *responseBuffer) Flush() {} + +func sensitive(k string) bool { + key := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(k, "_", ""), "-", "")) + for _, part := range []string{"password", "passwd", "secret", "credential", "cookie", "authorization", "apikey", "accesstoken", "devicetoken", "refreshtoken", "recoverycode"} { + if strings.Contains(key, part) { + return true + } + } + switch key { + case "token", "tokenhash", "digest", "rawjson", "rawpayload", "rawresponse", "requestheaders", "responseheaders": + return true + } + return false +} +func redact(v any) any { + switch value := v.(type) { + case map[string]any: + for k, item := range value { + if sensitive(k) { + delete(value, k) + } else { + value[k] = redact(item) + } + } + case []any: + for i, item := range value { + value[i] = redact(item) + } + } + return v +} diff --git a/server/app/goauto/clientapi/gateway_test.go b/server/app/goauto/clientapi/gateway_test.go new file mode 100644 index 0000000..22a4677 --- /dev/null +++ b/server/app/goauto/clientapi/gateway_test.go @@ -0,0 +1,176 @@ +package clientapi + +import ( + "context" + "crypto/tls" + "encoding/json" + "github.com/gin-gonic/gin" + "go-admin/app/goauto/clientkey" + "go-admin/common/clientprincipal" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func fixture(t *testing.T) (*gorm.DB, clientkey.Service) { + t.Helper() + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + sql, _ := db.DB() + sql.SetMaxOpenConns(1) + t.Cleanup(func() { sql.Close() }) + if err = db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil { + t.Fatal(err) + } + return db, clientkey.Service{DB: db, Modules: Catalog(Inventory())} +} +func TestEveryRouteIsExplicitlyScoped(t *testing.T) { + db, s := fixture(t) + routes := Inventory() + if len(s.Modules) != 12 { + t.Fatal("menu groups lost") + } + router := gin.New() + seen := map[string]bool{} + for _, e := range routes { + key := e.Method + e.Path + if seen[key] { + t.Fatal("duplicate route") + } + seen[key] = true + if strings.Contains(e.Path, "payment") || strings.Contains(e.Path, "token") || strings.Contains(e.Path, "client-keys") || e.Handle == nil { + t.Fatal("forbidden route") + } + router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), func(c *gin.Context) { c.JSON(200, gin.H{"data": "ok"}) }) + if e.Method != "GET" && e.Capability == "read" { + t.Fatal("mutating read permission") + } + } + for _, e := range routes { + grant := clientkey.Grant{Module: e.Module} + v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{grant}, 1) + if err != nil { + t.Fatal(err) + } + path := e.Path + for _, param := range []string{":productId", ":runId", ":recordId", ":jobId", ":batchId", ":shopId", ":ruleId", ":taskId"} { + path = strings.ReplaceAll(path, param, "1") + } + req := httptest.NewRequest(e.Method, "https://example.test/api/client/v1"+path, nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + want := 200 + if e.Capability != "read" { + want = 403 + } + if rr.Code != want { + t.Fatalf("%s got %d want %d", keyFor(e), rr.Code, want) + } + if e.Capability == "write" { + grant.Write = true + } else if e.Capability != "read" { + grant.Actions = []string{e.Capability} + } + if _, err = s.Update(context.Background(), v.ID, 1, 1, []clientkey.Grant{grant}, false); err != nil { + t.Fatal(err) + } + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req.Clone(context.Background())) + if rr.Code != 200 { + t.Fatalf("authorized %s failed: %d", keyFor(e), rr.Code) + } + } +} +func keyFor(e Endpoint) string { return e.Method + " " + e.Path } +func TestRevocationTransportRedactionAndAudit(t *testing.T) { + db, s := fixture(t) + v, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products"}}, 1) + if err != nil { + t.Fatal(err) + } + e := Endpoint{Method: "GET", Path: "/pdd-products", Module: "pdd_products", Capability: "read"} + router := gin.New() + calls := 0 + router.GET("/api/client/v1/pdd-products", Gate(db, e), func(c *gin.Context) { + calls++ + id, ok := clientprincipal.Get(c) + if !ok || id.KeyID != v.ID { + t.Error("client attribution missing") + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"apiKey": "sentinel-secret", "rawJson": "sentinel-raw", "title": "product", "nested": []any{gin.H{"device_token": "sentinel-token"}}}}) + }) + send := func(tlsOn bool, credential, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest("GET", "http://example.test"+path, nil) + if tlsOn { + req.TLS = &tls.ConnectionState{} + } + req.Header.Set("Authorization", "Bearer "+credential) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr + } + path := "/api/client/v1/pdd-products" + if send(false, token, path).Code != 426 || send(true, "jwt", path).Code != 401 || send(true, token, path+"?token=invalid").Code != 400 { + t.Fatal("transport or credential fallback accepted") + } + rr := send(true, token, path) + if rr.Code != 200 || strings.Contains(rr.Body.String(), "sentinel") || !strings.Contains(rr.Body.String(), "product") { + t.Fatal("redaction failed") + } + if _, err = s.Update(context.Background(), v.ID, 1, 1, nil, true); err != nil { + t.Fatal(err) + } + if send(true, token, path).Code != 401 || calls != 1 { + t.Fatal("revocation not immediate") + } + var logs []clientkey.Audit + db.Find(&logs) + raw, _ := json.Marshal(logs) + if strings.Contains(string(raw), token) || strings.Contains(string(raw), "sentinel") { + t.Fatal("audit leaked payload") + } +} +func TestAuditUnavailableDoesNotExecute(t *testing.T) { + db, s := fixture(t) + _, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1) + if err != nil { + t.Fatal(err) + } + db.Migrator().DropTable(&clientkey.Audit{}) + router := gin.New() + called := false + e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"} + router.POST(e.Path, Gate(db, e), func(c *gin.Context) { called = true; c.JSON(200, gin.H{}) }) + req := httptest.NewRequest("POST", "https://example.test"+e.Path, nil) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != 503 || called { + t.Fatal("executed without audit") + } +} +func TestForwardedProtoOnlyTrustedLoopback(t *testing.T) { + t.Setenv("GOAUTO_TRUST_FORWARDED_PROTO", "true") + r := httptest.NewRequest(http.MethodGet, "http://example.test", nil) + r.Header.Set("X-Forwarded-Proto", "https") + r.RemoteAddr = "192.0.2.1:1234" + if secure(r) { + t.Fatal("untrusted forwarded header") + } + r.RemoteAddr = "127.0.0.1:1234" + if !secure(r) { + t.Fatal("trusted proxy rejected") + } + t.Setenv("GOAUTO_TRUST_FORWARDED_PROTO", "false") + if secure(r) { + t.Fatal("implicit proxy trust") + } +} diff --git a/server/app/goauto/clientapi/integration_test.go b/server/app/goauto/clientapi/integration_test.go new file mode 100644 index 0000000..496e95a --- /dev/null +++ b/server/app/goauto/clientapi/integration_test.go @@ -0,0 +1,79 @@ +package clientapi + +import ( + "context" + "encoding/json" + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "go-admin/app/goauto/clientkey" + "go-admin/app/goauto/models" + "go-admin/app/goauto/product" + "go-admin/common/middleware" + "net/http/httptest" + "strings" + "testing" +) + +func TestClientCanCreateProductWithoutLoginAndReplay(t *testing.T) { + db, s := fixture(t) + if err := db.AutoMigrate(&models.PDDProduct{}); err != nil { + t.Fatal(err) + } + _, token, err := s.Create(context.Background(), "test", []clientkey.Grant{{Module: "pdd_products", Write: true}}, 1) + if err != nil { + t.Fatal(err) + } + e := Endpoint{Method: "POST", Path: "/pdd-products", Module: "pdd_products", Capability: "write"} + router := gin.New() + router.Use(func(c *gin.Context) { c.Set("db", db); c.Next() }) + router.POST("/api/client/v1/pdd-products", Gate(db, e), product.Handler{}.Create) + for n := 0; n < 2; n++ { + req := httptest.NewRequest("POST", "https://example.test/api/client/v1/pdd-products", strings.NewReader(`{"requestId":"00000000-0000-4000-8000-000000000237","url":"https://mobile.yangkeduo.com/goods.html?goods_id=100000000001"}`)) + req.Header.Set("Authorization", "Bearer "+token) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("product create failed status %d", rr.Code) + } + var body struct { + Data struct { + Replayed bool `json:"replayed"` + } + } + json.Unmarshal(rr.Body.Bytes(), &body) + if (n == 1) != body.Data.Replayed { + t.Fatal("business idempotency changed") + } + } + var count int64 + db.Model(&models.PDDProduct{}).Count(&count) + if count != 1 { + t.Fatal("duplicate business write") + } +} + +func TestManagementRequiresAdminNotClientIdentity(t *testing.T) { + db, s := fixture(t) + h := clientkey.Handler{DB: db, Modules: s.Modules} + for _, role := range []string{"admin", "purchaser", "client", ""} { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)}) + c.Next() + }) + router.POST("/keys", middleware.RequireRoleKey("admin"), RequireHTTPS, h.Create) + req := httptest.NewRequest("POST", "https://example.test/keys", strings.NewReader(`{"name":"test","grants":[{"module":"pdd_products","write":false,"actions":[]}]}`)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + want := 403 + if role == "admin" { + want = 200 + } + if rr.Code != want { + t.Fatalf("role %q status %d", role, rr.Code) + } + if role == "admin" && rr.Header().Get("Cache-Control") != "no-store" { + t.Fatal("one-time secret cacheable") + } + } +} diff --git a/server/app/goauto/clientapi/matching.go b/server/app/goauto/clientapi/matching.go new file mode 100644 index 0000000..7e27516 --- /dev/null +++ b/server/app/goauto/clientapi/matching.go @@ -0,0 +1,45 @@ +package clientapi + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + "go-admin/app/goauto/aimatching" + "io" + "net/http" +) + +// resolveSpecs accepts only specification text, never a provider URL/key, +// prompt, raw order, address or account. It returns a suggestion, not an order. +func resolveSpecs(c *gin.Context) { + var req struct { + TargetColor string `json:"targetColor"` + TargetSize string `json:"targetSize"` + Colors []string `json:"colors"` + Sizes []string `json:"sizes"` + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + d := json.NewDecoder(c.Request.Body) + d.DisallowUnknownFields() + if d.Decode(&req) != nil || d.Decode(&struct{}{}) != io.EOF || len(req.Colors) > 200 || len(req.Sizes) > 200 { + c.JSON(422, gin.H{"code": 422, "message": "规格请求无效"}) + return + } + for _, s := range append(append([]string{req.TargetColor, req.TargetSize}, req.Colors...), req.Sizes...) { + if len([]rune(s)) > 255 { + c.JSON(422, gin.H{"code": 422, "message": "规格值过长"}) + return + } + } + db, err := pkg.GetOrm(c) + if err != nil { + c.JSON(503, gin.H{"code": 503}) + return + } + v, err := aimatching.NewService(db).Resolve(c.Request.Context(), aimatching.MatchRequest{TargetColor: req.TargetColor, TargetSize: req.TargetSize, Colors: req.Colors, Sizes: req.Sizes}) + if err != nil { + c.JSON(422, gin.H{"code": 422, "message": "未获得可靠匹配,请检查候选规格或联系管理员"}) + return + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"mappedColor": v.MappedColor, "mappedSize": v.MappedSize, "source": v.Source}}) +} diff --git a/server/app/goauto/clientapi/routes.go b/server/app/goauto/clientapi/routes.go new file mode 100644 index 0000000..63e02f2 --- /dev/null +++ b/server/app/goauto/clientapi/routes.go @@ -0,0 +1,170 @@ +// Package clientapi exposes only the reviewed client route inventory. It never +// forwards arbitrary URLs or synthesizes a logged-in administrator identity. +package clientapi + +import ( + "go-admin/app/goauto/access" + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/clientkey" + "go-admin/app/goauto/device" + "go-admin/app/goauto/product" + "go-admin/app/goauto/purchase" + "go-admin/app/goauto/purchaserule" + "go-admin/app/goauto/rule" + "go-admin/app/goauto/shopeeproduct" + "go-admin/app/goauto/sybimport" + "go-admin/app/goauto/sybinnercode" + "go-admin/app/goauto/sybshop" + "go-admin/app/goauto/task" + "go-admin/common/middleware" + "sort" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +type Endpoint struct { + Method, Path, Module, Capability string + Handle gin.HandlerFunc +} + +func Inventory() []Endpoint { + p := product.Handler{} + s := shopeeproduct.Handler{} + y := sybimport.Handler{} + i := sybinnercode.Handler{} + shop := sybshop.Handler{} + r := rule.Handler{} + pr := purchaserule.Handler{} + t := task.Handler{} + buy := purchase.Handler{} + return []Endpoint{ + {"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs}, + {"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List}, + {"GET", "/pdd-products", access.ModulePDDProducts, "read", p.List}, + {"GET", "/pdd-products/:productId", access.ModulePDDProducts, "read", p.Detail}, + {"POST", "/pdd-products", access.ModulePDDProducts, "write", p.Create}, + {"PATCH", "/pdd-products/:productId", access.ModulePDDProducts, "write", p.Update}, + {"GET", "/shopee-products", access.ModuleShopeeProducts, "read", s.List}, + {"GET", "/shopee-products/:productId", access.ModuleShopeeProducts, "read", s.Detail}, + {"POST", "/shopee-products", access.ModuleShopeeProducts, "write", s.Create}, + {"PATCH", "/shopee-products/:productId", access.ModuleShopeeProducts, "write", s.Update}, + {"POST", "/shopee-products/:productId/link-pdd", access.ModuleShopeeProducts, "write", s.LinkPDD}, + {"POST", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "write", s.AddSpecValue}, + {"PUT", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "write", s.SetMapping}, + {"DELETE", "/shopee-products/:productId/specs/values", access.ModuleShopeeProducts, "delete", s.RemoveSpecValue}, + {"DELETE", "/shopee-products/:productId/specs/mapping", access.ModuleShopeeProducts, "delete", s.ClearMapping}, + {"POST", "/shopee-products/batch-delete", access.ModuleShopeeProducts, "delete", s.BatchDelete}, + {"POST", "/shopee-products/:productId/specs/mapping/auto-match", access.ModuleShopeeProducts, "match", s.AutoMatchMappings}, + {"POST", "/shopee-products/:productId/specs/mapping/confirm", access.ModuleShopeeProducts, "match", s.ConfirmMapping}, + {"GET", "/syb-products", access.ModuleSYBProducts, "read", y.List}, + {"GET", "/syb-products/:productId", access.ModuleSYBProducts, "read", y.Detail}, + {"PATCH", "/syb-products/:productId/correction", access.ModuleSYBProducts, "write", y.ManualCorrect}, + {"POST", "/syb-products/:productId/reparse", access.ModuleSYBProducts, "reparse", y.Reparse}, + {"POST", "/syb-products/reparse-batch", access.ModuleSYBProducts, "reparse", y.ReparseBatch}, + {"GET", "/syb-products/sync-runs", access.ModuleSYBSyncRuns, "read", y.ListSyncRuns}, + {"GET", "/syb-products/sync-runs/:runId", access.ModuleSYBSyncRuns, "read", y.SyncRunDetail}, + {"POST", "/syb-products/import", access.ModuleSYBSyncRuns, "sync", y.Import}, + {"GET", "/syb-inner-codes", access.ModuleSYBInnerCodes, "read", i.List}, + {"GET", "/syb-inner-codes/:recordId", access.ModuleSYBInnerCodes, "read", i.Detail}, + {"GET", "/syb-inner-codes/match-jobs/:jobId", access.ModuleSYBInnerCodes, "read", i.MatchJob}, + {"GET", "/syb-inner-codes/apply-batches/:batchId", access.ModuleSYBInnerCodes, "read", i.ApplyBatch}, + {"POST", "/syb-inner-codes/rematch", access.ModuleSYBInnerCodes, "match", i.Rematch}, + {"POST", "/syb-inner-codes/import", access.ModuleSYBInnerCodes, "import", i.Import}, + {"POST", "/syb-inner-codes/batch-delete", access.ModuleSYBInnerCodes, "delete", i.Delete}, + {"POST", "/syb-inner-codes/apply-preview", access.ModuleSYBInnerCodes, "writeback", i.ApplyPreview}, + {"POST", "/syb-inner-codes/apply", access.ModuleSYBInnerCodes, "writeback", i.Apply}, + {"POST", "/syb-inner-codes/:recordId/recheck", access.ModuleSYBInnerCodes, "match", i.Recheck}, + {"GET", "/syb-shops", access.ModuleSYBShops, "read", shop.List}, + {"POST", "/syb-shops", access.ModuleSYBShops, "write", shop.Create}, + {"PATCH", "/syb-shops/:shopId/name", access.ModuleSYBShops, "write", shop.Rename}, + {"PATCH", "/syb-shops/:shopId/enabled", access.ModuleSYBShops, "write", shop.SetEnabled}, + {"DELETE", "/syb-shops/:shopId", access.ModuleSYBShops, "delete", shop.Delete}, + {"GET", "/collection-rules", access.ModuleCollectionRules, "read", r.List}, + {"POST", "/collection-rules", access.ModuleCollectionRules, "write", r.Create}, + {"PATCH", "/collection-rules/:ruleId", access.ModuleCollectionRules, "write", r.Update}, + {"DELETE", "/collection-rules/:ruleId", access.ModuleCollectionRules, "delete", r.Delete}, + {"GET", "/purchase-rules", access.ModulePurchaseRules, "read", pr.List}, + {"GET", "/purchase-rules/current", access.ModulePurchaseRules, "read", pr.Current}, + {"POST", "/purchase-rules", access.ModulePurchaseRules, "write", pr.Create}, + {"PATCH", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "write", pr.Update}, + {"DELETE", "/purchase-rules/:ruleId", access.ModulePurchaseRules, "delete", pr.Delete}, + {"PUT", "/purchase-rules/current", access.ModulePurchaseRules, "activate", pr.SetCurrent}, + {"GET", "/collection-tasks", access.ModuleCollectionTasks, "read", t.AdminList}, + {"GET", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "read", t.AdminDetail}, + {"POST", "/collection-tasks", access.ModuleCollectionTasks, "collect", t.AdminCreate}, + {"POST", "/collection-tasks/batch", access.ModuleCollectionTasks, "collect", t.AdminBatchCreate}, + {"POST", "/collection-tasks/:taskId/reset", access.ModuleCollectionTasks, "collect", t.AdminReset}, + {"DELETE", "/collection-tasks/:taskId", access.ModuleCollectionTasks, "delete", t.AdminDelete}, + {"GET", "/purchase-tasks", access.ModulePurchaseTasks, "read", buy.AdminList}, + {"GET", "/purchase-tasks/:taskId", access.ModulePurchaseTasks, "read", buy.AdminDetail}, + {"POST", "/purchase-tasks/batch-preview", access.ModulePurchaseTasks, "purchase", buy.AdminBatchPreview}, + {"POST", "/purchase-tasks", access.ModulePurchaseTasks, "purchase", buy.AdminCreate}, + {"POST", "/purchase-tasks/batch", access.ModulePurchaseTasks, "purchase", buy.AdminBatchCreate}, + {"POST", "/purchase-tasks/batch-retry", access.ModulePurchaseTasks, "purchase", buy.AdminBatchRetry}, + {"POST", "/purchase-tasks/stock", access.ModulePurchaseTasks, "purchase", buy.AdminCreateStock}, + {"GET", "/ai-matching-settings", access.ModuleAIMatching, "read", func(c *gin.Context) { + db, err := pkg.GetOrm(c) + if err != nil { + c.JSON(500, gin.H{"code": 500}) + return + } + v, err := aimatching.NewService(db).Settings(c.Request.Context()) + if err != nil { + c.JSON(500, gin.H{"code": 500}) + return + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"enabled": v.Enabled}}) + }}, + } +} + +func Catalog(routes []Endpoint) []clientkey.Module { + mods := map[string]clientkey.Module{} + for _, m := range access.GoAutoModules() { + mods[m.Key] = clientkey.Module{Key: m.Key, Title: m.Title, Actions: []string{}} + } + for _, e := range routes { + m := mods[e.Module] + if e.Capability == "write" { + m.Writable = true + } else if e.Capability != "read" { + found := false + for _, a := range m.Actions { + if a == e.Capability { + found = true + } + } + if !found { + m.Actions = append(m.Actions, e.Capability) + } + } + mods[e.Module] = m + } + out := []clientkey.Module{} + for _, g := range access.GoAutoMenuGroups() { + for _, key := range g.ModuleKeys { + m := mods[key] + m.Group = g.Title + sort.Strings(m.Actions) + out = append(out, m) + } + } + return out +} + +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + routes := Inventory() + catalog := Catalog(routes) + h := clientkey.Handler{Modules: catalog} + management := engine.Group("/api/admin/v1/client-keys", auth.MiddlewareFunc(), middleware.RequireRoleKey("admin"), RequireHTTPS) + management.GET("", h.List) + management.GET("/modules", h.Catalog) + management.POST("", h.Create) + management.PATCH("/:keyId/grants", h.Edit) + management.POST("/:keyId/disable", h.Disable) + for _, e := range routes { + engine.Handle(e.Method, "/api/client/v1"+e.Path, Gate(nil, e), e.Handle) + } +} diff --git a/server/app/goauto/clientkey/handler.go b/server/app/goauto/clientkey/handler.go new file mode 100644 index 0000000..22bc6d6 --- /dev/null +++ b/server/app/goauto/clientkey/handler.go @@ -0,0 +1,127 @@ +package clientkey + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/sdk/pkg" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" + "gorm.io/gorm" +) + +type Handler struct { + DB *gorm.DB + Modules []Module +} + +func (h Handler) service(c *gin.Context) (Service, bool) { + db := h.DB + var err error + if db == nil { + db, err = pkg.GetOrm(c) + } + if err != nil || db == nil { + failure(c, errors.New("database unavailable")) + return Service{}, false + } + c.Header("Cache-Control", "no-store") + return Service{db, h.Modules}, true +} +func failure(c *gin.Context, err error) { + status, message := 500, "服务端处理失败" + switch { + case errors.Is(err, ErrInvalid): + status, message = 422, ErrInvalid.Error() + case errors.Is(err, ErrConflict): + status, message = 409, ErrConflict.Error() + case errors.Is(err, gorm.ErrRecordNotFound): + status, message = 404, "密钥不存在" + } + c.AbortWithStatusJSON(status, gin.H{"code": status, "message": message}) +} +func (h Handler) Catalog(c *gin.Context) { c.JSON(200, gin.H{"code": 200, "data": h.Modules}) } +func (h Handler) List(c *gin.Context) { + s, ok := h.service(c) + if !ok { + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + size := 20 + var total int64 + var keys []Key + if err := s.DB.WithContext(c.Request.Context()).Model(&Key{}).Count(&total).Error; err != nil { + failure(c, err) + return + } + if err := s.DB.WithContext(c.Request.Context()).Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&keys).Error; err != nil { + failure(c, err) + return + } + items := make([]View, 0, len(keys)) + for _, k := range keys { + items = append(items, view(k)) + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"items": items, "total": total, "page": page, "pageSize": size}}) +} +func decode(c *gin.Context, v any) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + d := json.NewDecoder(c.Request.Body) + d.DisallowUnknownFields() + if d.Decode(v) != nil || d.Decode(&struct{}{}) != io.EOF { + failure(c, ErrInvalid) + return false + } + return true +} +func (h Handler) Create(c *gin.Context) { + var req struct { + Name string `json:"name"` + Grants []Grant `json:"grants"` + } + if !decode(c, &req) { + return + } + s, ok := h.service(c) + if !ok { + return + } + result, secret, err := s.Create(c.Request.Context(), req.Name, req.Grants, uint64(user.GetUserId(c))) + if err != nil { + failure(c, err) + return + } + c.JSON(200, gin.H{"code": 200, "data": gin.H{"key": result, "secret": secret}}) +} +func (h Handler) Edit(c *gin.Context) { h.update(c, false) } +func (h Handler) Disable(c *gin.Context) { h.update(c, true) } +func (h Handler) update(c *gin.Context, disable bool) { + id, err := strconv.ParseUint(c.Param("keyId"), 10, 64) + if err != nil { + failure(c, ErrInvalid) + return + } + var req struct { + Version uint64 `json:"version"` + Grants []Grant `json:"grants"` + } + if !decode(c, &req) { + return + } + s, ok := h.service(c) + if !ok { + return + } + result, err := s.Update(c.Request.Context(), id, req.Version, uint64(user.GetUserId(c)), req.Grants, disable) + if err != nil { + failure(c, err) + return + } + c.JSON(200, gin.H{"code": 200, "data": result}) +} diff --git a/server/app/goauto/clientkey/service.go b/server/app/goauto/clientkey/service.go new file mode 100644 index 0000000..09222b8 --- /dev/null +++ b/server/app/goauto/clientkey/service.go @@ -0,0 +1,222 @@ +// Package clientkey implements independent, revocable client credentials (#237). +package clientkey + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "sort" + "strings" + "time" + + "gorm.io/gorm" +) + +var ErrInvalid = errors.New("名称或授权无效") +var ErrConflict = errors.New("密钥已停用或授权已被修改,请刷新后重试") +var ErrCredential = errors.New("客户端密钥无效或已停用") + +type Grant struct { + Module string `json:"module"` + Write bool `json:"write"` + Actions []string `json:"actions"` +} +type Module struct { + Key string `json:"key"` + Title string `json:"title"` + Group string `json:"group"` + Writable bool `json:"writable"` + Actions []string `json:"actions"` +} +type Key struct { + ID uint64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:80;not null" json:"name"` + Prefix string `gorm:"size:24;not null" json:"prefix"` + Digest string `gorm:"size:64;uniqueIndex;not null" json:"-"` + GrantsJSON string `gorm:"type:text;not null" json:"-"` + Enabled bool `gorm:"not null" json:"enabled"` + Version uint64 `gorm:"not null" json:"version"` + CreatedBy uint64 `json:"createdBy"` + UpdatedBy uint64 `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastUsedAt *time.Time `json:"lastUsedAt"` +} + +func (Key) TableName() string { return "client_api_key" } + +type Audit struct { + ID uint64 `gorm:"primaryKey"` + KeyID uint64 `gorm:"index;not null"` + ActorID uint64 + Event string `gorm:"size:32;not null"` + RequestID string `gorm:"size:32;index"` + Method string `gorm:"size:10"` + Route string `gorm:"size:180"` + Status int + Changes string `gorm:"type:text"` + CreatedAt time.Time +} + +func (Audit) TableName() string { return "client_api_key_audit" } + +type View struct { + Key + Grants []Grant `json:"grants"` +} + +func view(k Key) View { + var g []Grant + _ = json.Unmarshal([]byte(k.GrantsJSON), &g) + return View{Key: k, Grants: g} +} + +type Service struct { + DB *gorm.DB + Modules []Module +} + +func (s Service) Normalize(in []Grant) ([]Grant, error) { + if len(in) == 0 || len(in) > len(s.Modules) { + return nil, ErrInvalid + } + catalog := map[string]Module{} + for _, m := range s.Modules { + catalog[m.Key] = m + } + seen := map[string]bool{} + out := make([]Grant, 0, len(in)) + for _, g := range in { + m, ok := catalog[g.Module] + if !ok || seen[g.Module] || (g.Write && !m.Writable) { + return nil, ErrInvalid + } + seen[g.Module] = true + allowed := map[string]bool{} + for _, a := range m.Actions { + allowed[a] = true + } + used := map[string]bool{} + actions := []string{} + for _, a := range g.Actions { + if !allowed[a] || used[a] { + return nil, ErrInvalid + } + used[a] = true + actions = append(actions, a) + } + sort.Strings(actions) + out = append(out, Grant{g.Module, g.Write, actions}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module }) + return out, nil +} +func (s Service) Create(ctx context.Context, name string, grants []Grant, actor uint64) (View, string, error) { + name = strings.TrimSpace(name) + if name == "" || len([]rune(name)) > 80 || actor == 0 { + return View{}, "", ErrInvalid + } + g, err := s.Normalize(grants) + if err != nil { + return View{}, "", err + } + raw, _ := json.Marshal(g) + secret := make([]byte, 32) + if _, err = rand.Read(secret); err != nil { + return View{}, "", err + } + token := "gak_" + hex.EncodeToString(secret) + digest := sha256.Sum256([]byte(token)) + k := Key{Name: name, Prefix: token[:16], Digest: hex.EncodeToString(digest[:]), GrantsJSON: string(raw), Enabled: true, Version: 1, CreatedBy: actor, UpdatedBy: actor} + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&k).Error; err != nil { + return err + } + return tx.Create(&Audit{KeyID: k.ID, ActorID: actor, Event: "create", Changes: string(raw)}).Error + }) + if err != nil { + return View{}, "", err + } + return view(k), token, nil +} +func (s Service) Update(ctx context.Context, id, version, actor uint64, grants []Grant, disable bool) (View, error) { + if id == 0 || version == 0 || actor == 0 { + return View{}, ErrInvalid + } + var raw []byte + if !disable { + g, err := s.Normalize(grants) + if err != nil { + return View{}, err + } + raw, _ = json.Marshal(g) + } + var k Key + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.First(&k, id).Error; err != nil { + return err + } + changes := map[string]any{"version": version + 1, "updated_by": actor, "updated_at": time.Now().UTC()} + event := "edit" + if disable { + changes["enabled"] = false + event = "disable" + } else { + changes["grants_json"] = string(raw) + } + result := tx.Model(&Key{}).Where("id = ? AND version = ? AND enabled = ?", id, version, true).Updates(changes) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrConflict + } + diff, _ := json.Marshal(map[string]string{"before": k.GrantsJSON, "after": string(raw)}) + if err := tx.Create(&Audit{KeyID: id, ActorID: actor, Event: event, Changes: string(diff)}).Error; err != nil { + return err + } + return tx.First(&k, id).Error + }) + return view(k), err +} +func (s Service) Authenticate(ctx context.Context, token string) (Key, error) { + if len(token) != 68 || !strings.HasPrefix(token, "gak_") { + return Key{}, ErrCredential + } + if _, err := hex.DecodeString(token[4:]); err != nil { + return Key{}, ErrCredential + } + digest := sha256.Sum256([]byte(token)) + var k Key + err := s.DB.WithContext(ctx).Where("digest = ? AND enabled = ?", hex.EncodeToString(digest[:]), true).First(&k).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return Key{}, ErrCredential + } + return k, err +} +func (k Key) Allows(module, capability string) bool { + var grants []Grant + if json.Unmarshal([]byte(k.GrantsJSON), &grants) != nil { + return false + } + for _, g := range grants { + if g.Module != module { + continue + } + if capability == "read" { + return true + } + if capability == "write" { + return g.Write + } + for _, a := range g.Actions { + if a == capability { + return true + } + } + } + return false +} diff --git a/server/app/goauto/clientkey/service_test.go b/server/app/goauto/clientkey/service_test.go new file mode 100644 index 0000000..60f5736 --- /dev/null +++ b/server/app/goauto/clientkey/service_test.go @@ -0,0 +1,115 @@ +package clientkey + +import ( + "context" + "encoding/json" + "errors" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "strings" + "testing" +) + +func testService(t *testing.T) Service { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + sql, _ := db.DB() + sql.SetMaxOpenConns(1) + t.Cleanup(func() { sql.Close() }) + if err = db.AutoMigrate(&Key{}, &Audit{}); err != nil { + t.Fatal(err) + } + return Service{db, []Module{{Key: "products", Writable: true, Actions: []string{"delete"}}, {Key: "devices", Actions: []string{}}}} +} +func TestCredentialLifecycle(t *testing.T) { + s := testService(t) + ctx := context.Background() + v, token, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 7) + if err != nil { + t.Fatal(err) + } + if len(token) != 68 || v.Digest == token { + t.Fatal("invalid credential generation") + } + wire, _ := json.Marshal(v) + if strings.Contains(string(wire), token) || strings.Contains(string(wire), v.Digest) { + t.Fatal("secret serialized") + } + k, err := s.Authenticate(ctx, token) + if err != nil || !k.Allows("products", "read") || k.Allows("products", "write") || k.Allows("devices", "read") { + t.Fatal("default grants") + } + updated, err := s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "products", Write: true, Actions: []string{"delete"}}}, false) + if err != nil { + t.Fatal(err) + } + k, err = s.Authenticate(ctx, token) + if err != nil || !k.Allows("products", "write") || !k.Allows("products", "delete") || updated.Version != 2 || k.Digest != v.Digest { + t.Fatal("edit changed credential or failed to apply grants") + } + if _, err = s.Update(ctx, k.ID, 1, 8, []Grant{{Module: "devices"}}, false); !errors.Is(err, ErrConflict) { + t.Fatal("stale edit accepted") + } + if _, err = s.Update(ctx, k.ID, 2, 8, nil, true); err != nil { + t.Fatal(err) + } + if _, err = s.Authenticate(ctx, token); !errors.Is(err, ErrCredential) { + t.Fatal("disabled credential accepted") + } + if _, err = s.Update(ctx, k.ID, 3, 8, []Grant{{Module: "products"}}, false); !errors.Is(err, ErrConflict) { + t.Fatal("disabled key edited") + } + var logs []Audit + s.DB.Find(&logs) + if len(logs) != 3 { + t.Fatalf("audit count %d", len(logs)) + } + data, _ := json.Marshal(logs) + if strings.Contains(string(data), token) { + t.Fatal("secret in audit") + } +} +func TestInvalidGrantsAndCredentials(t *testing.T) { + s := testService(t) + for _, g := range [][]Grant{nil, {{Module: "admin"}}, {{Module: "devices", Write: true}}, {{Module: "products", Actions: []string{"payment"}}}, {{Module: "products"}, {Module: "products"}}} { + if _, err := s.Normalize(g); err == nil { + t.Fatal("invalid grant accepted") + } + } + for _, token := range []string{"", "jwt", "gak_" + strings.Repeat("z", 64), strings.Repeat("a", 68)} { + if _, err := s.Authenticate(context.Background(), token); !errors.Is(err, ErrCredential) { + t.Fatal("invalid credential accepted") + } + } +} +func TestAuditFailureRollsBack(t *testing.T) { + s := testService(t) + ctx := context.Background() + v, _, err := s.Create(ctx, "Tool", []Grant{{Module: "products"}}, 1) + if err != nil { + t.Fatal(err) + } + if err = s.DB.Migrator().DropTable(&Audit{}); err != nil { + t.Fatal(err) + } + if _, err = s.Update(ctx, v.ID, 1, 1, nil, true); err == nil { + t.Fatal("audit failure ignored") + } + var k Key + s.DB.First(&k, v.ID) + if !k.Enabled || k.Version != 1 { + t.Fatal("mutation not rolled back") + } + if _, secret, err := s.Create(ctx, "Failed", []Grant{{Module: "products"}}, 1); err == nil || secret != "" { + t.Fatal("creation audit failure ignored") + } + var count int64 + s.DB.Model(&Key{}).Count(&count) + if count != 1 { + t.Fatal("key persisted without audit") + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index e04e56c..5bfb059 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "go-admin/common/clientprincipal" "io" "net/http" "strconv" @@ -470,6 +471,9 @@ func writeAdminReplay(c *gin.Context, data any, replayed bool) { } func allowedOperator(c *gin.Context) bool { + if _, ok := clientprincipal.Get(c); ok { + return true + } role, _ := jwt.ExtractClaims(c)["rolekey"].(string) if role == "admin" || role == "purchaser" { return true @@ -544,6 +548,9 @@ func writeError(c *gin.Context, err error) { c.JSON(status, gin.H{"code": code, "message": msg, "retryable": retryable}) } func operatorID(c *gin.Context) uint64 { + if id, ok := clientprincipal.Get(c); ok { + return id.AuthorizedBy + } claims := jwt.ExtractClaims(c) switch v := claims["identity"].(type) { case float64: diff --git a/server/app/goauto/sybimport/import_handler.go b/server/app/goauto/sybimport/import_handler.go index 1d271af..b955527 100644 --- a/server/app/goauto/sybimport/import_handler.go +++ b/server/app/goauto/sybimport/import_handler.go @@ -3,6 +3,8 @@ package sybimport import ( "context" "errors" + "fmt" + "go-admin/common/clientprincipal" "net/http" "strconv" "strings" @@ -114,7 +116,8 @@ func isImportAlreadyRunning(err error) bool { // scheduler delegate to StartImport, and every downstream SYB call is read-only. func (handler Handler) Import(c *gin.Context) { role := claimString(jwt.ExtractClaims(c)["rolekey"]) - if role != "admin" && role != "purchaser" { + client, clientOK := clientprincipal.Get(c) + if role != "admin" && role != "purchaser" && !clientOK { c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员或采购员可以开始同步"}) return } @@ -128,9 +131,13 @@ func (handler Handler) Import(c *gin.Context) { return } claims := jwt.ExtractClaims(c) - result, err := StartImport(c.Request.Context(), service.DB, request, ImportActor{ + actor := ImportActor{ ID: claimUint64(claims["identity"]), Name: claimString(claims["nice"]), - }, false) + } + if clientOK { + actor = ImportActor{ID: client.AuthorizedBy, Name: fmt.Sprintf("client-key:%d", client.KeyID)} + } + result, err := StartImport(c.Request.Context(), service.DB, request, actor, false) if err != nil { var serviceErr *ServiceError if errors.As(err, &serviceErr) { diff --git a/server/app/goauto/sybinnercode/handler.go b/server/app/goauto/sybinnercode/handler.go index 6383c4d..936d28c 100644 --- a/server/app/goauto/sybinnercode/handler.go +++ b/server/app/goauto/sybinnercode/handler.go @@ -3,6 +3,7 @@ package sybinnercode import ( "encoding/json" "errors" + "go-admin/common/clientprincipal" "io" "net/http" "strconv" @@ -81,13 +82,19 @@ func (h Handler) Import(c *gin.Context) { if !ok { return } - result, err := service.Import(c.Request.Context(), src, file.Filename, uint64(user.GetUserId(c)), c.PostForm("requestId")) + result, err := service.Import(c.Request.Context(), src, file.Filename, clientOperator(c), c.PostForm("requestId")) if err != nil { writeError(c, err) return } c.JSON(http.StatusOK, gin.H{"code": 200, "data": result}) } +func clientOperator(c *gin.Context) uint64 { + if p, ok := clientprincipal.Get(c); ok { + return p.AuthorizedBy + } + return uint64(user.GetUserId(c)) +} func (h Handler) Delete(c *gin.Context) { var request DeleteRequest if err := decode(c, &request); err != nil { @@ -98,7 +105,7 @@ func (h Handler) Delete(c *gin.Context) { if !ok { return } - result, err := service.Delete(c.Request.Context(), uint64(user.GetUserId(c)), request) + result, err := service.Delete(c.Request.Context(), clientOperator(c), request) if err != nil { writeError(c, err) return @@ -150,7 +157,7 @@ func (h Handler) Apply(c *gin.Context) { if !ok { return } - result, err := service.QueueApply(c.Request.Context(), uint64(user.GetUserId(c)), request) + result, err := service.QueueApply(c.Request.Context(), clientOperator(c), request) if err != nil { writeError(c, err) return diff --git a/server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go b/server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go new file mode 100644 index 0000000..c985532 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go @@ -0,0 +1,42 @@ +package version_local + +import ( + "fmt" + "go-admin/app/goauto/clientkey" + "go-admin/cmd/migrate/migration" + migrationmodels "go-admin/cmd/migrate/migration/models" + common "go-admin/common/models" + "gorm.io/gorm" + "runtime" +) + +func init() { + _, file, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(file), migrateClientAPIKey) +} +func migrateClientAPIKey(db *gorm.DB, version string) error { + if err := db.AutoMigrate(&clientkey.Key{}, &clientkey.Audit{}); err != nil { + return err + } + return db.Transaction(func(tx *gorm.DB) error { + var parent migrationmodels.SysMenu + if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil { + return err + } + child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{MenuName: "GoAutoClientKeys", Title: "客户端密钥", Icon: "lock", Path: "/client-keys/index", MenuType: "C", Action: "无", ParentId: parent.MenuId, Component: "/goauto/client-keys/index", Sort: 6, Visible: "0", IsFrame: "1"}) + if err != nil { + return err + } + if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil { + return err + } + var admin migrationmodels.SysRole + if err = tx.Where("role_key = ?", "admin").First(&admin).Error; err != nil { + return err + } + if err = tx.Model(&admin).Association("SysMenu").Append(&child); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1788798000000_client_api_key_test.go b/server/cmd/migrate/migration/version-local/1788798000000_client_api_key_test.go new file mode 100644 index 0000000..cff84e5 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1788798000000_client_api_key_test.go @@ -0,0 +1,45 @@ +package version_local + +import ( + "go-admin/app/goauto/clientkey" + migrationmodels "go-admin/cmd/migrate/migration/models" + common "go-admin/common/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "testing" +) + +func TestClientKeyMigrationOnlyAdminMenu(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sql, _ := db.DB() + defer sql.Close() + if err = db.AutoMigrate(&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{}, &common.Migration{}); err != nil { + t.Fatal(err) + } + admin := migrationmodels.SysRole{RoleKey: "admin"} + purchaser := migrationmodels.SysRole{RoleKey: "purchaser"} + parent := migrationmodels.SysMenu{MenuName: "GoAutoCollectionManagement"} + for _, v := range []any{&admin, &purchaser, &parent} { + if err = db.Create(v).Error; err != nil { + t.Fatal(err) + } + } + if err = migrateClientAPIKey(db, "client-key-test"); err != nil { + t.Fatal(err) + } + if !db.Migrator().HasTable(&clientkey.Key{}) || !db.Migrator().HasTable(&clientkey.Audit{}) { + t.Fatal("missing tables") + } + var child migrationmodels.SysMenu + if err = db.Where("menu_name = ?", "GoAutoClientKeys").First(&child).Error; err != nil { + t.Fatal(err) + } + if child.ParentId != parent.MenuId { + t.Fatal("wrong menu group") + } + assertRoleHasMenu(t, db, admin.RoleId, child.MenuId, true) + assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, false) +} diff --git a/server/common/clientprincipal/principal.go b/server/common/clientprincipal/principal.go new file mode 100644 index 0000000..58ac7c7 --- /dev/null +++ b/server/common/clientprincipal/principal.go @@ -0,0 +1,20 @@ +// Package clientprincipal carries an authenticated client identity, never an +// administrator or purchaser JWT. Only the client gateway sets it. +package clientprincipal + +import "github.com/gin-gonic/gin" + +const contextKey = "goauto.authenticatedClient" + +type Identity struct { + KeyID uint64 + RequestID string + AuthorizedBy uint64 +} + +func Set(c *gin.Context, id Identity) { c.Set(contextKey, id) } +func Get(c *gin.Context) (Identity, bool) { + v, ok := c.Get(contextKey) + id, valid := v.(Identity) + return id, ok && valid && id.KeyID > 0 +} diff --git a/server/common/middleware/logger.go b/server/common/middleware/logger.go index f475988..afb071e 100644 --- a/server/common/middleware/logger.go +++ b/server/common/middleware/logger.go @@ -24,6 +24,12 @@ import ( // LoggerToFile 日志记录到文件 func LoggerToFile() gin.HandlerFunc { return func(c *gin.Context) { + // #237: client request/response bodies and one-time credentials must never + // enter the legacy operation logger. The client gateway keeps metadata-only audit. + if strings.HasPrefix(c.Request.URL.Path, "/api/client/") || strings.HasPrefix(c.Request.URL.Path, "/api/admin/v1/client-keys") { + c.Next() + return + } log := api.GetRequestLogger(c) // 开始时间 startTime := time.Now() diff --git a/web/src/api/goauto/client-keys.js b/web/src/api/goauto/client-keys.js new file mode 100644 index 0000000..de7e1b6 --- /dev/null +++ b/web/src/api/goauto/client-keys.js @@ -0,0 +1,8 @@ +import request from '@/utils/request' + +const base = '/api/admin/v1/client-keys' +export const listClientKeys = page => request({ url: base, method: 'get', params: { page }, suppressErrorMessage: true }) +export const clientKeyModules = () => request({ url: `${base}/modules`, method: 'get', suppressErrorMessage: true }) +export const createClientKey = data => request({ url: base, method: 'post', data, suppressErrorMessage: true }) +export const editClientKey = (id, data) => request({ url: `${base}/${id}/grants`, method: 'patch', data, suppressErrorMessage: true }) +export const disableClientKey = (id, version) => request({ url: `${base}/${id}/disable`, method: 'post', data: { version }, suppressErrorMessage: true }) diff --git a/web/src/views/goauto/client-keys/index.vue b/web/src/views/goauto/client-keys/index.vue new file mode 100644 index 0000000..7e77bfa --- /dev/null +++ b/web/src/views/goauto/client-keys/index.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/web/tests/fixtures/client-keys.html b/web/tests/fixtures/client-keys.html new file mode 100644 index 0000000..cc5ae55 --- /dev/null +++ b/web/tests/fixtures/client-keys.html @@ -0,0 +1,2 @@ + +#237 客户端密钥隔离测试

仅本地组件测试:全部请求由内存适配器处理,不连接业务服务,不创建真实密钥。

diff --git a/web/tests/fixtures/client-keys.js b/web/tests/fixtures/client-keys.js new file mode 100644 index 0000000..fbefa5a --- /dev/null +++ b/web/tests/fixtures/client-keys.js @@ -0,0 +1,31 @@ +// Manual browser fixture for the production component, not an offline prototype. +import { createApp, h } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import Page from '../../src/views/goauto/client-keys/index.vue' +import request from '../../src/utils/request' + +const definitions = [ + ['syb_products', 'SYB 商品', true, ['reparse']], ['syb_sync_runs', 'SYB 同步记录', false, ['sync']], + ['syb_inner_codes', '档口入库码', false, ['import', 'match', 'writeback', 'delete']], ['shopee_products', '虾皮商品', true, ['match', 'delete']], + ['pdd_products', 'PDD 商品', true, []], ['collection_tasks', '采集任务', false, ['collect', 'delete']], ['purchase_tasks', '采购管理', false, ['purchase']], + ['syb_shops', 'SYB 店铺', true, ['delete']], ['collection_rules', '采集规则', true, ['delete']], ['purchase_rules', '采购规则', true, ['delete', 'activate']], ['devices', '设备列表', false, []], ['ai_matching', 'AI 规格匹配', false, ['match']] +] +const modules = definitions.map(([key, title, writable, actions], index) => ({ key, title, writable, actions, group: index < 7 ? '采集采购' : '采采管理' })) +let items = [{ id: 1, name: '商品同步工具(测试)', prefix: 'gak_DEMO', version: 1, enabled: true, updatedBy: 1, updatedAt: '2026-09-07T00:00:00Z', grants: [{ module: 'pdd_products', write: true, actions: [] }] }] +request.defaults.adapter = async config => { + let data + const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data + if (config.url.endsWith('/modules')) data = modules + else if (config.method === 'get') data = { items: structuredClone(items), total: items.length } + else if (config.url.endsWith('/grants')) { items[0].grants = body.grants; items[0].version++; data = items[0] } else if (config.url.endsWith('/disable')) { items[0].enabled = false; items[0].version++; data = items[0] } else if (config.method === 'post' && config.url === '/api/admin/v1/client-keys') { + const key = { id: 2, name: body.name, prefix: 'gak_DEMO_2', enabled: true, version: 1, grants: body.grants } + items = [key, ...items]; data = { key, secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' } + } else throw new Error('Unexpected fixture request') + return { status: 200, statusText: 'OK', headers: {}, config, data: { code: 200, data }} +} +const app = createApp(Page) +app.use(ElementPlus) +app.config.globalProperties.$store = { getters: { roles: ['admin'] }} +app.component('BasicLayout', { setup(_, { slots }) { return () => h('main', { style: 'padding:24px;background:#f3f6fa;min-height:90vh;font-family:Arial,sans-serif' }, slots.wrapper?.()) } }) +app.mount('#app') diff --git a/web/tests/unit/client-keys.spec.js b/web/tests/unit/client-keys.spec.js new file mode 100644 index 0000000..a49dfd0 --- /dev/null +++ b/web/tests/unit/client-keys.spec.js @@ -0,0 +1,61 @@ +import Page from '@/views/goauto/client-keys/index.vue' +import { createClientKey, editClientKey, listClientKeys, clientKeyModules } from '@/api/goauto/client-keys' + +jest.mock('@/api/goauto/client-keys', () => ({ createClientKey: jest.fn(), editClientKey: jest.fn(), disableClientKey: jest.fn(), listClientKeys: jest.fn(), clientKeyModules: jest.fn() })) +jest.mock('element-plus', () => ({ ElMessage: { success: jest.fn(), error: jest.fn(), warning: jest.fn() }, ElMessageBox: { confirm: jest.fn() }})) + +function vm() { + const value = { ...Page.data(), $store: { getters: { roles: ['admin'] }}} + for (const [name, fn] of Object.entries(Page.methods)) value[name] = fn.bind(value) + for (const [name, fn] of Object.entries(Page.computed)) Object.defineProperty(value, name, { get: fn.bind(value) }) + return value +} +const row = () => ({ id: 12, version: 2, name: 'Tool', prefix: 'masked', enabled: true, grants: [{ module: 'pdd_products', write: false, actions: [] }] }) +beforeEach(() => { jest.clearAllMocks(); listClientKeys.mockResolvedValue({ data: { items: [], total: 0 }}); clientKeyModules.mockResolvedValue({ data: [] }) }) + +test('edit prefill and cancel never change the existing row or create a key', () => { + const p = vm(); const original = row(); p.openEditor(original) + expect(p.changed).toBe(false) + p.grants.pdd_products.write = true; expect(p.changed).toBe(true) + p.closeEditor() + expect(original.grants[0].write).toBe(false) + expect(editClientKey).not.toHaveBeenCalled(); expect(createClientKey).not.toHaveBeenCalled() +}) +test('module group selection defaults read-only and removing a module removes actions', () => { + const p = vm(); p.selectGroup({ modules: [{ key: 'one' }, { key: 'two' }] }, true) + expect(p.grants.one).toEqual({ module: 'one', write: false, actions: [] }) + p.grants.one.actions.push('delete'); p.selectModule('one', false); p.selectModule('one', true) + expect(p.grants.one.actions).toEqual([]) +}) +test('edit saves same ID with version, does not issue a new key', async() => { + const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true + editClientKey.mockResolvedValue({ data: {}}); await p.save() + expect(editClientKey).toHaveBeenCalledWith(12, { version: 2, grants: [{ module: 'pdd_products', write: true, actions: [] }] }) + expect(createClientKey).not.toHaveBeenCalled(); expect(p.secret).toBe(''); expect(p.editorOpen).toBe(false) +}) +test('failure preserves changes and conflict prevents blind retry', async() => { + const p = vm(); p.openEditor(row()); p.grants.pdd_products.write = true + editClientKey.mockRejectedValue({ response: { status: 409, data: { message: 'conflict' }}}) + await p.save(); expect(p.editorOpen).toBe(true); expect(p.grants.pdd_products.write).toBe(true); expect(p.conflicted).toBe(true) + await p.save(); expect(editClientKey).toHaveBeenCalledTimes(1) +}) +test('disabled keys are read-only and no modules cannot be saved', async() => { + const p = vm(); p.openEditor({ ...row(), enabled: false }); expect(p.editorOpen).toBe(false) + p.openEditor({ ...row(), enabled: false }, true); expect(p.readonly).toBe(true) + p.openEditor(row()); p.selectModule('pdd_products', false); await p.save(); expect(editClientKey).not.toHaveBeenCalled() +}) +test('secret is cleared when dialog or cached view closes', () => { + const p = vm(); p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.clearSecret(); expect(p.secret).toBe('') + p.secret = 'DEMO_NOT_A_VALID_SECRET'; p.secretOpen = true; Page.deactivated.call(p); expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false) +}) +test('ordinary users do not load or open management', async() => { + const p = vm(); p.$store.getters.roles = ['purchaser']; await p.load(); p.openEditor(); expect(listClientKeys).not.toHaveBeenCalled(); expect(p.editorOpen).toBe(false) +}) +test('late creation response after navigation does not retain a secret', async() => { + const p = vm(); p.name = 'test'; p.selectModule('pdd_products', true) + let resolve + createClientKey.mockImplementation(() => new Promise(done => { resolve = done })) + const pending = p.save(); Page.deactivated.call(p) + resolve({ data: { secret: 'DEMO_ONLY_NOT_A_VALID_SECRET' }}); await pending + expect(p.secret).toBe(''); expect(p.secretOpen).toBe(false) +})