package comm import "strings" // 应用发版与入口控制配置(表 TableAppRelease,存 console 共享库 postgres)。 // // 一行 = 一个应用(`app_name=”` 为全局默认兜底),**不分渠道**。对应后台 // 「服务配置 → 版本控制」与「服务配置 → 游客显隐控制」两个页面。 // // 为什么从 ChannelApp 里拆出来:版本号、强更、游客开关这几项本来就是「整个应用一套」, // 按渠道各存一份的直接后果是同一个开关有 8 处能改、客户端只读其中一处 —— 线上真出过 // 「后台明明开了游客登录、App 就是不显示」的事故。拆出来之后这些字段**只有这一个来源**。 // // 仍留在 ChannelApp 里的是真正因渠道而异的东西:下载地址、上架状态、支付渠道、 // 是否允许跳过设备绑定。其中下载地址在这里也有一份**默认值**,渠道没单独填时用它 —— // 华为/小米/App Store 的商店链接各不相同,客户端是直接把它当 URL 打开的, // 一套地址会把小米用户引到华为商店,所以渠道级覆盖必须保留。 type AppRelease struct { RowId uint64 `gorm:"primaryKey;autoIncrement;column:row_id" json:"row_id"` AppName string `gorm:"column:app_name;size:64;uniqueIndex:uidx_app_release" json:"app_name"` // ''=全局默认 // ── 版本控制 ── Version string `gorm:"column:version;size:32" json:"version"` // 当前线上版本 MinVersion string `gorm:"column:min_version;size:32" json:"min_version"` // 最低支持版本,低于它的客户端必须更新 ForceUpdate bool `gorm:"column:force_update" json:"force_update"` // 强制更新总开关(配合 MinVersion) Changelog string `gorm:"column:changelog" json:"changelog"` // 更新日志,下发给客户端展示 Address string `gorm:"column:address" json:"address"` // 默认下载地址;渠道填了自己的则以渠道为准 ReviewVersion string `gorm:"column:review_version;size:32" json:"review_version"` // 送审版本号;客户端报这一版时隐藏未过审功能 // ── iOS 单独一套版本号(2026-09-23 加)── // iOS 审核慢,Android 先发 1.0.3 时 iOS 商店里往往还是 1.0.2;共用一个 Version 会让 // iOS 用户被提示更新、点进 App Store 却没有新版。Version/MinVersion 从此只管 Android, // iOS 渠道(GlobalApple)读这两列;ForceUpdate 开关两端共用。取值一律走 VersionFor/MinVersionFor。 IosVersion string `gorm:"column:ios_version;size:32" json:"ios_version"` IosMinVersion string `gorm:"column:ios_min_version;size:32" json:"ios_min_version"` // ── 下载落地页(2026-09-23 加)── // 分发桶上那份静态下载页的公开地址(外包装二维码指向它),如 https://dl.ymaikj.com/EAIMAR/download.html。 // 后台「版本与分发」页编辑页面源码、点「保存」发布到 OSS 时一并写这一列;留空 = 用默认地址 // <分发域名>/<应用名>/download.html。只有 console 读写它,不下发给客户端。 DownloadPage string `gorm:"column:download_page" json:"download_page"` // ── 游客显隐 ── Tourists bool `gorm:"column:tourists" json:"tourists"` // 允许游客登录(总开关) TouristsVersion string `gorm:"column:touristsversion;size:32" json:"touristsversion"` // 生效版本,白名单语义见 ShowTourists Createtime int64 `gorm:"column:createtime" json:"createtime"` Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` } // 客户端平台。发版配置里 Android / iOS 各一套版本号,按渠道推导(见 PlatformOfChannel)。 const ( PlatformAndroid = "android" PlatformIOS = "ios" ) // PlatformOfChannel 由渠道号推导平台:只有 App Store 渠道是 iOS,其余(国内五家商店、官方、Google)都是 Android。 // 客户端不单独上报平台,渠道号本身就带这个信息(iOS 恒为 GlobalApple,见 version_update_service.dart)。 func PlatformOfChannel(channel int32) string { if channel == ChannelGlobalApple { return PlatformIOS } return PlatformAndroid } // VersionFor 取某平台的线上版本号;iOS 没单独填时**不回退 Android 的**—— // 回退等于又回到「共用一个版本号」的老问题,iOS 用户会被指向一个商店里还没有的版本。 func (r *AppRelease) VersionFor(platform string) string { if r == nil { return "" } if platform == PlatformIOS { return strings.TrimSpace(r.IosVersion) } return strings.TrimSpace(r.Version) } // MinVersionFor 取某平台的最低支持版本;**留空按等于线上版本处理**(2026-09-23 定的口径): // 运营勾上强更、不填最低版本,语义就是「所有低于线上版本的都必须更新」; // 填了则只强更低于它的,介于两者之间的仍是推荐更新。 func (r *AppRelease) MinVersionFor(platform string) string { if r == nil { return "" } min := r.MinVersion if platform == PlatformIOS { min = r.IosMinVersion } if m := strings.TrimSpace(min); m != "" { return m } return r.VersionFor(platform) } // NeedForceUpdateOn 判断某平台某客户端版本是否必须更新:开了强更开关、且客户端版本低于该平台最低支持版本 //(最低版本留空 = 线上版本,见 MinVersionFor)。 // // 客户端版本为空(老客户端没上报)时不强更,避免把所有人挡在门外 —— 这个判定一旦判宽只是 // 少弹一次强更框,判严就是全员无法使用,失败方向必须偏向放行。 func (r *AppRelease) NeedForceUpdateOn(clientVersion, platform string) bool { if r == nil || !r.ForceUpdate { return false } min := r.MinVersionFor(platform) if min == "" || strings.TrimSpace(clientVersion) == "" { return false } return CompareVersion(clientVersion, min) < 0 } // NeedUpdateOn 判断某平台是否有更新可用:该平台线上版本高于客户端版本。 func (r *AppRelease) NeedUpdateOn(clientVersion, platform string) bool { ver := r.VersionFor(platform) if ver == "" || strings.TrimSpace(clientVersion) == "" { return false } return CompareVersion(clientVersion, ver) < 0 } // NeedForceUpdate / NeedUpdate 是 Android 口径的便捷写法(老调用点与测试沿用)。 func (r *AppRelease) NeedForceUpdate(clientVersion string) bool { return r.NeedForceUpdateOn(clientVersion, PlatformAndroid) } func (r *AppRelease) NeedUpdate(clientVersion string) bool { return r.NeedUpdateOn(clientVersion, PlatformAndroid) } // IsReviewing 判断某客户端版本是否正是「送审版本」——客户端据此隐藏未过审的功能。 func (r *AppRelease) IsReviewing(clientVersion string) bool { if r == nil || strings.TrimSpace(r.ReviewVersion) == "" { return false } return CompareVersion(clientVersion, r.ReviewVersion) == 0 } // ShowTourists 判断某客户端版本是否该看到「游客登录」入口。 // // 判定一律在服务端做:客户端只拿结论,不拿规则。各端各写一遍版本比较必然出现实现不一致, // 而这个开关直接关系到应用商店过审,判错代价很高。 // // 语义是「白名单」:**只有被点名的那一个版本可见,其余一律隐藏**。 // - Tourists=false → 一律隐藏(总开关关掉) // - Tourists=true 且 TouristsVersion="" → 一律隐藏(没点名任何版本 = 谁都不放行) // - Tourists=true 且 TouristsVersion=X → 只有版本号等于 X 的客户端可见 // // 送审场景:只给正在审核的那一版开游客登录,线上其它版本不受影响。 // // ⚠️ 留空按「全隐藏」而非「全放行」处理,是刻意选的失败方向:这个开关一旦判宽, // 后果是不该露出游客入口的版本露了出去(过审风险、白嫖开户礼);判严只是入口少显示。 // 后台保存时会拦住「勾了开关却没填版本」这种等于没开的配置,避免运营踩空。 // // 客户端没上报版本时同样隐藏——点名了版本却认不出来是哪一版,只能从严。 func (r *AppRelease) ShowTourists(clientVersion string) bool { if r == nil || !r.Tourists { return false } want := strings.TrimSpace(r.TouristsVersion) if want == "" { return false } if strings.TrimSpace(clientVersion) == "" { return false } return CompareVersion(clientVersion, want) == 0 } // ShowIdVerify 判断某客户端版本是否显示「实名认证」、并按实名限制功能(2026-09-23 定的口径)。 // // 与游客登录**同一个口子**:两者都是为应对应用商店审核,所以后台只有「游客显隐控制」一个开关, // 能看到游客登录的那一版同时显示实名认证并要求实名,其余版本实名整个隐藏、功能不受实名限制。 // 不分 Android / iOS(用户定的)。 // // 单独成函数、单独下发字段(DBChannelApp.idverify)而不是让客户端直接读 tourists: // 将来要把实名和游客拆开控制时只改这里和后台,不用再发一版 App。 func (r *AppRelease) ShowIdVerify(clientVersion string) bool { return r.ShowTourists(clientVersion) } // DownloadAddress 取该渠道实际生效的下载地址:渠道填了就用渠道的,没填回退到应用默认。 func (r *AppRelease) DownloadAddress(channelAddress string) string { if s := strings.TrimSpace(channelAddress); s != "" { return channelAddress } if r == nil { return "" } return r.Address } // ResolveDownloadAddress 算出某渠道客户端「立即更新」要打开的地址(2026-09-23 定的回退链): // // Android 各渠道:本渠道(须已上架)→ 官方渠道(100) 的地址 → 应用默认下载地址 // iOS / Google:只认本渠道(须已上架),没有就空——绝不能回退到官方渠道或默认地址, // 那两处如今放的是 .apk 直链,iPhone 打开一个 apk 等于把用户引进死胡同。 // // 「须已上架」:渠道行状态是审核中 / 已下架时,商店里还是旧包,跳过去用户下到的仍是旧版, // 所以这条渠道地址视同没有;Android 顺着回退链拿官方包,iOS 则不提示更新。 // 返回空串 = 没有任何可去的地方,调用方据此**不提示更新**(强更也不行——一个点了没反应 // 又关不掉的弹窗等于把用户锁死)。 func (r *AppRelease) ResolveDownloadAddress(channel int32, ch *ChannelApp, officialAddress string) string { chAddr := "" if ch != nil && ch.Status == ChannelStatusOnline { chAddr = strings.TrimSpace(ch.Address) } if chAddr != "" { return chAddr } if channel == ChannelGlobalApple || channel == ChannelGlobalGoogle { return "" } if s := strings.TrimSpace(officialAddress); s != "" { return s } if r == nil { return "" } return strings.TrimSpace(r.Address) } // PickAppRelease 从「应用行 + 全局行」里挑生效的一条:应用行覆盖全局行,都没有返回 nil。 // 与 agent / 第三方服务 / 渠道分发下发同语义(应用优先、无则回退全局)。 func PickAppRelease(rows []*AppRelease, app string) *AppRelease { var global, scoped *AppRelease for _, r := range rows { if r == nil { continue } switch r.AppName { case app: scoped = r case "": global = r } } if scoped != nil { return scoped } return global }