启动链路真实化:版本解析层对齐线上配置,复刻 Android chuliversion_1(H5 零改动)

对照原 Android 工程 + 线上实测配置,修正 4 处会破坏 H5 适配的根因:
1. 配置树层级改为真实的 agentlist→gamelist→channellist→marketlist
   (原抄了废弃 Bean1 的 agent→channel→market→game)。
2. 资源下载字段名 game_download → 真实的 game_zip(原永远取空,资源升级永不触发)。
3. marketid/版本为数字,匹配/比较做 string↔number 归一(原 ===  "3" 永不命中 market 层)。
4. 大厅 app_config.gameid 为空时回退 version.xml 的 <game id>(复刻 versd.getGameid()),
   否则大厅永远定位不到自己的 game_zip。

实现:
- RemoteConfig/VersionResolver 按真实格式重写,VersionResolver 复刻 chuliversion_1
  的 game/channel/market 三层累积 + market 层 app 升级,"0"/空不覆盖。
- ConfigManager 拆分 fetchRemote(IO) / resolveWith(纯函数 + gameid 回退),移除失效的二级 url 编排。
- VersionXml 补解析 <game id>;ResourceManager 增 localGameId()。
- StartupOrchestrator 调整时序:内置资源(version.xml 种子)就绪→取 gameid→解析远程,拉取仍并行。
- 内置 gamehall_builtin.zip 改为真实版本种子(game id=G2hw... version=42),离线首启阻断、联网下载真实大厅。
- app_config.json gameconfig 切生产端点(去 _test),不再使用测试配置。
- VersionResolver 单测重写为真实格式 + 线上大厅样例。

