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 @@
+
+ 按菜单模块开放客户端访问。完整密钥仅在创建成功后显示一次。 {{ row.prefix }}•••• 客户端地址:/api/client/v1。仅允许 HTTPS;不开放支付、账号权限管理及敏感密钥读取。 标识:{{ selected.prefix }}•••• · 保存不会更换 API Key,完整密钥不可再次查看。 {{ selected.enabled ? '保存成功后,后续请求按新授权校验;已执行操作不回滚。' : '密钥已停用,只允许查看授权。' }} 最近修改:管理员 #{{ selected.updatedBy }} · {{ formatTime(selected.updatedAt) }} 至少选择一个模块;要禁止全部访问,请使用停用。 请求头:Authorization: Bearer <客户端密钥>客户端密钥
{{ secret }}
仅本地组件测试:全部请求由内存适配器处理,不连接业务服务,不创建真实密钥。
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) +})