refactor(mp-harmony): 完善隐私协议配置同步流程与鸿蒙双轨适配

1.  新增隐私配置同步脚本,自动从manifest和site.js同步真实隐私协议URL到鸿蒙构建产物
2.  补充鸿蒙元服务隐私托管双轨制适配逻辑
3.  新增完整的隐私弹窗组件单元测试覆盖
4.  修复构建产物隐私配置被占位符覆盖的问题
This commit is contained in:
2026-07-24 14:43:02 +08:00
parent 352c359326
commit f4a770e246
5 changed files with 2451 additions and 3 deletions

View File

@@ -35,6 +35,347 @@ async function syncSiteToStub(mode = 'production') {
}
}
/**
* 同步 manifest.json 的 mp-harmony.privacy 配置到 stub 项目的 app.json
*
* 背景:
* 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl 等字段,
* 实际生效的是编译产物里 ascf/ascf_src/app.json (ascf_build 拷贝自此)。
* 但当前 build 流程不会自动从 manifest.json 注入 privacy 字段,
* 如果只改 manifest.json 而忘记改 app.json, 审核时拿到的是旧 URL。
*
* 同步策略:
* 1. 读取 manifest.json 的 mp-harmony.privacy, 期望里面含占位符 YOUR_DOMAIN / YOUR_UNIACID
* 2. 从 site.js 解析 baseUrl (域名) + uniacid
* 3. 把占位符替换成 site.js 里的真实值, 写入 stub app.json
* 4. 若 manifest.json 中无 mp-harmony.privacy → 保留 app.json 现有配置 (向后兼容)
* 5. 若 site.js 缺 baseUrl 或 uniacid → 跳过同步, 警告用户
*
* 设计取舍:
* - manifest.json 用占位符占位 (避免每个 uniacid 切换都要改一次 manifest)
* - site.js 永远是真实值来源 (这是项目里已经存在的部署配置)
* - 同步在构建时执行, 一份配置适配所有 uniacid
*
* 依赖: syncSiteToStub 必须先于本函数执行 (在 main() 中已保证)
*/
/**
* 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释)
* 原因: manifest.json 顶部有 /* ... *\/ 注释, 用正则 `/\*[\s\S]*?\*\//g` 简单剥离时,
* 会跨过字符串字面量, 把"https://..."注释中的字符拼到真实 URL 后面,
* 引入换行符导致 JSON.parse 失败 (Bad control character)。
* 必须逐字符扫描, 跳过字符串内的 /*、//。
*/
function stripJsonComments(input) {
let out = '';
let i = 0;
const n = input.length;
while (i < n) {
const c = input[i];
const next = i + 1 < n ? input[i + 1] : '';
if (c === '/' && next === '*') {
i += 2;
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
i += 2;
continue;
}
if (c === '/' && next === '/') {
i += 2;
while (i < n && input[i] !== '\n') i++;
continue;
}
if (c === '"') {
out += c;
i++;
while (i < n) {
const sc = input[i];
if (sc === '\\') {
out += sc + (input[i + 1] || '');
i += 2;
continue;
}
out += sc;
i++;
if (sc === '"') break;
}
continue;
}
out += c;
i++;
}
return out;
}
/**
* 从 site.js 提取 baseUrl + uniacid
*
* site.js 形如:
* module.exports = {
* baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名
* uniacid: 2482,//后台对应uniacid
* };
*
* 不直接 require, 因为:
* 1. site.js 在前端构建产物中用, 可能依赖一些运行时变量 (uni/Config/...)
* 2. 同步逻辑要在 vue.config.js 触发, 不希望引入额外依赖
* 3. regex 提取足够安全, 避免执行任意 JS
*
* @returns {{baseUrl: string, uniacid: string}|null} 解析失败返回 null
*/
function parseSiteConfig(siteJsSource) {
// baseUrl: 单/双引号字符串, 可带尾斜杠
const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/)
// uniacid: 数字或字符串
const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/)
if (!baseUrlMatch || !uniacidMatch) {
return null
}
return {
baseUrl: baseUrlMatch[1],
uniacid: uniacidMatch[1],
}
}
/**
* 把 baseUrl 拆成 protocol + host (无尾斜杠)
* 因为 site.js 的 baseUrl 通常带尾斜杠, 占位符 YOUR_DOMAIN
* 在 URL 中间出现 (例: https://YOUR_DOMAIN/api/xxx), 直接拼接
* 会产生双斜杠. 这里规范化为"协议://域名(无尾斜杠)".
*
* @param {string} baseUrl 例: "https://xcx.aigc-quickapp.com/"
* @returns {string} 例: "https://xcx.aigc-quickapp.com"
*/
function normalizeBaseUrlForPlaceholder(baseUrl) {
// 去掉尾斜杠
let url = baseUrl.replace(/\/+$/, '')
// 若没有协议, 加 https:// (避免出现 //xcx... 这种相对协议)
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url
}
return url
}
/**
* 递归替换隐私配置对象中的占位符
* @param {*} value 任意 JSON 值
* @param {object} siteConfig {baseUrl, uniacid}
* @returns {*} 替换后的新值 (不修改原对象)
*/
function replacePlaceholders(value, siteConfig) {
if (typeof value === 'string') {
return value
.replace(/YOUR_DOMAIN/g, siteConfig.baseUrl)
.replace(/YOUR_UNIACID/g, siteConfig.uniacid)
}
if (Array.isArray(value)) {
return value.map(v => replacePlaceholders(v, siteConfig))
}
if (value !== null && typeof value === 'object') {
const out = {}
for (const k of Object.keys(value)) {
out[k] = replacePlaceholders(value[k], siteConfig)
}
return out
}
return value
}
/**
* 自定义错误: 验证 app.json 写后内容失败时抛出
* 上层 try/catch 会捕获并 process.exitCode = 1, 让 vue.config.js / npm run build 失败
*/
class AppJsonVerificationError extends Error {
constructor(messages) {
super('app.json 写后验证失败:\n - ' + messages.join('\n - '))
this.name = 'AppJsonVerificationError'
this.messages = messages
}
}
/**
* 验证写完的 app.json 内容正确
*
* 关键字段:
* - 顶级 privacy 块存在
* - privacyContractUrl / serviceContractUrl 都以 https:// + baseUrl 开头
* - URL 不含占位符 YOUR_DOMAIN / YOUR_UNIACID
* - URL 不含双协议头 (https://https://)
* - URL 不含双斜杠 (https://x.com//api)
* - URL 的 uniacid= 参数值 == siteConfig.uniacid
* - JSON 仍能正确 parse (没有序列化失败)
*
* 失败行为: 抛 AppJsonVerificationError, 包含所有不通过项
*
* @param {string} appJsonPath
* @param {{baseUrl: string, uniacid: string}} siteConfig
*/
async function verifyWrittenAppJson(appJsonPath, siteConfig) {
const errors = []
// 1. 重新读取 + parse
let written
try {
const raw = await fsp.readFile(appJsonPath, 'utf8')
written = JSON.parse(raw)
} catch (e) {
throw new AppJsonVerificationError([`文件读取/解析失败: ${e.message}`])
}
// 2. 顶级 privacy 块
const privacy = written.privacy
if (!privacy || typeof privacy !== 'object') {
throw new AppJsonVerificationError(['privacy 块缺失或不是对象'])
}
// 3. URL 字段检查
const urlFields = ['privacyContractUrl', 'serviceContractUrl']
for (const field of urlFields) {
const url = privacy[field]
if (typeof url !== 'string') {
errors.push(`${field} 缺失或不是字符串 (实际: ${typeof url})`)
continue
}
// 不含占位符
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(url)) {
errors.push(`${field} 仍含占位符: ${url}`)
}
// 不含双协议头
if (/^https:\/\/https:|^http:\/\/http:/i.test(url)) {
errors.push(`${field} 含双协议头: ${url}`)
}
// 不含双斜杠 (协议头之后, 路径开始之前)
// 关键: 截到第一个 ? 或 # 之前, 检查是否含 // (避免误报 query 里的 //)
const afterProtocol = url.replace(/^https?:\/\//i, '')
const queryStart = afterProtocol.search(/[?#]/)
const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart)
if (beforeQuery.includes('//')) {
errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`)
}
// 必须以 https:// + siteConfig.baseUrl 开头
const expectedPrefix = siteConfig.baseUrl + '/'
if (!url.startsWith(expectedPrefix)) {
errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`)
}
// uniacid= 参数值 == siteConfig.uniacid
const uniacidMatch = url.match(/[?&]uniacid=([^&]+)/)
if (!uniacidMatch) {
errors.push(`${field} 缺少 uniacid= 参数: ${url}`)
} else if (uniacidMatch[1] !== siteConfig.uniacid) {
errors.push(`${field} 的 uniacid=${uniacidMatch[1]} 不等于 site.js 的 uniacid=${siteConfig.uniacid}`)
}
}
// 4. contractName 字段未被破坏 (manifest 里的 zh/en name 应该原样保留)
const nameFields = ['privacyContractName', 'serviceContractName']
for (const field of nameFields) {
const name = privacy[field]
if (!name || typeof name !== 'object' || !name.zh || !name.en) {
errors.push(`${field} 结构损坏 (应有 zh/en): ${JSON.stringify(name)}`)
}
}
if (errors.length > 0) {
throw new AppJsonVerificationError(errors)
}
}
/**
* 同步 privacy 到**任意**目标 app.json 路径
*
* @param {string} appJsonPath 目标 app.json 文件绝对路径
* @param {string} [label] 标识 (用于日志, 例 "stub" / "build")
* @returns {Promise<{written: boolean, siteConfig?: object}>}
*
* 应用场景:
* - syncPrivacyToPath('stub-app-projects/.../app.json', 'stub')
* → 写 stub ascf_src/app.json (HBuilderX 之外的源)
* - syncPrivacyToPath('unpackage/dist/build/.mp-harmony/app.json', 'build')
* → 写 HBuilderX 构建产物的 app.json, **必须**在打 ZIP 之前
* → 否则发行 ZIP 里的 app.json 仍是占位符
*/
async function syncPrivacyToPath(appJsonPath, label = '') {
try {
const cwd = path.join(__dirname, '..');
const manifestPath = path.join(cwd, 'manifest.json');
const siteJsPath = path.join(cwd, 'site.js');
if (!await exists(manifestPath)) {
console.warn('[harmony.patch] manifest.json 不存在, 跳过 privacy 同步');
return { written: false };
}
if (!await exists(appJsonPath)) {
console.warn(`[harmony.patch] app.json 不存在 (${label || appJsonPath}), 跳过 privacy 同步`);
return { written: false };
}
// 1. 读 manifest.json, 提取 mp-harmony.privacy 配置 (含占位符)
const manifestRaw = await fsp.readFile(manifestPath, 'utf8');
const manifestClean = stripJsonComments(manifestRaw);
const manifest = JSON.parse(manifestClean);
const privacyConfigTemplate = manifest && manifest['mp-harmony'] && manifest['mp-harmony'].privacy;
if (!privacyConfigTemplate) {
return { written: false };
}
// 2. 读 site.js, 提取真实 baseUrl + uniacid
if (!await exists(siteJsPath)) {
console.warn('[harmony.patch] site.js 不存在, 跳过 privacy 同步');
return { written: false };
}
const siteJsSource = await fsp.readFile(siteJsPath, 'utf8');
const siteConfigRaw = parseSiteConfig(siteJsSource);
if (!siteConfigRaw) {
console.warn('[harmony.patch] ⚠️ site.js 解析失败 (找不到 baseUrl / uniacid), 跳过 privacy 同步');
return { written: false };
}
const siteConfig = {
baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl),
uniacid: siteConfigRaw.uniacid,
};
// 3. 占位符替换
const privacyConfig = replacePlaceholders(privacyConfigTemplate, siteConfig)
// 4. 校验替换结果
const finalStr = JSON.stringify(privacyConfig)
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(finalStr)) {
console.warn('[harmony.patch] ⚠️ 替换后仍含占位符, 跳过写入. 请检查 site.js / manifest.json 配置')
return { written: false };
}
// 5. 写目标 app.json
const appJsonRaw = await fsp.readFile(appJsonPath, 'utf8');
const appJson = JSON.parse(appJsonRaw);
appJson.privacy = privacyConfig;
await fsp.writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8');
const labelSuffix = label ? ` (${label})` : '';
console.log(`[harmony.patch] privacy 配置已同步到 app.json${labelSuffix} (baseUrl=${siteConfig.baseUrl}, uniacid=${siteConfig.uniacid})`);
// 6. 写后验证
await verifyWrittenAppJson(appJsonPath, siteConfig)
return { written: true, siteConfig }
} catch (err) {
console.error(`[harmony.patch] 同步 privacy 配置失败 (${label || appJsonPath}):`, err);
process.exitCode = 1;
return { written: false }
}
}
/**
* 同步 privacy 到 stub ascf_src/app.json (兼容旧调用)
*/
async function syncPrivacyToStub() {
const cwd = path.join(__dirname, '..');
const stubAppJsonPath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'app.json');
return syncPrivacyToPath(stubAppJsonPath, 'stub');
}
async function copyAssets(mode = 'production') {
try {
const cwd = path.join(__dirname, '..');
@@ -96,7 +437,8 @@ async function main() {
const argv = process.argv.slice(2);
const options = {
noZip: argv.includes('--no-zip'),
mode: 'production'
mode: 'production',
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
};
const modeIndex = argv.indexOf('--mode');
@@ -104,18 +446,46 @@ async function main() {
options.mode = argv[modeIndex + 1];
}
// 子命令: --sync-privacy-only
// vue.config.js _copyMpHarmonyBuild() 后, 构建产物的 app.json
// 会覆盖 stub ascf_src/app.json (因为产物里 app.json 含占位符)。
// vue.config.js 此时再 exec 这个子命令, 强制重新同步一次。
if (options.syncPrivacyOnly) {
console.log('[harmony.patch] --sync-privacy-only: 重新同步 privacy (覆盖 _copyMpHarmonyBuild 的拷贝)');
await syncPrivacyToStub();
return;
}
// 同步 site.js 到 stub 项目源码目录,确保编译时能动态导入
await syncSiteToStub(options.mode);
// 同步 manifest.json 的 mp-harmony.privacy 到 stub app.json (防止上架时 URL 不同步)
await syncPrivacyToStub();
await copyAssets(options.mode);
// 关键: 在打 ZIP 之前, 同步一次 **构建产物** 的 app.json
// HBuilderX 构建产物的 app.json 含占位符, 不修正就直接打 ZIP,
// 发行包里的 app.json 还是占位符, 上架会被审核驳回。
// 这步必须在 ZIP 之前, 必须在 _copyMpHarmonyBuild 之后 (vue.config.js 顺序)。
if (!options.noZip && options.mode === 'production') {
const cwd = path.join(__dirname, '..');
const buildAppJsonPath = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony', 'app.json');
if (await exists(buildAppJsonPath)) {
console.log('[harmony.patch] 同步构建产物的 app.json (打 ZIP 前)');
await syncPrivacyToPath(buildAppJsonPath, 'build');
} else {
console.warn(`[harmony.patch] 构建产物的 app.json 不存在: ${buildAppJsonPath}`);
}
}
if (!options.noZip && options.mode === 'production') {
const cwd = path.join(__dirname, '..');
const sourceDir = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony');
const destDir = path.join(cwd, 'unpackage', 'dist', 'build');
const zipFilePath = await createZipWithSystemCommand(sourceDir, destDir, 'mp-harmony');
console.log(`ZIP 文件路径: ${zipFilePath}`);
await openFileDirectory(zipFilePath);
} else {
console.log('跳过创建 ZIP 文件和打开目录');