diff --git a/apps/admin/app/composables/useApi.ts b/apps/admin/app/composables/useApi.ts index 682b122f..bd30239f 100644 --- a/apps/admin/app/composables/useApi.ts +++ b/apps/admin/app/composables/useApi.ts @@ -89,30 +89,65 @@ export function useApi() { // 浏览器用同样的 Content-Type PUT 直传,最终 URL(去签名 query)作为文件地址返回。 // 注意:OSS 预签名 PUT 把 Content-Type 计入签名,故这里 PUT 的 Content-Type 必须与提交给后端 // 签名的 content_type 完全一致;filename 也要一并提交,后端才能拼出完整 object key。 - async function uploadFile(file: File, path: string): Promise { + // + // opts.target='dist' 走「分发桶」(dl.ymaikj.com),用于 App 安装包;缺省走主桶(固件/产品图)。 + // opts.onProgress 给大文件用:安装包几十 MB,没有进度的话用户只看到按钮转圈转两分钟, + // 分不清是在传还是卡死了。 + async function uploadFile( + file: File, + path: string, + opts: { target?: string; appName?: string; onProgress?: (percent: number) => void } = {}, + ): Promise { const contentType = file.type || 'application/octet-stream' + // ⚠️ opts.appName 是页面上选中的应用名,安装包上传必须带: + // X-App-Id 头来自登录态的 currentAppId,只有绑定了应用的运营/代理账号才有; + // 超管在页面顶部选的应用只是个 app_name 字符串,不会进请求头, + // 不带 app_name 的话超管传包必然被后端拒成「必须先选择应用」。 const token = await webApi<{ upload_url?: string url?: string - }>('getcostoken', { path, filename: file.name, content_type: contentType }) + content_type?: string + }>('getcostoken', { + path, + filename: file.name, + content_type: contentType, + target: opts.target || '', + app_name: opts.appName || '', + }) + + // ⚠️ 以后端回带的为准:服务端会按扩展名强制改写 Content-Type(安装包就是这样), + // 而浏览器对 .apk 给出的 File.type 往往是**空字符串**。照 file.type 发 PUT 会与签名不符, + // OSS 回 403 SignatureDoesNotMatch,报错里完全看不出是类型对不上。 + const putType = token.content_type || contentType const uploadUrl = token.upload_url || token.url || '' if (!uploadUrl) { throw new Error('未获取到上传地址:请确认已在【第三方服务配置→存储】配置并启用「阿里云 OSS」') } - const putResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'Content-Type': contentType, - }, - body: file, + // ⚠️ 用 XHR 不用 fetch:fetch 没有上传进度事件(只有下载方向的 stream), + // 而这个入口就是给几十 MB 的安装包用的。 + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('PUT', uploadUrl, true) + // 必须与后端签名时用的 Content-Type 完全一致,理由见上面 putType + xhr.setRequestHeader('Content-Type', putType) + if (opts.onProgress) { + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) opts.onProgress!(Math.round((e.loaded / e.total) * 100)) + } + } + xhr.onload = () => + xhr.status >= 200 && xhr.status < 300 + ? resolve() + : reject(new Error(`上传失败:HTTP ${xhr.status}`)) + // ⚠️ 跨域被拒时 XHR 只会走到 onerror 且拿不到任何细节(浏览器刻意不暴露), + // 所以这里必须把最可能的原因写进文案,否则排查时只看到一句「网络错误」。 + xhr.onerror = () => + reject(new Error('上传失败:可能是目标存储桶没有配置跨域(CORS)规则,需允许来源 ' + location.origin + ' 的 PUT')) + xhr.send(file) }) - if (!putResponse.ok) { - throw new Error(`Upload failed: ${putResponse.status}`) - } - // 优先用后端给的裸 URL;兜底自己去掉签名 query。 return token.url || uploadUrl.split('?')[0] } diff --git a/apps/client/lib/data/services/version_update_service.dart b/apps/client/lib/data/services/version_update_service.dart index 26779750..afb93744 100644 --- a/apps/client/lib/data/services/version_update_service.dart +++ b/apps/client/lib/data/services/version_update_service.dart @@ -6,6 +6,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:logger/logger.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:get_storage/get_storage.dart'; import '../models/channel_app_model.dart'; import 'network/api.dart'; @@ -115,8 +116,45 @@ class VersionUpdateService extends GetxService { /// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口 /// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。 /// 单数接口本来就在白名单里。 - Future checkForUpdate() async { - if (_lastCheckTime != null && + /// 网页下载页:Android 上打开服务端给的地址失败时的兜底(页面会按品牌跳商店 / 直接给安装包)。 + /// 与后台「版本控制」页推荐填的默认地址、服务端 comm.DefaultDistDomain 是同一个。 + static const String officialDownloadPage = 'https://dl.ymaikj.com'; + + /// 推荐更新的「稍后提醒」:记住被推迟的版本号与时刻,同一版本 [_snoozeHours] 小时内冷启动不再弹。 + /// 只作用于冷启动的自动检查;设置页手动「检查更新」不看它。强更从不受它影响。 + static const String _snoozeKey = 'update_snooze'; + static const int _snoozeHours = 24; + + void snoozeVersion(String version) { + try { + GetStorage().write(_snoozeKey, { + 'version': version, + 'ts': DateTime.now().millisecondsSinceEpoch, + }); + } catch (e) { + _logger.w('记录稍后提醒失败: $e'); + } + } + + bool isSnoozed(String version) { + try { + final raw = GetStorage().read(_snoozeKey); + if (raw is! Map) return false; + if (raw['version']?.toString() != version) return false; + final ts = raw['ts']; + if (ts is! int) return false; + return DateTime.now().millisecondsSinceEpoch - ts < + Duration(hours: _snoozeHours).inMilliseconds; + } catch (_) { + return false; + } + } + + /// [bypassCache] 为 true 时跳过 10 分钟缓存真的发一次请求——设置页手动「检查更新」用, + /// 否则刚发完版用户主动点检查可能拿到缓存里的「已是最新」。 + Future checkForUpdate({bool bypassCache = false}) async { + if (!bypassCache && + _lastCheckTime != null && DateTime.now().difference(_lastCheckTime!).inMinutes < _cacheIntervalMinutes) { // [1]没有超过10分钟,返回缓存数据 @@ -409,8 +447,16 @@ class VersionUpdateService extends GetxService { } try { - _logger.d('lxm---执行更新操作,下载地址: ${updateInfo.appInfo!.address}'); - return await _openDownloadUrl(updateInfo.appInfo!.address!); + final address = updateInfo.appInfo!.address ?? ''; + _logger.d('lxm---执行更新操作,下载地址: $address'); + if (address.isNotEmpty && await _openDownloadUrl(address)) return true; + // Android 兜底:服务端给的地址打不开(商店未装 / 链接失效)就去网页下载页, + // 它按品牌跳商店、认不出就直接给安装包。iOS 不兜底——那页面给的是 apk。 + if (Platform.isAndroid) { + _logger.w('下载地址打不开,回退网页下载页: $officialDownloadPage'); + return await _openDownloadUrl(officialDownloadPage); + } + return false; } catch (e) { _logger.e('执行更新失败: $e'); return false; diff --git a/apps/client/lib/modules/common/widgets/version_update_dialog.dart b/apps/client/lib/modules/common/widgets/version_update_dialog.dart index 8566067c..aff51b3f 100644 --- a/apps/client/lib/modules/common/widgets/version_update_dialog.dart +++ b/apps/client/lib/modules/common/widgets/version_update_dialog.dart @@ -120,6 +120,8 @@ class VersionUpdateDialog extends StatelessWidget { Expanded( child: TextButton( onPressed: () { + // 记住这一版被推迟了:冷启动 24 小时内不再弹(见 VersionUpdateService.isSnoozed) + VersionUpdateService.to.snoozeVersion(updateInfo.latestVersion); Get.back(); onCancel?.call(); }, diff --git a/apps/client/lib/modules/settings/controllers/settings_controller.dart b/apps/client/lib/modules/settings/controllers/settings_controller.dart index f18d8fe2..ab61d907 100644 --- a/apps/client/lib/modules/settings/controllers/settings_controller.dart +++ b/apps/client/lib/modules/settings/controllers/settings_controller.dart @@ -336,7 +336,8 @@ class SettingsController extends GetxController { ); final versionService = VersionUpdateService.to; - final updateInfo = await versionService.checkForUpdate(); + // 用户主动点的检查要真的发请求,不吃 10 分钟缓存 + final updateInfo = await versionService.checkForUpdate(bypassCache: true); // 关闭加载提示 - 使用精确的关闭方式 if (dialogShown) { diff --git a/apps/client/lib/modules/splash/controllers/splash_controller.dart b/apps/client/lib/modules/splash/controllers/splash_controller.dart index 682ef267..8f86412b 100644 --- a/apps/client/lib/modules/splash/controllers/splash_controller.dart +++ b/apps/client/lib/modules/splash/controllers/splash_controller.dart @@ -12,6 +12,7 @@ import '../../../data/models/user_Info.dart'; import '../../../routes/app_routes.dart'; import '../../../data/services/network/api.dart'; import '../../../data/services/network/dio_manager.dart'; +import '../../../data/models/channel_app_model.dart'; import '../../../data/services/version_update_service.dart'; import '../../../modules/common/widgets/version_update_dialog.dart'; import '../../../modules/common/widgets/privacy_policy_dialog.dart'; @@ -242,6 +243,12 @@ class SplashController extends GetxController { if (updateInfo.hasUpdate) { Logger.i(_tag, '发现新版本: ${updateInfo.latestVersion}'); + // 推荐更新被用户「稍后提醒」过:同一版本 24 小时内不再弹。强更不受影响。 + if (updateInfo.updateType == UpdateType.optional && + versionService.isSnoozed(updateInfo.latestVersion)) { + Logger.i(_tag, '该版本已被稍后提醒,本次不弹'); + return; + } // 等待到下一微任务,降低与其它 UI 的并发竞争 await Future.delayed(Duration.zero); try { diff --git a/apps/services/comm/apprelease.go b/apps/services/comm/apprelease.go index e8e4981b..a18a3e77 100644 --- a/apps/services/comm/apprelease.go +++ b/apps/services/comm/apprelease.go @@ -27,6 +27,13 @@ type AppRelease struct { 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"` + // ── 游客显隐 ── Tourists bool `gorm:"column:tourists" json:"tourists"` // 允许游客登录(总开关) TouristsVersion string `gorm:"column:touristsversion;size:32" json:"touristsversion"` // 生效版本,白名单语义见 ShowTourists @@ -35,26 +42,82 @@ type AppRelease struct { Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` } -// NeedForceUpdate 判断某客户端版本是否必须更新:开了强更开关、配了最低版本、且客户端版本低于它。 +// 客户端平台。发版配置里 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) NeedForceUpdate(clientVersion string) bool { - if r == nil || !r.ForceUpdate || strings.TrimSpace(r.MinVersion) == "" { +func (r *AppRelease) NeedForceUpdateOn(clientVersion, platform string) bool { + if r == nil || !r.ForceUpdate { return false } - if strings.TrimSpace(clientVersion) == "" { + min := r.MinVersionFor(platform) + if min == "" || strings.TrimSpace(clientVersion) == "" { return false } - return CompareVersion(clientVersion, r.MinVersion) < 0 + return CompareVersion(clientVersion, min) < 0 } -// NeedUpdate 判断是否有更新可用:线上版本高于客户端版本。 -func (r *AppRelease) NeedUpdate(clientVersion string) bool { - if r == nil || strings.TrimSpace(r.Version) == "" || strings.TrimSpace(clientVersion) == "" { +// NeedUpdateOn 判断某平台是否有更新可用:该平台线上版本高于客户端版本。 +func (r *AppRelease) NeedUpdateOn(clientVersion, platform string) bool { + ver := r.VersionFor(platform) + if ver == "" || strings.TrimSpace(clientVersion) == "" { return false } - return CompareVersion(clientVersion, r.Version) < 0 + 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 判断某客户端版本是否正是「送审版本」——客户端据此隐藏未过审的功能。 @@ -107,6 +170,36 @@ func (r *AppRelease) DownloadAddress(channelAddress string) string { 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 { diff --git a/apps/services/comm/apprelease_test.go b/apps/services/comm/apprelease_test.go index 0aaad8ba..a90f2d3e 100644 --- a/apps/services/comm/apprelease_test.go +++ b/apps/services/comm/apprelease_test.go @@ -19,7 +19,15 @@ func TestAppReleaseNeedForceUpdate(t *testing.T) { } noMin := &AppRelease{ForceUpdate: true} if noMin.NeedForceUpdate("1.0.0") { - t.Fatal("未配最低版本时不该强更") + t.Fatal("线上版本与最低版本都没配时不该强更") + } + // 2026-09-23 口径:最低版本留空 = 线上版本,「开了强更就对所有低版本强更」 + blankMin := &AppRelease{ForceUpdate: true, Version: "2.0.0"} + if !blankMin.NeedForceUpdate("1.9.9") { + t.Fatal("最低版本留空时应按线上版本强更") + } + if blankMin.NeedForceUpdate("2.0.0") { + t.Fatal("已是线上版本不该强更") } var nilRel *AppRelease if nilRel.NeedForceUpdate("1.0.0") { @@ -114,6 +122,30 @@ func TestAppReleaseDownloadAddress(t *testing.T) { } } +// iOS 与 Android 各一套版本号:iOS 没填时不回退 Android 的(否则又回到共用一个版本号的老问题)。 +func TestAppReleasePlatformVersions(t *testing.T) { + r := &AppRelease{Version: "1.0.3", MinVersion: "1.0.1", IosVersion: "1.0.2", ForceUpdate: true} + if r.VersionFor(PlatformAndroid) != "1.0.3" || r.VersionFor(PlatformIOS) != "1.0.2" { + t.Fatal("两个平台应各取各的线上版本") + } + if r.MinVersionFor(PlatformAndroid) != "1.0.1" { + t.Fatal("Android 填了最低版本应取它") + } + if r.MinVersionFor(PlatformIOS) != "1.0.2" { + t.Fatal("iOS 最低版本留空应等于 iOS 线上版本") + } + if !r.NeedUpdateOn("1.0.2", PlatformAndroid) || r.NeedUpdateOn("1.0.2", PlatformIOS) { + t.Fatal("1.0.2 在 Android 上是旧版、在 iOS 上是最新") + } + noIos := &AppRelease{Version: "1.0.3", ForceUpdate: true} + if noIos.NeedUpdateOn("0.0.1", PlatformIOS) || noIos.NeedForceUpdateOn("0.0.1", PlatformIOS) { + t.Fatal("iOS 没填版本时不该拿 Android 的版本去判 iOS") + } + if PlatformOfChannel(ChannelGlobalApple) != PlatformIOS || PlatformOfChannel(ChannelChinaHuawei) != PlatformAndroid || PlatformOfChannel(ChannelGlobalGoogle) != PlatformAndroid { + t.Fatal("只有 App Store 渠道是 iOS") + } +} + func TestPickAppRelease(t *testing.T) { rows := []*AppRelease{ {AppName: "", Version: "1.0.0"}, diff --git a/apps/services/modules/console/api_apprelease.go b/apps/services/modules/console/api_apprelease.go index 3d2ec773..b7c51262 100644 --- a/apps/services/modules/console/api_apprelease.go +++ b/apps/services/modules/console/api_apprelease.go @@ -84,6 +84,8 @@ func (this *serverComp) saveAppVersion(c *gin.Context) { Changelog string `json:"changelog"` Address string `json:"address"` ReviewVersion string `json:"review_version"` + IosVersion string `json:"ios_version"` + IosMinVersion string `json:"ios_min_version"` } if err := c.ShouldBindJSON(&req); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) @@ -92,15 +94,22 @@ func (this *serverComp) saveAppVersion(c *gin.Context) { req.Version = strings.TrimSpace(req.Version) req.MinVersion = strings.TrimSpace(req.MinVersion) req.ReviewVersion = strings.TrimSpace(req.ReviewVersion) + req.IosVersion = strings.TrimSpace(req.IosVersion) + req.IosMinVersion = strings.TrimSpace(req.IosMinVersion) - // 强更必须有最低支持版本,否则「开了强更却挡不住任何人」,是配置期就能发现的错。 - if req.ForceUpdate && req.MinVersion == "" { - writeErr(c, pb.ErrorCode_ReqParameterError, "开启强制更新时必须填「最低支持版本」") + // 2026-09-23 起最低支持版本可以留空:留空 = 线上版本,即「开了强更就对所有低版本强更」。 + // 但两个平台的线上版本都没填时强更没有任何效果,这是配置期就能发现的错。 + if req.ForceUpdate && req.Version == "" && req.IosVersion == "" { + writeErr(c, pb.ErrorCode_ReqParameterError, "开启强制更新时至少要填一个平台的「当前线上版本」") return } // 最低支持版本不该高于线上版本,否则线上用户全被判定为需强更却又没有更高版本可更。 if req.MinVersion != "" && req.Version != "" && comm.CompareVersion(req.MinVersion, req.Version) > 0 { - writeErr(c, pb.ErrorCode_ReqParameterError, "「最低支持版本」不能高于「当前线上版本」") + writeErr(c, pb.ErrorCode_ReqParameterError, "Android「最低支持版本」不能高于「当前线上版本」") + return + } + if req.IosMinVersion != "" && req.IosVersion != "" && comm.CompareVersion(req.IosMinVersion, req.IosVersion) > 0 { + writeErr(c, pb.ErrorCode_ReqParameterError, "iOS「最低支持版本」不能高于「当前线上版本」") return } @@ -116,6 +125,8 @@ func (this *serverComp) saveAppVersion(c *gin.Context) { m.Changelog = req.Changelog m.Address = strings.TrimSpace(req.Address) m.ReviewVersion = req.ReviewVersion + m.IosVersion = req.IosVersion + m.IosMinVersion = req.IosMinVersion this.saveAppRelease(c, m) } diff --git a/apps/services/modules/console/api_config.go b/apps/services/modules/console/api_config.go index d99a4d7e..b257c2bc 100644 --- a/apps/services/modules/console/api_config.go +++ b/apps/services/modules/console/api_config.go @@ -93,6 +93,15 @@ func ensureConfigTables() error { if err := pg.CreateTable(comm.TableAppRelease, &comm.AppRelease{}); err != nil { return err } + // 2026-09-23 加的 iOS 单独版本号两列:CreateTable 对已存在表跳过 AutoMigrate,故显式 ALTER(幂等)。 + for _, s := range []string{ + "ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS ios_version varchar(32) DEFAULT ''", + "ALTER TABLE " + comm.TableAppRelease + " ADD COLUMN IF NOT EXISTS ios_min_version varchar(32) DEFAULT ''", + } { + if res := postgres.Exec(s); res.Error != nil { + return res.Error + } + } // 按应用分离的新表(app_name=''为全局默认,'X'为应用X覆盖):第三方服务配置 / 区域覆盖 / Agent 配置。 // 独立于老的 third_svc_config / third_svc_region_override / agent 表,避免影响现有服务接口。 if err := pg.CreateTable(comm.TableSvcConfig, &ThirdSvcConfig{}); err != nil { diff --git a/apps/services/modules/console/api_upload.go b/apps/services/modules/console/api_upload.go index d8385a31..6ace65d9 100644 --- a/apps/services/modules/console/api_upload.go +++ b/apps/services/modules/console/api_upload.go @@ -11,6 +11,7 @@ package console // 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。 import ( + "fmt" "path" "strings" @@ -56,15 +57,92 @@ func buildObjectKey(dir, name string) string { return key } -// getUploadURL 前端固件/图片直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。 +// distContentTypes 分发桶里按扩展名强制的 Content-Type。 +// +// ⚠️ 不能信前端传上来的 content_type:它计入预签名,一旦签成 text/plain 之类, +// 安卓点下载会变成在浏览器里打开一堆乱码,而且要等真机装包时才发现。 +// apk 用官方类型即可,浏览器不会尝试预览它,所以不需要再折腾 Content-Disposition。 +var distContentTypes = map[string]string{ + ".apk": "application/vnd.android.package-archive", +} + +// distAppSeg 把应用名清洗成可安全放进 object key 的一段。 +// +// 值来自 X-App-Id 查库(不是前端传的),但仍然要洗:库里的名字是人填的, +// 出现 "/" 或 ".." 就能把包传到别的目录去。只留字母数字和 -_,其余换成 _。 +func distAppSeg(app string) string { + app = strings.TrimSpace(app) + var b strings.Builder + for _, r := range app { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + // 洗完只剩下划线(应用名是 "//" 之类)时当作空:否则会造出 "__" 这种垃圾目录, + // 而且它非空、能过下面的判断,等于白洗了。 + seg := b.String() + if strings.Trim(seg, "_") == "" { + return "" + } + return seg +} + +// buildDistObjectKey 生成「分发桶」里的对象 key:<目录>/<应用>/<文件名>。 +// +// 与主桶不同,这里**不加 EAIMAR 根前缀**:分发桶直接挂在 dl.ymaikj.com 上, +// 传到 android/EAIMAR/x.apk 就是 https://dl.ymaikj.com/android/EAIMAR/x.apk, +// 多一层固定前缀会让下载地址凭空多出一段。目录只允许 DistUploadDirs 里的那几个。 +// +// ⚠️ **应用名这一段是必须的**:一个桶要放多个应用(EAIMAR / deepGlass …)的安装包, +// 不按应用分目录的话,两个应用只要打出同名文件就会互相覆盖——而且覆盖是静默的, +// 要等用户下到别家的包才会发现。 +func buildDistObjectKey(app, dir, name string) (string, error) { + seg := distAppSeg(app) + if seg == "" { + return "", fmt.Errorf("上传安装包必须先选择应用(请求缺少 X-App-Id)") + } + dir = strings.Trim(path.Clean("/"+strings.TrimSpace(dir)), "/") + if dir == "" || dir == "." { + return "", fmt.Errorf("缺少上传目录") + } + // 只认第一段,且必须在白名单里:path.Clean 已经解掉了 ..,这里再卡一次目录名 + top := dir + if i := strings.Index(dir, "/"); i >= 0 { + top = dir[:i] + } + if !comm.DistUploadDirs[top] { + return "", fmt.Errorf("不支持的上传目录: %s", top) + } + name = path.Base(strings.TrimSpace(name)) + if name == "" || name == "." || name == "/" { + return "", fmt.Errorf("文件名无效") + } + return path.Join(dir, seg, name), nil +} + +// getUploadURL 前端固件/图片/安装包直传:读取阿里云 OSS 配置,为目标对象签发一个预签名 PUT URL。 // // 路由沿用 api_getcostoken(前端 useApi.uploadFile 用该名,避免前端改路由)。 -// 请求体 { path, filename, content_type };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。 +// 请求体 { path, filename, content_type, target };返回 { upload_url(预签名PUT), url(去签名的公网URL), key }。 +// +// target="dist" 时传到「分发桶」(dl.ymaikj.com),用于 App 安装包;缺省走主桶(固件/产品图)。 func (this *serverComp) getUploadURL(c *gin.Context) { var req struct { Path string `json:"path"` Filename string `json:"filename"` ContentType string `json:"content_type"` + Target string `json:"target"` + // AppName 页面上选中的应用名(app_registry 的 name / app_name)。 + // + // ⚠️ 只在没有 X-App-Id 时才用它。X-App-Id 来自登录态里的 currentAppId, + // 只有运营/代理这种「绑定了应用」的账号才有;超管在「版本与分发」页顶部选了应用, + // 那个选择只是页面里的一个 app_name 字符串,根本不会进请求头——于是超管传安装包 + // 必然被拒成「必须先选择应用」,且看起来像是没选。前端现在把页面上选的应用名 + // 一并放进请求体,这里按名字反查注册表补上作用域。 + AppName string `json:"app_name"` } _ = c.ShouldBindJSON(&req) if strings.TrimSpace(req.Filename) == "" { @@ -78,18 +156,38 @@ func (this *serverComp) getUploadURL(c *gin.Context) { appName = app.Name } } + if appName == "" { + if want := strings.TrimSpace(req.AppName); want != "" { + if app, err := this.module.model.getAppByNameOrAppName(want); err == nil && app != nil { + appName = app.Name + } + } + } conf, err := this.loadAliyunOSSConf(appName) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } + var key string + if strings.EqualFold(strings.TrimSpace(req.Target), "dist") { + conf = conf.ForDist() + if key, err = buildDistObjectKey(appName, req.Path, req.Filename); err != nil { + writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) + return + } + } else { + key = buildObjectKey(req.Path, req.Filename) + } presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket) if err != nil { writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error()) return } - key := buildObjectKey(req.Path, req.Filename) ct := strings.TrimSpace(req.ContentType) + // 扩展名认识就以服务端为准,别信前端(理由见 distContentTypes) + if forced := distContentTypes[strings.ToLower(path.Ext(key))]; forced != "" { + ct = forced + } if ct == "" { ct = "application/octet-stream" } @@ -101,5 +199,8 @@ func (this *serverComp) getUploadURL(c *gin.Context) { // 公网访问 URL(存进 updatepackageaddress 的裸地址): // 配了自定义域名 → https:///(走 CDN);否则去掉预签名 query 的裸 OSS 地址。 publicURL := conf.PublicURL(uploadURL, key) - writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key}) + // ⚠️ 必须把实际签名用的 content_type 回带:服务端会按扩展名强制改写它(见 distContentTypes), + // 而浏览器对 .apk 给出的 File.type 往往是**空字符串**。前端若仍按 file.type 发 PUT, + // 与签名不符,OSS 直接回 403 SignatureDoesNotMatch——且报错里完全看不出是类型对不上。 + writeOK(c, gin.H{"upload_url": uploadURL, "url": publicURL, "key": key, "content_type": ct}) } diff --git a/apps/services/modules/console/model_registry.go b/apps/services/modules/console/model_registry.go index 7462a5a4..5cc71ee9 100644 --- a/apps/services/modules/console/model_registry.go +++ b/apps/services/modules/console/model_registry.go @@ -133,6 +133,16 @@ func (this *modelComp) getAppByName(name string) (model *AppRegistry, err error) return } +// getAppByNameOrAppName 按「部署名 name」或「应用名 app_name」任一命中查注册项。 +// +// 后台页面上的应用选择器给的是 app_name(EAIMAR / deepGlass),而 getAppByName 只查 name; +// 两列在现网虽然同值,但按 CLAUDE.md 的口径它们语义不同,这里两列都认,别依赖它们恰好相等。 +func (this *modelComp) getAppByNameOrAppName(name string) (model *AppRegistry, err error) { + model = &AppRegistry{} + err = postgres.FindOne(comm.TableAppRegistry, model, "name=? OR app_name=?", name, name) + return +} + // addApp 新增注册项(Insert 后 model.Id 被回填)。 func (this *modelComp) addApp(model *AppRegistry) (err error) { err = postgres.Insert(comm.TableAppRegistry, model) diff --git a/apps/services/modules/user/api_getchannelapp.go b/apps/services/modules/user/api_getchannelapp.go index 995fcfb7..5ee1a3f1 100644 --- a/apps/services/modules/user/api_getchannelapp.go +++ b/apps/services/modules/user/api_getchannelapp.go @@ -19,7 +19,7 @@ import ( // 升级判定一律由服务端算:版本号比较要逐段按整数比("1.10.0" > "1.9.0",按字符串比会判反), // 各客户端各写一遍必然有实现不一致,故统一收敛到 comm.CompareVersion / NeedForceUpdate。 func (this *apiComp) GetChannelApp(session comm.IUserSession, req *pb.UserGetChannelAppReq) (resp *pb.UserGetChannelAppResp, errdata *pb.ErrorData) { - cfg, err := this.module.model.getChannelAppCfg(req.Channel) + rows, err := this.module.model.loadChannelApps() if err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, @@ -35,29 +35,47 @@ func (this *apiComp) GetChannelApp(session comm.IUserSession, req *pb.UserGetCha } return } - resp = &pb.UserGetChannelAppResp{App: toPbChannelApp(req.Channel, cfg, rel, req.Version)} - resp.Needupdate, resp.Forceupdate, resp.Reviewing = evalChannelUpgrade(rel, req.Version) + cfg := pickChannelAppCfg(rows, req.Channel) + app := toPbChannelApp(req.Channel, cfg, rel, req.Version, officialChannelAddress(rows)) + resp = &pb.UserGetChannelAppResp{App: app} + address := "" + if app != nil { + address = app.Address + } + resp.Needupdate, resp.Forceupdate, resp.Reviewing = evalChannelUpgrade(rel, cfg, req.Channel, req.Version, address) return } -// evalChannelUpgrade 由应用发版配置与客户端版本算出三个升级判定。 +// evalChannelUpgrade 由应用发版配置、本渠道行、客户端版本算出三个升级判定(2026-09-23 口径)。 // -// 判定只看 [comm.AppRelease](按应用、不分渠道),与渠道行无关 —— 版本号本来就是整个 -// 应用一套的,早先按渠道各存一份导致同一个开关有 8 处能改、客户端只读一处。 +// - 版本号按平台取(iOS 渠道读 ios_version / ios_min_version),见 comm.PlatformOfChannel。 +// - 推荐更新 = 客户端 < 该平台线上版本;强制更新 = 开关开 且 客户端 < 该平台最低版本 +// (最低版本留空 = 线上版本);强更必然意味着有更新。 +// - 渠道行状态不是「已上架」(审核中 / 已下架)时**不推送推荐更新**:商店里还是旧包,提示了 +// 用户也下不到新版。强更不受这条限制——它的地址已经由回退链换成官方包。 +// - address 是最终算出的下载地址(toPbChannelApp 的 Address):为空说明没有任何可去的地方, +// 两种更新都不提示。强更也不例外:一个点了没反应又关不掉的弹窗等于把用户锁死。 // // 未配置或客户端没上报版本时全部为 false —— 客户端照旧走「无更新」分支, // 绝不能因为拿不到版本就判成强更,那会把所有老客户端挡在门外。 -func evalChannelUpgrade(rel *comm.AppRelease, clientVer string) (need, force, reviewing bool) { +func evalChannelUpgrade(rel *comm.AppRelease, ch *comm.ChannelApp, channel int32, clientVer, address string) (need, force, reviewing bool) { if rel == nil || strings.TrimSpace(clientVer) == "" { return false, false, false } - need = rel.NeedUpdate(clientVer) - force = rel.NeedForceUpdate(clientVer) + platform := comm.PlatformOfChannel(channel) + need = rel.NeedUpdateOn(clientVer, platform) + force = rel.NeedForceUpdateOn(clientVer, platform) // 强更必然意味着有更新可用:即便运营忘了把线上版本填得比最低支持版本高,也不该出现 // 「必须更新但没有更新」这种客户端无法处理的组合。 if force { need = true } + if ch != nil && ch.Status != comm.ChannelStatusOnline && !force { + need = false + } + if strings.TrimSpace(address) == "" { + need, force = false, false + } reviewing = rel.IsReviewing(clientVer) return } diff --git a/apps/services/modules/user/model_user.go b/apps/services/modules/user/model_user.go index 2c64aa8e..88541e2b 100644 --- a/apps/services/modules/user/model_user.go +++ b/apps/services/modules/user/model_user.go @@ -459,24 +459,27 @@ func (this *modelUserComp) loadAppRelease() (*comm.AppRelease, error) { // 「这一版客户端到底该不该显示游客登录入口」——下发的 Tourists 是结论而非配置, // 客户端直接照着显隐即可,不需要也不应该再拿 Touristsversion 自己比一遍。 // Touristsversion 仍原样下发,仅供排查「为什么这版看不到入口」。 -func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, clientVer string) *pb.DBChannelApp { +// +// officialAddr 是官方渠道(100) 生效的下载地址,Android 渠道没填自己的地址时先回退到它, +// 再回退应用默认地址;iOS / Google 不参与回退。完整回退链见 comm.AppRelease.ResolveDownloadAddress。 +// +// Version / Minversion 按渠道所属平台下发(iOS 渠道给 iOS 那套),客户端拿到的就是「自己这个平台」的线上版本。 +func toPbChannelApp(channel int32, c *comm.ChannelApp, rel *comm.AppRelease, clientVer string, officialAddr string) *pb.DBChannelApp { if c == nil && rel == nil { return nil } out := &pb.DBChannelApp{Channel: channel} - channelAddress := "" if c != nil { out.Description = c.Description out.Allowskipdevicebinding = c.AllowSkipDeviceBinding out.Paychannels = c.PayChannels out.Status = c.Status - channelAddress = c.Address } - // 渠道填了本渠道商店链接就用它,否则回退应用默认地址。 - out.Address = rel.DownloadAddress(channelAddress) + out.Address = rel.ResolveDownloadAddress(channel, c, officialAddr) if rel != nil { - out.Version = rel.Version - out.Minversion = rel.MinVersion + platform := comm.PlatformOfChannel(channel) + out.Version = rel.VersionFor(platform) + out.Minversion = rel.MinVersionFor(platform) out.Forceupdate = rel.ForceUpdate out.Changelog = rel.Changelog out.Reviewversion = rel.ReviewVersion @@ -493,15 +496,29 @@ func (this *modelUserComp) getChannelAppCfg(channel int32) (*comm.ChannelApp, er if err != nil { return nil, err } + return pickChannelAppCfg(rows, channel), nil +} + +// pickChannelAppCfg 从已加载的渠道行里挑某渠道生效的一条(应用行覆盖全局行);未配置、停用或草稿返回 nil。 +func pickChannelAppCfg(rows []*comm.ChannelApp, channel int32) *comm.ChannelApp { picked := comm.PickChannelApp(rows, channel) if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { - return nil, nil + return nil } - return picked, nil + return picked +} + +// officialChannelAddress 官方渠道(100) 生效的下载地址:Android 渠道没填自己地址时的第一级回退。 +// 官方渠道放的是我们自己的包(网页下载页或 apk 直链),不看上架状态——它不经过任何商店审核。 +func officialChannelAddress(rows []*comm.ChannelApp) string { + if o := pickChannelAppCfg(rows, comm.ChannelOfficial); o != nil { + return o.Address + } + return "" } func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model *pb.DBChannelApp, err error) { - cfg, err := this.getChannelAppCfg(channel) + rows, err := this.loadChannelApps() if err != nil { return nil, err } @@ -509,7 +526,7 @@ func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model if err != nil { return nil, err } - return toPbChannelApp(channel, cfg, rel, clientVer), nil + return toPbChannelApp(channel, pickChannelAppCfg(rows, channel), rel, clientVer, officialChannelAddress(rows)), nil } // getChannelApps 下发全部渠道:同一渠道应用行覆盖全局行;草稿状态不下发。 @@ -526,17 +543,18 @@ func (this *modelUserComp) getChannelApps(clientVer string) (models []*pb.DBChan return nil, err } seen := map[int32]bool{} + officialAddr := officialChannelAddress(rows) models = make([]*pb.DBChannelApp, 0, len(rows)) for _, r := range rows { if seen[r.Channel] { continue } seen[r.Channel] = true - picked := comm.PickChannelApp(rows, r.Channel) - if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { + picked := pickChannelAppCfg(rows, r.Channel) + if picked == nil { continue } - models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer)) + models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer, officialAddr)) } return models, nil } diff --git a/apps/services/modules/user/module_test.go b/apps/services/modules/user/module_test.go index ba5d85e5..25883a01 100644 --- a/apps/services/modules/user/module_test.go +++ b/apps/services/modules/user/module_test.go @@ -425,7 +425,7 @@ func TestEvalChannelUpgrade(t *testing.T) { } for _, c := range cases { t.Run(c.name, func(t *testing.T) { - need, force, reviewing := evalChannelUpgrade(c.cfg, c.client) + need, force, reviewing := evalChannelUpgrade(c.cfg, nil, comm.ChannelChinaXiaomi, c.client, "https://dl/app.apk") if need != c.need || force != c.force || reviewing != c.reviewing { t.Fatalf("client=%q 得到 need=%v force=%v reviewing=%v,期望 need=%v force=%v reviewing=%v", c.client, need, force, reviewing, c.need, c.force, c.reviewing) @@ -437,7 +437,7 @@ func TestEvalChannelUpgrade(t *testing.T) { // 运营把最低支持版本填得比线上版本还高时,不能出现「必须更新但没有更新可用」这种客户端无解的组合。 func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) { cfg := &comm.AppRelease{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true} - need, force, _ := evalChannelUpgrade(cfg, "1.5.0") + need, force, _ := evalChannelUpgrade(cfg, nil, comm.ChannelChinaXiaomi, "1.5.0", "https://dl/app.apk") if !force { t.Fatal("低于最低支持版本应强更") } @@ -448,11 +448,42 @@ func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) { // 后台还没配发版信息时 model 层给的是 nil,这里确认 nil 不会 panic 也不会误判。 func TestEvalChannelUpgradeNilSafe(t *testing.T) { - if need, force, reviewing := evalChannelUpgrade(nil, "1.0.0"); need || force || reviewing { + if need, force, reviewing := evalChannelUpgrade(nil, nil, comm.ChannelChinaXiaomi, "1.0.0", "https://dl/app.apk"); need || force || reviewing { t.Fatal("nil 配置必须全 false") } } +// 2026-09-23 口径:版本号按平台分、渠道未上架不推、没地址不提示。每条都是线上会直接踩到的组合。 +func TestEvalChannelUpgradePlatformStatusAddress(t *testing.T) { + rel := &comm.AppRelease{Version: "1.0.3", IosVersion: "1.0.2", ForceUpdate: true} + apk := "https://dl/app.apk" + + // iOS 渠道读 ios_version:Android 已发 1.0.3、iOS 商店还是 1.0.2 时,iOS 的 1.0.2 用户不该被提示 + if need, _, _ := evalChannelUpgrade(rel, nil, comm.ChannelGlobalApple, "1.0.2", "https://apps.apple.com/x"); need { + t.Fatal("iOS 客户端已是 iOS 线上版本,不该提示更新(不能拿 Android 的版本号判)") + } + if need, force, _ := evalChannelUpgrade(rel, nil, comm.ChannelChinaXiaomi, "1.0.2", apk); !need || !force { + t.Fatal("Android 1.0.2 低于 Android 线上 1.0.3,且最低版本留空 = 线上版本,应强更") + } + // 最低版本留空 = 线上版本:填了最低版本后,介于两者之间的只推荐不强制 + withMin := &comm.AppRelease{Version: "1.0.3", MinVersion: "1.0.1", ForceUpdate: true} + if need, force, _ := evalChannelUpgrade(withMin, nil, comm.ChannelChinaXiaomi, "1.0.2", apk); !need || force { + t.Fatal("高于最低版本、低于线上版本:只推荐不强制") + } + // 渠道审核中:商店里还是旧包,推荐更新不推;强更不受影响 + reviewing := &comm.ChannelApp{Channel: comm.ChannelChinaXiaomi, Status: comm.ChannelStatusReviewing, Address: "https://xiaomi/store"} + if need, _, _ := evalChannelUpgrade(withMin, reviewing, comm.ChannelChinaXiaomi, "1.0.2", apk); need { + t.Fatal("渠道审核中时不该推送推荐更新") + } + if need, force, _ := evalChannelUpgrade(withMin, reviewing, comm.ChannelChinaXiaomi, "1.0.0", apk); !need || !force { + t.Fatal("渠道审核中也挡不住强更(地址已由回退链换成官方包)") + } + // 没有任何可去的地址:两种更新都不提示,强更也不例外 + if need, force, _ := evalChannelUpgrade(rel, nil, comm.ChannelChinaXiaomi, "1.0.0", ""); need || force { + t.Fatal("没有下载地址时不该提示任何更新,否则弹出一个点了没反应又关不掉的框") + } +} + // --------------------------------------------------------------------------- // ⑤ 老客户端兼容字段 // --------------------------------------------------------------------------- @@ -838,7 +869,7 @@ func TestToPbChannelAppSurvivesMissingChannelRow(t *testing.T) { Address: "https://default/app.apk", } - got := toPbChannelApp(comm.ChannelChinaHonor, nil, rel, "1.0.1") + got := toPbChannelApp(comm.ChannelChinaHonor, nil, rel, "1.0.1", "") if got == nil { t.Fatal("渠道行缺失但发版配置存在时必须照常下发,否则应用级开关会被渠道行卡死") } @@ -863,7 +894,7 @@ func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) { Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store", PayChannels: "wxpay", AllowSkipDeviceBinding: true, Status: comm.ChannelStatusOnline, } - got := toPbChannelApp(comm.ChannelChinaXiaomi, ch, nil, "1.0.1") + got := toPbChannelApp(comm.ChannelChinaXiaomi, ch, nil, "1.0.1", "") if got == nil { t.Fatal("发版配置未配时仍应下发渠道信息") } @@ -881,15 +912,44 @@ func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) { // 渠道地址优先于应用默认地址:各商店链接不同,一套地址会把用户引到别家商店。 func TestToPbChannelAppAddressPrecedence(t *testing.T) { rel := &comm.AppRelease{Address: "https://default/app.apk"} - ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store"} - if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1"); got.Address != "https://huawei/store" { - t.Fatalf("渠道地址应覆盖应用默认地址,实际 %q", got.Address) + ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store", Status: comm.ChannelStatusOnline} + if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://huawei/store" { + t.Fatalf("渠道地址应覆盖官方渠道与应用默认地址,实际 %q", got.Address) + } +} + +// 回退链(2026-09-23):Android 渠道 → 官方渠道(100) → 默认地址;未上架的渠道地址视同没有;iOS 只认自己的商店链接。 +func TestToPbChannelAppAddressFallbackChain(t *testing.T) { + rel := &comm.AppRelease{Address: "https://default/app.apk", Version: "1.0.3", IosVersion: "1.0.2", MinVersion: "1.0.1"} + // 渠道没填地址 → 官方渠道 + if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://official/app.apk" { + t.Fatalf("渠道缺失时应先回退官方渠道地址,实际 %q", got.Address) + } + // 官方渠道也没有 → 默认地址 + if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", ""); got.Address != "https://default/app.apk" { + t.Fatalf("官方渠道也没有时应回退默认地址,实际 %q", got.Address) + } + // 渠道审核中:它的商店链接视同没有 + reviewing := &comm.ChannelApp{Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store", Status: comm.ChannelStatusReviewing} + if got := toPbChannelApp(comm.ChannelChinaXiaomi, reviewing, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://official/app.apk" { + t.Fatalf("渠道未上架时不该用它的地址,实际 %q", got.Address) + } + // iOS:没填 App Store 链接就是空,绝不能回退到 apk + if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.1", "https://official/app.apk"); got.Address != "" { + t.Fatalf("iOS 没有商店链接时地址必须为空,实际 %q", got.Address) + } + // iOS 的版本号来自 ios_version / ios_min_version(留空 = 线上版本) + if got := toPbChannelApp(comm.ChannelGlobalApple, nil, rel, "1.0.1", ""); got.Version != "1.0.2" || got.Minversion != "1.0.2" { + t.Fatalf("iOS 渠道应下发 iOS 那套版本号,实际 version=%q min=%q", got.Version, got.Minversion) + } + if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, rel, "1.0.1", ""); got.Version != "1.0.3" || got.Minversion != "1.0.1" { + t.Fatalf("Android 渠道应下发 Android 那套版本号,实际 version=%q min=%q", got.Version, got.Minversion) } } // 两张表都没配才返回 nil。 func TestToPbChannelAppBothNil(t *testing.T) { - if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1"); got != nil { + if got := toPbChannelApp(comm.ChannelChinaXiaomi, nil, nil, "1.0.1", ""); got != nil { t.Fatalf("两边都没配时应返回 nil,实际 %+v", got) } }