注:远程配置格式属原生内部消费(H5 不可见),按铁律以线上服务器为准对齐。devecocli build 通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-26 17:43:20 +08:00
co-authored by Claude Opus 4.8
parent 4b671c7437
commit 799e78d8af
10 changed files with 282 additions and 356 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
// 配置 // 配置
export { AppConfig, AppConfigRaw, normalizeConfig } from './src/main/ets/config/AppConfig'; export { AppConfig, AppConfigRaw, normalizeConfig } from './src/main/ets/config/AppConfig';
export { ConfigManager, LOCAL_CONFIG_RAWFILE } from './src/main/ets/config/ConfigManager'; export { ConfigManager, LOCAL_CONFIG_RAWFILE } from './src/main/ets/config/ConfigManager';
export { RemoteConfig, AgentNode, ChannelNode, MarketNode, GameOverrideNode, GameTreeNode, export { RemoteConfig, AgentNode, GameNode, ChannelNode, MarketNode, Scalar, asStr,
VersionFields, MatchKeys, VersionDecision } from './src/main/ets/config/RemoteConfig'; VersionFields, MatchKeys, VersionDecision } from './src/main/ets/config/RemoteConfig';
// 版本决策(纯函数) // 版本决策(纯函数)
@@ -1,16 +1,19 @@
/** /**
* 配置管理(契约 §4,框架 §8.2)。负责: * 配置管理(对齐真实线上配置 + Android chuliversion_1)。负责:
* - 加载本地配置(rawfile/app_config.json)。 * - 加载本地配置(rawfile/app_config.json)。
* - 拼远程配置 URLgameconfig '-'→'/' + .txt),GET 禁缓存 + ?a=时间戳。 * - 拼远程配置 URLgameconfig '-'→'/' + .txt),GET 禁缓存 + ?a=时间戳。
* - 校验(长度<30 视为错误文案 → 走 showmessage 阻断)、解析、二级 url 二次请求 * - 校验(长度<30 视为错误文案 → 走 showmessage 阻断)、解析为 RemoteConfig
* - 经 VersionResolver 产出版本决策。 * - 经 VersionResolver 产出版本决策(gameid 为空时由调用方传入 version.xml 回退值)
*
* 注:真实 `.txt` 配置(顶层仅 agentlist、字段 game_zip、数字 marketid)无二级 url 二次请求,
* 故移除旧的 fetchSecondary 编排。
*/ */
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { util } from '@kit.ArkTS'; import { util } from '@kit.ArkTS';
import { HttpClient } from 'platform'; import { HttpClient } from 'platform';
import { Logger } from 'common'; import { Logger } from 'common';
import { AppConfig, AppConfigRaw, normalizeConfig } from './AppConfig'; import { AppConfig, AppConfigRaw, normalizeConfig } from './AppConfig';
import { AgentNode, GameTreeNode, MatchKeys, RemoteConfig, VersionDecision, VersionFields } from './RemoteConfig'; import { MatchKeys, RemoteConfig, VersionDecision } from './RemoteConfig';
import { VersionResolver } from '../version/VersionResolver'; import { VersionResolver } from '../version/VersionResolver';
/** 本地配置 rawfile 文件名。 */ /** 本地配置 rawfile 文件名。 */
@@ -42,13 +45,17 @@ export class ConfigManager {
return this.local; return this.local;
} }
/** 分层匹配 key(§4.4)。 */ /**
matchKeys(): MatchKeys { * 分层匹配 key(§4.4)。gameid 为空(大厅)时回退到 version.xml 的 game id
* fallbackGameId,复刻 Android versd.getGameid())。
*/
matchKeys(fallbackGameId: string = ''): MatchKeys {
const gameid: string = this.local.gameid !== '' ? this.local.gameid : fallbackGameId;
return { return {
agentid: this.local.agent, agentid: this.local.agent,
channelid: this.local.channel, channelid: this.local.channel,
marketid: this.local.market, marketid: this.local.market,
gameid: this.local.gameid, gameid,
}; };
} }
@@ -73,93 +80,17 @@ export class ConfigManager {
ConfigManager.log.w(`remote config too short (${body.length}) -> treat as block message`); ConfigManager.log.w(`remote config too short (${body.length}) -> treat as block message`);
return { showmessage: body }; return { showmessage: body };
} }
const remote: RemoteConfig = JSON.parse(body) as RemoteConfig; return JSON.parse(body) as RemoteConfig;
await this.fetchSecondary(remote);
return remote;
} }
/** 拉取远程配置并产出版本决策(一步到位)。 */ /** 纯函数:用已拉取远程配置 + gameid 回退值产出版本决策。 */
async resolve(): Promise<VersionDecision> { resolveWith(remote: RemoteConfig, fallbackGameId: string): VersionDecision {
return VersionResolver.resolve(remote, this.matchKeys(fallbackGameId));
}
/** 便捷:拉取 + 解析一步到位(gameid 回退值由调用方提供)。 */
async resolve(fallbackGameId: string = ''): Promise<VersionDecision> {
const remote: RemoteConfig = await this.fetchRemote(); const remote: RemoteConfig = await this.fetchRemote();
return VersionResolver.resolve(remote, this.matchKeys()); return this.resolveWith(remote, fallbackGameId);
}
/**
* 二级 url 二次请求(§4.3):匹配到的 agent/game 节点若带非空 url
* 拉取其专属二级配置并用同 id 节点的子树覆盖内联节点。
* ⚠ 合并语义按契约推断,需在 T-M2-12 用真实服务端响应校验;失败静默回退内联配置。
*/
private async fetchSecondary(remote: RemoteConfig): Promise<void> {
const k: MatchKeys = this.matchKeys();
const agent: AgentNode | undefined =
(remote.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
if (agent !== undefined && agent.url !== undefined && agent.url !== '') {
const sub: RemoteConfig | undefined = await this.tryFetchSub(agent.url);
const subAgent: AgentNode | undefined =
sub === undefined ? undefined : (sub.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
if (subAgent !== undefined) {
// 既并入子树,也并入二级节点本层的版本字段(此前只换 channellist 会丢节点自身的版本/下载地址)
ConfigManager.copyVersionFields(agent, subAgent);
if (subAgent.channellist !== undefined) {
agent.channellist = subAgent.channellist;
}
ConfigManager.log.i('secondary agent config merged');
}
}
const game: GameTreeNode | undefined =
(remote.gamelist ?? []).find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid);
if (game !== undefined && game.url !== undefined && game.url !== '') {
const sub: RemoteConfig | undefined = await this.tryFetchSub(game.url);
const subGame: GameTreeNode | undefined =
sub === undefined ? undefined : (sub.gamelist ?? []).find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid);
if (subGame !== undefined) {
ConfigManager.copyVersionFields(game, subGame);
if (subGame.agentlist !== undefined) {
game.agentlist = subGame.agentlist;
}
ConfigManager.log.i('secondary game config merged');
}
}
}
/** 用 src 的非空版本字段覆盖 dst(二级配置节点本层的版本/下载地址不丢失)。 */
private static copyVersionFields(dst: VersionFields, src: VersionFields): void {
if (src.app_version !== undefined && src.app_version !== '') {
dst.app_version = src.app_version;
}
if (src.app_download !== undefined && src.app_download !== '') {
dst.app_download = src.app_download;
}
if (src.app_size !== undefined && src.app_size !== '') {
dst.app_size = src.app_size;
}
if (src.game_version !== undefined && src.game_version !== '') {
dst.game_version = src.game_version;
}
if (src.game_download !== undefined && src.game_download !== '') {
dst.game_download = src.game_download;
}
if (src.game_size !== undefined && src.game_size !== '') {
dst.game_size = src.game_size;
}
if (src.showmessage !== undefined && src.showmessage !== '') {
dst.showmessage = src.showmessage;
}
}
private async tryFetchSub(url: string): Promise<RemoteConfig | undefined> {
try {
const res = await HttpClient.getString(url);
if (res.code < 200 || res.code >= 300) {
return undefined;
}
return JSON.parse(res.body.trim()) as RemoteConfig;
} catch (e) {
const err = e as Error;
ConfigManager.log.w(`secondary config fetch failed ${url}: ${err.message}`);
return undefined;
}
} }
} }
@@ -1,65 +1,72 @@
/** /**
* 远程配置 DTO(契约 §4.3 2.0 格式:agentlist / gamelist 两棵树并存)。 * 远程配置 DTO —— **严格对齐真实线上 `.txt` 配置**(实测 http://tsgames.daoqi88.cn/config/update_jsonv2.txt
* 与原 Android `Bean(Txt)` + `chuliversion_1` 解析路径,而非旧契约示意文档。
* *
* 每层节点可携带可覆盖字段(§4.4):app_version/app_download/app_size/ * 真实层级(game 在 channel/market 之上):
* game_version/game_download/game_size/url/showmessage。越深层越后赋值,覆盖前层。 * agentlist[] → gamelist[] → channellist[] → marketlist[]
* 字段名与契约真实字段(非旧示意文档)严格一致。 *
* 真实字段要点(与旧实现的差异,均为"破坏 H5 适配"的根因):
* - 资源下载字段名是 **game_zip**(非 game_download)。
* - marketid / app_version / game_version 在 JSON 里是**数字**,匹配/比较需做 string↔number 归一。
* - app 升级字段 app_download/app_version 落在 market 层;资源字段 game_zip/game_version
* 可出现在 game / channel / market 各层,越深越后、覆盖前层("0"/空视为未设置不覆盖)。
*/ */
/** 每层可覆盖的版本字段(§4.4)。空串视为"未设置",不覆盖前层。 */ /** JSON 里既可能是字符串也可能是数字的标量(id、版本号等)。 */
export type Scalar = string | number;
/** 把 Scalar 归一为字符串(undefined/null → '')。 */
export function asStr(v: Scalar | undefined): string {
if (v === undefined) {
return '';
}
return typeof v === 'number' ? `${v}` : v;
}
/** 每层可携带的资源/App 升级字段。空串或 "0"version)视为未设置。 */
export interface VersionFields { export interface VersionFields {
app_version?: string; app_version?: Scalar;
app_download?: string; app_download?: string;
app_size?: string; app_size?: string;
game_version?: string; game_version?: Scalar;
game_download?: string; /** 资源 zip 下载地址(真实字段名 game_zip)。 */
game_zip?: string;
game_size?: string; game_size?: string;
/** 指向二级配置地址,非空则二次请求(§4.3)。 */
url?: string;
showmessage?: string; showmessage?: string;
} }
/** 市场层节点marketid 命中;其 gamelist 为该市场下的游戏覆盖)。 */ /** 市场层(marketid 数字命中;携带 app 升级 + 可选资源覆盖)。 */
export interface MarketNode extends VersionFields { export interface MarketNode extends VersionFields {
marketid?: string; marketid?: Scalar;
gamelist?: GameOverrideNode[];
} }
/** 市场下的游戏覆盖节点(gameid 命中,设 app/game 升级)。 */ /** 渠道层(channelid 命中;可覆盖资源字段;含 marketlist)。 */
export interface GameOverrideNode extends VersionFields {
gameid?: string;
}
/** 渠道层节点。 */
export interface ChannelNode extends VersionFields { export interface ChannelNode extends VersionFields {
channelid?: string; channelid?: string;
marketlist?: MarketNode[]; marketlist?: MarketNode[];
} }
/** 代理层节点。 */ /** 游戏层(gameid 命中;携带大厅资源 game_zip/game_version;含 channellist。 */
export interface AgentNode extends VersionFields { export interface GameNode extends VersionFields {
agentid?: string; gameid?: string;
agentname?: string; game_hall_dir?: string;
channellist?: ChannelNode[]; channellist?: ChannelNode[];
} }
/** 游戏树顶层节点(gamelist[]gameid 命中;url 非空则二次请求;内含 agentlist)。 */ /** 代理层(agentid 命中;含 gamelist)。 */
export interface GameTreeNode extends VersionFields { export interface AgentNode extends VersionFields {
gameid?: string; agentid?: string;
agentlist?: AgentNode[]; agentname?: string;
gamelist?: GameNode[];
} }
/** 远程配置根(2.0 格式)。 */ /** 远程配置根(真实格式仅顶层 agentlist;顶层 showmessage 非空则阻断)。 */
export interface RemoteConfig extends VersionFields { export interface RemoteConfig {
/** 顶层公告:非空 → 阻断弹窗(§3.2)。 */
showmessage?: string; showmessage?: string;
/** 代理配置树。 */
agentlist?: AgentNode[]; agentlist?: AgentNode[];
/** 游戏配置树(2.0 才有;缺失即 1.0 老格式)。 */
gamelist?: GameTreeNode[];
} }
/** 分层匹配 key(来自本地配置 §4.2)。 */ /** 分层匹配 key(来自本地配置 + version.xml 回退的 gameid)。 */
export interface MatchKeys { export interface MatchKeys {
agentid: string; agentid: string;
channelid: string; channelid: string;
@@ -69,13 +76,13 @@ export interface MatchKeys {
/** 最终版本决策结果。 */ /** 最终版本决策结果。 */
export interface VersionDecision { export interface VersionDecision {
/** 顶层公告(非空则阻断)。 */ /** 顶层/命中层公告(非空则阻断)。 */
showmessage: string; showmessage: string;
/** App 升级(取代理树/游戏树中 version 更高者)。 */ /** App(apk)升级版本与下载地址(HarmonyOS 不安装 apk,仅作记录/比较用)。 */
appVersion: number; appVersion: number;
appDownload: string; appDownload: string;
appSize: string; appSize: string;
/** 资源(游戏)升级(取更高者)。 */ /** 资源(大厅/子游戏 zip)升级版本与下载地址(来源 game_zip)。 */
gameVersion: number; gameVersion: number;
gameDownload: string; gameDownload: string;
gameSize: string; gameSize: string;
@@ -84,7 +84,7 @@ export class ResourceManager {
return VersionXml.parse(FileSystem.readText(p)); return VersionXml.parse(FileSystem.readText(p));
} }
} }
return { version: 0, gameName: '' }; return { version: 0, gameName: '', gameId: '' };
} }
/** 本地资源版本(version.xml 的 version;缺失返回 0)。 */ /** 本地资源版本(version.xml 的 version;缺失返回 0)。 */
@@ -97,6 +97,11 @@ export class ResourceManager {
return this.readVersionXml().gameName; return this.readVersionXml().gameName;
} }
/** 本地 version.xml 的 game id(大厅 app_config.gameid 为空时的回退匹配 key;缺失返回 '')。 */
localGameId(): string {
return this.readVersionXml().gameId;
}
/** 持久化路径到 KvStore(供容器/重启读取)。 */ /** 持久化路径到 KvStore(供容器/重启读取)。 */
async persistPaths(): Promise<void> { async persistPaths(): Promise<void> {
this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath); this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath);
@@ -15,6 +15,8 @@ export interface VersionXmlInfo {
version: number; version: number;
/** 子 <game name="..."> 游戏名(缺失 → '')。 */ /** 子 <game name="..."> 游戏名(缺失 → '')。 */
gameName: string; gameName: string;
/** 子 <game id="..."> 游戏 ID(大厅 app_config.gameid 为空时的回退匹配 key)。 */
gameId: string;
} }
export class VersionXml { export class VersionXml {
@@ -22,7 +24,7 @@ export class VersionXml {
/** 解析 version + gameName。属性回调紧随其开始标签,故用 currentTag 把 name 归属到 game 标签。 */ /** 解析 version + gameName。属性回调紧随其开始标签,故用 currentTag 把 name 归属到 game 标签。 */
static parse(xmlText: string): VersionXmlInfo { static parse(xmlText: string): VersionXmlInfo {
const info: VersionXmlInfo = { version: 0, gameName: '' }; const info: VersionXmlInfo = { version: 0, gameName: '', gameId: '' };
if (xmlText.length === 0) { if (xmlText.length === 0) {
return info; return info;
} }
@@ -43,6 +45,9 @@ export class VersionXml {
} else if (name === 'name' && currentTag === 'game') { } else if (name === 'name' && currentTag === 'game') {
// 根 <game> 无 name 属性;只有子 <game id name/> 触发,正是所需游戏名 // 根 <game> 无 name 属性;只有子 <game id name/> 触发,正是所需游戏名
info.gameName = val; info.gameName = val;
} else if (name === 'id' && currentTag === 'game') {
// 子 <game id="..."> 的 id:大厅 gameid 回退匹配 key(根 <game> 无 id 属性)
info.gameId = val;
} }
return true; return true;
}, },
@@ -12,7 +12,7 @@ import { KvStore } from 'platform';
import { Logger } from 'common'; import { Logger } from 'common';
import { ConfigManager } from '../config/ConfigManager'; import { ConfigManager } from '../config/ConfigManager';
import { AppConfig } from '../config/AppConfig'; import { AppConfig } from '../config/AppConfig';
import { VersionDecision } from '../config/RemoteConfig'; import { RemoteConfig, VersionDecision } from '../config/RemoteConfig';
import { ResourceManager } from '../resource/ResourceManager'; import { ResourceManager } from '../resource/ResourceManager';
import { AppDataInjector, AppDataValues } from '../resource/AppDataInjector'; import { AppDataInjector, AppDataValues } from '../resource/AppDataInjector';
@@ -93,24 +93,25 @@ export class StartupOrchestrator {
return { enter: true, entryUrl, message: '' }; return { enter: true, entryUrl, message: '' };
} }
// T-M5-07 并行化:远程配置(网络 IO,1-5s)与内置资源准备(本地拷贝解压,0.2-0.8s) // T-M5-07 并行化:远程配置**拉取**(网络 IO,1-5s)与内置资源准备(本地拷贝解压,0.2-0.8s)
// 无数据依赖——prepareBuiltin 只依赖本地 config,不依赖远程 decision。两者同时进行, // 并行。注意:版本**解析**需要 version.xml 的 game id 作回退匹配 key(大厅 app_config.gameid
// 总耗时由 max(远程, 内置) 决定而非求和。 // 为空,复刻 Android versd.getGameid()),故解析必须在内置资源就绪后做——只拉取可并行,
// 解析为纯函数置于汇合点,仍由 max(远程, 内置) 决定耗时。
this.stage(StartupStage.FETCH_REMOTE_CONFIG, 20); this.stage(StartupStage.FETCH_REMOTE_CONFIG, 20);
const tRemote: number = Date.now(); const tRemote: number = Date.now();
const remotePromise: Promise<VersionDecision | undefined> = const remotePromise: Promise<RemoteConfig | undefined> =
(async (): Promise<VersionDecision | undefined> => { (async (): Promise<RemoteConfig | undefined> => {
try { try {
const d: VersionDecision = await config.resolve(); const r: RemoteConfig = await config.fetchRemote();
this.mark('fetch_remote_config', tRemote); this.mark('fetch_remote_config', tRemote);
return d; return r;
} catch (e) { } catch (e) {
StartupOrchestrator.log.w(`remote config failed, fallback to local: ${(e as Error).message}`); StartupOrchestrator.log.w(`remote config failed, fallback to local: ${(e as Error).message}`);
return undefined; return undefined;
} }
})(); })();
// 内置资源准备(与上面远程请求并行推进) // 内置资源准备(与上面远程请求并行推进)——为 version.xml(版本 + game id 种子)落盘
this.stage(StartupStage.PREPARE_RESOURCE, 35); this.stage(StartupStage.PREPARE_RESOURCE, 35);
const tBuiltin: number = Date.now(); const tBuiltin: number = Date.now();
if (!resource.isPrepared()) { if (!resource.isPrepared()) {
@@ -118,8 +119,11 @@ export class StartupOrchestrator {
} }
this.mark('prepare_builtin', tBuiltin); this.mark('prepare_builtin', tBuiltin);
// 汇合:取远程版本决策 // 汇合:取远程原始配置,用 version.xml 的 game id 作 gameid 回退后解析版本决策
const decision: VersionDecision | undefined = await remotePromise; const remote: RemoteConfig | undefined = await remotePromise;
const fallbackGameId: string = resource.localGameId();
const decision: VersionDecision | undefined =
remote === undefined ? undefined : config.resolveWith(remote, fallbackGameId);
this.stage(StartupStage.RESOLVE_VERSION, 55); this.stage(StartupStage.RESOLVE_VERSION, 55);
if (decision !== undefined && decision.showmessage !== '') { if (decision !== undefined && decision.showmessage !== '') {
StartupOrchestrator.log.w(`blocked by showmessage: ${decision.showmessage}`); StartupOrchestrator.log.w(`blocked by showmessage: ${decision.showmessage}`);
@@ -1,151 +1,121 @@
/** /**
* 分层版本决策(契约 §4.4,框架 §8.2)。**纯函数,无 IO,便于单测** * 分层版本决策 —— **复刻 Android `chuliversion_1` 真实逻辑**纯函数,无 IO,便于单测
* *
* 输入已拉取/合并好的远程配置(含两棵树)+ 本地匹配 key,输出最终 App/资源版本决策。 * 遍历真实层级 agentlist → gamelist → channellist → marketlist,逐层下钻匹配
* 规则: * agentid / gameid / channelid / marketidmarketid 数字与本地字符串归一比较)。
* - 代理树:agentid → channelid → marketid → (market.gamelist) gameid**越深越后、覆盖前层**。 *
* - 游戏树:gameid → agentid → channelid → marketid,同样后层覆盖。 * 累积规则(与 Android 一致):
* - 最终:app 升级 game 升级各自取"两棵树中 version 更高者"的下载地址。 * - 资源升级 game_zip/game_version:在 game、channel、market 三层累积,越深越后、覆盖前层;
* - 二级 `url` 二次请求(§4.3)属 IO,由 ConfigManager 编排后把合并结果喂给本函数 * "0" 或空的 game_version 不覆盖(视为未设置)
* - App 升级 app_download/app_version:取 market 层值。
* - showmessage:任一命中层非空即生效(阻断)。
* - 最终资源版本与本地 version.xml 比较由调用方决定是否下载 game_zip。
*/ */
import { AgentNode, ChannelNode, GameOverrideNode, GameTreeNode, MarketNode, import { AgentNode, ChannelNode, GameNode, MarketNode,
MatchKeys, RemoteConfig, VersionDecision, VersionFields } from '../config/RemoteConfig'; MatchKeys, RemoteConfig, Scalar, VersionDecision, VersionFields, asStr } from '../config/RemoteConfig';
/** 用 next 的非空字段覆盖 cur(空串/undefined 不覆盖)。 */ /** 版本号转整数(空/"0"/非数字 → 0)。 */
function ov(cur: string | undefined, next: string | undefined): string | undefined { function parseVer(v: Scalar | undefined): number {
return (next !== undefined && next !== '') ? next : cur; const s: string = asStr(v);
} if (s === '') {
/** 把 n 的非空可覆盖字段并入 acc(深层覆盖浅层)。 */
function merge(acc: VersionFields, n: VersionFields): void {
acc.app_version = ov(acc.app_version, n.app_version);
acc.app_download = ov(acc.app_download, n.app_download);
acc.app_size = ov(acc.app_size, n.app_size);
acc.game_version = ov(acc.game_version, n.game_version);
acc.game_download = ov(acc.game_download, n.game_download);
acc.game_size = ov(acc.game_size, n.game_size);
acc.showmessage = ov(acc.showmessage, n.showmessage);
}
/** 版本号转整数(空/非数字 → 0)。 */
function parseVer(s: string | undefined): number {
if (s === undefined || s === '') {
return 0; return 0;
} }
const n: number = parseInt(s, 10); const n: number = parseInt(s, 10);
return isNaN(n) ? 0 : n; return isNaN(n) ? 0 : n;
} }
/** 取首个非空串。 */ /** game_version 是否为"有效非零"(用于决定是否覆盖累积值)。 */
function firstNonEmpty(a: string | undefined, b: string | undefined, c: string | undefined): string { function hasVer(v: Scalar | undefined): boolean {
if (a !== undefined && a !== '') { return parseVer(v) > 0;
return a; }
/** 累积器:复刻 chuliversion_1 的 newgame_zip / newgame_version + app 字段 + showmessage。 */
class Acc {
gameZip: string = '';
gameVersion: number = 0;
gameSize: string = '';
appDownload: string = '';
appVersion: number = 0;
appSize: string = '';
showmessage: string = '';
/** 资源字段覆盖(game/channel/market 各层调用):非空 zip 覆盖;非零 version 覆盖。 */
mergeGame(n: VersionFields): void {
const zip: string = n.game_zip ?? '';
if (zip !== '') {
this.gameZip = zip;
}
if (hasVer(n.game_version)) {
this.gameVersion = parseVer(n.game_version);
}
const size: string = n.game_size ?? '';
if (size !== '') {
this.gameSize = size;
}
} }
if (b !== undefined && b !== '') {
return b; /** App 升级字段(market 层):下载地址与版本均非空才采纳。 */
mergeApp(n: VersionFields): void {
const dl: string = n.app_download ?? '';
const ver: string = asStr(n.app_version);
if (dl !== '' && ver !== '') {
this.appDownload = dl;
this.appVersion = parseVer(n.app_version);
this.appSize = n.app_size ?? '';
}
}
/** 命中层公告非空即记录(最后非空者生效)。 */
mergeMsg(n: VersionFields): void {
const msg: string = n.showmessage ?? '';
if (msg !== '') {
this.showmessage = msg;
}
} }
return (c !== undefined && c !== '') ? c : '';
} }
export class VersionResolver { export class VersionResolver {
/** 代理树:agentid → channelid → marketid → gameid,逐层并入。 */ /** 真实层级遍历:agent → game → channel → market。 */
static resolveAgentTree(agents: AgentNode[], k: MatchKeys): VersionFields {
const acc: VersionFields = {};
const agent: AgentNode | undefined = agents.find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
if (agent === undefined) {
return acc;
}
merge(acc, agent);
const channel: ChannelNode | undefined =
(agent.channellist ?? []).find((c: ChannelNode) => (c.channelid ?? '') === k.channelid);
if (channel === undefined) {
return acc;
}
merge(acc, channel);
const market: MarketNode | undefined =
(channel.marketlist ?? []).find((m: MarketNode) => (m.marketid ?? '') === k.marketid);
if (market === undefined) {
return acc;
}
merge(acc, market);
// gameid 为空表示"不参与 game 维度匹配"(大厅);非空才在 market.gamelist 里命中
if (k.gameid !== '') {
const game: GameOverrideNode | undefined =
(market.gamelist ?? []).find((g: GameOverrideNode) => (g.gameid ?? '') === k.gameid);
if (game !== undefined) {
merge(acc, game);
}
}
return acc;
}
/** 游戏树:gameid → agentid → channelid → marketid,逐层并入。gameid 为空则不参与游戏树。 */
static resolveGameTree(games: GameTreeNode[], k: MatchKeys): VersionFields {
const acc: VersionFields = {};
if (k.gameid === '') {
return acc;
}
const game: GameTreeNode | undefined = games.find((g: GameTreeNode) => (g.gameid ?? '') === k.gameid);
if (game === undefined) {
return acc;
}
merge(acc, game);
const agent: AgentNode | undefined =
(game.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === k.agentid);
if (agent === undefined) {
return acc;
}
merge(acc, agent);
const channel: ChannelNode | undefined =
(agent.channellist ?? []).find((c: ChannelNode) => (c.channelid ?? '') === k.channelid);
if (channel === undefined) {
return acc;
}
merge(acc, channel);
const market: MarketNode | undefined =
(channel.marketlist ?? []).find((m: MarketNode) => (m.marketid ?? '') === k.marketid);
if (market !== undefined) {
merge(acc, market);
}
return acc;
}
/** 最终决策:两棵树各取更高 version 的下载地址。 */
static resolve(remote: RemoteConfig, keys: MatchKeys): VersionDecision { static resolve(remote: RemoteConfig, keys: MatchKeys): VersionDecision {
const a: VersionFields = VersionResolver.resolveAgentTree(remote.agentlist ?? [], keys); const acc: Acc = new Acc();
const g: VersionFields = VersionResolver.resolveGameTree(remote.gamelist ?? [], keys); const rootMsg: string = remote.showmessage ?? '';
if (rootMsg !== '') {
const aGameV: number = parseVer(a.game_version); acc.showmessage = rootMsg;
const gGameV: number = parseVer(g.game_version);
// 取更高 version 者;平手时取"有下载地址"的一棵(避免选中只有版本号无下载地址的层)
let gameWin: VersionFields;
if (gGameV > aGameV) {
gameWin = g;
} else if (aGameV > gGameV) {
gameWin = a;
} else {
gameWin = (a.game_download !== undefined && a.game_download !== '') ? a : g;
} }
const aAppV: number = parseVer(a.app_version); const agent: AgentNode | undefined =
const gAppV: number = parseVer(g.app_version); (remote.agentlist ?? []).find((a: AgentNode) => (a.agentid ?? '') === keys.agentid);
let appWin: VersionFields; if (agent !== undefined) {
if (gAppV > aAppV) { acc.mergeMsg(agent);
appWin = g; const game: GameNode | undefined =
} else if (aAppV > gAppV) { (agent.gamelist ?? []).find((g: GameNode) => (g.gameid ?? '') === keys.gameid);
appWin = a; if (game !== undefined) {
} else { acc.mergeMsg(game);
appWin = (a.app_download !== undefined && a.app_download !== '') ? a : g; acc.mergeGame(game);
const channel: ChannelNode | undefined =
(game.channellist ?? []).find((c: ChannelNode) => (c.channelid ?? '') === keys.channelid);
if (channel !== undefined) {
acc.mergeMsg(channel);
acc.mergeGame(channel);
const market: MarketNode | undefined =
(channel.marketlist ?? []).find((m: MarketNode) => asStr(m.marketid) === keys.marketid);
if (market !== undefined) {
acc.mergeMsg(market);
acc.mergeGame(market);
acc.mergeApp(market);
}
}
}
} }
return { return {
showmessage: firstNonEmpty(a.showmessage, g.showmessage, remote.showmessage), showmessage: acc.showmessage,
appVersion: Math.max(aAppV, gAppV), appVersion: acc.appVersion,
appDownload: appWin.app_download ?? '', appDownload: acc.appDownload,
appSize: appWin.app_size ?? '', appSize: acc.appSize,
gameVersion: Math.max(aGameV, gGameV), gameVersion: acc.gameVersion,
gameDownload: gameWin.game_download ?? '', gameDownload: acc.gameZip,
gameSize: gameWin.game_size ?? '', gameSize: acc.gameSize,
}; };
} }
} }
@@ -7,7 +7,7 @@
"market": "3", "market": "3",
"gameid": "", "gameid": "",
"weburl": "", "weburl": "",
"gameconfig": "tsgames.daoqi88.cn-config_test-update_jsonv2_test", "gameconfig": "tsgames.daoqi88.cn-config-update_jsonv2",
"other": "", "other": "",
"tuiguang": "" "tuiguang": ""
} }
+89 -85
View File
@@ -2,11 +2,13 @@ import { describe, it, expect } from '@ohos/hypium';
import { VersionResolver, RemoteConfig, MatchKeys, VersionDecision } from 'domain_resource'; import { VersionResolver, RemoteConfig, MatchKeys, VersionDecision } from 'domain_resource';
/** /**
* 分层版本决策单测(T-M2-06,契约 §4.4)。纯函数,构造典型多层样例验证后层覆盖、 * 分层版本决策单测(对齐真实线上 `.txt` 配置 + Android chuliversion_1)。
* 两棵树取更高 version、showmessage 阻断。 * 真实层级:agentlist → gamelist → channellist → marketlist;资源字段 game_zip
* marketid/版本为数字;大厅 gameid 由 version.xml 回退提供。
*/ */
export default function versionResolverTest() { export default function versionResolverTest() {
const keys: MatchKeys = { agentid: 'A1', channelid: 'C1', marketid: 'M1', gameid: 'G1' }; // 大厅典型 keygameid 来自 version.xml 回退(G1
const keys: MatchKeys = { agentid: 'A1', channelid: 'C1', marketid: '3', gameid: 'G1' };
describe('VersionResolver', () => { describe('VersionResolver', () => {
it('empty_config_zero_decision', 0, () => { it('empty_config_zero_decision', 0, () => {
@@ -17,74 +19,83 @@ export default function versionResolverTest() {
expect(d.gameDownload).assertEqual(''); expect(d.gameDownload).assertEqual('');
}); });
it('agent_tree_deeper_overrides', 0, () => { it('game_level_resource_picked', 0, () => {
// 代理树:agent 设 game_version=10market 覆盖为 20(深层覆盖浅层) // 资源 game_zip/game_version 落在 game 层,channel/market 覆盖 → 取 game 层
const remote: RemoteConfig = {
agentlist: [{
agentid: 'A1', game_version: '10', game_download: 'a_url', game_size: '100',
channellist: [{
channelid: 'C1',
marketlist: [{ marketid: 'M1', game_version: '20', game_download: 'm_url' }],
}],
}],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(20);
expect(d.gameDownload).assertEqual('m_url');
});
it('market_game_level_overrides', 0, () => {
// market.gamelist 中 gameid 命中,覆盖到最深
const remote: RemoteConfig = { const remote: RemoteConfig = {
agentlist: [{ agentlist: [{
agentid: 'A1', agentid: 'A1',
channellist: [{ gamelist: [{
channelid: 'C1', gameid: 'G1', game_version: 261, game_zip: 'hall.zip', game_size: '4.6M',
marketlist: [{ channellist: [{ channelid: 'C1', game_version: 0,
marketid: 'M1', game_version: '20', marketlist: [{ marketid: 3, app_version: 49, app_download: 'apk' }] }],
gamelist: [{ gameid: 'G1', game_version: '30', game_download: 'g_url' }],
}],
}], }],
}], }],
}; };
const d: VersionDecision = VersionResolver.resolve(remote, keys); const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(30); expect(d.gameVersion).assertEqual(261);
expect(d.gameDownload).assertEqual('g_url'); expect(d.gameDownload).assertEqual('hall.zip');
expect(d.appVersion).assertEqual(49);
expect(d.appDownload).assertEqual('apk');
}); });
it('two_trees_take_higher_version', 0, () => { it('deeper_layer_overrides_resource', 0, () => {
// 代理树 game_version=20,游戏树 game_version=25 → 取 25 及其下载地址 // game 层 game_zip=hall.zipmarket 层覆盖为 m.zip / 更高版本
const remote: RemoteConfig = { const remote: RemoteConfig = {
agentlist: [{ agentid: 'A1', game_version: '20', game_download: 'agent_url' }], agentlist: [{
gamelist: [{ agentid: 'A1',
gameid: 'G1', gamelist: [{
agentlist: [{ agentid: 'A1', game_version: '25', game_download: 'game_url' }], gameid: 'G1', game_version: 100, game_zip: 'hall.zip',
channellist: [{ channelid: 'C1',
marketlist: [{ marketid: 3, game_version: 120, game_zip: 'm.zip' }] }],
}],
}], }],
}; };
const d: VersionDecision = VersionResolver.resolve(remote, keys); const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(25); expect(d.gameVersion).assertEqual(120);
expect(d.gameDownload).assertEqual('game_url'); expect(d.gameDownload).assertEqual('m.zip');
}); });
it('app_and_game_independent_winners', 0, () => { it('numeric_marketid_matches_string_key', 0, () => {
// app 升级取代理树(app_version=49)game 升级取游戏树(game_version=30) // JSON marketid 为数字 3,本地 key 为字符串 '3',应归一命中
const remote: RemoteConfig = { const remote: RemoteConfig = {
agentlist: [{ agentid: 'A1', app_version: '49', app_download: 'apk_url', game_version: '10' }], agentlist: [{ agentid: 'A1',
gamelist: [{ gamelist: [{ gameid: 'G1', game_version: 50, game_zip: 'z',
gameid: 'G1', channellist: [{ channelid: 'C1',
agentlist: [{ agentid: 'A1', app_version: '40', game_version: '30', game_download: 'g_url' }], marketlist: [{ marketid: 3, app_version: 49, app_download: 'apk' }] }] }] }],
}],
}; };
const d: VersionDecision = VersionResolver.resolve(remote, keys); const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.appVersion).assertEqual(49); expect(d.appVersion).assertEqual(49);
expect(d.appDownload).assertEqual('apk_url'); expect(d.appDownload).assertEqual('apk');
expect(d.gameVersion).assertEqual(30);
expect(d.gameDownload).assertEqual('g_url');
}); });
it('no_match_returns_empty', 0, () => { it('zero_game_version_does_not_override', 0, () => {
// channel 层 game_version=0 不应把 game 层 261 覆盖掉
const remote: RemoteConfig = { const remote: RemoteConfig = {
agentlist: [{ agentid: 'OTHER', game_version: '99', game_download: 'x' }], agentlist: [{ agentid: 'A1',
gamelist: [{ gameid: 'G1', game_version: 261, game_zip: 'hall.zip',
channellist: [{ channelid: 'C1', game_version: 0, game_zip: '',
marketlist: [{ marketid: 3, game_version: 0 }] }] }] }],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(261);
expect(d.gameDownload).assertEqual('hall.zip');
});
it('no_agent_match_returns_empty', 0, () => {
const remote: RemoteConfig = {
agentlist: [{ agentid: 'OTHER',
gamelist: [{ gameid: 'G1', game_version: 99, game_zip: 'x' }] }],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(0);
expect(d.gameDownload).assertEqual('');
});
it('no_game_match_returns_empty', 0, () => {
// gameid 回退值不匹配任何 gamelist 节点
const remote: RemoteConfig = {
agentlist: [{ agentid: 'A1',
gamelist: [{ gameid: 'OTHER', game_version: 99, game_zip: 'x' }] }],
}; };
const d: VersionDecision = VersionResolver.resolve(remote, keys); const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(0); expect(d.gameVersion).assertEqual(0);
@@ -95,49 +106,42 @@ export default function versionResolverTest() {
const top: RemoteConfig = { showmessage: '停服维护公告' }; const top: RemoteConfig = { showmessage: '停服维护公告' };
expect(VersionResolver.resolve(top, keys).showmessage).assertEqual('停服维护公告'); expect(VersionResolver.resolve(top, keys).showmessage).assertEqual('停服维护公告');
const layer: RemoteConfig = { const layer: RemoteConfig = {
agentlist: [{ agentid: 'A1', showmessage: '代理层公告' }], agentlist: [{ agentid: 'A1', showmessage: '代理层公告',
gamelist: [{ gameid: 'G1', showmessage: '游戏层公告' }] }],
}; };
expect(VersionResolver.resolve(layer, keys).showmessage).assertEqual('代理层公告'); // 最后非空命中层(游戏层)生效
expect(VersionResolver.resolve(layer, keys).showmessage).assertEqual('游戏层公告');
}); });
it('empty_string_does_not_override', 0, () => { it('realworld_lobby_sample', 0, () => {
// 深层空串不应覆盖浅层非空值 // 复刻线上 tsgames.daoqi88.cn 大厅命中:game 261 + market3 app49market 无 game_zip
const liveKeys: MatchKeys = {
agentid: 'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',
channelid: 'FtJf073aa0d6rI1xD8J1Y42fINTm0ziK',
marketid: '3',
gameid: 'G2hw0ubng0zcoI0r4mx3H2yr4GejidwO',
};
const remote: RemoteConfig = { const remote: RemoteConfig = {
agentlist: [{ agentlist: [{
agentid: 'A1', game_download: 'keep', game_version: '10', agentid: 'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',
channellist: [{ channelid: 'C1', game_download: '' }], gamelist: [{
gameid: 'G2hw0ubng0zcoI0r4mx3H2yr4GejidwO',
game_version: 261, game_zip: 'http://tsgames.daoqi88.cn/zip2/gamehall.zip', game_size: '4.61M',
channellist: [{
channelid: 'FtJf073aa0d6rI1xD8J1Y42fINTm0ziK', game_version: 0,
marketlist: [
{ marketid: 1, app_version: 1, app_download: '' },
{ marketid: 2, app_version: 43, app_download: 'itms://x' },
{ marketid: 3, app_version: 49, app_download: 'http://daoqi.daoqi88.cn/apk/gamehall.apk' },
],
}],
}],
}], }],
}; };
const d: VersionDecision = VersionResolver.resolve(remote, keys); const d: VersionDecision = VersionResolver.resolve(remote, liveKeys);
expect(d.gameDownload).assertEqual('keep'); expect(d.gameVersion).assertEqual(261);
}); expect(d.gameDownload).assertEqual('http://tsgames.daoqi88.cn/zip2/gamehall.zip');
expect(d.appVersion).assertEqual(49);
it('empty_gameid_skips_game_tree', 0, () => {
// 大厅 gameid='' 不应误命中 gamelist 中 gameid 为空的节点
const lobbyKeys: MatchKeys = { agentid: 'A1', channelid: 'C1', marketid: 'M1', gameid: '' };
const remote: RemoteConfig = {
gamelist: [{ gameid: '', game_version: '99', game_download: 'should_not_match',
agentlist: [{ agentid: 'A1', game_version: '99', game_download: 'should_not_match' }] }],
agentlist: [{ agentid: 'A1', game_version: '10', game_download: 'agent_only',
channellist: [{ channelid: 'C1',
marketlist: [{ marketid: 'M1',
gamelist: [{ gameid: '', game_version: '88', game_download: 'should_not_match' }] }] }] }],
};
const d: VersionDecision = VersionResolver.resolve(remote, lobbyKeys);
expect(d.gameVersion).assertEqual(10);
expect(d.gameDownload).assertEqual('agent_only');
});
it('tie_prefers_nonempty_download', 0, () => {
// 两树 game_version 相等(20),代理树只有版本号无下载地址 → 应取游戏树的下载地址
const remote: RemoteConfig = {
agentlist: [{ agentid: 'A1', game_version: '20' }],
gamelist: [{ gameid: 'G1',
agentlist: [{ agentid: 'A1', game_version: '20', game_download: 'game_url' }] }],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(20);
expect(d.gameDownload).assertEqual('game_url');
}); });
}); });
} }