From 6e65935e3c0c6a897242f5b9e95cabe2e9ec6822 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sat, 12 Sep 2026 17:07:49 +0800 Subject: [PATCH] feat: manage global app release policies --- .../api/admin/imconfiginternal/im_config.go | 23 ++++++ .../internalapi/imconfig/im_config.go | 45 ++++++++++++ server/internal/logic/sys/im_app_release.go | 29 ++++---- .../model/input/sysin/im_app_release.go | 71 +++++++++++++++++-- .../model/input/sysin/im_app_release_test.go | 39 ++++++++++ .../data/generate/im_app_releases_global.sql | 26 +++++++ web/src/views/imAppRelease/index.vue | 35 ++------- web/src/views/imAppRelease/model.ts | 26 +------ 8 files changed, 220 insertions(+), 74 deletions(-) create mode 100644 server/internal/model/input/sysin/im_app_release_test.go create mode 100644 server/storage/data/generate/im_app_releases_global.sql diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 9acd7e7..4633ef2 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -68,3 +68,26 @@ type TenantLoginIPWhitelistRes struct { AppList []string `json:"app_list" dc:"APP登录IP白名单"` WebList []string `json:"web_list" dc:"Web登录IP白名单"` } + +type AppReleaseLatestReq struct { + g.Meta `path:"/internal/im-config/app/releases/latest" method:"get" tags:"IM内部配置" summary:"获取客户端最新版本策略"` + Platform string `json:"platform" v:"required#平台不能为空" dc:"平台"` + Channel string `json:"channel" dc:"渠道"` +} + +type AppReleaseLatestRes struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Channel string `json:"channel"` + LatestVersion string `json:"latest_version"` + MinSupportedVersion string `json:"min_supported_version"` + UpdateType string `json:"update_type"` + ReleaseNotes string `json:"release_notes"` + StoreURL string `json:"store_url"` + PackageURL string `json:"package_url"` + Checksum string `json:"checksum"` + RemindAfterSeconds int `json:"remind_after_seconds"` + PublishedAt string `json:"published_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index 342ca5d..32a1a55 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -102,6 +102,51 @@ func (c *cConfig) TenantLoginIPWhitelist(ctx context.Context, req *api.TenantLog return &api.TenantLoginIPWhitelistRes{TenantId: data.TenantId, AppList: data.AppList, WebList: data.WebList}, nil } +func (c *cConfig) AppReleaseLatest(ctx context.Context, req *api.AppReleaseLatestReq) (res *api.AppReleaseLatestRes, err error) { + platform := strings.ToLower(strings.TrimSpace(req.Platform)) + channel := strings.ToLower(strings.TrimSpace(req.Channel)) + if platform != "ios" && platform != "android" { + return nil, gerror.New("客户端平台不合法") + } + if channel == "" { + channel = "stable" + } + if strings.ContainsAny(channel, " \t\r\n/") || len(channel) > 32 { + return nil, gerror.New("客户端渠道不合法") + } + var row struct { + ID int64 `json:"id"` + Platform string `json:"platform"` + Channel string `json:"channel"` + LatestVersion string `json:"latest_version"` + MinSupportedVersion string `json:"min_supported_version"` + UpdateType string `json:"update_type"` + ReleaseNotes string `json:"release_notes"` + StoreURL string `json:"store_url"` + PackageURL string `json:"package_url"` + Checksum string `json:"checksum"` + RemindAfterSeconds int `json:"remind_after_seconds"` + PublishedAt *gtime.Time `json:"published_at"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` + } + err = imtenant.RegistryDB().Model("im_app_releases").Safe().Ctx(ctx). + Where("platform", platform).Where("channel", channel).Limit(1).Scan(&row) + if err != nil { + return nil, gerror.Wrap(err, "获取IM客户端版本策略失败") + } + if row.ID <= 0 { + return nil, gerror.New("IM客户端版本策略不存在") + } + return &api.AppReleaseLatestRes{ + ID: row.ID, Platform: row.Platform, Channel: row.Channel, + LatestVersion: row.LatestVersion, MinSupportedVersion: row.MinSupportedVersion, + UpdateType: row.UpdateType, ReleaseNotes: row.ReleaseNotes, StoreURL: row.StoreURL, + PackageURL: row.PackageURL, Checksum: row.Checksum, RemindAfterSeconds: row.RemindAfterSeconds, + PublishedAt: gtimeToRFC3339(row.PublishedAt), CreatedAt: gtimeToRFC3339(row.CreatedAt), UpdatedAt: gtimeToRFC3339(row.UpdatedAt), + }, nil +} + func getTenant(ctx context.Context, field string, value any) (*entity.ImTenant, error) { var tenant *entity.ImTenant if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).Where(field, value).Scan(&tenant); err != nil { diff --git a/server/internal/logic/sys/im_app_release.go b/server/internal/logic/sys/im_app_release.go index 396ca55..85c95b0 100644 --- a/server/internal/logic/sys/im_app_release.go +++ b/server/internal/logic/sys/im_app_release.go @@ -2,7 +2,12 @@ package sys import ( "context" + "fmt" + "hotgo/internal/consts" + "hotgo/internal/library/contexts" + "hotgo/internal/library/imkafka" + "hotgo/internal/library/imtenant" "hotgo/internal/model/input/sysin" "hotgo/internal/service" @@ -26,10 +31,7 @@ func (s *sSysImAppRelease) List(ctx context.Context, in *sysin.ImAppReleaseListI if err = in.Filter(ctx); err != nil { return } - db, tenant, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return - } + db := imtenant.RegistryDB() if err = s.ensureDefaults(ctx, db); err != nil { return } @@ -45,10 +47,6 @@ func (s *sSysImAppRelease) List(ctx context.Context, in *sysin.ImAppReleaseListI err = gerror.Wrap(err, "获取IM客户端版本策略失败,请稍后重试!") return } - for _, item := range list { - item.TenantId = tenant.Id - item.TenantName = tenant.Name - } return } @@ -56,10 +54,7 @@ func (s *sSysImAppRelease) Edit(ctx context.Context, in *sysin.ImAppReleaseEditI if err = in.Filter(ctx); err != nil { return } - db, tenant, err := resolveImTenantDB(ctx, in.TenantId) - if err != nil { - return - } + db := imtenant.RegistryDB() if err = s.ensureDefaults(ctx, db); err != nil { return } @@ -96,9 +91,17 @@ func (s *sSysImAppRelease) Edit(ctx context.Context, in *sysin.ImAppReleaseEditI } targetId = idValue.Int64() } - _ = writeImAdminAudit(ctx, tenant.Id, action, "im_app_release", targetId, "success", g.Map{ + _ = writeImAdminAudit(ctx, 0, action, "im_app_release", targetId, "success", g.Map{ "platform": in.Platform, "channel": in.Channel, "update_type": in.UpdateType, }) + if err = imkafka.PublishConfigEvent(ctx, imkafka.ConfigEventInput{ + TenantCode: "*", EventType: "app.release.updated", Group: "app_release", + Keys: []string{in.Platform, in.Channel}, BizID: in.Platform + ":" + in.Channel, + Actor: &imkafka.Actor{Type: "admin", ID: fmt.Sprint(contexts.GetUserId(ctx))}, + CreatedBy: contexts.GetUserId(ctx), + }); err != nil { + g.Log().Warningf(ctx, "版本策略已保存,缓存失效事件等待 Outbox 重试: %v", err) + } return nil } diff --git a/server/internal/model/input/sysin/im_app_release.go b/server/internal/model/input/sysin/im_app_release.go index 5b4f1fb..9147a8f 100644 --- a/server/internal/model/input/sysin/im_app_release.go +++ b/server/internal/model/input/sysin/im_app_release.go @@ -2,10 +2,13 @@ package sysin import ( "context" + "net/url" + "strconv" + "strings" + "hotgo/internal/consts" "hotgo/internal/library/dict" "hotgo/internal/model/input/form" - "strings" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/os/gtime" @@ -13,7 +16,6 @@ import ( type ImAppReleaseListInp struct { form.PageReq - TenantId int64 `json:"tenantId" dc:"租户ID"` Platform string `json:"platform" dc:"平台"` Channel string `json:"channel" dc:"渠道"` } @@ -28,8 +30,6 @@ func (in *ImAppReleaseListInp) Filter(ctx context.Context) (err error) { } type ImAppReleaseModel struct { - TenantId int64 `json:"tenantId" dc:"租户ID"` - TenantName string `json:"tenantName" dc:"租户名称"` Id int64 `json:"id" dc:"ID"` Platform string `json:"platform" dc:"平台"` Channel string `json:"channel" dc:"渠道"` @@ -47,7 +47,6 @@ type ImAppReleaseModel struct { } type ImAppReleaseEditInp struct { - TenantId int64 `json:"tenantId" dc:"租户ID"` Id int64 `json:"id" dc:"ID"` Platform string `json:"platform" v:"required#平台不能为空" dc:"平台"` Channel string `json:"channel" v:"required#渠道不能为空" dc:"渠道"` @@ -82,11 +81,73 @@ func (in *ImAppReleaseEditInp) Filter(ctx context.Context) (err error) { if in.MinSupportedVersion == "" || len([]rune(in.MinSupportedVersion)) > 64 { return gerror.New("最低支持版本不能为空且不能超过64个字符") } + latest, err := parseAppVersion(in.LatestVersion) + if err != nil { + return gerror.New("最新版本号格式不正确,请使用数字点分版本号") + } + minimum, err := parseAppVersion(in.MinSupportedVersion) + if err != nil { + return gerror.New("最低支持版本号格式不正确,请使用数字点分版本号") + } + if compareAppVersion(latest, minimum) < 0 { + return gerror.New("最新版本不能低于最低支持版本") + } if !dict.HasOptionKey(consts.ImAppUpdateTypeOptions, in.UpdateType) { return gerror.New("更新类型不合法") } if in.RemindAfterSeconds < 0 { return gerror.New("再次提醒时间不能小于0") } + if in.Platform == consts.ImAppPlatformIos && in.UpdateType != consts.ImAppUpdateTypeNone { + storeURL, parseErr := url.Parse(in.StoreUrl) + if parseErr != nil || storeURL.Scheme != "https" || !strings.EqualFold(storeURL.Hostname(), "apps.apple.com") { + return gerror.New("iOS更新策略需要配置 apps.apple.com 的 HTTPS 商店地址") + } + in.PackageUrl = "" + in.Checksum = "" + } return nil } + +func parseAppVersion(raw string) ([]uint64, error) { + raw = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(raw)), "v") + parts := strings.Split(raw, ".") + if len(parts) == 0 || len(parts) > 8 { + return nil, gerror.New("版本号格式不正确") + } + result := make([]uint64, len(parts)) + for i, part := range parts { + if part == "" { + return nil, gerror.New("版本号格式不正确") + } + value, err := strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, err + } + result[i] = value + } + return result, nil +} + +func compareAppVersion(left, right []uint64) int { + length := len(left) + if len(right) > length { + length = len(right) + } + for i := 0; i < length; i++ { + var l, r uint64 + if i < len(left) { + l = left[i] + } + if i < len(right) { + r = right[i] + } + if l < r { + return -1 + } + if l > r { + return 1 + } + } + return 0 +} diff --git a/server/internal/model/input/sysin/im_app_release_test.go b/server/internal/model/input/sysin/im_app_release_test.go new file mode 100644 index 0000000..99c5807 --- /dev/null +++ b/server/internal/model/input/sysin/im_app_release_test.go @@ -0,0 +1,39 @@ +package sysin + +import "testing" + +func TestImAppReleaseEditFilter(t *testing.T) { + tests := []struct { + name string + input ImAppReleaseEditInp + wantErr bool + }{ + { + name: "valid ios release", + input: ImAppReleaseEditInp{Platform: "ios", Channel: "stable", LatestVersion: "1.10.0", MinSupportedVersion: "1.9.9", UpdateType: "recommended", StoreUrl: "https://apps.apple.com/app/id123"}, + }, + { + name: "minimum newer than latest", + input: ImAppReleaseEditInp{Platform: "android", Channel: "stable", LatestVersion: "1.2.0", MinSupportedVersion: "2.0.0", UpdateType: "force"}, + wantErr: true, + }, + { + name: "invalid version", + input: ImAppReleaseEditInp{Platform: "android", Channel: "stable", LatestVersion: "1.2-beta", MinSupportedVersion: "1.0.0", UpdateType: "recommended"}, + wantErr: true, + }, + { + name: "ios store url required", + input: ImAppReleaseEditInp{Platform: "ios", Channel: "stable", LatestVersion: "2.0.0", MinSupportedVersion: "1.0.0", UpdateType: "force", StoreUrl: "https://example.com/app"}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.Filter(nil) + if (err != nil) != tt.wantErr { + t.Fatalf("Filter() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/server/storage/data/generate/im_app_releases_global.sql b/server/storage/data/generate/im_app_releases_global.sql new file mode 100644 index 0000000..bab3f58 --- /dev/null +++ b/server/storage/data/generate/im_app_releases_global.sql @@ -0,0 +1,26 @@ +-- Global MeetingIM client release policy. This table belongs to hotgo and is not tenant-scoped. +CREATE TABLE IF NOT EXISTS `im_app_releases` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `platform` VARCHAR(32) NOT NULL, + `channel` VARCHAR(32) NOT NULL DEFAULT 'stable', + `latest_version` VARCHAR(64) NOT NULL, + `min_supported_version` VARCHAR(64) NOT NULL, + `update_type` VARCHAR(16) NOT NULL DEFAULT 'none', + `release_notes` TEXT NOT NULL, + `store_url` VARCHAR(512) NOT NULL DEFAULT '', + `package_url` VARCHAR(512) NOT NULL DEFAULT '', + `checksum` VARCHAR(128) NOT NULL DEFAULT '', + `remind_after_seconds` INT NOT NULL DEFAULT 0, + `published_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_app_releases_platform_channel` (`platform`, `channel`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='MeetingIM全局客户端版本策略'; + +INSERT INTO `im_app_releases` + (`platform`, `channel`, `latest_version`, `min_supported_version`, `update_type`, `release_notes`) +VALUES + ('ios', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。'), + ('android', 'stable', '1.0.0', '1.0.0', 'none', '当前已是最新版本。') +ON DUPLICATE KEY UPDATE `platform` = VALUES(`platform`); diff --git a/web/src/views/imAppRelease/index.vue b/web/src/views/imAppRelease/index.vue index 5536f09..05f3d5a 100644 --- a/web/src/views/imAppRelease/index.vue +++ b/web/src/views/imAppRelease/index.vue @@ -1,7 +1,7 @@