feat(clientapi): 客户端密钥可上传 Agent APK 并单独授权设为当前 (#384)

devices 模块新增 release_upload(列表/上传)与 release_activate(设为当前)两个显式动作,
不挂在 read/write 上;不开放下载。Upload 创建人统一取操作人(客户端取 AuthorizedBy,
Admin 仍取 JWT 用户)。网关请求体上限仅对 APK 上传端点放宽到 MaxAPKBytes+1MB,其余仍为 16MB。

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-10-12 18:06:37 +08:00
co-authored by Claude Sonnet 5.5
parent 3495de96a2
commit 19484f0a68
5 changed files with 428 additions and 2 deletions
+10 -1
View File
@@ -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 {
@@ -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())
}
}
+11 -1
View File
@@ -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
+250
View File
@@ -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)
}
}
+5
View File
@@ -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},