Browse Source

feat: unify platform version and config management

DESKTOP-HN5QP3V\Administrator 2 tuần trước cách đây
mục cha
commit
a145dfdedd

+ 38 - 0
app/api/v1/channel.go

@@ -4,6 +4,7 @@ import (
 	"dsbqj-admin/app/service"
 	"dsbqj-admin/pkg/app"
 	"dsbqj-admin/pkg/e"
+	"dsbqj-admin/pkg/serializer"
 	"net/http"
 
 	"github.com/gin-gonic/gin"
@@ -23,6 +24,43 @@ func ChannelAll(c *gin.Context) {
 	}
 }
 
+func PlatformList(c *gin.Context) {
+	var g = app.Gin{C: c}
+	v, err := (&service.PlatformListService{}).List()
+	if err != nil {
+		g.Response(http.StatusOK, e.ERROR, err.Error())
+		return
+	}
+	g.Response(http.StatusOK, e.SUCCESS, serializer.BuildPlatformList(v))
+}
+func PlatformCreate(c *gin.Context) {
+	var g = app.Gin{C: c}
+	var s service.PlatformCreateService
+	if err := c.ShouldBindJSON(&s); err != nil {
+		g.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	g.Response(http.StatusOK, e.SUCCESS, s.Create())
+}
+func PlatformUpdate(c *gin.Context) {
+	var g = app.Gin{C: c}
+	var s service.PlatformUpdateService
+	if err := c.ShouldBindJSON(&s); err != nil {
+		g.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	g.Response(http.StatusOK, e.SUCCESS, s.Update())
+}
+func PlatformDelete(c *gin.Context) {
+	var g = app.Gin{C: c}
+	var s service.PlatformDeleteService
+	if err := c.ShouldBindJSON(&s); err != nil {
+		g.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	g.Response(http.StatusOK, e.SUCCESS, s.Delete())
+}
+
 // ChannelList 渠道列表
 func ChannelList(c *gin.Context) {
 	var appG = app.Gin{C: c}

+ 59 - 11
app/api/v1/version.go

@@ -12,22 +12,13 @@ import (
 
 const versionExternalConfigKey = "version_external_config"
 
-func isOldVersion(v string) bool {
-	// 比如 < 1.2.0 都算老版本
-	return v <= "1.0.67"
-}
-
 func CheckVersion(c *gin.Context) {
 	var appG = app.Gin{C: c}
 	service := service.VersionCheckService{}
 	err := c.ShouldBind(&service)
 	if err != nil {
-		if isOldVersion(service.Version) {
-			service.Channel = "wx"
-		} else {
-			appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
-			return
-		}
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
 	}
 
 	if res, err := service.Check(); err != nil {
@@ -134,6 +125,63 @@ func VersionExternalConfig(c *gin.Context) {
 	}
 }
 
+func VersionAbVer(c *gin.Context) {
+	var appG = app.Gin{C: c}
+	service := service.VersionConfigUploadService{}
+	if err := c.ShouldBind(&service); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	if err := service.ValidateAuth(); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	if err := service.SaveAbVer(); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	appG.Response(http.StatusOK, e.SUCCESS, nil)
+}
+
+func VersionMD5(c *gin.Context) {
+	var appG = app.Gin{C: c}
+	service := service.VersionConfigUploadService{}
+	if err := c.ShouldBind(&service); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	if err := service.ValidateAuth(); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	if err := service.SaveMD5(); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	appG.Response(http.StatusOK, e.SUCCESS, nil)
+}
+
+func VersionConfigList(c *gin.Context) {
+	var appG = app.Gin{C: c}
+	service := service.VersionConfigManageService{Type: c.Query("type"), PlatformFilter: c.Query("platform")}
+	res, err := service.List()
+	if err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	appG.Response(http.StatusOK, e.SUCCESS, res)
+}
+
+func VersionConfigSave(c *gin.Context) {
+	var appG = app.Gin{C: c}
+	service := service.VersionConfigManageService{}
+	if err := c.ShouldBind(&service); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	appG.Response(http.StatusOK, e.SUCCESS, service.Save())
+}
+
 func VersionExternalAuth() gin.HandlerFunc {
 	return func(c *gin.Context) {
 		var appG = app.Gin{C: c}

+ 10 - 0
app/router/router.go

@@ -66,7 +66,10 @@ func registerVersionRouter(r *gin.Engine) {
 		apiv1.POST("version/check", v1.CheckVersion)
 		apiv1.POST("version/server", v1.ServerVersion)
 		apiv1.POST("version/external", v1.VersionExternalAuth(), v1.VersionExternalConfig)
+		apiv1.POST("version/abver", v1.VersionAbVer)
+		apiv1.POST("version/md5", v1.VersionMD5)
 		apiv1.GET("channel/all", v1.ChannelAll)
+		apiv1.GET("platform/all", v1.PlatformList)
 	}
 }
 
@@ -82,10 +85,17 @@ func registerAdminRouter(r *gin.Engine) {
 			authed.GET("versions", v1.ShowVersions)
 			authed.POST("version", v1.VersionCreate)
 			authed.PUT("version", v1.VersionEdit)
+			authed.GET("version/configs", v1.VersionConfigList)
+			authed.POST("version/config", v1.VersionConfigSave)
+			authed.PUT("version/config", v1.VersionConfigSave)
 			authed.PUT("version/prod", v1.VersionProd)
 			authed.DELETE("version/:key", v1.VersionDelete)
 			authed.POST("version/reload", v1.ReloadVersion)
 			authed.GET("channel/list", v1.ChannelList)
+			authed.GET("platform/list", v1.PlatformList)
+			authed.POST("platform", v1.PlatformCreate)
+			authed.PUT("platform", v1.PlatformUpdate)
+			authed.POST("platform/delete", v1.PlatformDelete)
 			authed.POST("channel", v1.ChannelCreate)
 			authed.PUT("channel", v1.ChannelUpdate)
 			authed.POST("channel/delete", v1.ChannelDelete)

+ 1 - 1
app/service/channel.go

@@ -75,7 +75,7 @@ func (s *ChannelListService) List() (interface{}, error) {
 type ChannelCreateService struct {
 	Name        string `json:"name" binding:"required"`
 	Code        string `json:"code" binding:"required"`
-	Platform    string `json:"platform" binding:"required"`
+	Platform    string `json:"platform"`
 	Description string `json:"description"`
 	Status      *bool  `json:"status"`
 }

+ 74 - 0
app/service/platform.go

@@ -0,0 +1,74 @@
+package service
+
+import (
+	"context"
+	"dsbqj-admin/model/mongo/platform"
+	"errors"
+	"github.com/kamva/mgm/v3"
+	"go.mongodb.org/mongo-driver/bson"
+	"go.mongodb.org/mongo-driver/bson/primitive"
+)
+
+type PlatformListService struct{}
+
+func (*PlatformListService) List() ([]platform.Platform, error) {
+	var v []platform.Platform
+	err := mgm.Coll(&platform.Platform{}).SimpleFind(&v, bson.M{})
+	return v, err
+}
+
+type PlatformCreateService struct {
+	Name   string `json:"name" binding:"required"`
+	Code   string `json:"code" binding:"required"`
+	Status *bool  `json:"status"`
+}
+
+func (s *PlatformCreateService) Create() error {
+	v := &platform.Platform{Name: s.Name, Code: s.Code, Status: true}
+	if s.Status != nil {
+		v.Status = *s.Status
+	}
+	return mgm.Coll(v).Create(v)
+}
+
+type PlatformUpdateService struct {
+	ID     string  `json:"id" binding:"required"`
+	Name   *string `json:"name"`
+	Code   *string `json:"code"`
+	Status *bool   `json:"status"`
+}
+
+func (s *PlatformUpdateService) Update() error {
+	id, err := primitive.ObjectIDFromHex(s.ID)
+	if err != nil {
+		return err
+	}
+	u := bson.M{}
+	if s.Name != nil {
+		u["name"] = *s.Name
+	}
+	if s.Code != nil {
+		u["code"] = *s.Code
+	}
+	if s.Status != nil {
+		u["status"] = *s.Status
+	}
+	if len(u) == 0 {
+		return errors.New("没有需要更新的内容")
+	}
+	_, err = mgm.Coll(&platform.Platform{}).UpdateOne(context.Background(), bson.M{"_id": id}, bson.M{"$set": u})
+	return err
+}
+
+type PlatformDeleteService struct {
+	ID string `json:"id" binding:"required"`
+}
+
+func (s *PlatformDeleteService) Delete() error {
+	id, err := primitive.ObjectIDFromHex(s.ID)
+	if err != nil {
+		return err
+	}
+	_, err = mgm.Coll(&platform.Platform{}).DeleteOne(context.Background(), bson.M{"_id": id})
+	return err
+}

+ 272 - 45
app/service/version.go

@@ -4,16 +4,22 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"sort"
 	"strconv"
+	"strings"
 	"time"
 
 	"dsbqj-admin/app/task"
+	"dsbqj-admin/model/mongo/abver"
+	"dsbqj-admin/model/mongo/md5"
 	"dsbqj-admin/model/mongo/version"
 	"dsbqj-admin/tool"
 
 	"github.com/kamva/mgm/v3"
 	"go.mongodb.org/mongo-driver/bson"
 	"go.mongodb.org/mongo-driver/bson/primitive"
+	"go.mongodb.org/mongo-driver/mongo"
+	"go.mongodb.org/mongo-driver/mongo/options"
 )
 
 const versionExternalReqTimeoutSec = 30 * 60
@@ -27,54 +33,93 @@ func (service *VersionReloadService) Reload() error {
 }
 
 type VersionServerService struct {
-	Channel string `json:"channel" binding:"required"`
+	Platform      string `json:"platform"`
+	LegacyChannel string `json:"channel"`
 }
 
 func (service *VersionServerService) Servers() ([]*version.Version, error) {
-	return task.VersionTask.Servers(service.Channel), nil
+	platform := resolvePlatform(service.Platform, service.LegacyChannel)
+	if platform == "" {
+		return nil, errors.New("平台不能为空")
+	}
+	items := task.VersionTask.Servers(platform)
+	result := make([]*version.Version, 0, len(items))
+	for _, item := range items {
+		result = append(result, versionWithConfig(item, platform, task.NormalizeEnvVersion(item.EnvVersion)))
+	}
+	return result, nil
 }
 
 type VersionCheckService struct {
-	Version    string `json:"version" binding:"required"`
-	Channel    string `json:"channel" binding:"required"`
-	EnvVersion string `json:"envVersion"`
+	Version       string `json:"version"`
+	Platform      string `json:"platform"`
+	LegacyChannel string `json:"channel"`
+	EnvVersion    string `json:"envVersion"`
 }
 
 func (service *VersionCheckService) Check() (*version.Version, error) {
-	return task.VersionTask.Check(service.Version, service.Channel, service.EnvVersion), nil
+	platform := resolvePlatform(service.Platform, service.LegacyChannel)
+	if platform == "" {
+		return nil, errors.New("平台不能为空")
+	}
+	result := task.VersionTask.Check(service.Version, platform, service.EnvVersion)
+	if result == nil {
+		return nil, nil
+	}
+	return versionWithConfig(result, platform, task.NormalizeEnvVersion(result.EnvVersion)), nil
+}
+
+func versionWithConfig(item *version.Version, platform, env string) *version.Version {
+	result := *item
+	abVer := task.VersionConfigTask.GetAbVer(platform, env)
+	md5 := task.VersionConfigTask.GetMD5(platform, env)
+	result.AbVer, result.AbVerVersion = abVer.Value, abVer.Version
+	result.MD5, result.MD5Version = md5.Value, md5.Version
+	return &result
+}
+
+func resolvePlatform(platform, legacyChannel string) string {
+	if value := strings.TrimSpace(platform); value != "" {
+		return value
+	}
+	return strings.TrimSpace(legacyChannel)
 }
 
 type VersionShowService struct {
 	CondPage
-	Channel    string `form:"channel" json:"channel"`
+	Platform   string `form:"platform" json:"platform"`
 	EnvVersion string `form:"envVersion" json:"envVersion"`
 }
 
 func (service *VersionShowService) List() ([]version.Version, error) {
 	versions := make([]version.Version, 0)
 	var queryDoc = bson.M{}
-	if service.Channel != "" {
-		queryDoc["channel"] = service.Channel
+	clauses := make([]bson.M, 0, 2)
+	if service.Platform != "" {
+		clauses = append(clauses, bson.M{"$or": []bson.M{{"platform": service.Platform}, {"platform": ""}, {"platform": bson.M{"$exists": false}}}})
 	}
 	if service.EnvVersion != "" {
 		envVersion := task.NormalizeEnvVersion(service.EnvVersion)
 		if envVersion == "release" {
-			queryDoc["$or"] = []bson.M{
+			clauses = append(clauses, bson.M{"$or": []bson.M{
 				{"envVersion": envVersion},
 				{"envVersion": ""},
 				{"envVersion": bson.M{"$exists": false}},
-			}
+			}})
 		} else {
-			queryDoc["envVersion"] = envVersion
+			clauses = append(clauses, bson.M{"envVersion": envVersion})
 		}
 	}
+	if len(clauses) > 0 {
+		queryDoc["$and"] = clauses
+	}
 
 	err := mgm.Coll(&version.Version{}).SimpleFind(&versions, queryDoc)
 	return versions, err
 }
 
 type VersionCreateService struct {
-	Channel    string `json:"channel" binding:"required"`
+	Platform   string `json:"platform" binding:"required"`
 	EnvVersion string `json:"envVersion"`
 	Version    string `json:"version" binding:"required"`
 	Name       string `json:"name" binding:"required"`
@@ -83,20 +128,33 @@ type VersionCreateService struct {
 }
 
 func (service *VersionCreateService) Create() error {
+	envVersion := task.NormalizeEnvVersion(service.EnvVersion)
+	var exists version.Version
+	err := mgm.Coll(&version.Version{}).First(versionIdentityFilter(service.Platform, service.Version, envVersion), &exists)
+	if err == nil {
+		return errors.New("该平台、环境和版本号已存在")
+	}
+	if !errors.Is(err, mongo.ErrNoDocuments) {
+		return err
+	}
 	version := new(version.Version)
-	version.Channel = service.Channel
-	version.EnvVersion = task.NormalizeEnvVersion(service.EnvVersion)
+	version.Platform = service.Platform
+	version.EnvVersion = envVersion
 	version.Name = service.Name
 	version.Version = service.Version
 	version.CDN = service.CDN
 	version.Url = service.Url
 
-	return mgm.Coll(version).Create(version)
+	if err := mgm.Coll(version).Create(version); err != nil {
+		return err
+	}
+	task.VersionTask.Exec()
+	return nil
 }
 
 type VersionEditService struct {
 	Id         string  `json:"id" binding:"required"`
-	Channel    *string `json:"channel"`
+	Platform   *string `json:"platform"`
 	EnvVersion *string `json:"envVersion"`
 	Version    *string `json:"version"`
 	Name       *string `json:"name"`
@@ -106,36 +164,71 @@ type VersionEditService struct {
 }
 
 func (service *VersionEditService) Edit() error {
-	version := new(version.Version)
-
 	objectId, _ := primitive.ObjectIDFromHex(service.Id)
+	coll := mgm.Coll(&version.Version{})
+	var current version.Version
+	if err := coll.FindByID(objectId, &current); err != nil {
+		return err
+	}
+	platform, envVersion, versionNumber := current.Platform, task.NormalizeEnvVersion(current.EnvVersion), current.Version
+	if service.Platform != nil {
+		platform = strings.TrimSpace(*service.Platform)
+	}
+	if service.EnvVersion != nil {
+		envVersion = task.NormalizeEnvVersion(*service.EnvVersion)
+	}
+	if service.Version != nil {
+		versionNumber = *service.Version
+	}
+	var duplicate version.Version
+	duplicateFilter := versionIdentityFilter(platform, versionNumber, envVersion)
+	duplicateFilter["_id"] = bson.M{"$ne": objectId}
+	if err := coll.First(duplicateFilter, &duplicate); err == nil {
+		return errors.New("该平台、环境和版本号已存在")
+	} else if !errors.Is(err, mongo.ErrNoDocuments) {
+		return err
+	}
+
 	filterDoc := bson.M{"_id": objectId}
 	updateDoc := bson.M{}
-	if service.Channel != nil {
-		updateDoc["channel"] = service.Channel
+	if service.Platform != nil {
+		updateDoc["platform"] = platform
 	}
 	if service.EnvVersion != nil {
 		updateDoc["envVersion"] = task.NormalizeEnvVersion(*service.EnvVersion)
 	}
 	if service.Name != nil {
-		updateDoc["name"] = service.Name
+		updateDoc["name"] = *service.Name
 	}
 	if service.Version != nil {
-		updateDoc["version"] = service.Version
+		updateDoc["version"] = *service.Version
 	}
 	if service.CDN != nil {
-		updateDoc["cdn"] = service.CDN
+		updateDoc["cdn"] = *service.CDN
 	}
 	if service.Url != nil {
-		updateDoc["url"] = service.Url
+		updateDoc["url"] = *service.Url
 	}
 	if service.Default != nil {
-		updateDoc["default"] = service.Default
+		updateDoc["default"] = *service.Default
 	}
 
-	_, err := mgm.Coll(version).UpdateOne(context.Background(), filterDoc, bson.M{"$set": updateDoc})
+	_, err := coll.UpdateOne(context.Background(), filterDoc, bson.M{"$set": updateDoc})
+	if err != nil {
+		return err
+	}
+	task.VersionTask.Exec()
+	return nil
+}
 
-	return err
+func versionIdentityFilter(platform, versionNumber, envVersion string) bson.M {
+	filter := bson.M{"platform": platform, "version": versionNumber}
+	if task.NormalizeEnvVersion(envVersion) == "release" {
+		filter["$or"] = []bson.M{{"envVersion": "release"}, {"envVersion": ""}, {"envVersion": bson.M{"$exists": false}}}
+	} else {
+		filter["envVersion"] = "trial"
+	}
+	return filter
 }
 
 type VersionProdService struct {
@@ -156,9 +249,12 @@ func (service *VersionProdService) Update() error {
 	}
 
 	if service.IsProd {
+		scope := versionIdentityFilter(current.Platform, "", current.EnvVersion)
+		delete(scope, "version")
+		scope["_id"] = bson.M{"$ne": objectId}
 		if _, err := coll.UpdateMany(
 			context.Background(),
-			bson.M{"_id": bson.M{"$ne": objectId}},
+			scope,
 			bson.M{"$set": bson.M{"isProd": false}},
 		); err != nil {
 			return err
@@ -174,12 +270,143 @@ func (service *VersionProdService) Update() error {
 }
 
 type VersionExternalConfigService struct {
-	Channel string  `form:"channel" json:"channel" binding:"required"`
-	Version string  `form:"version" json:"version" binding:"required"`
-	AbVer   *string `form:"abVer" json:"abVer"`
-	MD5     *string `form:"md5" json:"md5"`
-	TimeSec int64   `form:"timesec" json:"timesec" binding:"required"`
-	Sign    string  `form:"sign" json:"sign" binding:"required"`
+	Platform string  `form:"platform" json:"platform" binding:"required"`
+	Version  string  `form:"version" json:"version" binding:"required"`
+	AbVer    *string `form:"abVer" json:"abVer"`
+	MD5      *string `form:"md5" json:"md5"`
+	TimeSec  int64   `form:"timesec" json:"timesec" binding:"required"`
+	Sign     string  `form:"sign" json:"sign" binding:"required"`
+}
+
+type VersionConfigUploadService struct {
+	Platform string `form:"platform" json:"platform" binding:"required"`
+	Version  string `form:"version" json:"version" binding:"required"`
+	Value    string `form:"value" json:"value" binding:"required"`
+	TimeSec  int64  `form:"timesec" json:"timesec" binding:"required"`
+	Sign     string `form:"sign" json:"sign" binding:"required"`
+}
+
+type VersionConfigManageService struct {
+	Type           string    `json:"type" form:"type" binding:"required"`
+	ID             string    `json:"id"`
+	PlatformFilter string    `json:"-"`
+	Version        string    `json:"version"`
+	Value          *string   `json:"value"`
+	EnvVersions    *[]string `json:"envVersions"`
+	External       *bool     `json:"external"`
+}
+
+func configCollection(configType string) interface{} {
+	if configType == "md5" {
+		return &md5.MD5{}
+	}
+	return &abver.AbVer{}
+}
+
+func (service *VersionConfigManageService) List() (interface{}, error) {
+	items := make([]interface{}, 0)
+	filter := bson.M{}
+	if service.PlatformFilter != "" {
+		filter["$or"] = []bson.M{{"platform": service.PlatformFilter}, {"platform": ""}, {"platform": bson.M{"$exists": false}}}
+	}
+	if service.Type == "md5" {
+		result := make([]md5.MD5, 0)
+		err := mgm.Coll(&md5.MD5{}).SimpleFind(&result, filter)
+		sort.SliceStable(result, func(i, j int) bool {
+			return task.CompareConfigVersion(result[i].Version, result[j].Version) > 0
+		})
+		for i := range result {
+			items = append(items, result[i])
+		}
+		return items, err
+	}
+	result := make([]abver.AbVer, 0)
+	err := mgm.Coll(&abver.AbVer{}).SimpleFind(&result, filter)
+	sort.SliceStable(result, func(i, j int) bool {
+		return task.CompareConfigVersion(result[i].Version, result[j].Version) > 0
+	})
+	for i := range result {
+		items = append(items, result[i])
+	}
+	return items, err
+}
+
+func (service *VersionConfigManageService) Save() error {
+	if service.ID == "" {
+		return errors.New("配置记录编号不能为空")
+	}
+	id, err := primitive.ObjectIDFromHex(service.ID)
+	if err != nil {
+		return err
+	}
+	update := bson.M{}
+	if service.Value != nil {
+		update["value"] = *service.Value
+	}
+	if service.EnvVersions != nil {
+		update["envVersions"] = *service.EnvVersions
+	}
+	if service.External != nil {
+		update["external"] = *service.External
+	}
+	if len(update) == 0 {
+		return errors.New("没有需要更新的内容")
+	}
+	if service.Type == "md5" {
+		_, err = mgm.Coll(&md5.MD5{}).UpdateOne(context.Background(), bson.M{"_id": id}, bson.M{"$set": update})
+		if err == nil {
+			task.VersionConfigTask.Reload()
+		}
+		return err
+	}
+	_, err = mgm.Coll(&abver.AbVer{}).UpdateOne(context.Background(), bson.M{"_id": id}, bson.M{"$set": update})
+	if err == nil {
+		task.VersionConfigTask.Reload()
+	}
+	return err
+}
+
+func (service *VersionConfigUploadService) ValidateAuth() error {
+	deltaTime := time.Now().Unix() - service.TimeSec
+	if deltaTime < 0 {
+		deltaTime = -deltaTime
+	}
+	if deltaTime > versionExternalReqTimeoutSec {
+		return errors.New("时间戳已过期")
+	}
+	params := map[string]any{"platform": service.Platform, "version": service.Version, "value": service.Value, "timesec": strconv.FormatInt(service.TimeSec, 10)}
+	if tool.GenSign(params) != service.Sign {
+		return errors.New("签名错误")
+	}
+	return nil
+}
+
+func (service *VersionConfigUploadService) SaveAbVer() error {
+	var exists abver.AbVer
+	filter := bson.M{"platform": service.Platform, "version": service.Version}
+	if err := mgm.Coll(&abver.AbVer{}).First(filter, &exists); err == nil {
+		return errors.New("abVer 配置版本已存在,不能重复上报")
+	}
+	item := &abver.AbVer{Platform: service.Platform, Version: service.Version, Value: service.Value, External: false}
+	_, err := mgm.Coll(item).UpdateOne(context.Background(), filter, bson.M{"$set": item}, options.Update().SetUpsert(true))
+	if err == nil {
+		task.VersionConfigTask.Reload()
+	}
+	return err
+}
+
+func (service *VersionConfigUploadService) SaveMD5() error {
+	var exists md5.MD5
+	filter := bson.M{"platform": service.Platform, "version": service.Version}
+	if err := mgm.Coll(&md5.MD5{}).First(filter, &exists); err == nil {
+		return errors.New("md5 配置版本已存在,不能重复上报")
+	}
+	item := &md5.MD5{Platform: service.Platform, Version: service.Version, Value: service.Value, External: false}
+	_, err := mgm.Coll(item).UpdateOne(context.Background(), filter, bson.M{"$set": item}, options.Update().SetUpsert(true))
+	if err == nil {
+		task.VersionConfigTask.Reload()
+	}
+	return err
 }
 
 func (service *VersionExternalConfigService) Save() error {
@@ -188,8 +415,8 @@ func (service *VersionExternalConfigService) Save() error {
 	}
 
 	filterDoc := bson.M{
-		"channel": service.Channel,
-		"version": service.Version,
+		"platform": service.Platform,
+		"version":  service.Version,
 	}
 
 	coll := mgm.Coll(&version.Version{})
@@ -197,10 +424,6 @@ func (service *VersionExternalConfigService) Save() error {
 	if err := coll.First(filterDoc, &current); err != nil {
 		return errors.New("version not found")
 	}
-	if current.IsProd {
-		return errors.New("production version does not allow external upload")
-	}
-
 	updateDoc := bson.M{}
 	if service.AbVer != nil {
 		updateDoc["abVer"] = *service.AbVer
@@ -227,9 +450,9 @@ func (service *VersionExternalConfigService) ValidateAuth() error {
 	}
 
 	params := map[string]any{
-		"channel": service.Channel,
-		"version": service.Version,
-		"timesec": strconv.FormatInt(service.TimeSec, 10),
+		"platform": service.Platform,
+		"version":  service.Version,
+		"timesec":  strconv.FormatInt(service.TimeSec, 10),
 	}
 	if service.AbVer != nil {
 		params["abVer"] = *service.AbVer
@@ -255,5 +478,9 @@ func (service *VersionDeleteService) Delete() error {
 	filterDoc := bson.M{"_id": objectId}
 
 	_, err := mgm.Coll(version).DeleteOne(context.Background(), filterDoc)
-	return err
+	if err != nil {
+		return err
+	}
+	task.VersionTask.Exec()
+	return nil
 }

+ 24 - 0
app/service/version_check_test.go

@@ -0,0 +1,24 @@
+package service
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+)
+
+func TestVersionCheckBindAllowsMissingVersion(t *testing.T) {
+	ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
+	ctx.Request, _ = http.NewRequest(http.MethodPost, "/api/v1/version/check", strings.NewReader(`{"platform":"wx","envVersion":"trial"}`))
+	ctx.Request.Header.Set("Content-Type", "application/json")
+
+	var request VersionCheckService
+	if err := ctx.ShouldBind(&request); err != nil {
+		t.Fatalf("empty version should be accepted by the request binder: %v", err)
+	}
+	if request.Version != "" || request.Platform != "wx" || request.EnvVersion != "trial" {
+		t.Fatalf("unexpected request values: %#v", request)
+	}
+}

+ 71 - 36
app/task/version.go

@@ -19,10 +19,10 @@ var VersionTask *Versions
 
 type Versions struct {
 	sync.Mutex
-	VChannels util.SafeMap[string, *ChannelVersions]
+	VPlatforms util.SafeMap[string, *PlatformVersions]
 }
 
-type ChannelVersions struct {
+type PlatformVersions struct {
 	VList util.SafeArray[*version.Version]
 	VMap  util.SafeMap[string, *version.Version]
 }
@@ -40,77 +40,112 @@ func VersionInit() *Versions {
 }
 
 func (this *Versions) Exec() {
+	VersionConfigTask.Reload()
 	var versions = make([]*version.Version, 0)
 	err := mgm.Coll(&version.Version{}).SimpleFind(&versions, bson.M{})
 	if err == nil {
-		this.VChannels.Clear()
+		this.VPlatforms.Clear()
 		for _, v := range versions {
-			if _, ok := this.VChannels.Get(v.Channel); !ok {
-				this.VChannels.Set(v.Channel, new(ChannelVersions))
+			key := strings.TrimSpace(v.Platform)
+			if _, ok := this.VPlatforms.Get(key); !ok {
+				this.VPlatforms.Set(key, new(PlatformVersions))
 			}
 
-			channelVersion, _ := this.VChannels.Get(v.Channel)
-			channelVersion.VList.Push(v)
-			channelVersion.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
+			platformVersions, _ := this.VPlatforms.Get(key)
+			platformVersions.VList.Push(v)
+			platformVersions.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
 		}
 	}
 }
 
-func (this *Versions) Check(version string, channel string, envVersion string) *version.Version {
+func (this *Versions) Check(version string, platform string, envVersion string) *version.Version {
 	envVersionRequired := strings.TrimSpace(envVersion) != ""
 	envVersion = NormalizeEnvVersion(envVersion)
+	platform = strings.TrimSpace(platform)
 
-	channelVersions, ok := this.VChannels.Get(channel)
-	if !ok { // 遍历列表
+	if result := this.checkPlatform(platform, version, envVersion, envVersionRequired); result != nil {
+		return result
+	}
+	// Historical records did not carry platform. They remain a read-only fallback
+	// until data is migrated, and never override platform-specific records.
+	if platform != "" {
+		if result := this.checkPlatform("", version, envVersion, envVersionRequired); result != nil {
+			return result
+		}
+	}
+	candidates := []string{platform}
+	if platform != "" {
+		candidates = append(candidates, "")
+	}
+	for _, candidate := range candidates {
+		if result := this.checkPlatform(candidate, version, releaseEnvVersion, true); result != nil {
+			return result
+		}
+		if result := this.checkPlatform(candidate, "", releaseEnvVersion, true); result != nil {
+			return result
+		}
+	}
+	return nil
+}
+
+func (this *Versions) checkPlatform(platform, version, envVersion string, envVersionRequired bool) *version.Version {
+	platformVersions, ok := this.VPlatforms.Get(strings.TrimSpace(platform))
+	if !ok {
 		return nil
 	}
 
+	if strings.TrimSpace(version) == "" {
+		return this.defaultVersion(platformVersions, envVersion)
+	}
+
 	if envVersionRequired {
-		if data := this.defaultVersion(channelVersions, channel, envVersion); data != nil {
-			return data
-		}
-		if data, ok := channelVersions.VMap.Get(versionMapKey(version, envVersion)); ok {
+		if data, ok := platformVersions.VMap.Get(versionMapKey(version, envVersion)); ok {
 			return data
 		}
 		return nil
 	}
 
-	if data := this.version(channelVersions, channel, version); data != nil {
+	if data, ok := platformVersions.VMap.Get(versionMapKey(version, releaseEnvVersion)); ok {
 		return data
 	}
-
-	return this.defaultVersion(channelVersions, channel, envVersion)
-}
-
-func (this *Versions) version(channelVersions *ChannelVersions, channel string, version string) *version.Version {
-	for _, v := range channelVersions.VList.View() {
-		if v.Channel == channel && v.Version == version {
-			return v
-		}
+	if data, ok := platformVersions.VMap.Get(versionMapKey(version, trialEnvVersion)); ok {
+		return data
 	}
 	return nil
 }
 
-func (this *Versions) defaultVersion(channelVersions *ChannelVersions, channel string, envVersion string) *version.Version {
-	for _, v := range channelVersions.VList.View() {
-		if v.Default && v.Channel == channel && NormalizeEnvVersion(v.EnvVersion) == envVersion {
-			return v
+func (this *Versions) defaultVersion(platformVersions *PlatformVersions, envVersion string) *version.Version {
+	var fallback *version.Version
+	var selected *version.Version
+	for _, v := range platformVersions.VList.View() {
+		if NormalizeEnvVersion(v.EnvVersion) != envVersion {
+			continue
+		}
+		if fallback == nil || CompareConfigVersion(v.Version, fallback.Version) > 0 {
+			fallback = v
+		}
+		if v.Default && (selected == nil || CompareConfigVersion(v.Version, selected.Version) > 0) {
+			selected = v
 		}
 	}
-	return nil
+	if selected != nil {
+		return selected
+	}
+	return fallback
 }
 
 func versionMapKey(version string, envVersion string) string {
 	return envVersion + ":" + version
 }
 
-func (this *Versions) Servers(channel string) []*version.Version {
+func (this *Versions) Servers(platform string) []*version.Version {
 	var res = make([]*version.Version, 0)
-	if channelVersions, ok := this.VChannels.Get(channel); ok {
-		for _, v := range channelVersions.VList.View() {
-			if v.Channel == channel {
-				res = append(res, v)
-			}
+	if platformVersions, ok := this.VPlatforms.Get(strings.TrimSpace(platform)); ok {
+		res = append(res, platformVersions.VList.View()...)
+	}
+	if len(res) == 0 && strings.TrimSpace(platform) != "" {
+		if legacyVersions, ok := this.VPlatforms.Get(""); ok {
+			res = append(res, legacyVersions.VList.View()...)
 		}
 	}
 	return res

+ 146 - 0
app/task/version_config.go

@@ -0,0 +1,146 @@
+package task
+
+import (
+	"dsbqj-admin/model/mongo/abver"
+	"dsbqj-admin/model/mongo/md5"
+	"strings"
+	"sync"
+
+	"github.com/kamva/mgm/v3"
+	"go.mongodb.org/mongo-driver/bson"
+)
+
+type VersionConfigCache struct {
+	sync.RWMutex
+	abVers map[string][]*abver.AbVer
+	md5s   map[string][]*md5.MD5
+}
+
+type ConfigValue struct {
+	Version string
+	Value   string
+}
+
+var VersionConfigTask = &VersionConfigCache{}
+
+func (cache *VersionConfigCache) Reload() {
+	abVers := make([]*abver.AbVer, 0)
+	md5s := make([]*md5.MD5, 0)
+	if mgm.Coll(&abver.AbVer{}).SimpleFind(&abVers, bson.M{}) != nil || mgm.Coll(&md5.MD5{}).SimpleFind(&md5s, bson.M{}) != nil {
+		return
+	}
+	newAbVers := make(map[string][]*abver.AbVer)
+	for _, item := range abVers {
+		key := configKey(item.Platform)
+		newAbVers[key] = append(newAbVers[key], item)
+	}
+	newMD5s := make(map[string][]*md5.MD5)
+	for _, item := range md5s {
+		key := configKey(item.Platform)
+		newMD5s[key] = append(newMD5s[key], item)
+	}
+	cache.Lock()
+	cache.abVers, cache.md5s = newAbVers, newMD5s
+	cache.Unlock()
+}
+
+func (cache *VersionConfigCache) RefreshAbVer(item *abver.AbVer) {
+	cache.Reload()
+}
+
+func (cache *VersionConfigCache) RefreshMD5(item *md5.MD5) {
+	cache.Reload()
+}
+
+func (cache *VersionConfigCache) GetAbVer(platform, env string) ConfigValue {
+	cache.RLock()
+	defer cache.RUnlock()
+	if value, ok := highestAbVer(cache.abVers[configKey(platform)], env); ok {
+		return value
+	}
+	if strings.TrimSpace(platform) != "" {
+		value, _ := highestAbVer(cache.abVers[configKey("")], env)
+		return value
+	}
+	return ConfigValue{}
+}
+
+func highestAbVer(items []*abver.AbVer, env string) (ConfigValue, bool) {
+	var result *abver.AbVer
+	for _, item := range items {
+		if item.External && configEnvMatches(item.EnvVersions, env) && (result == nil || CompareConfigVersion(item.Version, result.Version) > 0) {
+			result = item
+		}
+	}
+	if result == nil {
+		return ConfigValue{}, false
+	}
+	return ConfigValue{Version: result.Version, Value: result.Value}, true
+}
+
+func (cache *VersionConfigCache) GetMD5(platform, env string) ConfigValue {
+	cache.RLock()
+	defer cache.RUnlock()
+	if value, ok := highestMD5(cache.md5s[configKey(platform)], env); ok {
+		return value
+	}
+	if strings.TrimSpace(platform) != "" {
+		value, _ := highestMD5(cache.md5s[configKey("")], env)
+		return value
+	}
+	return ConfigValue{}
+}
+
+func highestMD5(items []*md5.MD5, env string) (ConfigValue, bool) {
+	var result *md5.MD5
+	for _, item := range items {
+		if item.External && configEnvMatches(item.EnvVersions, env) && (result == nil || CompareConfigVersion(item.Version, result.Version) > 0) {
+			result = item
+		}
+	}
+	if result == nil {
+		return ConfigValue{}, false
+	}
+	return ConfigValue{Version: result.Version, Value: result.Value}, true
+}
+
+func configKey(platform string) string { return strings.TrimSpace(platform) }
+
+func configEnvMatches(envs []string, env string) bool {
+	if len(envs) == 0 {
+		return true
+	}
+	for _, item := range envs {
+		if NormalizeEnvVersion(item) == env {
+			return true
+		}
+	}
+	return false
+}
+func CompareConfigVersion(left, right string) int {
+	l, r := strings.Split(left, "."), strings.Split(right, ".")
+	for i := 0; i < len(l) || i < len(r); i++ {
+		lv, rv := 0, 0
+		if i < len(l) {
+			for _, c := range l[i] {
+				if c >= '0' && c <= '9' {
+					lv = lv*10 + int(c-'0')
+				}
+			}
+		}
+		if i < len(r) {
+			for _, c := range r[i] {
+				if c >= '0' && c <= '9' {
+					rv = rv*10 + int(c-'0')
+				}
+			}
+		}
+		if lv != rv {
+			if lv < rv {
+				return -1
+			}
+			return 1
+		}
+	}
+	return 0
+}

+ 64 - 0
app/task/version_config_test.go

@@ -0,0 +1,64 @@
+package task
+
+import (
+	"testing"
+
+	"dsbqj-admin/model/mongo/abver"
+	"dsbqj-admin/model/mongo/md5"
+)
+
+func TestVersionConfigCacheReturnsValueAndTagVersion(t *testing.T) {
+	cache := &VersionConfigCache{
+		abVers: map[string][]*abver.AbVer{
+			"wx": {
+				{Platform: "wx", Version: "1.0.2", Value: "old", EnvVersions: []string{"release"}, External: true},
+				{Platform: "wx", Version: "1.0.10", Value: "new", EnvVersions: []string{"release"}, External: true},
+				{Platform: "wx", Version: "1.0.20", Value: "hidden", External: false},
+				{Platform: "wx", Version: "2.0.0", Value: "trial", EnvVersions: []string{"trial"}, External: true},
+			},
+		},
+		md5s: map[string][]*md5.MD5{
+			"wx": {
+				{Platform: "wx", Version: "1.0.5", Value: "hashes", EnvVersions: []string{"release"}, External: true},
+			},
+		},
+	}
+
+	if got := cache.GetAbVer("wx", "release"); got != (ConfigValue{Version: "1.0.10", Value: "new"}) {
+		t.Fatalf("abVer should return its selected tag and value together: %#v", got)
+	}
+	if got := cache.GetMD5("wx", "release"); got != (ConfigValue{Version: "1.0.5", Value: "hashes"}) {
+		t.Fatalf("md5 should return its own tag and value: %#v", got)
+	}
+	if got := cache.GetAbVer("wx", "trial"); got != (ConfigValue{Version: "2.0.0", Value: "trial"}) {
+		t.Fatalf("trial should select its own tag and value: %#v", got)
+	}
+	if got := cache.GetMD5("wx", "trial"); got != (ConfigValue{}) {
+		t.Fatalf("missing config should return empty tag and value: %#v", got)
+	}
+}
+
+func TestVersionConfigCacheLegacyFallbackAndEmptyValue(t *testing.T) {
+	cache := &VersionConfigCache{
+		abVers: map[string][]*abver.AbVer{
+			"":   {{Version: "1.0.1", Value: "legacy", External: true}},
+			"wx": {{Platform: "wx", Version: "1.0.2", Value: "", External: true}},
+		},
+	}
+
+	if got := cache.GetAbVer("wx", "release"); got != (ConfigValue{Version: "1.0.2", Value: ""}) {
+		t.Fatalf("platform record with empty value must not fall back to legacy tag: %#v", got)
+	}
+	if got := cache.GetAbVer("dy", "release"); got != (ConfigValue{Version: "1.0.1", Value: "legacy"}) {
+		t.Fatalf("missing platform record should use legacy fallback: %#v", got)
+	}
+}
+
+func TestCompareConfigVersionUsesNumericSegments(t *testing.T) {
+	if CompareConfigVersion("1.0.10", "1.0.9") <= 0 {
+		t.Fatal("1.0.10 should sort before 1.0.9 in descending order")
+	}
+	if CompareConfigVersion("1.0.4", "1.0.3") <= 0 {
+		t.Fatal("1.0.4 should sort before 1.0.3 in descending order")
+	}
+}

+ 136 - 41
app/task/version_test.go

@@ -7,26 +7,26 @@ import (
 
 func TestVersionsCheckEnvVersionAndDefault(t *testing.T) {
 	versions := VersionInit()
-	channel := new(ChannelVersions)
+	platform := new(PlatformVersions)
 
-	releaseDefault := &version.Version{Channel: "wx", EnvVersion: "release", Version: "1.0.0", Url: "正式服默认地址", Default: true}
-	releaseExact := &version.Version{Channel: "wx", EnvVersion: "release", Version: "1.0.1", Url: "正式服版本地址"}
-	trialDefault := &version.Version{Channel: "wx", EnvVersion: "trial", Version: "1.0.0", Url: "体验服默认地址", Default: true}
-	trialExact := &version.Version{Channel: "wx", EnvVersion: "trial", Version: "1.0.1", Url: "体验服版本地址"}
+	releaseDefault := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.0", Url: "正式服默认地址", Default: true}
+	releaseExact := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.1", Url: "正式服版本地址"}
+	trialDefault := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "1.0.0", Url: "体验服默认地址", Default: true}
+	trialExact := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "1.0.1", Url: "体验服版本地址"}
 
 	for _, v := range []*version.Version{releaseDefault, releaseExact, trialDefault, trialExact} {
-		channel.VList.Push(v)
-		channel.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
+		platform.VList.Push(v)
+		platform.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
 	}
 
-	versions.VChannels.Set("wx", channel)
+	versions.VPlatforms.Set("wx", platform)
 
-	if got := versions.Check("1.0.1", "wx", "trial"); got != trialDefault {
-		t.Fatalf("传入体验版环境版本时应优先返回体验版默认地址,实际返回 %#v", got)
+	if got := versions.Check("1.0.1", "wx", "trial"); got != trialExact {
+		t.Fatalf("传入版本号和体验版环境时应精确匹配体验版版本,实际返回 %#v", got)
 	}
 
-	if got := versions.Check("1.0.1", "wx", "release"); got != releaseDefault {
-		t.Fatalf("传入正式服环境版本时应优先返回正式服默认地址,实际返回 %#v", got)
+	if got := versions.Check("1.0.1", "wx", "release"); got != releaseExact {
+		t.Fatalf("传入版本号和正式服环境时应精确匹配正式服版本,实际返回 %#v", got)
 	}
 
 	if got := versions.Check("1.0.1", "wx", ""); got != releaseExact {
@@ -37,73 +37,79 @@ func TestVersionsCheckEnvVersionAndDefault(t *testing.T) {
 		t.Fatalf("未传环境版本时应优先按版本号返回任意环境版本,实际返回 %#v", got)
 	}
 
-	if got := versions.Check("9.9.9", "wx", "trial"); got != trialDefault {
-		t.Fatalf("体验版未知版本应返回体验版默认地址,实际返回 %#v", got)
+	if got := versions.Check("9.9.9", "wx", "trial"); got != releaseDefault {
+		t.Fatalf("体验版版本号未命中应回退到正式服默认版本,实际返回 %#v", got)
 	}
 
 	if got := versions.Check("9.9.9", "wx", "develop"); got != releaseDefault {
-		t.Fatalf("开发版未知版本应返回正式服默认地址,实际返回 %#v", got)
+		t.Fatalf("版本号未命中应回退到正式服默认版本,实际返回 %#v", got)
 	}
 }
 
 func TestVersionsCheckWithoutEnvVersionUsesVersionFirst(t *testing.T) {
 	versions := VersionInit()
-	channel := new(ChannelVersions)
+	platform := new(PlatformVersions)
 
-	releaseDefault := &version.Version{Channel: "wx", EnvVersion: "release", Version: "1.0.0", Url: "正式服默认地址", Default: true}
-	trialExact := &version.Version{Channel: "wx", EnvVersion: "trial", Version: "0.2.0", Url: "体验版版本地址"}
+	releaseDefault := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.0", Url: "正式服默认地址", Default: true}
+	trialExact := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "0.2.0", Url: "体验版版本地址"}
 
 	for _, v := range []*version.Version{releaseDefault, trialExact} {
-		channel.VList.Push(v)
-		channel.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
+		platform.VList.Push(v)
+		platform.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
 	}
-	versions.VChannels.Set("wx", channel)
+	versions.VPlatforms.Set("wx", platform)
 
 	if got := versions.Check("0.2.0", "wx", ""); got != trialExact {
 		t.Fatalf("未传环境版本时应按版本号命中体验版版本,实际返回 %#v", got)
 	}
 
 	if got := versions.Check("9.9.9", "wx", ""); got != releaseDefault {
-		t.Fatalf("未传环境版本且版本号不存在时应返回正式服默认地址,实际返回 %#v", got)
+		t.Fatalf("传入不存在的版本号应回退到正式服默认地址,实际返回 %#v", got)
 	}
 }
 
-func TestVersionsCheckEnvVersionPriority(t *testing.T) {
+func TestVersionsCheckVersionConditionOverridesDefault(t *testing.T) {
 	versions := VersionInit()
-	channel := new(ChannelVersions)
+	platform := new(PlatformVersions)
 
-	releaseDefault := &version.Version{Channel: "wx", EnvVersion: "release", Version: "1.0.0", Url: "正式服默认地址", Default: true}
-	releaseExact := &version.Version{Channel: "wx", EnvVersion: "release", Version: "1.0.1", Url: "正式服版本地址"}
-	trialDefault := &version.Version{Channel: "wx", EnvVersion: "trial", Version: "2.0.0", Url: "体验版默认地址", Default: true}
-	trialExact := &version.Version{Channel: "wx", EnvVersion: "trial", Version: "1.0.1", Url: "体验版版本地址"}
+	releaseDefault := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.0", Url: "正式服默认地址", Default: true}
+	releaseExact := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.1", Url: "正式服版本地址"}
+	trialDefault := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "2.0.0", Url: "体验版默认地址", Default: true}
+	trialExact := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "1.0.1", Url: "体验版版本地址"}
 
 	for _, v := range []*version.Version{releaseDefault, releaseExact, trialDefault, trialExact} {
-		channel.VList.Push(v)
-		channel.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
+		platform.VList.Push(v)
+		platform.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
 	}
-	versions.VChannels.Set("wx", channel)
+	versions.VPlatforms.Set("wx", platform)
 
-	if got := versions.Check("1.0.1", "wx", "release"); got != releaseDefault {
-		t.Fatalf("传入正式服环境版本时应优先返回正式服默认地址,实际返回 %#v", got)
+	if got := versions.Check("1.0.1", "wx", "release"); got != releaseExact {
+		t.Fatalf("传入正式服版本号时应匹配指定版本,实际返回 %#v", got)
 	}
 
-	if got := versions.Check("1.0.1", "wx", "trial"); got != trialDefault {
-		t.Fatalf("传入体验版环境版本时应优先返回体验版默认地址,实际返回 %#v", got)
+	if got := versions.Check("1.0.1", "wx", "trial"); got != trialExact {
+		t.Fatalf("传入体验版版本号时应匹配指定版本,实际返回 %#v", got)
+	}
+	if got := versions.Check("", "wx", "release"); got != releaseDefault {
+		t.Fatalf("不传版本号时应返回正式服默认版本,实际返回 %#v", got)
+	}
+	if got := versions.Check("", "wx", "trial"); got != trialDefault {
+		t.Fatalf("不传版本号时应返回体验版默认版本,实际返回 %#v", got)
 	}
 }
 
 func TestVersionsCheckEnvVersionWithoutDefault(t *testing.T) {
 	versions := VersionInit()
-	channel := new(ChannelVersions)
+	platform := new(PlatformVersions)
 
-	releaseExact := &version.Version{Channel: "wx", EnvVersion: "release", Version: "1.0.1", Url: "正式服版本地址"}
-	trialExact := &version.Version{Channel: "wx", EnvVersion: "trial", Version: "1.0.1", Url: "体验版版本地址"}
+	releaseExact := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.1", Url: "正式服版本地址"}
+	trialExact := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "1.0.1", Url: "体验版版本地址"}
 
 	for _, v := range []*version.Version{releaseExact, trialExact} {
-		channel.VList.Push(v)
-		channel.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
+		platform.VList.Push(v)
+		platform.VMap.Set(versionMapKey(v.Version, NormalizeEnvVersion(v.EnvVersion)), v)
 	}
-	versions.VChannels.Set("wx", channel)
+	versions.VPlatforms.Set("wx", platform)
 
 	if got := versions.Check("1.0.1", "wx", "release"); got != releaseExact {
 		t.Fatalf("正式服没有默认地址时应返回正式服版本地址,实际返回 %#v", got)
@@ -113,3 +119,92 @@ func TestVersionsCheckEnvVersionWithoutDefault(t *testing.T) {
 		t.Fatalf("体验版没有默认地址时应返回体验版版本地址,实际返回 %#v", got)
 	}
 }
+
+func TestVersionsCheckFallsBackToLegacyRecords(t *testing.T) {
+	versions := VersionInit()
+	legacy := new(PlatformVersions)
+	record := &version.Version{Channel: "bb", Version: "1.0.0", EnvVersion: "release", Url: "历史地址"}
+	legacy.VList.Push(record)
+	legacy.VMap.Set(versionMapKey(record.Version, NormalizeEnvVersion(record.EnvVersion)), record)
+	versions.VPlatforms.Set("", legacy)
+
+	if got := versions.Check("1.0.0", "wx", ""); got != record {
+		t.Fatalf("平台记录不存在时应兼容读取历史空平台记录,实际返回 %#v", got)
+	}
+}
+
+func TestVersionsCheckWithoutVersionUsesEnvironmentDefault(t *testing.T) {
+	versions := VersionInit()
+	platform := new(PlatformVersions)
+	releaseDefault := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.0", Default: true}
+	trialDefault := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "1.0.1", Default: true}
+	otherPlatformDefault := &version.Version{Platform: "dy", EnvVersion: "trial", Version: "9.9.9", Default: true}
+	platform.VList.Push(releaseDefault)
+	platform.VList.Push(trialDefault)
+	versions.VPlatforms.Set("wx", platform)
+	other := new(PlatformVersions)
+	other.VList.Push(otherPlatformDefault)
+	versions.VPlatforms.Set("dy", other)
+
+	if got := versions.Check("", "wx", "trial"); got != trialDefault {
+		t.Fatalf("empty version should use the trial default for the requested platform: %#v", got)
+	}
+	if got := versions.Check("", "wx", "release"); got != releaseDefault {
+		t.Fatalf("empty version should use the release default for the requested platform: %#v", got)
+	}
+	if got := versions.Check("", "wx", ""); got != releaseDefault {
+		t.Fatalf("empty version and environment should use the release default: %#v", got)
+	}
+	if got := versions.Check("", "dy", "trial"); got != otherPlatformDefault {
+		t.Fatalf("empty version should not cross platform boundaries: %#v", got)
+	}
+}
+
+func TestVersionsCheckWithoutVersionFallsBackToLatestEnvironmentVersion(t *testing.T) {
+	versions := VersionInit()
+	platform := new(PlatformVersions)
+	releaseOld := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.2"}
+	releaseLatest := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.10"}
+	trial := &version.Version{Platform: "wx", EnvVersion: "trial", Version: "2.0.0"}
+	for _, item := range []*version.Version{releaseOld, trial, releaseLatest} {
+		platform.VList.Push(item)
+	}
+	versions.VPlatforms.Set("wx", platform)
+
+	if got := versions.Check("", "wx", ""); got != releaseLatest {
+		t.Fatalf("空版本号和环境应返回最新正式服版本,实际返回 %#v", got)
+	}
+	if got := versions.Check("", "wx", "trial"); got != trial {
+		t.Fatalf("空版本号应只在指定环境内选择,实际返回 %#v", got)
+	}
+	if got := versions.Check("9.9.9", "wx", "trial"); got != releaseLatest {
+		t.Fatalf("指定版本和环境均未命中应返回最新正式服版本,实际返回 %#v", got)
+	}
+	if got := versions.Check("", "dy", ""); got != nil {
+		t.Fatalf("不应跨平台返回版本,实际返回 %#v", got)
+	}
+
+	releaseOld.Default = true
+	if got := versions.Check("", "wx", ""); got != releaseOld {
+		t.Fatalf("正式服默认标记应优先于最新版本,实际返回 %#v", got)
+	}
+}
+
+func TestVersionsCheckTrialMissFallsBackToMatchingReleaseVersion(t *testing.T) {
+	versions := VersionInit()
+	platform := new(PlatformVersions)
+	releaseDefault := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.0", Default: true}
+	releaseExact := &version.Version{Platform: "wx", EnvVersion: "release", Version: "1.0.1"}
+	for _, item := range []*version.Version{releaseDefault, releaseExact} {
+		platform.VList.Push(item)
+		platform.VMap.Set(versionMapKey(item.Version, releaseEnvVersion), item)
+	}
+	versions.VPlatforms.Set("wx", platform)
+
+	if got := versions.Check("1.0.1", "wx", "trial"); got != releaseExact {
+		t.Fatalf("体验服环境未命中时应优先返回同版本正式服配置,实际返回 %#v", got)
+	}
+	if got := versions.Check("9.9.9", "wx", "trial"); got != releaseDefault {
+		t.Fatalf("版本号未命中时应返回正式服默认配置,实际返回 %#v", got)
+	}
+}

+ 15 - 0
model/mongo/abver/abver.go

@@ -0,0 +1,15 @@
+package abver
+
+import "github.com/kamva/mgm/v3"
+
+type AbVer struct {
+	mgm.DefaultModel `bson:",inline" json:"id"`
+	Channel          string   `bson:"channel,omitempty" json:"channel,omitempty"` // Legacy only; new uploads do not set it.
+	Platform         string   `bson:"platform" json:"platform"`
+	Version          string   `bson:"version" json:"version"`
+	Value            string   `bson:"value" json:"value"`
+	EnvVersions      []string `bson:"envVersions" json:"envVersions"`
+	External         bool     `bson:"external" json:"external"`
+}
+
+func (AbVer) CollectionName() string { return "version_abvers" }

+ 15 - 0
model/mongo/md5/md5.go

@@ -0,0 +1,15 @@
+package md5
+
+import "github.com/kamva/mgm/v3"
+
+type MD5 struct {
+	mgm.DefaultModel `bson:",inline" json:"id"`
+	Channel          string   `bson:"channel,omitempty" json:"channel,omitempty"` // Legacy only; new uploads do not set it.
+	Platform         string   `bson:"platform" json:"platform"`
+	Version          string   `bson:"version" json:"version"`
+	Value            string   `bson:"value" json:"value"`
+	EnvVersions      []string `bson:"envVersions" json:"envVersions"`
+	External         bool     `bson:"external" json:"external"`
+}
+
+func (MD5) CollectionName() string { return "version_md5s" }

+ 12 - 0
model/mongo/platform/platform.go

@@ -0,0 +1,12 @@
+package platform
+
+import "github.com/kamva/mgm/v3"
+
+type Platform struct {
+	mgm.DefaultModel `bson:",inline" json:"id"`
+	Name             string `bson:"name" json:"name"`
+	Code             string `bson:"code" json:"code"`
+	Status           bool   `bson:"status" json:"status"`
+}
+
+func (Platform) CollectionName() string { return "platforms" }

+ 6 - 3
model/mongo/version/version.go

@@ -6,14 +6,17 @@ import (
 
 type Version struct {
 	mgm.DefaultModel `bson:",inline" json:"id"`
-	Channel          string `bson:"channel" json:"channel"`
+	Channel          string `bson:"channel,omitempty" json:"channel,omitempty"` // Legacy only; new release matching ignores channel.
+	Platform         string `bson:"platform" json:"platform"`
 	EnvVersion       string `bson:"envVersion" json:"envVersion"`
 	Version          string `bson:"version" json:"version"`
 	Name             string `bson:"name" json:"name"`
 	CDN              string `bson:"cdn" json:"cdn"`
 	Url              string `bson:"url" json:"url"`
-	AbVer            string `bson:"abVer" json:"abVer"`
-	MD5              string `bson:"md5" json:"md5"`
+	AbVer            string `bson:"-" json:"abVer,omitempty"`
+	AbVerVersion     string `bson:"-" json:"abVerVersion,omitempty"`
+	MD5              string `bson:"-" json:"md5,omitempty"`
+	MD5Version       string `bson:"-" json:"md5Version,omitempty"`
 	Default          bool   `bson:"default" json:"default"`
 	IsProd           bool   `bson:"isProd" json:"isProd"`
 }

+ 18 - 0
pkg/serializer/platform.go

@@ -0,0 +1,18 @@
+package serializer
+
+import "dsbqj-admin/model/mongo/platform"
+
+type PlatformResponse struct {
+	ID     string `json:"id"`
+	Name   string `json:"name"`
+	Code   string `json:"code"`
+	Status bool   `json:"status"`
+}
+
+func BuildPlatformList(list []platform.Platform) []*PlatformResponse {
+	result := make([]*PlatformResponse, 0, len(list))
+	for i := range list {
+		result = append(result, &PlatformResponse{ID: list[i].ID.Hex(), Name: list[i].Name, Code: list[i].Code, Status: list[i].Status})
+	}
+	return result
+}

+ 20 - 10
pkg/serializer/version.go

@@ -6,22 +6,32 @@ import (
 )
 
 type Version struct {
-	Version    string `json:"version"`
-	EnvVersion string `json:"envVersion"`
-	Name       string `json:"name"`
-	CDN        string `json:"cdn"`
-	Url        string `json:"url"`
+	Platform     string `json:"platform"`
+	Version      string `json:"version"`
+	EnvVersion   string `json:"envVersion"`
+	Name         string `json:"name"`
+	CDN          string `json:"cdn"`
+	Url          string `json:"url"`
+	AbVer        string `json:"abVer"`
+	AbVerVersion string `json:"abVerVersion"`
+	MD5          string `json:"md5"`
+	MD5Version   string `json:"md5Version"`
 }
 
 func BuildVersion(version *version.Version) *Version {
 	var res *Version
 	if version != nil {
 		res = &Version{
-			Version:    version.Version,
-			EnvVersion: normalizeEnvVersion(version.EnvVersion),
-			Name:       version.Name,
-			CDN:        version.CDN,
-			Url:        version.Url,
+			Platform:     version.Platform,
+			Version:      version.Version,
+			EnvVersion:   normalizeEnvVersion(version.EnvVersion),
+			Name:         version.Name,
+			CDN:          version.CDN,
+			Url:          version.Url,
+			AbVer:        version.AbVer,
+			AbVerVersion: version.AbVerVersion,
+			MD5:          version.MD5,
+			MD5Version:   version.MD5Version,
 		}
 	}
 	return res

+ 19 - 0
pkg/serializer/version_test.go

@@ -0,0 +1,19 @@
+package serializer
+
+import (
+	"testing"
+
+	modelversion "dsbqj-admin/model/mongo/version"
+)
+
+func TestBuildVersionIncludesConfigTagVersions(t *testing.T) {
+	item := &modelversion.Version{
+		Platform: "wx", Version: "1.0.2", EnvVersion: "release",
+		AbVer: "ab-value", AbVerVersion: "1.0.10",
+		MD5: "md5-value", MD5Version: "1.0.5",
+	}
+	got := BuildVersion(item)
+	if got.AbVer != "ab-value" || got.AbVerVersion != "1.0.10" || got.MD5 != "md5-value" || got.MD5Version != "1.0.5" {
+		t.Fatalf("config value and tag version should both be serialized: %#v", got)
+	}
+}