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:
@@ -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(¤tPublished).Error
|
||||
if err == nil {
|
||||
currentPublished.PublishStatus = "superseded"
|
||||
currentPublished.SupersededBy = &version.ID
|
||||
currentPublished.DeprecatedAt = &now
|
||||
database.DB.Save(¤tPublished)
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user