diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 236878f..95a2003 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -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) diff --git a/server/app/goauto/access/modules.go b/server/app/goauto/access/modules.go index 040281f..1adf63a 100644 --- a/server/app/goauto/access/modules.go +++ b/server/app/goauto/access/modules.go @@ -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"): diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go index c37c62b..a70b5f3 100644 --- a/server/app/goauto/access/purchaser.go +++ b/server/app/goauto/access/purchaser.go @@ -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}, diff --git a/server/app/goauto/apprelease/apk.go b/server/app/goauto/apprelease/apk.go new file mode 100644 index 0000000..ad7e7cd --- /dev/null +++ b/server/app/goauto/apprelease/apk.go @@ -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) +} diff --git a/server/app/goauto/apprelease/apk_test.go b/server/app/goauto/apprelease/apk_test.go new file mode 100644 index 0000000..46002e5 --- /dev/null +++ b/server/app/goauto/apprelease/apk_test.go @@ -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) + } +} diff --git a/server/app/goauto/apprelease/handler.go b/server/app/goauto/apprelease/handler.go new file mode 100644 index 0000000..8cd92d4 --- /dev/null +++ b/server/app/goauto/apprelease/handler.go @@ -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}) +} diff --git a/server/app/goauto/apprelease/router.go b/server/app/goauto/apprelease/router.go new file mode 100644 index 0000000..8ca95f0 --- /dev/null +++ b/server/app/goauto/apprelease/router.go @@ -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) +} diff --git a/server/app/goauto/apprelease/service.go b/server/app/goauto/apprelease/service.go new file mode 100644 index 0000000..d26b97e --- /dev/null +++ b/server/app/goauto/apprelease/service.go @@ -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} +} diff --git a/server/app/goauto/apprelease/service_test.go b/server/app/goauto/apprelease/service_test.go new file mode 100644 index 0000000..9fd2f9a --- /dev/null +++ b/server/app/goauto/apprelease/service_test.go @@ -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") + } +} diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index 2276cf5..c0c0d67 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -29,6 +29,8 @@ LIMIT 1` func MigratedModels() []any { return []any{ &models.AgentDevice{}, + &models.AgentAppRelease{}, + &models.AgentAppReleaseSetting{}, &models.PDDProduct{}, &models.AIMatchingSetting{}, &models.ShopeeProduct{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 62e2f17..fdc0bc6 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -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"` diff --git a/server/cmd/migrate/migration/version-local/1787983700000_agent_app_release.go b/server/cmd/migrate/migration/version-local/1787983700000_agent_app_release.go new file mode 100644 index 0000000..11b9298 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787983700000_agent_app_release.go @@ -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 + }) +} diff --git a/web/src/api/goauto/agent-app-releases.js b/web/src/api/goauto/agent-app-releases.js new file mode 100644 index 0000000..0c18ebe --- /dev/null +++ b/web/src/api/goauto/agent-app-releases.js @@ -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()}` }}) } diff --git a/web/src/views/goauto/devices/index.vue b/web/src/views/goauto/devices/index.vue index 3444566..a0e9990 100644 --- a/web/src/views/goauto/devices/index.vue +++ b/web/src/views/goauto/devices/index.vue @@ -7,7 +7,7 @@
Android 客户端注册后会自动出现在这里,无需审核。
-