refactor(mp-harmony): 完善隐私协议配置同步流程与鸿蒙双轨适配
1. 新增隐私配置同步脚本,自动从manifest和site.js同步真实隐私协议URL到鸿蒙构建产物 2. 补充鸿蒙元服务隐私托管双轨制适配逻辑 3. 新增完整的隐私弹窗组件单元测试覆盖 4. 修复构建产物隐私配置被占位符覆盖的问题
This commit is contained in:
@@ -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 文件和打开目录');
|
||||
|
||||
Reference in New Issue
Block a user