feat(goauto): manage authenticated agent releases (#143)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ LIMIT 1`
|
||||
func MigratedModels() []any {
|
||||
return []any{
|
||||
&models.AgentDevice{},
|
||||
&models.AgentAppRelease{},
|
||||
&models.AgentAppReleaseSetting{},
|
||||
&models.PDDProduct{},
|
||||
&models.AIMatchingSetting{},
|
||||
&models.ShopeeProduct{},
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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()}` }}) }
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user