Files
lucky_shop/scripts/harmony.patch.js
ZF sun f4a770e246 refactor(mp-harmony): 完善隐私协议配置同步流程与鸿蒙双轨适配
1.  新增隐私配置同步脚本,自动从manifest和site.js同步真实隐私协议URL到鸿蒙构建产物
2.  补充鸿蒙元服务隐私托管双轨制适配逻辑
3.  新增完整的隐私弹窗组件单元测试覆盖
4.  修复构建产物隐私配置被占位符覆盖的问题
2026-07-24 14:43:02 +08:00

513 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* harmony.patch.js
*
* 该脚本用于鸿蒙元服务的构建后处理(仅在生产构建时由 vue.config.js 触发):
* 1. 拷贝 harmony-configs 配置文件到构建产物目录
* 2. 拷贝 site.js 到构建产物目录
* 3. 创建 ZIP 压缩包
*
* 使用:
* node harmony.patch.js # 拷贝文件并创建 ZIP(默认 mode=production)
* node harmony.patch.js --no-zip # 只拷贝文件,不创建 ZIP
*/
const fs = require('fs');
const fsp = fs.promises;
const path = require('path');
const { openFileDirectory } = require('./open-directory');
const { createZipWithSystemCommand } = require('./create-zip');
async function syncSiteToStub(mode = 'production') {
try {
const cwd = path.join(__dirname, '..');
const srcSitePath = path.join(cwd, 'site.js');
const stubSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'site.js');
if (await exists(srcSitePath)) {
await fsp.copyFile(srcSitePath, stubSitePath);
console.log('site.js 已同步到 stub 项目源码目录');
}
} catch (err) {
console.error('同步 site.js 到 stub 失败:', err);
process.exitCode = 1;
}
}
/**
* 同步 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, '..');
const destDir = path.join(cwd, 'unpackage', 'dist', mode === 'production' ? 'build' : 'dev', '.mp-harmony');
await ensureDir(destDir);
const copyPromises = [];
if (await exists(path.join(cwd, 'harmony-configs'))) {
copyPromises.push(copyDirectory(path.join(cwd, 'harmony-configs'), path.join(destDir, 'harmony-configs')));
}
const srcSitePath = path.join(cwd, 'site.js');
if (await exists(srcSitePath)) {
copyPromises.push(fsp.copyFile(srcSitePath, path.join(destDir, 'site.js')));
// 同步一份到 entry rawfile,让 FormAbility 能在运行时 getRawFileContent('site.js') 读取
// (FormAbility 进程读不到 ascf_src/ 那份 webpack bundle,需要 rawfile 副本)
const rawfileSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'site.js');
copyPromises.push(fsp.copyFile(srcSitePath, rawfileSitePath));
}
await Promise.all(copyPromises);
// 给所有子组件 .json 注入 componentPlaceholder, 避免 "diy-* is asyncComponent" 报错.
// 见 scripts/harmony-component-placeholder.js 说明. 该脚本跳过页面级, 只处理组件.
try {
const { execFileSync } = require('child_process');
execFileSync(process.execPath, [
path.join(__dirname, 'harmony-component-placeholder.js'),
destDir,
], { stdio: 'inherit', cwd });
} catch (e) {
console.warn('[harmony.patch] ⚠️ harmony-component-placeholder 注入失败:', e.message);
}
console.log('HarmonyOS 资源拷贝完成');
} catch (err) {
console.error('发生错误:', err);
process.exitCode = 1;
}
}
async function copyDirectory(src, dest) {
await ensureDir(dest);
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
} else {
await fsp.copyFile(srcPath, destPath);
}
}
}
async function main() {
const argv = process.argv.slice(2);
const options = {
noZip: argv.includes('--no-zip'),
mode: 'production',
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
};
const modeIndex = argv.indexOf('--mode');
if (modeIndex !== -1 && modeIndex + 1 < argv.length) {
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 文件和打开目录');
}
}
async function exists(p) {
try {
await fsp.access(p, fs.constants.F_OK);
return true;
} catch (e) {
return false;
}
}
async function ensureDir(dirPath) {
try {
await fsp.mkdir(dirPath, { recursive: true });
} catch (e) {
if (e.code !== 'EEXIST') throw e;
}
}
main();