Browse Source

版本更新判定改口径:按平台分版本、最低版本留空=线上版本、渠道未上架不推、地址回退链

判定仍全在服务端算好下发,客户端只拿结论。规则:

1. Android / iOS 各一套版本号:app_release 加 ios_version / ios_min_version
   (启动 ALTER ... ADD COLUMN IF NOT EXISTS 幂等),原 version/min_version 只管
   Android,平台由渠道号推导(只有 GlobalApple=101 是 iOS)。iOS 没填时**不回退
   Android 的**——回退等于又回到共用一个版本号:Android 先发 1.0.3 而 iOS 商店还是
   1.0.2 时,iOS 用户会被指向一个商店里没有的版本。
2. 最低支持版本留空 = 线上版本:勾上强更不填最低版本,就是「所有低版本都强更」;
   填了则只强更低于它的,介于两者之间的仍是推荐。后台校验相应改成「至少填一个
   平台的线上版本」。
3. 渠道行状态不是「已上架」(审核中/已下架)时不推送推荐更新——商店里还是旧包。
   强更不受此限,它的地址会由回退链换成官方包。
4. 下载地址回退链:Android 渠道 = 本渠道(须已上架)→ 官方渠道(100) → 默认下载
   地址;iOS / Google 只认本渠道,没有就空——那两处如今放的是 apk 直链。
5. 算不出任何地址时两种更新都不提示,强更也不例外:一个点了没反应又关不掉的
   弹窗等于把用户锁死。

客户端三处配套:推荐更新的「稍后提醒」记住版本号,同一版本 24 小时内冷启动不再弹
(强更与手动检查不受影响);设置页手动检查绕过 10 分钟缓存;Android 上服务端地址
打不开时回退网页下载页(iOS 不兜底,那页给的是 apk)。

另修后台上传安装包报「必须先选择应用」:X-App-Id 头来自登录态的 currentAppId,
只有绑定了应用的运营/代理账号才有;超管在页面顶部选的应用只是个 app_name 字符串,
从不进请求头。现在 uploadFile 把它放进请求体,服务端没有 X-App-Id 时按 name /
app_name 反查注册表补作用域。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
4753d37c69
  1. 59
      apps/admin/app/composables/useApi.ts
  2. 54
      apps/client/lib/data/services/version_update_service.dart
  3. 2
      apps/client/lib/modules/common/widgets/version_update_dialog.dart
  4. 3
      apps/client/lib/modules/settings/controllers/settings_controller.dart
  5. 7
      apps/client/lib/modules/splash/controllers/splash_controller.dart
  6. 111
      apps/services/comm/apprelease.go
  7. 34
      apps/services/comm/apprelease_test.go
  8. 19
      apps/services/modules/console/api_apprelease.go
  9. 9
      apps/services/modules/console/api_config.go
  10. 109
      apps/services/modules/console/api_upload.go
  11. 10
      apps/services/modules/console/model_registry.go
  12. 36
      apps/services/modules/user/api_getchannelapp.go
  13. 46
      apps/services/modules/user/model_user.go
  14. 78
      apps/services/modules/user/module_test.go

59
apps/admin/app/composables/useApi.ts

