M2(领域层): ConfigManager/VersionResolver/ResourceManager/AppDataInjector/StartupOrchestrator

T-M2-05 ConfigManager:本地 rawfile/app_config.json(§4.2 实测值)、远程 URL 拼装('-'→'/'+.txt+?a=ts 禁缓存)、
        长度<30 走 showmessage 阻断、二级 url 二次请求(语义待真机校验)、产出版本决策
T-M2-06 VersionResolver:纯函数,agentlist/gamelist 两棵树 + agent→channel→market→game 后层覆盖 +
        两树取更高 version(§4.4)。单测 8 用例全通过,覆盖率 81.8%(≥80%)
T-M2-07 ResourceManager:沙箱路径规范、内置包拷贝解压、version.xml 解析(@ohos.xml)、远程 zip 下载/删旧/解压、版本比较
T-M2-09 AppDataInjector:加载前写 <gameDir>/app_data.js(§6 全局变量同名同义,JSON.stringify 安全字面量)
T-M2-11 StartupOrchestrator:INIT→…→ENTER_HALL 状态机,远程失败降级本地、showmessage 阻断、weburl 空/非空入口规则
配套:module.json5 声明 ohos.permission.INTERNET + reason_internet 字符串

devecocli build 通过;scripts/test.sh 单测全通过(修复 PermissionRequestResult 顶层导入)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lanterngamescn
2026-06-25 18:29:45 +08:00
parent ecb0e4642a
commit d2da223c8d
15 changed files with 953 additions and 5 deletions
+19 -3
View File
@@ -1,3 +1,19 @@
// domain_resource HAR —— 领域服务层(导出入口 SSOT) // domain_resource HAR —— 领域服务层(导出入口 SSOT,框架 §8
// 各子模块实现完成后在此统一 export。占位常量确保 HAR 可编译。
export const domain_resource_MODULE_VERSION: string = '1.0.0'; // 配置
export { AppConfig, AppConfigRaw, normalizeConfig } from './src/main/ets/config/AppConfig';
export { ConfigManager, LOCAL_CONFIG_RAWFILE } from './src/main/ets/config/ConfigManager';
export { RemoteConfig, AgentNode, ChannelNode, MarketNode, GameOverrideNode, GameTreeNode,
VersionFields, MatchKeys, VersionDecision } from './src/main/ets/config/RemoteConfig';
// 版本决策(纯函数)
export { VersionResolver } from './src/main/ets/version/VersionResolver';
// 资源
export { VersionXml } from './src/main/ets/resource/VersionXml';
export { ResourceManager, ResourcePaths } from './src/main/ets/resource/ResourceManager';
export { AppDataInjector, AppDataValues } from './src/main/ets/resource/AppDataInjector';
// 启动编排
export { StartupOrchestrator, StartupStage, StartupResult, StageCallback }
from './src/main/ets/startup/StartupOrchestrator';
@@ -0,0 +1,64 @@
/**
* 本地配置(契约 §4.2,框架 §8.2)。
*
* Android 用"目录名编码",本平台改为随包内置 rawfile/app_config.jsonKV),
* 只要提供同名配置值即可,H5 完全无感知。本仓库实测值见 rawfile/app_config.json。
*/
/** 本地配置(全键,§4.2)。 */
export interface AppConfig {
/** 代理商 ID(分层匹配 key)。 */
agent: string;
/** 渠道 ID(分层匹配 key)。 */
channel: string;
/** 资源解压父目录名。 */
gamedir: string;
/** 资源解压后的游戏目录名(大厅入口所在,通常 gamehall)。 */
gamestart: string;
/** App 版本号(与远程 app_version 比较)。 */
appversion: string;
/** 市场 ID(分层匹配 key;也经桥 getmarketname 暴露 H5)。 */
market: string;
/** 游戏 ID(空时回退用 version.xml game id)。 */
gameid: string;
/** 大厅 H5 远程地址;空 → 本地 file:// 加载。 */
weburl: string;
/** 远程配置地址('-'→'/' 后拼 .txt)。 */
gameconfig: string;
/** 业务自定义值(经桥 getOther/getothername 暴露 H5)。 */
other: string;
/** 推广/邀请码(写入 app_data.js 的 app_invitationcode)。 */
tuiguang: string;
}
/** 解析中间体(字段全可选,用于从 JSON 归一化)。 */
export interface AppConfigRaw {
agent?: string;
channel?: string;
gamedir?: string;
gamestart?: string;
appversion?: string;
market?: string;
gameid?: string;
weburl?: string;
gameconfig?: string;
other?: string;
tuiguang?: string;
}
/** 把可选解析体归一化为完整 AppConfig(缺失键 → 空串)。 */
export function normalizeConfig(raw: AppConfigRaw): AppConfig {
return {
agent: raw.agent ?? '',
channel: raw.channel ?? '',
gamedir: raw.gamedir ?? '',
gamestart: raw.gamestart ?? '',
appversion: raw.appversion ?? '',
market: raw.market ?? '',
gameid: raw.gameid ?? '',
weburl: raw.weburl ?? '',
gameconfig: raw.gameconfig ?? '',
other: raw.other ?? '',
tuiguang: raw.tuiguang ?? '',
};
}
@@ -0,0 +1,133 @@
/**
* 配置管理(契约 §4,框架 §8.2)。负责:
* - 加载本地配置(rawfile/app_config.json)。
* - 拼远程配置 URLgameconfig '-'→'/' + .txt),GET 禁缓存 + ?a=时间戳。
* - 校验(长度<30 视为错误文案 → 走 showmessage 阻断)、解析、二级 url 二次请求。
* - 经 VersionResolver 产出版本决策。
*/
import { common } from '@kit.AbilityKit';
import { util } from '@kit.ArkTS';
import { HttpClient } from 'platform';
import { Logger } from 'common';
import { AppConfig, AppConfigRaw, normalizeConfig } from './AppConfig';
import { AgentNode, GameTreeNode, MatchKeys, RemoteConfig, VersionDecision } from './RemoteConfig';
import { VersionResolver } from '../version/VersionResolver';
/** 本地配置 rawfile 文件名。 */
export const LOCAL_CONFIG_RAWFILE: string = 'app_config.json';
/** 远程配置错误文案阈值(<30 视为非 JSON 错误提示)。 */
const MIN_REMOTE_LEN: number = 30;
export class ConfigManager {
private static readonly log: Logger = Logger.tag('ConfigManager');
private readonly context: common.Context;
private readonly local: AppConfig;
private constructor(context: common.Context, local: AppConfig) {
this.context = context;
this.local = local;
}
/** 从 rawfile 加载本地配置。 */
static load(context: common.Context): ConfigManager {
const bytes: Uint8Array = context.resourceManager.getRawFileContentSync(LOCAL_CONFIG_RAWFILE);
const text: string = util.TextDecoder.create('utf-8').decodeToString(bytes);
const raw: AppConfigRaw = JSON.parse(text) as AppConfigRaw;
const local: AppConfig = normalizeConfig(raw);
ConfigManager.log.i(`local config loaded: agent=${local.agent} gamedir=${local.gamedir} weburl=${local.weburl}`);
return new ConfigManager(context, local);
}
getLocal(): AppConfig {
return this.local;
}
/** 分层匹配 key(§4.4)。 */
matchKeys(): MatchKeys {
return {
agentid: this.local.agent,
channelid: this.local.channel,
marketid: this.local.market,
gameid: this.local.gameid,
};
}
/** 远程配置主 URL(不含 ?a= 时间戳)。 */
remoteUrl(): string {
return `http://${this.local.gameconfig.replace(/-/g, '/')}.txt`;
}
/**
* 拉取并解析远程配置。返回 RemoteConfig。
* 约定:长度<30 → 返回 { showmessage: 错误文案 }(统一走 showmessage 阻断)。
* 网络/解析失败抛出,由 StartupOrchestrator 降级到本地缓存。
*/
async fetchRemote(): Promise<RemoteConfig> {
const url: string = `${this.remoteUrl()}?a=${Date.now()}`;
const res = await HttpClient.getString(url);
if (res.code < 200 || res.code >= 300) {
throw new Error(`remote config http ${res.code}`);
}
const body: string = res.body.trim();
if (body.length < MIN_REMOTE_LEN) {
ConfigManager.log.w(`remote config too short (${body.length}) -> treat as block message`);
return { showmessage: body };
}
const remote: RemoteConfig = JSON.parse(body) as RemoteConfig;
await this.fetchSecondary(remote);
return remote;
}
/** 拉取远程配置并产出版本决策(一步到位)。 */
async resolve(): Promise<VersionDecision> {
const remote: RemoteConfig = await this.fetchRemote();
return VersionResolver.resolve(remote, this.matchKeys());
}
/**
* 二级 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 && 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 && subGame.agentlist !== undefined) {
game.agentlist = subGame.agentlist;
ConfigManager.log.i('secondary game config merged');
}
}
}
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;
}
}
}
@@ -0,0 +1,82 @@
/**
* 远程配置 DTO(契约 §4.3 2.0 格式:agentlist / gamelist 两棵树并存)。
*
* 每层节点可携带可覆盖字段(§4.4):app_version/app_download/app_size/
* game_version/game_download/game_size/url/showmessage。越深层越后赋值,覆盖前层。
* 字段名与契约真实字段(非旧示意文档)严格一致。
*/
/** 每层可覆盖的版本字段(§4.4)。空串视为"未设置",不覆盖前层。 */
export interface VersionFields {
app_version?: string;
app_download?: string;
app_size?: string;
game_version?: string;
game_download?: string;
game_size?: string;
/** 指向二级配置地址,非空则二次请求(§4.3)。 */
url?: string;
showmessage?: string;
}
/** 市场层节点(marketid 命中;其 gamelist 为该市场下的游戏覆盖)。 */
export interface MarketNode extends VersionFields {
marketid?: string;
gamelist?: GameOverrideNode[];
}
/** 市场下的游戏覆盖节点(gameid 命中,设 app/game 升级)。 */
export interface GameOverrideNode extends VersionFields {
gameid?: string;
}
/** 渠道层节点。 */
export interface ChannelNode extends VersionFields {
channelid?: string;
marketlist?: MarketNode[];
}
/** 代理层节点。 */
export interface AgentNode extends VersionFields {
agentid?: string;
agentname?: string;
channellist?: ChannelNode[];
}
/** 游戏树顶层节点(gamelist[]gameid 命中;url 非空则二次请求;内含 agentlist)。 */
export interface GameTreeNode extends VersionFields {
gameid?: string;
agentlist?: AgentNode[];
}
/** 远程配置根(2.0 格式)。 */
export interface RemoteConfig extends VersionFields {
/** 顶层公告:非空 → 阻断弹窗(§3.2)。 */
showmessage?: string;
/** 代理配置树。 */
agentlist?: AgentNode[];
/** 游戏配置树(2.0 才有;缺失即 1.0 老格式)。 */
gamelist?: GameTreeNode[];
}
/** 分层匹配 key(来自本地配置 §4.2)。 */
export interface MatchKeys {
agentid: string;
channelid: string;
marketid: string;
gameid: string;
}
/** 最终版本决策结果。 */
export interface VersionDecision {
/** 顶层公告(非空则阻断)。 */
showmessage: string;
/** App 升级(取代理树/游戏树中 version 更高者)。 */
appVersion: number;
appDownload: string;
appSize: string;
/** 资源(游戏)升级(取更高者)。 */
gameVersion: number;
gameDownload: string;
gameSize: string;
}
@@ -0,0 +1,69 @@
/**
* app_data.js 注入(契约 §6,框架 §8.4)。
*
* 🔴 时序铁律:必须在大厅 Web **加载页面之前**写入 <解压根>/gamehall/app_data.js
* H5 首页用 <script src="app_data.js"> 同步读取)。由 StartupOrchestrator 的
* INJECT_APP_DATA 步调用,早于 ENTER_HALL。
*
* 全局变量名与含义严格照契约 §6(同名同义)。值用 JSON.stringify 生成安全 JS 字面量。
*/
import { FileSystem } from 'platform';
import { Logger } from 'common';
/** app_data.js 全局变量取值(§6)。 */
export interface AppDataValues {
/** version.xml 的 version(资源版本号)。 */
app_version: string;
/** gameconfig 配置(远程配置地址标识)。 */
app_gameconfig: string;
/** gamedir 配置(资源父目录名)。 */
app_gamedir: string;
/** gamestart 配置(游戏目录名 gamehall)。 */
app_gamestart: string;
/** agent 配置(代理商 ID)。 */
app_agent: string;
/** appversion 配置(App 版本号)。 */
app_appversion: string;
/** market 配置(市场 ID)。 */
app_market: string;
/** channel 配置(渠道 ID)。 */
app_channel: string;
/** tuiguang 配置(推广/邀请码)。 */
app_invitationcode: string;
/** 固定 '0'(大厅启动类型)。 */
app_Launchtype: string;
/** 游戏名。 */
app_gamename: string;
/** WiFi 信号等级初值。 */
app_getwifisignalLevel: string;
}
export class AppDataInjector {
private static readonly log: Logger = Logger.tag('AppDataInjector');
/** 生成 app_data.js 文本(一组全局 var 声明)。 */
static buildJs(v: AppDataValues): string {
const lines: string[] = [
`var app_version=${JSON.stringify(v.app_version)};`,
`var app_gameconfig=${JSON.stringify(v.app_gameconfig)};`,
`var app_gamedir=${JSON.stringify(v.app_gamedir)};`,
`var app_gamestart=${JSON.stringify(v.app_gamestart)};`,
`var app_agent=${JSON.stringify(v.app_agent)};`,
`var app_appversion=${JSON.stringify(v.app_appversion)};`,
`var app_market=${JSON.stringify(v.app_market)};`,
`var app_channel=${JSON.stringify(v.app_channel)};`,
`var app_invitationcode=${JSON.stringify(v.app_invitationcode)};`,
`var app_Launchtype=${JSON.stringify(v.app_Launchtype)};`,
`var app_gamename=${JSON.stringify(v.app_gamename)};`,
`var app_getwifisignalLevel=${JSON.stringify(v.app_getwifisignalLevel)};`,
];
return lines.join('\n') + '\n';
}
/** 把 app_data.js 写入游戏目录(gameDir = <解压根>/gamehall)。 */
static inject(gameDir: string, v: AppDataValues): void {
const path: string = `${gameDir}/app_data.js`;
FileSystem.writeText(path, AppDataInjector.buildJs(v));
AppDataInjector.log.i(`app_data.js injected -> ${path}`);
}
}
@@ -0,0 +1,127 @@
/**
* 资源管理(契约 §5,框架 §8.3)。负责大厅/子游戏资源的落盘与版本管理:
* - 路径规范(沙箱 filesDir)。
* - 首启:拷贝内置预置包(rawfile zip) → 解压。
* - 版本:解析本地 version.xml,与远程 game_version 比较。
* - 更新:下载远程 zip → 删旧 gamehall → 解压 → 收尾。
* - 持久化 urlpath/upurlpath 到 KvStore(供容器读取)。
*
* 沙箱简化(§5.1 许可):弃用 Android 的"<包名>/<时间戳>"多级目录,用固定目录,
* 对 H5 无影响(H5 只看到自己被 file:// 加载)。
*/
import { common } from '@kit.AbilityKit';
import { Downloader, FileSystem, KvStore, ProgressCallback, Unzipper } from 'platform';
import { Logger } from 'common';
import { AppConfig } from '../config/AppConfig';
import { VersionXml } from './VersionXml';
/** 资源路径集合。 */
export interface ResourcePaths {
/** 父目录(upurlpath)。 */
upurlpath: string;
/** 解压根(urlpath)。 */
urlpath: string;
/** 游戏目录(<urlpath>/<gamestart>,即 gamehall)。 */
gameDir: string;
/** 大厅入口文件磁盘路径。 */
indexPath: string;
/** 大厅入口 file:// URL(不含 ?Launchtype)。 */
indexUrl: string;
}
/** KvStore 键。 */
const KEY_URLPATH: string = 'urlpath';
const KEY_UPURLPATH: string = 'upurlpath';
/** 内置预置包 rawfile 名(可选;缺失则依赖远程下载)。 */
const BUILTIN_ZIP_RAWFILE: string = 'gamehall_builtin.zip';
export class ResourceManager {
private static readonly log: Logger = Logger.tag('ResourceManager');
private readonly context: common.Context;
private readonly config: AppConfig;
private readonly kv: KvStore;
private readonly cachedPaths: ResourcePaths;
constructor(context: common.Context, config: AppConfig, kv: KvStore) {
this.context = context;
this.config = config;
this.kv = kv;
this.cachedPaths = this.computePaths();
}
private computePaths(): ResourcePaths {
const upurlpath: string = `${this.context.filesDir}/tsgames`;
const urlpath: string = `${upurlpath}/${this.config.gamedir}`;
const gameDir: string = `${urlpath}/${this.config.gamestart}`;
const indexPath: string = `${gameDir}/index.html`;
return { upurlpath, urlpath, gameDir, indexPath, indexUrl: `file://${indexPath}` };
}
paths(): ResourcePaths {
return this.cachedPaths;
}
/** 大厅入口是否已就绪(index.html 存在)。 */
isPrepared(): boolean {
return FileSystem.exists(this.cachedPaths.indexPath);
}
/** 本地资源版本(version.xml 的 version;缺失返回 0)。 */
localVersion(): number {
const candidates: string[] = [
`${this.cachedPaths.gameDir}/version.xml`,
`${this.cachedPaths.urlpath}/version.xml`,
];
for (const p of candidates) {
if (FileSystem.exists(p)) {
return VersionXml.parseVersion(FileSystem.readText(p));
}
}
return 0;
}
/** 持久化路径到 KvStore(供容器/重启读取)。 */
async persistPaths(): Promise<void> {
this.kv.putString(KEY_URLPATH, this.cachedPaths.urlpath);
this.kv.putString(KEY_UPURLPATH, this.cachedPaths.upurlpath);
await this.kv.flush();
}
/**
* 首启拷贝内置预置包并解压(若 rawfile 存在)。
* 内置包顶层即含 gamehall/...,解压到 urlpath 即可。无内置包则跳过(依赖远程下载)。
*/
async prepareBuiltin(): Promise<void> {
let bytesAvailable: boolean = true;
try {
this.context.resourceManager.getRawFileContentSync(BUILTIN_ZIP_RAWFILE);
} catch (e) {
bytesAvailable = false;
}
if (!bytesAvailable) {
ResourceManager.log.w(`no builtin package (${BUILTIN_ZIP_RAWFILE}); rely on remote download`);
return;
}
const tmpZip: string = `${this.cachedPaths.upurlpath}/builtin.zip`;
FileSystem.copyRawFileTo(this.context.resourceManager, BUILTIN_ZIP_RAWFILE, tmpZip);
FileSystem.ensureDir(this.cachedPaths.urlpath);
await Unzipper.unzip(tmpZip, this.cachedPaths.urlpath);
FileSystem.rmrf(tmpZip);
ResourceManager.log.i('builtin package prepared');
}
/**
* 从远程下载 zip 并更新游戏资源(契约 §5.4):
* 下载 game_download(?a=ts) → 删旧 gamehall → 解压到 urlpath → 删 zip。
*/
async updateGame(downloadUrl: string, onProgress?: ProgressCallback): Promise<void> {
const url: string = downloadUrl.includes('?') ? downloadUrl : `${downloadUrl}?a=${Date.now()}`;
const zipPath: string = `${this.cachedPaths.upurlpath}/dowlod_zip/${Date.now()}Projects.zip`;
await Downloader.download(url, zipPath, onProgress);
FileSystem.rmrf(this.cachedPaths.gameDir);
FileSystem.ensureDir(this.cachedPaths.urlpath);
await Unzipper.unzip(zipPath, this.cachedPaths.urlpath);
FileSystem.rmrf(zipPath);
ResourceManager.log.i(`game updated from ${url}`);
}
}
@@ -0,0 +1,41 @@
/**
* version.xml 解析(契约 §5.3,框架 §8.3)。用 @kit.ArkTS xml.XmlPullParser。
*
* 结构:<game><agent id name/><game id name/><channel id name/><version value name/></game>
* 主用途:取 <version value="42"> 与远程 game_version 比较决定是否更新。
* `value` 属性为 version 标签独有,故用 attributeValueCallbackFunction 即可稳定抓取。
*/
import { xml, util } from '@kit.ArkTS';
import { Logger } from 'common';
export class VersionXml {
private static readonly log: Logger = Logger.tag('VersionXml');
/** 解析 version 整数(解析失败/缺失 → 0)。 */
static parseVersion(xmlText: string): number {
if (xmlText.length === 0) {
return 0;
}
let value: string = '';
try {
const buf: Uint8Array = new util.TextEncoder().encodeInto(xmlText);
const parser: xml.XmlPullParser = new xml.XmlPullParser(buf.buffer as object as ArrayBuffer, 'UTF-8');
const options: xml.ParseOptions = {
ignoreNameSpace: true,
attributeValueCallbackFunction: (name: string, val: string): boolean => {
if (name === 'value') {
value = val;
}
return true;
},
};
parser.parseXml(options);
} catch (e) {
const err = e as Error;
VersionXml.log.w(`parse version.xml failed: ${err.message}`);
return 0;
}
const n: number = parseInt(value, 10);
return isNaN(n) ? 0 : n;
}
}
@@ -0,0 +1,142 @@
/**
* 启动编排状态机(契约 §3,框架 §8.1)。把启动时序实现为显式状态机:
* INIT → LOAD_LOCAL_CONFIG → REQUEST_PERMISSIONS → FETCH_REMOTE_CONFIG
* → RESOLVE_VERSION → PREPARE_RESOURCE → INJECT_APP_DATA → ENTER_HALL
* 任一步失败 → FALLBACK(本地缓存) 或 BLOCK(showmessage 公告)。
*
* 权限:HarmonyOS 沙箱文件免存储权限;定位/电话等敏感权限由 M3 Provider 用时申请,
* 故启动期不弹窗(REQUEST_PERMISSIONS 当前为 no-op)。
*/
import { common } from '@kit.AbilityKit';
import { KvStore } from 'platform';
import { Logger } from 'common';
import { ConfigManager } from '../config/ConfigManager';
import { AppConfig } from '../config/AppConfig';
import { VersionDecision } from '../config/RemoteConfig';
import { ResourceManager } from '../resource/ResourceManager';
import { AppDataInjector, AppDataValues } from '../resource/AppDataInjector';
/** 启动阶段(用于进度展示/可观测)。 */
export enum StartupStage {
INIT = 'INIT',
LOAD_LOCAL_CONFIG = 'LOAD_LOCAL_CONFIG',
REQUEST_PERMISSIONS = 'REQUEST_PERMISSIONS',
FETCH_REMOTE_CONFIG = 'FETCH_REMOTE_CONFIG',
RESOLVE_VERSION = 'RESOLVE_VERSION',
PREPARE_RESOURCE = 'PREPARE_RESOURCE',
INJECT_APP_DATA = 'INJECT_APP_DATA',
ENTER_HALL = 'ENTER_HALL',
}
/** 启动结果。 */
export interface StartupResult {
/** true:进大厅;false:被公告阻断。 */
enter: boolean;
/** 大厅入口 URL(含 ?Launchtype=0),enter=true 时有效。 */
entryUrl: string;
/** 阻断公告文案,enter=false 时有效。 */
message: string;
}
/** 阶段进度回调。 */
export type StageCallback = (stage: StartupStage, percent: number) => void;
export class StartupOrchestrator {
private static readonly log: Logger = Logger.tag('StartupOrchestrator');
private readonly context: common.UIAbilityContext;
private readonly onStage?: StageCallback;
constructor(context: common.UIAbilityContext, onStage?: StageCallback) {
this.context = context;
this.onStage = onStage;
}
private stage(s: StartupStage, percent: number): void {
StartupOrchestrator.log.i(`stage=${s} ${percent}%`);
if (this.onStage !== undefined) {
this.onStage(s, percent);
}
}
/** 执行启动流程。 */
async run(): Promise<StartupResult> {
this.stage(StartupStage.INIT, 0);
// 1) 本地配置
this.stage(StartupStage.LOAD_LOCAL_CONFIG, 5);
const config: ConfigManager = ConfigManager.load(this.context);
const local: AppConfig = config.getLocal();
// 2) 权限(M2 no-op,敏感权限由 M3 Provider 用时申请)
this.stage(StartupStage.REQUEST_PERMISSIONS, 10);
const kv: KvStore = KvStore.create(this.context);
const resource: ResourceManager = new ResourceManager(this.context, local, kv);
// weburl 非空 → 远程 http 大厅,无需本地资源
if (local.weburl !== '') {
const entryUrl: string = `http://${local.weburl.replace(/-/g, '/')}?Launchtype=0`;
this.stage(StartupStage.ENTER_HALL, 100);
return { enter: true, entryUrl, message: '' };
}
// 3) 远程配置 + 4) 版本决策
let decision: VersionDecision | undefined = undefined;
try {
this.stage(StartupStage.FETCH_REMOTE_CONFIG, 20);
decision = await config.resolve();
this.stage(StartupStage.RESOLVE_VERSION, 35);
if (decision.showmessage !== '') {
StartupOrchestrator.log.w(`blocked by showmessage: ${decision.showmessage}`);
return { enter: false, entryUrl: '', message: decision.showmessage };
}
} catch (e) {
const err = e as Error;
StartupOrchestrator.log.w(`remote config failed, fallback to local: ${err.message}`);
}
// 5) 资源准备
this.stage(StartupStage.PREPARE_RESOURCE, 45);
if (!resource.isPrepared()) {
await resource.prepareBuiltin();
}
const localVer: number = resource.localVersion();
if (decision !== undefined && decision.gameVersion > localVer && decision.gameDownload !== '') {
StartupOrchestrator.log.i(`update needed: local=${localVer} remote=${decision.gameVersion}`);
try {
await resource.updateGame(decision.gameDownload, (p: number) => this.stage(StartupStage.PREPARE_RESOURCE, 45 + Math.floor(p * 0.4)));
} catch (e) {
const err = e as Error;
StartupOrchestrator.log.w(`update failed, keep local: ${err.message}`);
}
}
await resource.persistPaths();
if (!resource.isPrepared()) {
// 无可用资源且无网络 → 阻断
return { enter: false, entryUrl: '', message: '资源准备失败,请检查网络后重试' };
}
// 6) 注入 app_data.js(务必在进大厅之前)
this.stage(StartupStage.INJECT_APP_DATA, 90);
const values: AppDataValues = {
app_version: `${resource.localVersion()}`,
app_gameconfig: local.gameconfig,
app_gamedir: local.gamedir,
app_gamestart: local.gamestart,
app_agent: local.agent,
app_appversion: local.appversion,
app_market: local.market,
app_channel: local.channel,
app_invitationcode: local.tuiguang,
app_Launchtype: '0',
app_gamename: '',
app_getwifisignalLevel: '0',
};
AppDataInjector.inject(resource.paths().gameDir, values);
// 7) 进大厅
this.stage(StartupStage.ENTER_HALL, 100);
return { enter: true, entryUrl: `${resource.paths().indexUrl}?Launchtype=0`, message: '' };
}
}
@@ -0,0 +1,130 @@
/**
* 分层版本决策(契约 §4.4,框架 §8.2)。**纯函数,无 IO,便于单测**。
*
* 输入已拉取/合并好的远程配置(含两棵树)+ 本地匹配 key,输出最终 App/资源版本决策。
* 规则:
* - 代理树:agentid → channelid → marketid → (market.gamelist) gameid**越深越后、覆盖前层**。
* - 游戏树:gameid → agentid → channelid → marketid,同样后层覆盖。
* - 最终:app 升级与 game 升级各自取"两棵树中 version 更高者"的下载地址。
* - 二级 `url` 二次请求(§4.3)属 IO,由 ConfigManager 编排后把合并结果喂给本函数。
*/
import { AgentNode, ChannelNode, GameOverrideNode, GameTreeNode, MarketNode,
MatchKeys, RemoteConfig, VersionDecision, VersionFields } from '../config/RemoteConfig';
/** 用 next 的非空字段覆盖 cur(空串/undefined 不覆盖)。 */
function ov(cur: string | undefined, next: string | undefined): string | undefined {
return (next !== undefined && next !== '') ? next : cur;
}
/** 把 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;
}
const n: number = parseInt(s, 10);
return isNaN(n) ? 0 : n;
}
/** 取首个非空串。 */
function firstNonEmpty(a: string | undefined, b: string | undefined, c: string | undefined): string {
if (a !== undefined && a !== '') {
return a;
}
if (b !== undefined && b !== '') {
return b;
}
return (c !== undefined && c !== '') ? c : '';
}
export class VersionResolver {
/** 代理树:agentid → channelid → marketid → gameid,逐层并入。 */
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);
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,逐层并入。 */
static resolveGameTree(games: GameTreeNode[], k: MatchKeys): VersionFields {
const acc: VersionFields = {};
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 {
const a: VersionFields = VersionResolver.resolveAgentTree(remote.agentlist ?? [], keys);
const g: VersionFields = VersionResolver.resolveGameTree(remote.gamelist ?? [], keys);
const aGameV: number = parseVer(a.game_version);
const gGameV: number = parseVer(g.game_version);
const gameWin: VersionFields = gGameV > aGameV ? g : a;
const aAppV: number = parseVer(a.app_version);
const gAppV: number = parseVer(g.app_version);
const appWin: VersionFields = gAppV > aAppV ? g : a;
return {
showmessage: firstNonEmpty(a.showmessage, g.showmessage, remote.showmessage),
appVersion: Math.max(aAppV, gAppV),
appDownload: appWin.app_download ?? '',
appSize: appWin.app_size ?? '',
gameVersion: Math.max(aGameV, gGameV),
gameDownload: gameWin.game_download ?? '',
gameSize: gameWin.game_size ?? '',
};
}
}
+10
View File
@@ -46,6 +46,16 @@
} }
], ],
} }
],
"requestPermissions": [
{
"name": "ohos.permission.INTERNET",
"reason": "$string:reason_internet",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
] ]
} }
} }
@@ -11,6 +11,10 @@
{ {
"name": "EntryAbility_label", "name": "EntryAbility_label",
"value": "label" "value": "label"
},
{
"name": "reason_internet",
"value": "用于下载游戏资源与获取远程配置"
} }
] ]
} }
@@ -0,0 +1,13 @@
{
"agent": "veRa0qrBf0df2K1G4de2tgfmVxB2jxpv",
"channel": "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",
"gamedir": "FtJf073aa0d6rI1xD8J1Y42fINTm0ziK",
"gamestart": "gamehall",
"appversion": "49",
"market": "3",
"gameid": "",
"weburl": "",
"gameconfig": "tsgames.daoqi88.cn-config_test-update_jsonv2_test",
"other": "",
"tuiguang": ""
}
+2
View File
@@ -1,7 +1,9 @@
import localUnitTest from './LocalUnit.test'; import localUnitTest from './LocalUnit.test';
import bridgeTest from './Bridge.test'; import bridgeTest from './Bridge.test';
import versionResolverTest from './VersionResolver.test';
export default function testsuite() { export default function testsuite() {
localUnitTest(); localUnitTest();
bridgeTest(); bridgeTest();
versionResolverTest();
} }
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from '@ohos/hypium';
import { VersionResolver, RemoteConfig, MatchKeys, VersionDecision } from 'domain_resource';
/**
* 分层版本决策单测(T-M2-06,契约 §4.4)。纯函数,构造典型多层样例验证后层覆盖、
* 两棵树取更高 version、showmessage 阻断。
*/
export default function versionResolverTest() {
const keys: MatchKeys = { agentid: 'A1', channelid: 'C1', marketid: 'M1', gameid: 'G1' };
describe('VersionResolver', () => {
it('empty_config_zero_decision', 0, () => {
const d: VersionDecision = VersionResolver.resolve({}, keys);
expect(d.gameVersion).assertEqual(0);
expect(d.appVersion).assertEqual(0);
expect(d.showmessage).assertEqual('');
expect(d.gameDownload).assertEqual('');
});
it('agent_tree_deeper_overrides', 0, () => {
// 代理树:agent 设 game_version=10market 覆盖为 20(深层覆盖浅层)
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 = {
agentlist: [{
agentid: 'A1',
channellist: [{
channelid: 'C1',
marketlist: [{
marketid: 'M1', game_version: '20',
gamelist: [{ gameid: 'G1', game_version: '30', game_download: 'g_url' }],
}],
}],
}],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(30);
expect(d.gameDownload).assertEqual('g_url');
});
it('two_trees_take_higher_version', 0, () => {
// 代理树 game_version=20,游戏树 game_version=25 → 取 25 及其下载地址
const remote: RemoteConfig = {
agentlist: [{ agentid: 'A1', game_version: '20', game_download: 'agent_url' }],
gamelist: [{
gameid: 'G1',
agentlist: [{ agentid: 'A1', game_version: '25', game_download: 'game_url' }],
}],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(25);
expect(d.gameDownload).assertEqual('game_url');
});
it('app_and_game_independent_winners', 0, () => {
// app 升级取代理树(app_version=49)game 升级取游戏树(game_version=30)
const remote: RemoteConfig = {
agentlist: [{ agentid: 'A1', app_version: '49', app_download: 'apk_url', game_version: '10' }],
gamelist: [{
gameid: 'G1',
agentlist: [{ agentid: 'A1', app_version: '40', game_version: '30', game_download: 'g_url' }],
}],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.appVersion).assertEqual(49);
expect(d.appDownload).assertEqual('apk_url');
expect(d.gameVersion).assertEqual(30);
expect(d.gameDownload).assertEqual('g_url');
});
it('no_match_returns_empty', 0, () => {
const remote: RemoteConfig = {
agentlist: [{ agentid: 'OTHER', game_version: '99', game_download: 'x' }],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameVersion).assertEqual(0);
expect(d.gameDownload).assertEqual('');
});
it('showmessage_from_layer_or_top', 0, () => {
const top: RemoteConfig = { showmessage: '停服维护公告' };
expect(VersionResolver.resolve(top, keys).showmessage).assertEqual('停服维护公告');
const layer: RemoteConfig = {
agentlist: [{ agentid: 'A1', showmessage: '代理层公告' }],
};
expect(VersionResolver.resolve(layer, keys).showmessage).assertEqual('代理层公告');
});
it('empty_string_does_not_override', 0, () => {
// 深层空串不应覆盖浅层非空值
const remote: RemoteConfig = {
agentlist: [{
agentid: 'A1', game_download: 'keep', game_version: '10',
channellist: [{ channelid: 'C1', game_download: '' }],
}],
};
const d: VersionDecision = VersionResolver.resolve(remote, keys);
expect(d.gameDownload).assertEqual('keep');
});
});
}
@@ -4,7 +4,7 @@
* "用时申请"统一入口:能力 Provider(定位/相机/麦克风/电话等,M3)在使用前调用。 * "用时申请"统一入口:能力 Provider(定位/相机/麦克风/电话等,M3)在使用前调用。
* 注:HarmonyOS 沙箱内文件读写无需存储权限——故启动期不再申请存储权限(与 Android 不同)。 * 注:HarmonyOS 沙箱内文件读写无需存储权限——故启动期不再申请存储权限(与 Android 不同)。
*/ */
import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit'; import { abilityAccessCtrl, common, Permissions, PermissionRequestResult } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit'; import { BusinessError } from '@kit.BasicServicesKit';
import { Logger } from 'common'; import { Logger } from 'common';
@@ -21,7 +21,7 @@ export class PermissionGuard {
} }
const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); const atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager();
try { try {
const result: abilityAccessCtrl.PermissionRequestResult = const result: PermissionRequestResult =
await atManager.requestPermissionsFromUser(context, perms); await atManager.requestPermissionsFromUser(context, perms);
const authResults: number[] = result.authResults; const authResults: number[] = result.authResults;
const allGranted: boolean = authResults.length > 0 && authResults.every((r: number) => r === 0); const allGranted: boolean = authResults.length > 0 && authResults.every((r: number) => r === 0);