From 19484f0a6828a9c312e099a95c2c4c6ab0b2e4f9 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 12 Oct 2026 18:06:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(clientapi):=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E5=8F=AF=E4=B8=8A=E4=BC=A0=20Agent=20APK=20?= =?UTF-8?q?=E5=B9=B6=E5=8D=95=E7=8B=AC=E6=8E=88=E6=9D=83=E8=AE=BE=E4=B8=BA?= =?UTF-8?q?=E5=BD=93=E5=89=8D=20(#384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devices 模块新增 release_upload(列表/上传)与 release_activate(设为当前)两个显式动作, 不挂在 read/write 上;不开放下载。Upload 创建人统一取操作人(客户端取 AuthorizedBy, Admin 仍取 JWT 用户)。网关请求体上限仅对 APK 上传端点放宽到 MaxAPKBytes+1MB,其余仍为 16MB。 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/apprelease/handler.go | 11 +- server/app/goauto/apprelease/handler_test.go | 152 +++++++++++ server/app/goauto/clientapi/gateway.go | 12 +- server/app/goauto/clientapi/release_test.go | 250 +++++++++++++++++++ server/app/goauto/clientapi/routes.go | 5 + 5 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 server/app/goauto/apprelease/handler_test.go create mode 100644 server/app/goauto/clientapi/release_test.go diff --git a/server/app/goauto/apprelease/handler.go b/server/app/goauto/apprelease/handler.go index 8cd92d4..cd2aa8c 100644 --- a/server/app/goauto/apprelease/handler.go +++ b/server/app/goauto/apprelease/handler.go @@ -13,6 +13,7 @@ import ( "github.com/go-admin-team/go-admin-core/sdk/pkg" "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" "go-admin/app/goauto/device" + "go-admin/common/clientprincipal" "gorm.io/gorm" ) @@ -72,13 +73,21 @@ func (h Handler) Upload(c *gin.Context) { if !ok { return } - result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), uint64(user.GetUserId(c)), stream) + result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), operatorID(c), stream) if err != nil { writeError(c, err) return } c.JSON(http.StatusCreated, gin.H{"code": http.StatusOK, "data": result}) } + +// operatorID returns the authorizing user for client API keys and the JWT user otherwise. +func operatorID(c *gin.Context) uint64 { + if id, ok := clientprincipal.Get(c); ok { + return id.AuthorizedBy + } + return uint64(user.GetUserId(c)) +} func (h Handler) SetCurrent(c *gin.Context) { var req SetCurrentRequest if err := decode(c, &req); err != nil { diff --git a/server/app/goauto/apprelease/handler_test.go b/server/app/goauto/apprelease/handler_test.go new file mode 100644 index 0000000..8981de4 --- /dev/null +++ b/server/app/goauto/apprelease/handler_test.go @@ -0,0 +1,152 @@ +package apprelease + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "go-admin/app/goauto/models" + "go-admin/common/clientprincipal" +) + +// buildTestAPK returns a minimal zip whose binary AndroidManifest.xml carries +// the manifest versionCode/versionName attributes that parseAPK reads. +func buildTestAPK(t *testing.T, versionCode uint32, versionName string) []byte { + t.Helper() + pool := []string{"manifest", "versionCode", "versionName", versionName} + var strs bytes.Buffer + offsets := make([]uint32, len(pool)) + for i, s := range pool { + offsets[i] = uint32(strs.Len()) + strs.WriteByte(byte(len(s))) + strs.WriteByte(byte(len(s))) + strs.WriteString(s) + strs.WriteByte(0) + } + for strs.Len()%4 != 0 { + strs.WriteByte(0) + } + headerSize := 28 + poolSize := headerSize + len(pool)*4 + strs.Len() + var m bytes.Buffer + le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) } + le(uint16(0x0003)) + le(uint16(8)) + le(uint32(0)) // total size, patched below + le(uint16(0x0001)) + le(uint16(headerSize)) + le(uint32(poolSize)) + le(uint32(len(pool))) + le(uint32(0)) + le(uint32(0x100)) + le(uint32(headerSize + len(pool)*4)) + le(uint32(0)) + for _, o := range offsets { + le(o) + } + m.Write(strs.Bytes()) + attrs := 2 + le(uint16(0x0102)) + le(uint16(16)) + le(uint32(36 + attrs*20)) + le(uint32(1)) // line + le(uint32(0xffffffff)) // comment + le(uint32(0xffffffff)) // ns + le(uint32(0)) // name -> manifest + le(uint16(20)) // attributeStart + le(uint16(20)) // attributeSize + le(uint16(attrs)) + le(uint16(0)) + le(uint16(0)) + le(uint16(0)) + attr := func(name uint32, raw uint32, typ byte, data uint32) { + le(uint32(0xffffffff)) + le(name) + le(raw) + le(uint16(8)) + le(byte(0)) + le(typ) + le(data) + } + attr(1, 0xffffffff, 0x10, versionCode) + attr(2, 3, 0x03, 3) + raw := m.Bytes() + binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw))) + var out bytes.Buffer + zw := zip.NewWriter(&out) + w, err := zw.Create("AndroidManifest.xml") + if err != nil { + t.Fatal(err) + } + _, _ = w.Write(raw) + if err = zw.Close(); err != nil { + t.Fatal(err) + } + return out.Bytes() +} + +func uploadRequest(t *testing.T, filename string, content []byte) *http.Request { + t.Helper() + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, _ := mw.CreateFormFile("file", filename) + _, _ = part.Write(content) + _ = mw.WriteField("releaseNotes", "test") + _ = mw.Close() + req := httptest.NewRequest("POST", "/upload", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + return req +} + +func TestUploadCreatorUsesClientAuthorizerOrJWTUser(t *testing.T) { + gin.SetMode(gin.TestMode) + db := releaseTestDB(t) + h := Handler{DB: db, StorageDir: t.TempDir()} + router := gin.New() + router.POST("/upload", func(c *gin.Context) { + if c.GetHeader("X-Test-Client") != "" { + clientprincipal.Set(c, clientprincipal.Identity{KeyID: 5, RequestID: "r", AuthorizedBy: 42}) + } else { + c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"identity": float64(7)}) + } + h.Upload(c) + }) + send := func(client bool, filename string, content []byte) *httptest.ResponseRecorder { + req := uploadRequest(t, filename, content) + if client { + req.Header.Set("X-Test-Client", "1") + } + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr + } + if rr := send(true, "agent.apk", buildTestAPK(t, 101, "1.0.1")); rr.Code != 201 { + t.Fatalf("client upload %d %s", rr.Code, rr.Body.String()) + } + if rr := send(false, "agent.apk", buildTestAPK(t, 102, "1.0.2")); rr.Code != 201 { + t.Fatalf("admin upload %d %s", rr.Code, rr.Body.String()) + } + var rows []models.AgentAppRelease + if err := db.Order("version_code").Find(&rows).Error; err != nil || len(rows) != 2 { + t.Fatalf("rows=%d err=%v", len(rows), err) + } + if rows[0].CreatedBy != 42 || rows[1].CreatedBy != 7 { + t.Fatalf("createdBy client=%d admin=%d", rows[0].CreatedBy, rows[1].CreatedBy) + } + if rr := send(true, "agent.zip", buildTestAPK(t, 103, "1.0.3")); rr.Code != 422 { + t.Fatalf("non-apk accepted %d", rr.Code) + } + rr := send(true, "again.apk", buildTestAPK(t, 101, "1.0.1")) + var res struct{ Code string } + _ = json.Unmarshal(rr.Body.Bytes(), &res) + if rr.Code != 409 || res.Code != "AGENT_RELEASE_CONFLICT" { + t.Fatalf("duplicate versionCode %d %s", rr.Code, rr.Body.String()) + } +} diff --git a/server/app/goauto/clientapi/gateway.go b/server/app/goauto/clientapi/gateway.go index 967604d..5ba8542 100644 --- a/server/app/goauto/clientapi/gateway.go +++ b/server/app/goauto/clientapi/gateway.go @@ -13,6 +13,7 @@ import ( "github.com/gin-gonic/gin" "github.com/go-admin-team/go-admin-core/sdk/pkg" + "go-admin/app/goauto/apprelease" "go-admin/app/goauto/clientkey" "go-admin/common/clientprincipal" "gorm.io/gorm" @@ -80,7 +81,7 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc { 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) + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, bodyLimit(e)) original := c.Writer buffer := &responseBuffer{ResponseWriter: original, status: 200} c.Writer = buffer @@ -104,6 +105,15 @@ func Gate(db *gorm.DB, e Endpoint) gin.HandlerFunc { } } +// bodyLimit keeps 16MB for every endpoint except the Agent APK upload, which +// needs room for the APK itself plus multipart overhead (same bound as Admin). +func bodyLimit(e Endpoint) int64 { + if e.Method == "POST" && e.Path == "/agent-app-releases" { + return apprelease.MaxAPKBytes + (1 << 20) + } + return 16 << 20 +} + type responseBuffer struct { gin.ResponseWriter body bytes.Buffer diff --git a/server/app/goauto/clientapi/release_test.go b/server/app/goauto/clientapi/release_test.go new file mode 100644 index 0000000..0763314 --- /dev/null +++ b/server/app/goauto/clientapi/release_test.go @@ -0,0 +1,250 @@ +package clientapi + +import ( + "archive/zip" + "bytes" + "context" + "encoding/binary" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go-admin/app/goauto/apprelease" + "go-admin/app/goauto/clientkey" + "go-admin/app/goauto/models" +) + +// releaseAPK builds a minimal APK whose binary manifest carries the version fields. +func releaseAPK(t *testing.T, code uint32, name string) []byte { + t.Helper() + pool := []string{"manifest", "versionCode", "versionName", name} + var strs bytes.Buffer + offsets := make([]uint32, len(pool)) + for i, s := range pool { + offsets[i] = uint32(strs.Len()) + strs.WriteByte(byte(len(s))) + strs.WriteByte(byte(len(s))) + strs.WriteString(s) + strs.WriteByte(0) + } + for strs.Len()%4 != 0 { + strs.WriteByte(0) + } + var m bytes.Buffer + le := func(v any) { _ = binary.Write(&m, binary.LittleEndian, v) } + le(uint16(3)) + le(uint16(8)) + le(uint32(0)) + le(uint16(1)) + le(uint16(28)) + le(uint32(28 + len(pool)*4 + strs.Len())) + le(uint32(len(pool))) + le(uint32(0)) + le(uint32(0x100)) + le(uint32(28 + len(pool)*4)) + le(uint32(0)) + for _, o := range offsets { + le(o) + } + m.Write(strs.Bytes()) + le(uint16(0x0102)) + le(uint16(16)) + le(uint32(36 + 40)) + le(uint32(1)) + le(uint32(0xffffffff)) + le(uint32(0xffffffff)) + le(uint32(0)) + le(uint16(20)) + le(uint16(20)) + le(uint16(2)) + le(uint16(0)) + le(uint16(0)) + le(uint16(0)) + attr := func(n, raw uint32, typ byte, data uint32) { + le(uint32(0xffffffff)) + le(n) + le(raw) + le(uint16(8)) + le(byte(0)) + le(typ) + le(data) + } + attr(1, 0xffffffff, 0x10, code) + attr(2, 3, 0x03, 3) + raw := m.Bytes() + binary.LittleEndian.PutUint32(raw[4:], uint32(len(raw))) + var out bytes.Buffer + zw := zip.NewWriter(&out) + w, _ := zw.Create("AndroidManifest.xml") + _, _ = w.Write(raw) + _ = zw.Close() + return out.Bytes() +} + +func multipartUpload(content []byte, filename string) (*bytes.Buffer, string) { + var body bytes.Buffer + mw := multipart.NewWriter(&body) + part, _ := mw.CreateFormFile("file", filename) + _, _ = part.Write(content) + _ = mw.Close() + return &body, mw.FormDataContentType() +} + +func TestAgentReleaseActionsAreSeparateAndExplicit(t *testing.T) { + var devices clientkey.Module + for _, m := range Catalog(Inventory()) { + if m.Key == "devices" { + devices = m + } + } + if strings.Join(devices.Actions, ",") != "release_activate,release_upload" || devices.Writable { + t.Fatalf("devices catalog=%+v", devices) + } + db, s := fixture(t) + if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil { + t.Fatal(err) + } + h := apprelease.Handler{DB: db, StorageDir: t.TempDir()} + router := gin.New() + found := 0 + for _, e := range Inventory() { + if !strings.HasPrefix(e.Path, "/agent-app-releases") { + continue + } + found++ + switch e.Method + e.Path { + case "GET/agent-app-releases": + e.Handle = h.List + case "POST/agent-app-releases": + e.Handle = h.Upload + case "PUT/agent-app-releases/current": + e.Handle = h.SetCurrent + default: + t.Fatalf("unexpected release route %s", keyFor(e)) + } + router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle) + } + if found != 3 { + t.Fatalf("release routes=%d", found) + } + newKey := func(grant clientkey.Grant) string { + _, token, err := s.Create(context.Background(), "k", []clientkey.Grant{grant}, 42) + if err != nil { + t.Fatal(err) + } + return token + } + do := func(token, method, path, contentType string, body *bytes.Buffer) *httptest.ResponseRecorder { + if body == nil { + body = &bytes.Buffer{} + } + req := httptest.NewRequest(method, "https://example.test/api/client/v1"+path, body) + req.Header.Set("Authorization", "Bearer "+token) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + return rr + } + upload := func(token string, code uint32) *httptest.ResponseRecorder { + body, ct := multipartUpload(releaseAPK(t, code, "9.9.9"), "agent.apk") + return do(token, "POST", "/agent-app-releases", ct, body) + } + setCurrent := func(token string, id uint64) *httptest.ResponseRecorder { + raw, _ := json.Marshal(map[string]any{"requestId": uuid.NewString(), "releaseId": id}) + return do(token, "PUT", "/agent-app-releases/current", "application/json", bytes.NewBuffer(raw)) + } + + // A key with only devices read must be rejected everywhere (devices has no + // write capability, so a write grant cannot even be created). + if _, _, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Write: true}}, 42); err == nil { + t.Fatal("devices write grant must be invalid") + } + for name, token := range map[string]string{ + "devices read": newKey(clientkey.Grant{Module: "devices"}), + } { + if rr := do(token, "GET", "/agent-app-releases", "", nil); rr.Code != 403 { + t.Fatalf("%s list %d", name, rr.Code) + } + if rr := upload(token, 1); rr.Code != 403 { + t.Fatalf("%s upload %d", name, rr.Code) + } + if rr := setCurrent(token, 1); rr.Code != 403 { + t.Fatalf("%s activate %d", name, rr.Code) + } + } + uploader := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_upload"}}) + if rr := do(uploader, "GET", "/agent-app-releases", "", nil); rr.Code != 200 { + t.Fatalf("uploader list %d %s", rr.Code, rr.Body.String()) + } + if rr := upload(uploader, 201); rr.Code != 201 { + t.Fatalf("uploader upload %d %s", rr.Code, rr.Body.String()) + } + var row models.AgentAppRelease + if err := db.First(&row).Error; err != nil || row.CreatedBy != 42 { + t.Fatalf("createdBy=%d err=%v", row.CreatedBy, err) + } + if rr := setCurrent(uploader, row.ID); rr.Code != 403 { + t.Fatalf("uploader activated %d", rr.Code) + } + activator := newKey(clientkey.Grant{Module: "devices", Actions: []string{"release_activate"}}) + if rr := upload(activator, 202); rr.Code != 403 { + t.Fatalf("activator uploaded %d", rr.Code) + } + if rr := setCurrent(activator, row.ID); rr.Code != 200 { + t.Fatalf("activator set current %d %s", rr.Code, rr.Body.String()) + } + for _, e := range Inventory() { + if strings.Contains(e.Path, "download") { + t.Fatal("download must not be a client route") + } + } +} + +func TestAgentReleaseUploadBodyLimitIsScopedToUploadEndpoint(t *testing.T) { + if got := bodyLimit(Endpoint{Method: "POST", Path: "/agent-app-releases"}); got != apprelease.MaxAPKBytes+(1<<20) { + t.Fatalf("upload limit %d", got) + } + for _, e := range Inventory() { + if e.Method == "POST" && e.Path == "/agent-app-releases" { + continue + } + if got := bodyLimit(e); got != 16<<20 { + t.Fatalf("%s limit %d", keyFor(e), got) + } + } +} + +func TestAgentReleaseUploadAcceptsBodyAbove16MB(t *testing.T) { + db, s := fixture(t) + if err := db.AutoMigrate(&models.AgentAppRelease{}, &models.AgentAppReleaseSetting{}); err != nil { + t.Fatal(err) + } + e := Endpoint{Method: "POST", Path: "/agent-app-releases", Module: "devices", Capability: "release_upload", Handle: apprelease.Handler{DB: db, StorageDir: t.TempDir()}.Upload} + router := gin.New() + router.Handle(e.Method, "/api/client/v1"+e.Path, Gate(db, e), e.Handle) + _, token, err := s.Create(context.Background(), "k", []clientkey.Grant{{Module: "devices", Actions: []string{"release_upload"}}}, 42) + if err != nil { + t.Fatal(err) + } + // 17MB of non-APK data passes the transport limit and fails only at APK parsing. + body, ct := multipartUpload(bytes.Repeat([]byte{'x'}, 17<<20), "big.apk") + req := httptest.NewRequest(http.MethodPost, "https://example.test/api/client/v1"+e.Path, body) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", ct) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != 422 || !strings.Contains(rr.Body.String(), "文件不是有效 APK") { + t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String()) + } + var audits []clientkey.Audit + if err = db.Where("event = ?", "request").Find(&audits).Error; err != nil || len(audits) != 1 || audits[0].Status != 422 { + t.Fatalf("audit=%+v err=%v", audits, err) + } +} diff --git a/server/app/goauto/clientapi/routes.go b/server/app/goauto/clientapi/routes.go index 8e36881..9c7746f 100644 --- a/server/app/goauto/clientapi/routes.go +++ b/server/app/goauto/clientapi/routes.go @@ -5,6 +5,7 @@ package clientapi import ( "go-admin/app/goauto/access" "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/apprelease" "go-admin/app/goauto/clientkey" "go-admin/app/goauto/device" "go-admin/app/goauto/product" @@ -39,9 +40,13 @@ func Inventory() []Endpoint { pr := purchaserule.Handler{} t := task.Handler{} buy := purchase.Handler{} + release := apprelease.Handler{} return []Endpoint{ {"POST", "/ai-matching-settings/resolve", access.ModuleAIMatching, "match", resolveSpecs}, {"GET", "/devices", access.ModuleDevices, "read", device.Handler{}.List}, + {"GET", "/agent-app-releases", access.ModuleDevices, "release_upload", release.List}, + {"POST", "/agent-app-releases", access.ModuleDevices, "release_upload", release.Upload}, + {"PUT", "/agent-app-releases/current", access.ModuleDevices, "release_activate", release.SetCurrent}, {"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},