@ -89,29 +89,64 @@ export function useApi() {
// 浏览器用同样的 Content-Type PUT 直传,最终 URL(去签名 query)作为文件地址返回。 // 浏览器用同样的 Content-Type PUT 直传,最终 URL(去签名 query)作为文件地址返回。
// 注意:OSS 预签名 PUT 把 Content-Type 计入签名,故这里 PUT 的 Content-Type 必须与提交给后端 // 注意:OSS 预签名 PUT 把 Content-Type 计入签名,故这里 PUT 的 Content-Type 必须与提交给后端
// 签名的 content_type 完全一致;filename 也要一并提交,后端才能拼出完整 object key。 // 签名的 content_type 完全一致;filename 也要一并提交,后端才能拼出完整 object key。
async function uploadFile(file: File, path: string): Promise<string> { //
// 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<string> {
const contentType = file.type || 'application/octet-stream' const contentType = file.type || 'application/octet-stream'
// ⚠️ opts.appName 是页面上选中的应用名,安装包上传必须带:
// X-App-Id 头来自登录态的 currentAppId,只有绑定了应用的运营/代理账号才有;
// 超管在页面顶部选的应用只是个 app_name 字符串,不会进请求头,
// 不带 app_name 的话超管传包必然被后端拒成「必须先选择应用」。
const token = await webApi<{ const token = await webApi<{
upload_url?: string upload_url?: string
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 || '' const uploadUrl = token.upload_url || token.url || ''
if (!uploadUrl) { if (!uploadUrl) {
throw new Error('未获取到上传地址:请确认已在【第三方服务配置→存储】配置并启用「阿里云 OSS」') throw new Error('未获取到上传地址:请确认已在【第三方服务配置→存储】配置并启用「阿里云 OSS」')
} }
const putResponse = await fetch(uploadUrl, { // ⚠️ 用 XHR 不用 fetch:fetch 没有上传进度事件(只有下载方向的 stream),
method: 'PUT', // 而这个入口就是给几十 MB 的安装包用的。
headers: { await new Promise<void>((resolve, reject) => {
'Content-Type': contentType, const xhr = new XMLHttpRequest()
}, xhr.open('PUT', uploadUrl, true)
body: file, // 必须与后端签名时用的 Content-Type 完全一致,理由见上面 putType
}) xhr.setRequestHeader('Content-Type', putType)
if (opts.onProgress) {
if (!putResponse.ok) { xhr.upload.onprogress = (e) => {
throw new Error(`Upload failed: ${putResponse.status}`) 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)
})
// 优先用后端给的裸 URL;兜底自己去掉签名 query。 // 优先用后端给的裸 URL;兜底自己去掉签名 query。
return token.url || uploadUrl.split('?')[0] return token.url || uploadUrl.split('?')[0]

54
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:logger/logger.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:get_storage/get_storage.dart';
import '../models/channel_app_model.dart'; import '../models/channel_app_model.dart';
import 'network/api.dart'; import 'network/api.dart';
@ -115,8 +116,45 @@ class VersionUpdateService extends GetxService {
/// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口 /// - 更要命的是它**不在网关白名单里**,未登录时返回 `code:18 NoLogin`。而游客登录入口
/// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。 /// 恰恰只在未登录时才需要显示,于是入口在任何未登录设备上都不可能出现,且不报错。
/// 单数接口本来就在白名单里。 /// 单数接口本来就在白名单里。
Future<VersionUpdateInfo> checkForUpdate() async { /// 网页下载页:Android 上打开服务端给的地址失败时的兜底(页面会按品牌跳商店 / 直接给安装包)。
if (_lastCheckTime != null && /// 与后台「版本控制」页推荐填的默认地址、服务端 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<VersionUpdateInfo> checkForUpdate({bool bypassCache = false}) async {
if (!bypassCache &&
_lastCheckTime != null &&
DateTime.now().difference(_lastCheckTime!).inMinutes < DateTime.now().difference(_lastCheckTime!).inMinutes <
_cacheIntervalMinutes) { _cacheIntervalMinutes) {
// [1]没有超过10分钟,返回缓存数据 // [1]没有超过10分钟,返回缓存数据
@ -409,8 +447,16 @@ class VersionUpdateService extends GetxService {
} }
try { try {
_logger.d('lxm---执行更新操作,下载地址: ${updateInfo.appInfo!.address}'); final address = updateInfo.appInfo!.address ?? '';
return await _openDownloadUrl(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) { } catch (e) {
_logger.e('执行更新失败: $e'); _logger.e('执行更新失败: $e');
return false; return false;

2
apps/client/lib/modules/common/widgets/version_update_dialog.dart

@ -120,6 +120,8 @@ class VersionUpdateDialog extends StatelessWidget {
Expanded( Expanded(
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
// 记住这一版被推迟了:冷启动 24 小时内不再弹(见 VersionUpdateService.isSnoozed)
VersionUpdateService.to.snoozeVersion(updateInfo.latestVersion);
Get.back(); Get.back();
onCancel?.call(); onCancel?.call();
}, },

3
apps/client/lib/modules/settings/controllers/settings_controller.dart

@ -336,7 +336,8 @@ class SettingsController extends GetxController {
); );
final versionService = VersionUpdateService.to; final versionService = VersionUpdateService.to;
final updateInfo = await versionService.checkForUpdate(); // 用户主动点的检查要真的发请求,不吃 10 分钟缓存
final updateInfo = await versionService.checkForUpdate(bypassCache: true);
// 关闭加载提示 - 使用精确的关闭方式 // 关闭加载提示 - 使用精确的关闭方式
if (dialogShown) { if (dialogShown) {

7
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 '../../../routes/app_routes.dart';
import '../../../data/services/network/api.dart'; import '../../../data/services/network/api.dart';
import '../../../data/services/network/dio_manager.dart'; import '../../../data/services/network/dio_manager.dart';
import '../../../data/models/channel_app_model.dart';
import '../../../data/services/version_update_service.dart'; import '../../../data/services/version_update_service.dart';
import '../../../modules/common/widgets/version_update_dialog.dart'; import '../../../modules/common/widgets/version_update_dialog.dart';
import '../../../modules/common/widgets/privacy_policy_dialog.dart'; import '../../../modules/common/widgets/privacy_policy_dialog.dart';
@ -242,6 +243,12 @@ class SplashController extends GetxController {
if (updateInfo.hasUpdate) { if (updateInfo.hasUpdate) {
Logger.i(_tag, '发现新版本: ${updateInfo.latestVersion}'); Logger.i(_tag, '发现新版本: ${updateInfo.latestVersion}');
// 推荐更新被用户「稍后提醒」过:同一版本 24 小时内不再弹。强更不受影响。
if (updateInfo.updateType == UpdateType.optional &&
versionService.isSnoozed(updateInfo.latestVersion)) {
Logger.i(_tag, '该版本已被稍后提醒,本次不弹');
return;
}
// 等待到下一微任务,降低与其它 UI 的并发竞争 // 等待到下一微任务,降低与其它 UI 的并发竞争
await Future.delayed(Duration.zero); await Future.delayed(Duration.zero);
try { try {

111
apps/services/comm/apprelease.go

@ -27,6 +27,13 @@ type AppRelease struct {
Address string `gorm:"column:address" json:"address"` // 默认下载地址;渠道填了自己的则以渠道为准 Address string `gorm:"column:address" json:"address"` // 默认下载地址;渠道填了自己的则以渠道为准
ReviewVersion string `gorm:"column:review_version;size:32" json:"review_version"` // 送审版本号;客户端报这一版时隐藏未过审功能 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"` // 允许游客登录(总开关) Tourists bool `gorm:"column:tourists" json:"tourists"` // 允许游客登录(总开关)
TouristsVersion string `gorm:"column:touristsversion;size:32" json:"touristsversion"` // 生效版本,白名单语义见 ShowTourists TouristsVersion string `gorm:"column:touristsversion;size:32" json:"touristsversion"` // 生效版本,白名单语义见 ShowTourists
@ -35,26 +42,82 @@ type AppRelease struct {
Updatetime int64 `gorm:"column:updatetime" json:"updatetime"` 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 { func (r *AppRelease) NeedForceUpdateOn(clientVersion, platform string) bool {
if r == nil || !r.ForceUpdate || strings.TrimSpace(r.MinVersion) == "" { if r == nil || !r.ForceUpdate {
return false return false
} }
if strings.TrimSpace(clientVersion) == "" { min := r.MinVersionFor(platform)
if min == "" || strings.TrimSpace(clientVersion) == "" {
return false return false
} }
return CompareVersion(clientVersion, r.MinVersion) < 0 return CompareVersion(clientVersion, min) < 0
} }
// NeedUpdate 判断是否有更新可用:线上版本高于客户端版本。 // NeedUpdateOn 判断某平台是否有更新可用:该平台线上版本高于客户端版本。
func (r *AppRelease) NeedUpdate(clientVersion string) bool { func (r *AppRelease) NeedUpdateOn(clientVersion, platform string) bool {
if r == nil || strings.TrimSpace(r.Version) == "" || strings.TrimSpace(clientVersion) == "" { ver := r.VersionFor(platform)
if ver == "" || strings.TrimSpace(clientVersion) == "" {
return false 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 判断某客户端版本是否正是「送审版本」——客户端据此隐藏未过审的功能。 // IsReviewing 判断某客户端版本是否正是「送审版本」——客户端据此隐藏未过审的功能。
@ -107,6 +170,36 @@ func (r *AppRelease) DownloadAddress(channelAddress string) string {
return r.Address 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。 // PickAppRelease 从「应用行 + 全局行」里挑生效的一条:应用行覆盖全局行,都没有返回 nil。
// 与 agent / 第三方服务 / 渠道分发下发同语义(应用优先、无则回退全局)。 // 与 agent / 第三方服务 / 渠道分发下发同语义(应用优先、无则回退全局)。
func PickAppRelease(rows []*AppRelease, app string) *AppRelease { func PickAppRelease(rows []*AppRelease, app string) *AppRelease {

34
apps/services/comm/apprelease_test.go

@ -19,7 +19,15 @@ func TestAppReleaseNeedForceUpdate(t *testing.T) {
} }
noMin := &AppRelease{ForceUpdate: true} noMin := &AppRelease{ForceUpdate: true}
if noMin.NeedForceUpdate("1.0.0") { 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 var nilRel *AppRelease
if nilRel.NeedForceUpdate("1.0.0") { 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) { func TestPickAppRelease(t *testing.T) {
rows := []*AppRelease{ rows := []*AppRelease{
{AppName: "", Version: "1.0.0"}, {AppName: "", Version: "1.0.0"},

19
apps/services/modules/console/api_apprelease.go

@ -84,6 +84,8 @@ func (this *serverComp) saveAppVersion(c *gin.Context) {
Changelog string `json:"changelog"` Changelog string `json:"changelog"`
Address string `json:"address"` Address string `json:"address"`
ReviewVersion string `json:"review_version"` ReviewVersion string `json:"review_version"`
IosVersion string `json:"ios_version"`
IosMinVersion string `json:"ios_min_version"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) 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.Version = strings.TrimSpace(req.Version)
req.MinVersion = strings.TrimSpace(req.MinVersion) req.MinVersion = strings.TrimSpace(req.MinVersion)
req.ReviewVersion = strings.TrimSpace(req.ReviewVersion) req.ReviewVersion = strings.TrimSpace(req.ReviewVersion)
req.IosVersion = strings.TrimSpace(req.IosVersion)
req.IosMinVersion = strings.TrimSpace(req.IosMinVersion)
// 强更必须有最低支持版本,否则「开了强更却挡不住任何人」,是配置期就能发现的错。 // 2026-09-23 起最低支持版本可以留空:留空 = 线上版本,即「开了强更就对所有低版本强更」。
if req.ForceUpdate && req.MinVersion == "" { // 但两个平台的线上版本都没填时强更没有任何效果,这是配置期就能发现的错。
writeErr(c, pb.ErrorCode_ReqParameterError, "开启强制更新时必须填「最低支持版本」") if req.ForceUpdate && req.Version == "" && req.IosVersion == "" {
writeErr(c, pb.ErrorCode_ReqParameterError, "开启强制更新时至少要填一个平台的「当前线上版本」")
return return
} }
// 最低支持版本不该高于线上版本,否则线上用户全被判定为需强更却又没有更高版本可更。 // 最低支持版本不该高于线上版本,否则线上用户全被判定为需强更却又没有更高版本可更。
if req.MinVersion != "" && req.Version != "" && comm.CompareVersion(req.MinVersion, req.Version) > 0 { 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 return
} }
@ -116,6 +125,8 @@ func (this *serverComp) saveAppVersion(c *gin.Context) {
m.Changelog = req.Changelog m.Changelog = req.Changelog
m.Address = strings.TrimSpace(req.Address) m.Address = strings.TrimSpace(req.Address)
m.ReviewVersion = req.ReviewVersion m.ReviewVersion = req.ReviewVersion
m.IosVersion = req.IosVersion
m.IosMinVersion = req.IosMinVersion
this.saveAppRelease(c, m) this.saveAppRelease(c, m)
} }

9
apps/services/modules/console/api_config.go

@ -93,6 +93,15 @@ func ensureConfigTables() error {
if err := pg.CreateTable(comm.TableAppRelease, &comm.AppRelease{}); err != nil { if err := pg.CreateTable(comm.TableAppRelease, &comm.AppRelease{}); err != nil {
return err 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 配置。 // 按应用分离的新表(app_name=''为全局默认,'X'为应用X覆盖):第三方服务配置 / 区域覆盖 / Agent 配置。
// 独立于老的 third_svc_config / third_svc_region_override / agent 表,避免影响现有服务接口。 // 独立于老的 third_svc_config / third_svc_region_override / agent 表,避免影响现有服务接口。
if err := pg.CreateTable(comm.TableSvcConfig, &ThirdSvcConfig{}); err != nil { if err := pg.CreateTable(comm.TableSvcConfig, &ThirdSvcConfig{}); err != nil {

109
apps/services/modules/console/api_upload.go

@ -11,6 +11,7 @@ package console
// 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。 // 3. 预签名 PUT 会把 Content-Type 计入签名,故前端 PUT 必须带同样的 Content-Type(uploadFile 已对齐)。
import ( import (
"fmt"
"path" "path"
"strings" "strings"
@ -56,15 +57,92 @@ func buildObjectKey(dir, name string) string {
return key 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 用该名,避免前端改路由)。 // 路由沿用 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) { func (this *serverComp) getUploadURL(c *gin.Context) {
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Filename string `json:"filename"` Filename string `json:"filename"`
ContentType string `json:"content_type"` 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) _ = c.ShouldBindJSON(&req)
if strings.TrimSpace(req.Filename) == "" { if strings.TrimSpace(req.Filename) == "" {
@ -78,18 +156,38 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
appName = app.Name 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) conf, err := this.loadAliyunOSSConf(appName)
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_SystemError, err.Error()) writeErr(c, pb.ErrorCode_SystemError, err.Error())
return 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) presigner, err := oss.NewPresigner(conf.Endpoint, conf.AccessKeyId, conf.AccessSecret, conf.Bucket)
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error()) writeErr(c, pb.ErrorCode_SystemError, "初始化阿里云 OSS 失败: "+err.Error())
return return
} }
key := buildObjectKey(req.Path, req.Filename)
ct := strings.TrimSpace(req.ContentType) ct := strings.TrimSpace(req.ContentType)
// 扩展名认识就以服务端为准,别信前端(理由见 distContentTypes)
if forced := distContentTypes[strings.ToLower(path.Ext(key))]; forced != "" {
ct = forced
}
if ct == "" { if ct == "" {
ct = "application/octet-stream" ct = "application/octet-stream"
} }
@ -101,5 +199,8 @@ func (this *serverComp) getUploadURL(c *gin.Context) {
// 公网访问 URL(存进 updatepackageaddress 的裸地址): // 公网访问 URL(存进 updatepackageaddress 的裸地址):
// 配了自定义域名 → https://<domain>/<key>(走 CDN);否则去掉预签名 query 的裸 OSS 地址。 // 配了自定义域名 → https://<domain>/<key>(走 CDN);否则去掉预签名 query 的裸 OSS 地址。
publicURL := conf.PublicURL(uploadURL, key) 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})
} }

10
apps/services/modules/console/model_registry.go

@ -133,6 +133,16 @@ func (this *modelComp) getAppByName(name string) (model *AppRegistry, err error)
return 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 被回填)。 // addApp 新增注册项(Insert 后 model.Id 被回填)。
func (this *modelComp) addApp(model *AppRegistry) (err error) { func (this *modelComp) addApp(model *AppRegistry) (err error) {
err = postgres.Insert(comm.TableAppRegistry, model) err = postgres.Insert(comm.TableAppRegistry, model)

36
apps/services/modules/user/api_getchannelapp.go

@ -19,7 +19,7 @@ import (
// 升级判定一律由服务端算:版本号比较要逐段按整数比("1.10.0" > "1.9.0",按字符串比会判反), // 升级判定一律由服务端算:版本号比较要逐段按整数比("1.10.0" > "1.9.0",按字符串比会判反),
// 各客户端各写一遍必然有实现不一致,故统一收敛到 comm.CompareVersion / NeedForceUpdate。 // 各客户端各写一遍必然有实现不一致,故统一收敛到 comm.CompareVersion / NeedForceUpdate。
func (this *apiComp) GetChannelApp(session comm.IUserSession, req *pb.UserGetChannelAppReq) (resp *pb.UserGetChannelAppResp, errdata *pb.ErrorData) { 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 { if err != nil {
errdata = &pb.ErrorData{ errdata = &pb.ErrorData{
Code: pb.ErrorCode_DBError, Code: pb.ErrorCode_DBError,
@ -35,29 +35,47 @@ func (this *apiComp) GetChannelApp(session comm.IUserSession, req *pb.UserGetCha
} }
return return
} }
resp = &pb.UserGetChannelAppResp{App: toPbChannelApp(req.Channel, cfg, rel, req.Version)} cfg := pickChannelAppCfg(rows, req.Channel)
resp.Needupdate, resp.Forceupdate, resp.Reviewing = evalChannelUpgrade(rel, req.Version) 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 return
} }
// evalChannelUpgrade 由应用发版配置与客户端版本算出三个升级判定。 // evalChannelUpgrade 由应用发版配置、本渠道行、客户端版本算出三个升级判定(2026-09-23 口径)。
// //
// 判定只看 [comm.AppRelease](按应用、不分渠道),与渠道行无关 —— 版本号本来就是整个 // - 版本号按平台取(iOS 渠道读 ios_version / ios_min_version),见 comm.PlatformOfChannel。
// 应用一套的,早先按渠道各存一份导致同一个开关有 8 处能改、客户端只读一处。 // - 推荐更新 = 客户端 < 该平台线上版本;强制更新 = 开关开 且 客户端 < 该平台最低版本
// (最低版本留空 = 线上版本);强更必然意味着有更新。
// - 渠道行状态不是「已上架」(审核中 / 已下架)时**不推送推荐更新**:商店里还是旧包,提示了
// 用户也下不到新版。强更不受这条限制——它的地址已经由回退链换成官方包。
// - address 是最终算出的下载地址(toPbChannelApp 的 Address):为空说明没有任何可去的地方,
// 两种更新都不提示。强更也不例外:一个点了没反应又关不掉的弹窗等于把用户锁死。
// //
// 未配置或客户端没上报版本时全部为 false —— 客户端照旧走「无更新」分支, // 未配置或客户端没上报版本时全部为 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) == "" { if rel == nil || strings.TrimSpace(clientVer) == "" {
return false, false, false return false, false, false
} }
need = rel.NeedUpdate(clientVer) platform := comm.PlatformOfChannel(channel)
force = rel.NeedForceUpdate(clientVer) need = rel.NeedUpdateOn(clientVer, platform)
force = rel.NeedForceUpdateOn(clientVer, platform)
// 强更必然意味着有更新可用:即便运营忘了把线上版本填得比最低支持版本高,也不该出现 // 强更必然意味着有更新可用:即便运营忘了把线上版本填得比最低支持版本高,也不该出现
// 「必须更新但没有更新」这种客户端无法处理的组合。 // 「必须更新但没有更新」这种客户端无法处理的组合。
if force { if force {
need = true need = true
} }
if ch != nil && ch.Status != comm.ChannelStatusOnline && !force {
need = false
}
if strings.TrimSpace(address) == "" {
need, force = false, false
}
reviewing = rel.IsReviewing(clientVer) reviewing = rel.IsReviewing(clientVer)
return return
} }

46
apps/services/modules/user/model_user.go

@ -459,24 +459,27 @@ func (this *modelUserComp) loadAppRelease() (*comm.AppRelease, error) {
// 「这一版客户端到底该不该显示游客登录入口」——下发的 Tourists 是结论而非配置, // 「这一版客户端到底该不该显示游客登录入口」——下发的 Tourists 是结论而非配置,
// 客户端直接照着显隐即可,不需要也不应该再拿 Touristsversion 自己比一遍。 // 客户端直接照着显隐即可,不需要也不应该再拿 Touristsversion 自己比一遍。
// 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 { if c == nil && rel == nil {
return nil return nil
} }
out := &pb.DBChannelApp{Channel: channel} out := &pb.DBChannelApp{Channel: channel}
channelAddress := ""
if c != nil { if c != nil {
out.Description = c.Description out.Description = c.Description
out.Allowskipdevicebinding = c.AllowSkipDeviceBinding out.Allowskipdevicebinding = c.AllowSkipDeviceBinding
out.Paychannels = c.PayChannels out.Paychannels = c.PayChannels
out.Status = c.Status out.Status = c.Status
channelAddress = c.Address
} }
// 渠道填了本渠道商店链接就用它,否则回退应用默认地址。 out.Address = rel.ResolveDownloadAddress(channel, c, officialAddr)
out.Address = rel.DownloadAddress(channelAddress)
if rel != nil { if rel != nil {
out.Version = rel.Version platform := comm.PlatformOfChannel(channel)
out.Minversion = rel.MinVersion out.Version = rel.VersionFor(platform)
out.Minversion = rel.MinVersionFor(platform)
out.Forceupdate = rel.ForceUpdate out.Forceupdate = rel.ForceUpdate
out.Changelog = rel.Changelog out.Changelog = rel.Changelog
out.Reviewversion = rel.ReviewVersion out.Reviewversion = rel.ReviewVersion
@ -493,15 +496,29 @@ func (this *modelUserComp) getChannelAppCfg(channel int32) (*comm.ChannelApp, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
return pickChannelAppCfg(rows, channel), nil
}
// pickChannelAppCfg 从已加载的渠道行里挑某渠道生效的一条(应用行覆盖全局行);未配置、停用或草稿返回 nil。
func pickChannelAppCfg(rows []*comm.ChannelApp, channel int32) *comm.ChannelApp {
picked := comm.PickChannelApp(rows, channel) picked := comm.PickChannelApp(rows, channel)
if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { 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) { 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 { if err != nil {
return nil, err return nil, err
} }
@ -509,7 +526,7 @@ func (this *modelUserComp) getChannelApp(channel int32, clientVer string) (model
if err != nil { if err != nil {
return nil, err return nil, err
} }
return toPbChannelApp(channel, cfg, rel, clientVer), nil return toPbChannelApp(channel, pickChannelAppCfg(rows, channel), rel, clientVer, officialChannelAddress(rows)), nil
} }
// getChannelApps 下发全部渠道:同一渠道应用行覆盖全局行;草稿状态不下发。 // getChannelApps 下发全部渠道:同一渠道应用行覆盖全局行;草稿状态不下发。
@ -526,17 +543,18 @@ func (this *modelUserComp) getChannelApps(clientVer string) (models []*pb.DBChan
return nil, err return nil, err
} }
seen := map[int32]bool{} seen := map[int32]bool{}
officialAddr := officialChannelAddress(rows)
models = make([]*pb.DBChannelApp, 0, len(rows)) models = make([]*pb.DBChannelApp, 0, len(rows))
for _, r := range rows { for _, r := range rows {
if seen[r.Channel] { if seen[r.Channel] {
continue continue
} }
seen[r.Channel] = true seen[r.Channel] = true
picked := comm.PickChannelApp(rows, r.Channel) picked := pickChannelAppCfg(rows, r.Channel)
if picked == nil || !picked.Enable || picked.Status == comm.ChannelStatusDraft { if picked == nil {
continue continue
} }
models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer)) models = append(models, toPbChannelApp(picked.Channel, picked, rel, clientVer, officialAddr))
} }
return models, nil return models, nil
} }

78
apps/services/modules/user/module_test.go

@ -425,7 +425,7 @@ func TestEvalChannelUpgrade(t *testing.T) {
} }
for _, c := range cases { for _, c := range cases {
t.Run(c.name, func(t *testing.T) { 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 { 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", 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) 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) { func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
cfg := &comm.AppRelease{Version: "1.0.0", MinVersion: "2.0.0", ForceUpdate: true} 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 { if !force {
t.Fatal("低于最低支持版本应强更") t.Fatal("低于最低支持版本应强更")
} }
@ -448,11 +448,42 @@ func TestEvalChannelUpgradeForceImpliesNeed(t *testing.T) {
// 后台还没配发版信息时 model 层给的是 nil,这里确认 nil 不会 panic 也不会误判。 // 后台还没配发版信息时 model 层给的是 nil,这里确认 nil 不会 panic 也不会误判。
func TestEvalChannelUpgradeNilSafe(t *testing.T) { 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") 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", 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 { if got == nil {
t.Fatal("渠道行缺失但发版配置存在时必须照常下发,否则应用级开关会被渠道行卡死") t.Fatal("渠道行缺失但发版配置存在时必须照常下发,否则应用级开关会被渠道行卡死")
} }
@ -863,7 +894,7 @@ func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) {
Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store", Channel: comm.ChannelChinaXiaomi, Address: "https://xiaomi/store",
PayChannels: "wxpay", AllowSkipDeviceBinding: true, Status: comm.ChannelStatusOnline, 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 { if got == nil {
t.Fatal("发版配置未配时仍应下发渠道信息") t.Fatal("发版配置未配时仍应下发渠道信息")
} }
@ -881,15 +912,44 @@ func TestToPbChannelAppSurvivesMissingRelease(t *testing.T) {
// 渠道地址优先于应用默认地址:各商店链接不同,一套地址会把用户引到别家商店。 // 渠道地址优先于应用默认地址:各商店链接不同,一套地址会把用户引到别家商店。
func TestToPbChannelAppAddressPrecedence(t *testing.T) { func TestToPbChannelAppAddressPrecedence(t *testing.T) {
rel := &comm.AppRelease{Address: "https://default/app.apk"} rel := &comm.AppRelease{Address: "https://default/app.apk"}
ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store"} ch := &comm.ChannelApp{Channel: comm.ChannelChinaHuawei, Address: "https://huawei/store", Status: comm.ChannelStatusOnline}
if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1"); got.Address != "https://huawei/store" { if got := toPbChannelApp(comm.ChannelChinaHuawei, ch, rel, "1.0.1", "https://official/app.apk"); got.Address != "https://huawei/store" {
t.Fatalf("渠道地址应覆盖应用默认地址,实际 %q", got.Address) 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。 // 两张表都没配才返回 nil。
func TestToPbChannelAppBothNil(t *testing.T) { 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) t.Fatalf("两边都没配时应返回 nil,实际 %+v", got)
} }
} }

Loading…
Cancel
Save