feat(goauto): manage authenticated agent releases (#143)

This commit is contained in:
QiuSW
2026-08-29 21:34:32 +08:00
parent 56443c995b
commit f0f5f5bd14
14 changed files with 816 additions and 2 deletions
+2
View File
@@ -7,6 +7,7 @@ import (
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
goautoaimatching "go-admin/app/goauto/aimatching"
goautoapprelease "go-admin/app/goauto/apprelease"
goautodevice "go-admin/app/goauto/device"
goautoproduct "go-admin/app/goauto/product"
goautopurchase "go-admin/app/goauto/purchase"
@@ -52,6 +53,7 @@ func InitRouter() {
// 注册 GoAuto Agent 与设备管理路由。
goautodevice.InitRouter(r, authMiddleware)
goautoapprelease.InitRouter(r, authMiddleware)
goautoaimatching.InitRouter(r, authMiddleware)
goautotask.InitRouter(r, authMiddleware)
goautopurchase.InitRouter(r, authMiddleware)
+2
View File
@@ -130,6 +130,8 @@ func moduleKeyForAPI(path string) string {
switch {
case strings.HasPrefix(path, "/api/admin/v1/devices"):
return ModuleDevices
case strings.HasPrefix(path, "/api/admin/v1/agent-app-releases"):
return ModuleDevices
case strings.HasPrefix(path, "/api/admin/v1/pdd-product"):
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
+4
View File
@@ -18,6 +18,10 @@ var AdminAPIs = []APIPermission{
{"查看设备", "/api/admin/v1/devices", "GET", true},
{"停用设备", "/api/admin/v1/devices/:deviceId/disable", "POST", false},
{"吊销设备令牌", "/api/admin/v1/devices/:deviceId/token/revoke", "POST", false},
{"查看 Agent 版本", "/api/admin/v1/agent-app-releases", "GET", false},
{"上传 Agent APK", "/api/admin/v1/agent-app-releases", "POST", false},
{"设置当前 Agent 版本", "/api/admin/v1/agent-app-releases/current", "PUT", false},
{"下载 Agent APK", "/api/admin/v1/agent-app-releases/:releaseId/download", "GET", false},
{"查看 PDD 商品", "/api/admin/v1/pdd-products", "GET", true},
{"新增 PDD 商品", "/api/admin/v1/pdd-products", "POST", true},
+188
View File
@@ -0,0 +1,188 @@
package apprelease
import (
"archive/zip"
"encoding/binary"
"errors"
"fmt"
"io"
"unicode/utf16"
)
type apkMetadata struct {
VersionCode int64
VersionName string
}
func parseAPK(path string) (apkMetadata, error) {
r, err := zip.OpenReader(path)
if err != nil {
return apkMetadata{}, errors.New("文件不是有效 APK")
}
defer r.Close()
for _, file := range r.File {
if file.Name != "AndroidManifest.xml" {
continue
}
stream, err := file.Open()
if err != nil {
return apkMetadata{}, err
}
raw, err := io.ReadAll(io.LimitReader(stream, 4<<20))
stream.Close()
if err != nil {
return apkMetadata{}, err
}
return parseBinaryManifest(raw)
}
return apkMetadata{}, errors.New("APK 缺少 AndroidManifest.xml")
}
func parseBinaryManifest(raw []byte) (apkMetadata, error) {
if len(raw) < 8 || binary.LittleEndian.Uint16(raw) != 0x0003 {
return apkMetadata{}, errors.New("AndroidManifest.xml 格式无效")
}
var stringsPool []string
for offset := 8; offset+8 <= len(raw); {
typ := binary.LittleEndian.Uint16(raw[offset:])
header := int(binary.LittleEndian.Uint16(raw[offset+2:]))
size := int(binary.LittleEndian.Uint32(raw[offset+4:]))
if header < 8 || size < header || offset+size > len(raw) {
return apkMetadata{}, errors.New("AndroidManifest.xml 数据损坏")
}
if typ == 0x0001 {
pool, err := decodeStringPool(raw[offset : offset+size])
if err != nil {
return apkMetadata{}, err
}
stringsPool = pool
}
if typ == 0x0102 && len(stringsPool) > 0 && header >= 16 && offset+36 <= len(raw) {
nameIndex := binary.LittleEndian.Uint32(raw[offset+20:])
if poolString(stringsPool, nameIndex) == "manifest" {
count := int(binary.LittleEndian.Uint16(raw[offset+28:]))
attrSize := int(binary.LittleEndian.Uint16(raw[offset+26:]))
start := offset + 16 + int(binary.LittleEndian.Uint16(raw[offset+24:]))
if attrSize < 20 {
return apkMetadata{}, errors.New("APK manifest 属性格式无效")
}
result := apkMetadata{}
for i := 0; i < count; i++ {
pos := start + i*attrSize
if pos+20 > offset+size {
return apkMetadata{}, errors.New("APK manifest 属性越界")
}
name := poolString(stringsPool, binary.LittleEndian.Uint32(raw[pos+4:]))
rawIndex := binary.LittleEndian.Uint32(raw[pos+8:])
dataType := raw[pos+15]
data := binary.LittleEndian.Uint32(raw[pos+16:])
switch name {
case "versionCode":
result.VersionCode = int64(data)
case "versionName":
if rawIndex != 0xffffffff {
result.VersionName = poolString(stringsPool, rawIndex)
} else if dataType == 0x03 {
result.VersionName = poolString(stringsPool, data)
}
}
}
if result.VersionCode <= 0 || result.VersionName == "" {
return apkMetadata{}, errors.New("APK 缺少有效 versionCode/versionName")
}
return result, nil
}
}
offset += size
}
return apkMetadata{}, errors.New("APK manifest 节点不存在")
}
func decodeStringPool(raw []byte) ([]string, error) {
if len(raw) < 28 {
return nil, errors.New("APK 字符串池无效")
}
header := int(binary.LittleEndian.Uint16(raw[2:]))
count := int(binary.LittleEndian.Uint32(raw[8:]))
flags := binary.LittleEndian.Uint32(raw[16:])
start := int(binary.LittleEndian.Uint32(raw[20:]))
if header+count*4 > len(raw) || start > len(raw) {
return nil, errors.New("APK 字符串池越界")
}
result := make([]string, count)
for i := 0; i < count; i++ {
pos := start + int(binary.LittleEndian.Uint32(raw[header+i*4:]))
if pos >= len(raw) {
return nil, errors.New("APK 字符串偏移无效")
}
var value string
var err error
if flags&0x100 != 0 {
value, err = decodeUTF8String(raw, pos)
} else {
value, err = decodeUTF16String(raw, pos)
}
if err != nil {
return nil, err
}
result[i] = value
}
return result, nil
}
func decodeLength8(raw []byte, pos int) (int, int, error) {
if pos >= len(raw) {
return 0, pos, io.ErrUnexpectedEOF
}
v := int(raw[pos])
pos++
if v&0x80 != 0 {
if pos >= len(raw) {
return 0, pos, io.ErrUnexpectedEOF
}
v = (v&0x7f)<<8 | int(raw[pos])
pos++
}
return v, pos, nil
}
func decodeUTF8String(raw []byte, pos int) (string, error) {
_, pos, err := decodeLength8(raw, pos)
if err != nil {
return "", err
}
length, pos, err := decodeLength8(raw, pos)
if err != nil || pos+length > len(raw) {
return "", io.ErrUnexpectedEOF
}
return string(raw[pos : pos+length]), nil
}
func decodeUTF16String(raw []byte, pos int) (string, error) {
if pos+2 > len(raw) {
return "", io.ErrUnexpectedEOF
}
length := int(binary.LittleEndian.Uint16(raw[pos:]))
pos += 2
if length&0x8000 != 0 {
if pos+2 > len(raw) {
return "", io.ErrUnexpectedEOF
}
length = (length&0x7fff)<<16 | int(binary.LittleEndian.Uint16(raw[pos:]))
pos += 2
}
if pos+length*2 > len(raw) {
return "", io.ErrUnexpectedEOF
}
units := make([]uint16, length)
for i := range units {
units[i] = binary.LittleEndian.Uint16(raw[pos+i*2:])
}
return string(utf16.Decode(units)), nil
}
func poolString(pool []string, index uint32) string {
if index == 0xffffffff || int(index) >= len(pool) {
return ""
}
return pool[index]
}
func apkFileName(version int64, sha, unique string) string {
return fmt.Sprintf("agent-%d-%s-%s.apk", version, sha[:12], unique)
}
+24
View File
@@ -0,0 +1,24 @@
package apprelease
import (
"os"
"path/filepath"
"testing"
)
// The Android build is a sibling delivery unit and is not always present in
// isolated server CI jobs. When it is available, this verifies the parser
// against the exact APK format produced by this repository.
func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
path := filepath.FromSlash("../../../../android/app/build/outputs/apk/debug/app-debug.apk")
if _, err := os.Stat(path); err != nil {
t.Skip("Android debug APK is not built")
}
metadata, err := parseAPK(path)
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 41 || metadata.VersionName != "0.9.28" {
t.Fatalf("metadata=%+v", metadata)
}
}
+201
View File
@@ -0,0 +1,201 @@
package apprelease
import (
"encoding/json"
"errors"
"io"
"net/http"
"os"
"strconv"
"strings"
"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"
"go-admin/app/goauto/device"
"gorm.io/gorm"
)
type Handler struct {
DB *gorm.DB
StorageDir string
}
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 {
writeError(c, internal(err))
return nil, false
}
s := NewService(db)
if h.StorageDir != "" {
s.StorageDir = h.StorageDir
}
return s, true
}
func (h Handler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
s, ok := h.service(c)
if !ok {
return
}
result, err := s.List(c.Request.Context(), page, size)
if err != nil {
writeError(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": result})
}
func (h Handler) Upload(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxAPKBytes+(1<<20))
file, err := c.FormFile("file")
if err != nil {
writeError(c, invalid("file 必填"))
return
}
if !strings.EqualFold(filepathExt(file.Filename), ".apk") {
writeError(c, invalid("只允许上传 .apk 文件"))
return
}
stream, err := file.Open()
if err != nil {
writeError(c, internal(err))
return
}
defer stream.Close()
s, ok := h.service(c)
if !ok {
return
}
result, err := s.Upload(c.Request.Context(), c.PostForm("releaseNotes"), uint64(user.GetUserId(c)), stream)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"code": http.StatusOK, "data": result})
}
func (h Handler) SetCurrent(c *gin.Context) {
var req SetCurrentRequest
if err := decode(c, &req); err != nil {
writeError(c, invalid("请求 JSON 无效"))
return
}
s, ok := h.service(c)
if !ok {
return
}
item, replayed, err := s.SetCurrent(c.Request.Context(), req)
if err != nil {
writeError(c, err)
return
}
c.JSON(200, gin.H{"code": 200, "data": gin.H{"release": item, "replayed": replayed}})
}
func (h Handler) Latest(c *gin.Context) {
s, ok := h.service(c)
if !ok {
return
}
if _, err := device.NewService(s.DB).Authenticate(c.Request.Context(), bearer(c.GetHeader("Authorization"))); err != nil {
writeDeviceError(c, err)
return
}
item, err := s.Latest(c.Request.Context())
if err != nil {
writeError(c, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(200, gin.H{"data": item})
}
func (h Handler) AdminDownload(c *gin.Context) { h.download(c, false) }
func (h Handler) AgentDownload(c *gin.Context) { h.download(c, true) }
func (h Handler) download(c *gin.Context, agent bool) {
s, ok := h.service(c)
if !ok {
return
}
if agent {
if _, err := device.NewService(s.DB).Authenticate(c.Request.Context(), bearer(c.GetHeader("Authorization"))); err != nil {
writeDeviceError(c, err)
return
}
}
id, err := strconv.ParseUint(c.Param("releaseId"), 10, 64)
if err != nil || id == 0 {
writeError(c, invalid("releaseId 无效"))
return
}
row, err := s.Find(c.Request.Context(), id)
if err != nil {
writeError(c, err)
return
}
if _, err := os.Stat(row.FilePath); err != nil {
writeError(c, internal(err))
return
}
c.Header("Cache-Control", "private, no-store")
c.Header("Content-Disposition", `attachment; filename="agent-`+strconv.FormatInt(row.VersionCode, 10)+`.apk"`)
c.Header("X-Content-Type-Options", "nosniff")
c.File(row.FilePath)
}
func decode(c *gin.Context, v any) error {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
return err
}
if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("one object required")
}
return nil
}
func bearer(header string) string {
parts := strings.Fields(header)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
return parts[1]
}
return ""
}
func filepathExt(name string) string {
index := strings.LastIndex(name, ".")
if index < 0 {
return ""
}
return name[index:]
}
func writeError(c *gin.Context, err error) {
var target *ServiceError
if !errors.As(err, &target) {
target = internal(err).(*ServiceError)
}
status := 500
switch target.Code {
case "INVALID_REQUEST":
status = 422
case "AGENT_RELEASE_NOT_FOUND":
status = 404
case "AGENT_RELEASE_CONFLICT":
status = 409
}
c.JSON(status, gin.H{"code": target.Code, "message": target.Message, "retryable": false})
}
func writeDeviceError(c *gin.Context, err error) {
var target *device.ServiceError
if !errors.As(err, &target) {
c.JSON(500, gin.H{"code": "INTERNAL_ERROR", "message": "服务端处理失败"})
return
}
status := 401
if target.Code == device.CodeDeviceDisabled {
status = 403
}
c.JSON(status, gin.H{"code": target.Code, "message": target.Message, "retryable": target.Retryable})
}
+24
View File
@@ -0,0 +1,24 @@
package apprelease
import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/goauto/device"
"go-admin/common/middleware"
"os"
"strconv"
)
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
h := Handler{}
admin := engine.Group("/api/admin/v1/agent-app-releases").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
admin.GET("", h.List)
admin.POST("", h.Upload)
admin.PUT("/current", h.SetCurrent)
admin.GET("/:releaseId/download", h.AdminDownload)
trust, _ := strconv.ParseBool(os.Getenv("GOAUTO_TRUST_FORWARDED_PROTO"))
agent := engine.Group("/api/agent/v1/app").Use(device.RequireHTTPS(config.ApplicationConfig.Mode == "prod", trust))
agent.GET("/latest", h.Latest)
agent.GET("/releases/:releaseId/download", h.AgentDownload)
}
+228
View File
@@ -0,0 +1,228 @@
package apprelease
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
"go-admin/app/goauto/models"
"gorm.io/gorm"
)
const MaxAPKBytes int64 = 200 << 20
type ServiceError struct {
Code, Message string
Cause error
}
func (e *ServiceError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}
type ReleaseItem struct {
ID uint64 `json:"id"`
VersionCode int64 `json:"versionCode"`
VersionName string `json:"versionName"`
SHA256 string `json:"sha256"`
ByteSize int64 `json:"byteSize"`
ReleaseNotes string `json:"releaseNotes"`
CreatedBy uint64 `json:"createdBy"`
CreatedAt string `json:"createdAt"`
IsCurrent bool `json:"isCurrent"`
DownloadURL string `json:"downloadUrl"`
}
type ListResponse struct {
Items []ReleaseItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type SetCurrentRequest struct {
RequestID string `json:"requestId"`
ReleaseID uint64 `json:"releaseId"`
}
type Service struct {
DB *gorm.DB
StorageDir string
}
func NewService(db *gorm.DB) *Service {
dir := strings.TrimSpace(os.Getenv("GOAUTO_AGENT_RELEASE_DIR"))
if dir == "" {
dir = filepath.FromSlash("var/goauto-agent-releases")
}
return &Service{DB: db, StorageDir: dir}
}
func (s *Service) List(ctx context.Context, page, size int) (ListResponse, error) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
var total int64
if err := s.DB.WithContext(ctx).Model(&models.AgentAppRelease{}).Count(&total).Error; err != nil {
return ListResponse{}, internal(err)
}
var rows []models.AgentAppRelease
if err := s.DB.WithContext(ctx).Order("version_code DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return ListResponse{}, internal(err)
}
current := uint64(0)
var setting models.AgentAppReleaseSetting
if err := s.DB.WithContext(ctx).First(&setting, 1).Error; err == nil {
current = setting.ReleaseID
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return ListResponse{}, internal(err)
}
items := make([]ReleaseItem, 0, len(rows))
for _, row := range rows {
items = append(items, toItem(row, row.ID == current, true))
}
return ListResponse{Items: items, Total: total, Page: page, PageSize: size}, nil
}
func (s *Service) Upload(ctx context.Context, notes string, createdBy uint64, src io.Reader) (ReleaseItem, error) {
if createdBy == 0 {
return ReleaseItem{}, invalid("创建人无效")
}
if len(notes) > 4000 {
return ReleaseItem{}, invalid("更新说明不能超过 4000 字符")
}
if err := os.MkdirAll(s.StorageDir, 0750); err != nil {
return ReleaseItem{}, internal(err)
}
temp, err := os.CreateTemp(s.StorageDir, "upload-*.apk")
if err != nil {
return ReleaseItem{}, internal(err)
}
tempPath := temp.Name()
keep := false
defer func() {
temp.Close()
if !keep {
_ = os.Remove(tempPath)
}
}()
hash := sha256.New()
written, err := io.Copy(io.MultiWriter(temp, hash), io.LimitReader(src, MaxAPKBytes+1))
if err != nil {
return ReleaseItem{}, internal(err)
}
if written > MaxAPKBytes {
return ReleaseItem{}, invalid("APK 不能超过 200 MB")
}
if written == 0 {
return ReleaseItem{}, invalid("APK 文件为空")
}
if err := temp.Sync(); err != nil {
return ReleaseItem{}, internal(err)
}
if err := temp.Close(); err != nil {
return ReleaseItem{}, internal(err)
}
meta, err := parseAPK(tempPath)
if err != nil {
return ReleaseItem{}, invalid(err.Error())
}
sha := hex.EncodeToString(hash.Sum(nil))
finalPath := filepath.Join(s.StorageDir, apkFileName(meta.VersionCode, sha, uuid.NewString()))
if err := os.Rename(tempPath, finalPath); err != nil {
return ReleaseItem{}, internal(err)
}
keep = true
record := models.AgentAppRelease{VersionCode: meta.VersionCode, VersionName: meta.VersionName, FilePath: finalPath, SHA256: sha, ByteSize: written, ReleaseNotes: strings.TrimSpace(notes), CreatedBy: createdBy}
if err := s.DB.WithContext(ctx).Create(&record).Error; err != nil {
_ = os.Remove(finalPath)
return ReleaseItem{}, conflict("versionCode 已存在,不能重复上传", err)
}
return toItem(record, false, true), nil
}
func (s *Service) SetCurrent(ctx context.Context, req SetCurrentRequest) (ReleaseItem, bool, error) {
if _, err := uuid.Parse(strings.TrimSpace(req.RequestID)); err != nil {
return ReleaseItem{}, false, invalid("requestId 无效")
}
if req.ReleaseID == 0 {
return ReleaseItem{}, false, invalid("releaseId 无效")
}
replayed := false
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var setting models.AgentAppReleaseSetting
if err := tx.Where("last_update_request_id = ?", req.RequestID).First(&setting).Error; err == nil {
if setting.ReleaseID != req.ReleaseID {
return conflict("requestId 已用于其他版本", nil)
}
replayed = true
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return internal(err)
}
var release models.AgentAppRelease
if err := tx.First(&release, req.ReleaseID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return notFound()
}
return internal(err)
}
return tx.Save(&models.AgentAppReleaseSetting{ID: 1, ReleaseID: req.ReleaseID, LastUpdateRequestID: &req.RequestID}).Error
})
if err != nil {
return ReleaseItem{}, false, err
}
var row models.AgentAppRelease
if err := s.DB.WithContext(ctx).First(&row, req.ReleaseID).Error; err != nil {
return ReleaseItem{}, false, internal(err)
}
return toItem(row, true, true), replayed, nil
}
func (s *Service) Latest(ctx context.Context) (*ReleaseItem, error) {
var setting models.AgentAppReleaseSetting
if err := s.DB.WithContext(ctx).Joins("Release").First(&setting, 1).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, internal(err)
}
item := toItem(setting.Release, true, false)
return &item, nil
}
func (s *Service) Find(ctx context.Context, id uint64) (models.AgentAppRelease, error) {
var row models.AgentAppRelease
if err := s.DB.WithContext(ctx).First(&row, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return row, notFound()
}
return row, internal(err)
}
return row, nil
}
func toItem(row models.AgentAppRelease, current, admin bool) ReleaseItem {
prefix := "/api/agent/v1/app/releases"
if admin {
prefix = "/api/admin/v1/agent-app-releases"
}
return ReleaseItem{ID: row.ID, VersionCode: row.VersionCode, VersionName: row.VersionName, SHA256: row.SHA256, ByteSize: row.ByteSize, ReleaseNotes: row.ReleaseNotes, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt.UTC().Format("2006-01-02T15:04:05.999999999Z07:00"), IsCurrent: current, DownloadURL: fmt.Sprintf("%s/%d/download", prefix, row.ID)}
}
func invalid(m string) error { return &ServiceError{Code: "INVALID_REQUEST", Message: m} }
func notFound() error {
return &ServiceError{Code: "AGENT_RELEASE_NOT_FOUND", Message: "Agent 版本不存在"}
}
func conflict(m string, e error) error {
return &ServiceError{Code: "AGENT_RELEASE_CONFLICT", Message: m, Cause: e}
}
func internal(e error) error {
return &ServiceError{Code: "INTERNAL_ERROR", Message: "服务端处理失败", Cause: e}
}
@@ -0,0 +1,59 @@
package apprelease
import (
"context"
"testing"
"github.com/google/uuid"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func releaseTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = migrations.Migrate(db); err != nil {
t.Fatal(err)
}
return db
}
func TestLatestIsEmptyUntilAdminSelectsCurrent(t *testing.T) {
db := releaseTestDB(t)
service := NewService(db)
latest, err := service.Latest(context.Background())
if err != nil || latest != nil {
t.Fatalf("latest=%+v err=%v", latest, err)
}
release := models.AgentAppRelease{VersionCode: 41, VersionName: "0.9.28", FilePath: "private.apk", SHA256: "abc", ByteSize: 123, ReleaseNotes: "test", CreatedBy: 1}
if err = db.Create(&release).Error; err != nil {
t.Fatal(err)
}
item, replayed, err := service.SetCurrent(context.Background(), SetCurrentRequest{RequestID: uuid.NewString(), ReleaseID: release.ID})
if err != nil || replayed || !item.IsCurrent {
t.Fatalf("current=%+v replayed=%v err=%v", item, replayed, err)
}
latest, err = service.Latest(context.Background())
if err != nil || latest == nil || latest.VersionCode != 41 || latest.DownloadURL != "/api/agent/v1/app/releases/1/download" {
t.Fatalf("latest=%+v err=%v", latest, err)
}
}
func TestVersionCodeIsUnique(t *testing.T) {
db := releaseTestDB(t)
first := models.AgentAppRelease{VersionCode: 41, VersionName: "a", FilePath: "a", SHA256: "a", ByteSize: 1, ReleaseNotes: "", CreatedBy: 1}
second := first
second.VersionName = "b"
second.FilePath = "b"
if err := db.Create(&first).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&second).Error; err == nil {
t.Fatal("duplicate versionCode accepted")
}
}
+2
View File
@@ -29,6 +29,8 @@ LIMIT 1`
func MigratedModels() []any {
return []any{
&models.AgentDevice{},
&models.AgentAppRelease{},
&models.AgentAppReleaseSetting{},
&models.PDDProduct{},
&models.AIMatchingSetting{},
&models.ShopeeProduct{},
+25
View File
@@ -48,6 +48,31 @@ type AgentDevice struct {
func (AgentDevice) TableName() string { return "agent_device" }
type AgentAppRelease struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
VersionCode int64 `json:"versionCode" gorm:"not null;uniqueIndex:ux_agent_app_release_version_code"`
VersionName string `json:"versionName" gorm:"size:64;not null"`
FilePath string `json:"-" gorm:"type:text;not null"`
SHA256 string `json:"sha256" gorm:"size:64;not null"`
ByteSize int64 `json:"byteSize" gorm:"not null"`
ReleaseNotes string `json:"releaseNotes" gorm:"type:text;not null"`
CreatedBy uint64 `json:"createdBy" gorm:"not null"`
CreatedAt time.Time `json:"createdAt"`
}
func (AgentAppRelease) TableName() string { return "agent_app_release" }
type AgentAppReleaseSetting struct {
ID uint8 `json:"id" gorm:"primaryKey;autoIncrement:false"`
ReleaseID uint64 `json:"releaseId" gorm:"not null;index"`
Release AgentAppRelease `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"`
LastUpdateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_agent_app_release_setting_request_id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (AgentAppReleaseSetting) TableName() string { return "agent_app_release_setting" }
type PDDProduct struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
GoodsID string `json:"goodsId" gorm:"size:32;not null;uniqueIndex:ux_pdd_product_goods_id"`
@@ -0,0 +1,22 @@
package version_local
import (
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"runtime"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAgentAppRelease)
}
func migrateAgentAppRelease(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := goautomigrations.Migrate(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
+8
View File
@@ -0,0 +1,8 @@
import request from '@/utils/request'
import axios from 'axios'
import { getToken } from '@/utils/auth'
export function listAgentAppReleases(params) { return request({ url: '/api/admin/v1/agent-app-releases', method: 'get', params }) }
export function uploadAgentAppRelease(data, onUploadProgress) { return request({ url: '/api/admin/v1/agent-app-releases', method: 'post', data, onUploadProgress }) }
export function setCurrentAgentAppRelease(data) { return request({ url: '/api/admin/v1/agent-app-releases/current', method: 'put', data }) }
export function downloadAgentAppRelease(releaseId) { return axios({ baseURL: process.env.VUE_APP_BASE_API, url: `/api/admin/v1/agent-app-releases/${releaseId}/download`, method: 'get', responseType: 'blob', headers: { Authorization: `Bearer ${getToken()}` }}) }
+27 -2
View File
@@ -7,7 +7,7 @@
<h1>设备列表</h1>
<p>Android 客户端注册后会自动出现在这里,无需审核。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="getList">刷新</el-button>
<div class="heading-actions"><el-button v-if="isAdmin" type="primary" plain @click="openReleases">Agent 版本</el-button><el-button :icon="Refresh" :loading="loading" @click="getList">刷新</el-button></div>
</div>
<el-form ref="queryForm" :model="query" :inline="true" class="search-form" @submit.prevent="handleQuery">
@@ -94,6 +94,21 @@
@pagination="getList"
/>
</el-card>
<el-drawer v-model="releaseDrawer.open" title="Agent 版本管理" size="720px" destroy-on-close>
<el-alert type="info" :closable="false" show-icon title="上传后需明确设为当前版本;APK 下载需要 Admin 或设备 Token,不提供公开静态地址。" />
<el-form label-position="top" class="release-upload-form">
<el-form-item label="APK 文件"><el-upload :auto-upload="false" :limit="1" accept=".apk,application/vnd.android.package-archive" :on-change="onAPKChange" :on-remove="onAPKRemove"><el-button>选择 APK</el-button><template #tip><div class="el-upload__tip">最大 200 MB,服务端读取 APK 内 versionCode 和 versionName。</div></template></el-upload></el-form-item>
<el-form-item label="更新说明"><el-input v-model="releaseDrawer.notes" type="textarea" :rows="3" maxlength="4000" show-word-limit /></el-form-item>
<el-button type="primary" :loading="releaseDrawer.uploading" :disabled="!releaseDrawer.file" @click="uploadRelease">上传版本</el-button><span v-if="releaseDrawer.uploading" class="upload-progress">{{ releaseDrawer.progress }}%</span>
</el-form>
<el-table v-loading="releaseDrawer.loading" :data="releaseDrawer.items" border stripe empty-text="暂无 Agent 版本">
<el-table-column label="版本" min-width="150"><template #default="{ row }"><strong>{{ row.versionName }}</strong><div class="version-text">versionCode {{ row.versionCode }}</div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else>历史</span></template></el-table-column>
<el-table-column label="大小" width="110"><template #default="{ row }">{{ formatBytes(row.byteSize) }}</template></el-table-column>
<el-table-column label="更新说明" prop="releaseNotes" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" width="170" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="downloadRelease(row)">下载</el-button><el-button link type="success" :disabled="row.isCurrent" @click="activateRelease(row)">设为当前</el-button></template></el-table-column>
</el-table>
</el-drawer>
</template>
</BasicLayout>
</template>
@@ -102,6 +117,7 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import { disableDevice, listDevices, revokeDeviceToken } from '@/api/goauto/devices'
import { downloadAgentAppRelease, listAgentAppReleases, setCurrentAgentAppRelease, uploadAgentAppRelease } from '@/api/goauto/agent-app-releases'
export default {
name: 'GoAutoDeviceList',
@@ -113,7 +129,8 @@ export default {
loading: false,
devices: [],
total: 0,
query: { page: 1, pageSize: 20, name: '', status: '' }
query: { page: 1, pageSize: 20, name: '', status: '' },
releaseDrawer: { open: false, loading: false, uploading: false, progress: 0, file: null, notes: '', items: [] }
}
},
computed: {
@@ -147,6 +164,13 @@ export default {
statusType(status) {
return { online: 'success', offline: 'info', disabled: 'danger' }[status] || 'info'
},
async openReleases() { this.releaseDrawer.open = true; await this.loadReleases() },
async loadReleases() { this.releaseDrawer.loading = true; try { const response = await listAgentAppReleases({ page: 1, pageSize: 100 }); this.releaseDrawer.items = response.data.items } finally { this.releaseDrawer.loading = false } },
onAPKChange(file) { this.releaseDrawer.file = file.raw }, onAPKRemove() { this.releaseDrawer.file = null },
async uploadRelease() { if (!this.releaseDrawer.file) return; const form = new FormData(); form.append('file', this.releaseDrawer.file); form.append('releaseNotes', this.releaseDrawer.notes.trim()); this.releaseDrawer.uploading = true; this.releaseDrawer.progress = 0; try { await uploadAgentAppRelease(form, event => { if (event.total) this.releaseDrawer.progress = Math.round(event.loaded * 100 / event.total) }); ElMessage.success('Agent APK 已上传,请核对后设为当前版本'); this.releaseDrawer.file = null; this.releaseDrawer.notes = ''; await this.loadReleases() } finally { this.releaseDrawer.uploading = false } },
async activateRelease(row) { await ElMessageBox.confirm(`设为当前版本 ${row.versionName}(${row.versionCode})?Agent 检查更新后将看到此版本。`, '发布 Agent 版本', { type: 'warning', confirmButtonText: '设为当前', cancelButtonText: '取消' }); await setCurrentAgentAppRelease({ requestId: crypto.randomUUID(), releaseId: row.id }); ElMessage.success('当前 Agent 版本已更新'); await this.loadReleases() },
async downloadRelease(row) { const response = await downloadAgentAppRelease(row.id); const url = URL.createObjectURL(response.data); const link = document.createElement('a'); link.href = url; link.download = `agent-${row.versionCode}.apk`; link.click(); URL.revokeObjectURL(url) },
formatBytes(bytes) { return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : `${Math.ceil(bytes / 1024)} KB` },
async confirmDisable(row) {
await ElMessageBox.confirm(
`停用“${row.name}”后,该设备不能继续心跳、领取任务或更新注册信息。`,
@@ -204,6 +228,7 @@ export default {
border-radius: 8px;
background: #f8fafc;
}
.heading-actions{display:flex;gap:10px}.release-upload-form{margin:18px 0;padding:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.upload-progress{margin-left:12px;color:#606266}
.device-name {
color: #1f2937;