feat: 完善版本管理功能

后端改进:
- 新增版本发布状态管理 (draft/published/deprecated/superseded)
- 新增发布/废弃/回滚 API 接口
- 新增增量包生成功能 (仅包含差异文件)
- 新增删除版本时的依赖检查
- 数据库字段: publish_status, superseded_by, delta_package_*

前端改进:
- 版本列表新增发布状态列和增量包大小列
- 新增发布/废弃/回滚操作按钮
- 新增增量包下载功能
- 更新国际化翻译

修复:
- 修复 announcements 页面 typeOptions 未定义问题
- 修复 echarts 模块导入类型问题

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:31:52 +08:00
parent c3e44c74da
commit c755587a4d
13 changed files with 774 additions and 92 deletions
+11 -4
View File
@@ -234,17 +234,24 @@ type Version struct {
FullPackagePath string `gorm:"size:255" json:"full_package_path"`
FullPackageSize int64 `json:"full_package_size"`
FullPackageHash string `gorm:"size:64" json:"full_package_hash"`
DeltaPackagePath string `gorm:"size:255" json:"delta_package_path"`
DeltaPackageSize int64 `json:"delta_package_size"`
DeltaPackageHash string `gorm:"size:64" json:"delta_package_hash"`
Description string `gorm:"type:text" json:"description"`
Changelog string `gorm:"type:text" json:"changelog"`
Status string `gorm:"size:20;default:active" json:"status"`
PublishStatus string `gorm:"size:20;default:draft;index" json:"publish_status"` // draft, published, deprecated, superseded
SupersededBy *uint `json:"superseded_by"`
PublishedAt *time.Time `json:"published_at"`
DeprecatedAt *time.Time `json:"deprecated_at"`
BaseVersionID *uint `json:"base_version_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"`
Files []VersionFile `gorm:"foreignKey:VersionID" json:"files,omitempty"`
BaseVersion *Version `gorm:"foreignKey:BaseVersionID" json:"base_version,omitempty"`
Application Application `gorm:"foreignKey:ApplicationID" json:"application,omitempty"`
Files []VersionFile `gorm:"foreignKey:VersionID" json:"files,omitempty"`
BaseVersion *Version `gorm:"foreignKey:BaseVersionID" json:"base_version,omitempty"`
Superseder *Version `gorm:"foreignKey:SupersededBy" json:"superseder,omitempty"`
}
// VersionFile 版本文件模型
+3 -28
View File
@@ -903,7 +903,7 @@ func handleCreateVersion(c *gin.Context) {
Version: req.Version,
Description: req.Description,
FilePath: filePath,
Status: "active",
PublishStatus: "draft",
FileSize: fileSize,
}
@@ -952,7 +952,7 @@ func handleUpdateVersion(c *gin.Context) {
Version string `json:"version"`
Description string `json:"description"`
FilePath string `json:"file_path"`
Status string `json:"status"`
PublishStatus string `json:"publish_status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, 400, "参数错误")
@@ -968,7 +968,7 @@ func handleUpdateVersion(c *gin.Context) {
version.Version = req.Version
version.Description = req.Description
version.FilePath = req.FilePath
version.Status = req.Status
version.PublishStatus = req.PublishStatus
if err := database.DB.Save(&version).Error; err != nil {
response.Error(c, 500, "更新版本失败")
@@ -997,31 +997,6 @@ func handleDeleteVersion(c *gin.Context) {
response.Success(c, nil)
}
func handlePublishVersion(c *gin.Context) {
userID := c.GetUint("user_id")
appID := c.Param("id")
versionID := c.Param("versionId")
var app model.Application
if err := database.DB.Where("id = ? AND user_id = ?", appID, userID).First(&app).Error; err != nil {
response.Error(c, 404, "应用不存在")
return
}
var version model.Version
if err := database.DB.Where("id = ? AND application_id = ?", versionID, appID).First(&version).Error; err != nil {
response.Error(c, 404, "版本不存在")
return
}
version.Status = "published"
if err := database.DB.Save(&version).Error; err != nil {
response.Error(c, 500, "发布版本失败")
return
}
response.Success(c, version)
}
func handleManualDeduct(c *gin.Context) {
userID := c.GetUint("user_id")
+144 -1
View File
@@ -2,6 +2,8 @@ package admin
import (
"archive/zip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
@@ -61,6 +63,147 @@ func MergePatchWithFullPackage(fullPackagePath, patchPath string, appID uint) (s
return "/" + filepath.ToSlash(newFilePath), fileInfo.Size(), hash, nil
}
// GenerateDeltaPackage generates a delta package containing only changed/new files
func GenerateDeltaPackage(basePackagePath, newPackagePath string, appID uint) (string, int64, string, error) {
basePackageFile := filepath.Join(".", basePackagePath)
newPackageFile := filepath.Join(".", newPackagePath)
// 获取基础包的文件哈希映射
baseFiles, err := getZipFileHashes(basePackageFile)
if err != nil {
return "", 0, "", fmt.Errorf("read base package failed: %v", err)
}
// 获取新包的文件哈希映射
newFiles, err := getZipFileHashes(newPackageFile)
if err != nil {
return "", 0, "", fmt.Errorf("read new package failed: %v", err)
}
// 找出差异文件(新增或修改)
var deltaFiles []string
for filePath, hash := range newFiles {
if baseHash, exists := baseFiles[filePath]; !exists || baseHash != hash {
deltaFiles = append(deltaFiles, filePath)
}
}
if len(deltaFiles) == 0 {
log.Printf("[INFO] No delta files found, packages are identical")
return "", 0, "", nil
}
log.Printf("[INFO] Found %d delta files", len(deltaFiles))
// 创建增量包
timestamp := time.Now().Unix()
deltaFileName := fmt.Sprintf("delta_%d_%d.zip", appID, timestamp)
deltaFilePath := filepath.Join("uploads", "versions", deltaFileName)
if err := os.MkdirAll(filepath.Dir(deltaFilePath), 0755); err != nil {
return "", 0, "", fmt.Errorf("create dir failed: %v", err)
}
// 从新包中提取差异文件并打包
if err := createDeltaZip(newPackageFile, deltaFilePath, deltaFiles); err != nil {
return "", 0, "", fmt.Errorf("create delta package failed: %v", err)
}
fileInfo, err := os.Stat(deltaFilePath)
if err != nil {
return "", 0, "", fmt.Errorf("stat delta file failed: %v", err)
}
hash, err := calculateFileHash(deltaFilePath)
if err != nil {
return "", 0, "", fmt.Errorf("calculate hash failed: %v", err)
}
log.Printf("[INFO] Created delta package: %s, size: %d", deltaFilePath, fileInfo.Size())
return "/" + filepath.ToSlash(deltaFilePath), fileInfo.Size(), hash, nil
}
// getZipFileHashes returns a map of file paths to their SHA256 hashes
func getZipFileHashes(zipPath string) (map[string]string, error) {
r, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
defer r.Close()
fileHashes := make(map[string]string)
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
rc, err := f.Open()
if err != nil {
continue
}
hash := sha256.New()
io.Copy(hash, rc)
rc.Close()
fileHashes[f.Name] = hex.EncodeToString(hash.Sum(nil))
}
return fileHashes, nil
}
// createDeltaZip creates a zip file containing only the specified files from the source zip
func createDeltaZip(sourceZipPath, deltaZipPath string, deltaFiles []string) error {
r, err := zip.OpenReader(sourceZipPath)
if err != nil {
return err
}
defer r.Close()
deltaFileSet := make(map[string]bool)
for _, f := range deltaFiles {
deltaFileSet[f] = true
}
zipFile, err := os.Create(deltaZipPath)
if err != nil {
return err
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
for _, f := range r.File {
if !deltaFileSet[f.Name] {
continue
}
if f.FileInfo().IsDir() {
continue
}
writer, err := zipWriter.Create(f.Name)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
_, err = io.Copy(writer, rc)
rc.Close()
if err != nil {
return err
}
}
return nil
}
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
@@ -140,4 +283,4 @@ func createZipFromDir(srcDir, zipPath string) error {
_, err = io.Copy(writer, file)
return err
})
}
}
+248 -25
View File
@@ -31,6 +31,9 @@ func SetupVersionRoutes(r *gin.RouterGroup) {
versions.PUT("/:id", handleUpdateVersionGlobal)
versions.DELETE("/batch", handleBatchDeleteVersions)
versions.POST("/upload-zip", handleUploadVersionZip)
versions.POST("/:id/publish", handlePublishVersion)
versions.POST("/:id/deprecate", handleDeprecateVersion)
versions.POST("/:id/rollback", handleRollbackVersion)
}
}
@@ -178,6 +181,7 @@ func handleGetAllVersions(c *gin.Context) {
type VersionWithAppName struct {
model.Version
ApplicationName string `json:"application_name"`
Status string `json:"status"`
}
result := make([]VersionWithAppName, len(versions))
@@ -185,25 +189,16 @@ func handleGetAllVersions(c *gin.Context) {
appName := appNameMap[v.ApplicationID]
log.Printf("[DEBUG] Mapping version %d: ApplicationID=%d -> AppName=%s\n", i, v.ApplicationID, appName)
isSuperseded := false
if forcedIDs, ok := appForcedVersionIDs[v.ApplicationID]; ok {
for _, forcedID := range forcedIDs {
if forcedID > v.ID {
isSuperseded = true
break
}
}
}
if isSuperseded {
v.Status = "superseded"
} else {
v.Status = "active"
// 使用数据库中的 publish_status 作为状态
status := v.PublishStatus
if status == "" {
status = "draft"
}
result[i] = VersionWithAppName{
Version: v,
ApplicationName: appName,
Status: status,
}
}
@@ -238,15 +233,6 @@ func handleGetVersionByID(c *gin.Context) {
return
}
computedStatus := "active"
var forcedCount int64
database.DB.Model(&model.Version{}).
Where("application_id = ? AND id > ? AND update_strategy = ?", version.ApplicationID, version.ID, "forced").
Count(&forcedCount)
if forcedCount > 0 {
computedStatus = "superseded"
}
response.Success(c, gin.H{
"id": version.ID,
"application_id": version.ApplicationID,
@@ -261,7 +247,11 @@ func handleGetVersionByID(c *gin.Context) {
"update_method": version.UpdateMethod,
"custom_download_url": version.CustomDownloadURL,
"changelog": version.Changelog,
"status": computedStatus,
"status": version.PublishStatus,
"publish_status": version.PublishStatus,
"superseded_by": version.SupersededBy,
"published_at": version.PublishedAt,
"deprecated_at": version.DeprecatedAt,
"base_version_id": version.BaseVersionID,
"files": version.Files,
"created_at": version.CreatedAt,
@@ -323,7 +313,7 @@ func handleCreateVersionGlobal(c *gin.Context) {
CustomDownloadURL: req.CustomDownloadURL,
Description: req.Description,
Changelog: req.Changelog,
Status: "active",
PublishStatus: "draft",
}
if version.UpdateStrategy == "" {
@@ -342,6 +332,32 @@ func handleCreateVersionGlobal(c *gin.Context) {
version.FullPackagePath = version.FilePath
version.FullPackageSize = version.FileSize
version.FullPackageHash = version.FileHash
// 尝试生成增量包(与前一个版本对比)
var prevVersion model.Version
if err := database.DB.Where("application_id = ? AND update_type = ?",
req.ApplicationID, "full").
Order("id DESC").First(&prevVersion).Error; err == nil {
// 有前一个版本,生成增量包
prevFullPackagePath := prevVersion.FullPackagePath
if prevFullPackagePath == "" {
prevFullPackagePath = prevVersion.FilePath
}
deltaPath, deltaSize, deltaHash, err := GenerateDeltaPackage(
prevFullPackagePath,
version.FilePath,
req.ApplicationID,
)
if err != nil {
log.Printf("[WARN] Failed to generate delta package: %v\n", err)
} else if deltaPath != "" {
version.DeltaPackagePath = deltaPath
version.DeltaPackageSize = deltaSize
version.DeltaPackageHash = deltaHash
log.Printf("[INFO] Delta package created: %s, size: %d\n", deltaPath, deltaSize)
}
}
} else if version.UpdateType == "patch" {
// 补丁包:需要找到基础全量包并合并
var lastFullVersion model.Version
@@ -571,6 +587,21 @@ func handleBatchDeleteVersions(c *gin.Context) {
}
}
// 检查是否有其他补丁包依赖这些版本
var dependentPatches []model.Version
if err := database.DB.Where("base_version_id IN ?", req.IDs).Find(&dependentPatches).Error; err != nil {
response.Error(c, 500, "检查依赖关系失败")
return
}
if len(dependentPatches) > 0 {
dependentIDs := make([]string, len(dependentPatches))
for i, p := range dependentPatches {
dependentIDs[i] = fmt.Sprintf("v%d.%s", p.ID, p.Version)
}
response.Error(c, 400, fmt.Sprintf("以下补丁包依赖所选版本,无法删除: %s", strings.Join(dependentIDs, ", ")))
return
}
if err := database.DB.Where("id IN ? AND application_id IN ?", req.IDs, appIDs).Delete(&model.Version{}).Error; err != nil {
response.Error(c, 500, "批量删除版本失败")
return
@@ -765,3 +796,195 @@ func getFileType(filename string) string {
return "resource"
}
}
func handlePublishVersion(c *gin.Context) {
userID := c.GetUint("user_id")
versionID := c.Param("id")
var userApps []model.Application
if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil {
response.Error(c, 500, "获取应用列表失败")
return
}
appIDs := make([]uint, len(userApps))
for i, app := range userApps {
appIDs[i] = app.ID
}
var version model.Version
if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil {
response.Error(c, 404, "版本不存在")
return
}
if version.PublishStatus != "draft" && version.PublishStatus != "deprecated" {
response.Error(c, 400, "只能发布草稿或已废弃的版本")
return
}
now := time.Now()
version.PublishStatus = "published"
version.PublishedAt = &now
if err := database.DB.Save(&version).Error; err != nil {
response.Error(c, 500, "发布版本失败")
return
}
// 如果是强制更新,标记旧版本为 superseded
if version.UpdateStrategy == "forced" {
database.DB.Model(&model.Version{}).
Where("application_id = ? AND id < ? AND publish_status = ?", version.ApplicationID, version.ID, "published").
Updates(map[string]interface{}{
"publish_status": "superseded",
"superseded_by": version.ID,
"deprecated_at": now,
})
}
userIDPtr := &userID
versionIDPtr := &version.ID
service.CreateLog(service.LogParams{
UserID: userIDPtr,
LogType: "version",
Action: "publish",
Resource: "version",
ResourceID: versionIDPtr,
Details: fmt.Sprintf("发布版本: %s (应用ID: %d)", version.Version, version.ApplicationID),
})
response.Success(c, gin.H{
"id": version.ID,
"publish_status": version.PublishStatus,
"published_at": version.PublishedAt,
})
}
func handleDeprecateVersion(c *gin.Context) {
userID := c.GetUint("user_id")
versionID := c.Param("id")
var userApps []model.Application
if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil {
response.Error(c, 500, "获取应用列表失败")
return
}
appIDs := make([]uint, len(userApps))
for i, app := range userApps {
appIDs[i] = app.ID
}
var version model.Version
if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil {
response.Error(c, 404, "版本不存在")
return
}
if version.PublishStatus != "published" {
response.Error(c, 400, "只能废弃已发布的版本")
return
}
now := time.Now()
version.PublishStatus = "deprecated"
version.DeprecatedAt = &now
if err := database.DB.Save(&version).Error; err != nil {
response.Error(c, 500, "废弃版本失败")
return
}
// 如果有被该版本取代的版本,恢复它们为 published
database.DB.Model(&model.Version{}).
Where("superseded_by = ?", version.ID).
Updates(map[string]interface{}{
"publish_status": "published",
"superseded_by": nil,
"deprecated_at": nil,
})
userIDPtr := &userID
versionIDPtr := &version.ID
service.CreateLog(service.LogParams{
UserID: userIDPtr,
LogType: "version",
Action: "deprecate",
Resource: "version",
ResourceID: versionIDPtr,
Details: fmt.Sprintf("废弃版本: %s (应用ID: %d)", version.Version, version.ApplicationID),
})
response.Success(c, gin.H{
"id": version.ID,
"publish_status": version.PublishStatus,
"deprecated_at": version.DeprecatedAt,
})
}
func handleRollbackVersion(c *gin.Context) {
userID := c.GetUint("user_id")
versionID := c.Param("id")
var userApps []model.Application
if err := database.DB.Where("user_id = ?", userID).Find(&userApps).Error; err != nil {
response.Error(c, 500, "获取应用列表失败")
return
}
appIDs := make([]uint, len(userApps))
for i, app := range userApps {
appIDs[i] = app.ID
}
var version model.Version
if err := database.DB.Where("id = ? AND application_id IN ?", versionID, appIDs).First(&version).Error; err != nil {
response.Error(c, 404, "版本不存在")
return
}
// 只能回滚 superseded 状态的版本
if version.PublishStatus != "superseded" {
response.Error(c, 400, "只能回滚已被取代的版本")
return
}
now := time.Now()
version.PublishStatus = "published"
version.SupersededBy = nil
version.DeprecatedAt = nil
if err := database.DB.Save(&version).Error; err != nil {
response.Error(c, 500, "回滚版本失败")
return
}
// 找到当前发布的版本,将其改为 superseded(如果有的话)
var currentPublished model.Version
err := database.DB.Where("application_id = ? AND publish_status = ? AND id != ?",
version.ApplicationID, "published", version.ID).
Order("id DESC").First(&currentPublished).Error
if err == nil {
currentPublished.PublishStatus = "superseded"
currentPublished.SupersededBy = &version.ID
currentPublished.DeprecatedAt = &now
database.DB.Save(&currentPublished)
}
userIDPtr := &userID
versionIDPtr := &version.ID
service.CreateLog(service.LogParams{
UserID: userIDPtr,
LogType: "version",
Action: "rollback",
Resource: "version",
ResourceID: versionIDPtr,
Details: fmt.Sprintf("回滚版本: %s (应用ID: %d)", version.Version, version.ApplicationID),
})
response.Success(c, gin.H{
"id": version.ID,
"publish_status": version.PublishStatus,
})
}