#!/usr/bin/env node /** * replace-team-qrcode-url * * 目的: * - 扫描 corp.team collection * - 将 team.qrcodes[].qrcode 中旧前缀替换为新前缀 * - 将 team.qrcodes[].qrcode 中 corpId 参数从旧值替换为新值 * - 将 team.groupWelcomeConfig 中的建档链接/附件链接同步替换 * - 扫描 corp.staff-qrcode collection * - 将 staff-qrcode.archiveLink.url 中旧前缀替换为新前缀 * - 将 staff-qrcode.archiveLink.url 中 corpId 参数从旧值替换为新值 * - 将 staff-qrcode.fileList 中的附件链接同步替换 * * 安全: * - 默认 dry-run(不落库) * - `--apply --yes` 才会写入 */ const { MongoClient } = require("mongodb"); const dotenv = require("dotenv"); const fs = require("fs"); const path = require("path"); function isNonEmptyString(v) { return typeof v === "string" && v.trim().length > 0; } function safeNumber(n, fallback) { if (!Number.isFinite(n) || n <= 0) return fallback; return n; } function loadDotEnv() { const nodeEnv = process.env.NODE_ENV || "development"; const envFileName = `.env.${nodeEnv}`; const plainEnvFileName = `env.${nodeEnv}`; const explicitEnvFile = process.env.ENV_FILE || process.env.DOTENV_CONFIG_PATH || ""; const candidates = []; if (isNonEmptyString(explicitEnvFile)) { candidates.push( path.isAbsolute(explicitEnvFile) ? explicitEnvFile : path.resolve(process.cwd(), explicitEnvFile), path.isAbsolute(explicitEnvFile) ? explicitEnvFile : path.resolve(__dirname, explicitEnvFile) ); } candidates.push( path.resolve(process.cwd(), envFileName), path.resolve(__dirname, envFileName), path.resolve(process.cwd(), plainEnvFileName), path.resolve(__dirname, plainEnvFileName), path.resolve(process.cwd(), ".env"), path.resolve(__dirname, ".env") ); const picked = candidates.find((p) => { try { return fs.existsSync(p); } catch { return false; } }); if (picked) { dotenv.config({ path: picked }); console.log(`[env] loaded: ${picked}`); return; } dotenv.config(); } loadDotEnv(); function createDefaultOptions() { return { dbName: "corp", collection: "team", staffCollection: "staff-qrcode", batchDocs: 200, skipDocs: 0, limitDocs: 0, sortById: true, dryRun: true, yes: false, help: false, reportZh: false, logFile: "", mongoUri: "", mongoUriEnv: "", mongoHost: "", mongoPort: "", mongoUser: "", mongoPass: "", mongoAuthDb: "", corpId: "", corpIdSpecified: false, oldCorpId: "", newCorpId: "", oldPrefix: "https://www.youcan365.com/h5/#/", newPrefix: "https://crm.gykqyy.com/gkPatientWeChat/#/", }; } function parseArgs(argv) { const args = argv.slice(2); const opts = createDefaultOptions(); function takeValue(i) { if (i + 1 >= args.length) return ""; return args[i + 1]; } function setKeyValue(keyRaw, valueRaw) { const key = String(keyRaw || "").replace(/^--/, "").trim().toLowerCase(); const value = valueRaw; if (!key) return; if (key === "dbname" || key === "db") opts.dbName = value; else if (key === "collection") opts.collection = value; else if (key === "staffcollection") opts.staffCollection = value; else if (key === "mongouri" || key === "mongourl") opts.mongoUri = value; else if (key === "mongourienv") opts.mongoUriEnv = value; else if (key === "mongohost") opts.mongoHost = value; else if (key === "mongoport") opts.mongoPort = value; else if (key === "mongouser") opts.mongoUser = value; else if (key === "mongopass") opts.mongoPass = value; else if (key === "mongoauthdb") opts.mongoAuthDb = value; else if (key === "corpid") { opts.corpId = value; opts.corpIdSpecified = true; } else if (key === "oldcorpid" || key === "sourcecorpid" || key === "targetcorpid") opts.oldCorpId = value; else if (key === "newcorpid" || key === "tokencorpid") opts.newCorpId = value; else if (key === "oldprefix") opts.oldPrefix = value; else if (key === "newprefix") opts.newPrefix = value; else if (key === "batchdocs") opts.batchDocs = Number(value); else if (key === "skipdocs" || key === "skip-docs") opts.skipDocs = Number(value); else if (key === "limitdocs" || key === "limit-docs") opts.limitDocs = Number(value); else if (key === "logfile" || key === "log_file") opts.logFile = value; } for (let i = 0; i < args.length; i++) { const a = args[i]; if (!a) continue; if (a === "--") continue; if (typeof a === "string" && a.includes("=")) { const [k, ...rest] = a.split("="); setKeyValue(k, rest.join("=")); continue; } if (a === "--help" || a === "-h") opts.help = true; else if (a === "--apply") opts.dryRun = false; else if (a === "--yes") opts.yes = true; else if (a === "--reportZh" || a === "--zh") opts.reportZh = true; else if (a === "--dbName" || a === "--db") { opts.dbName = takeValue(i); i++; } else if (a === "--collection") { opts.collection = takeValue(i); i++; } else if (a === "--staffCollection") { opts.staffCollection = takeValue(i); i++; } else if (a === "--mongoUri" || a === "--mongoUrl" || a === "--mongoURL") { opts.mongoUri = takeValue(i); i++; } else if (a === "--mongoUriEnv") { opts.mongoUriEnv = takeValue(i); i++; } else if (a === "--mongoHost") { opts.mongoHost = takeValue(i); i++; } else if (a === "--mongoPort") { opts.mongoPort = takeValue(i); i++; } else if (a === "--mongoUser") { opts.mongoUser = takeValue(i); i++; } else if (a === "--mongoPass") { opts.mongoPass = takeValue(i); i++; } else if (a === "--mongoAuthDb") { opts.mongoAuthDb = takeValue(i); i++; } else if (a === "--corpId") { opts.corpId = takeValue(i); opts.corpIdSpecified = true; i++; } else if (a === "--oldCorpId" || a === "--sourceCorpId" || a === "--targetCorpId") { opts.oldCorpId = takeValue(i); i++; } else if (a === "--newCorpId" || a === "--tokenCorpId") { opts.newCorpId = takeValue(i); i++; } else if (a === "--oldPrefix") { opts.oldPrefix = takeValue(i); i++; } else if (a === "--newPrefix") { opts.newPrefix = takeValue(i); i++; } else if (a === "--batchDocs") { opts.batchDocs = Number(takeValue(i)); i++; } else if (a === "--skipDocs" || a === "--skip-docs") { opts.skipDocs = Number(takeValue(i)); i++; } else if (a === "--limitDocs" || a === "--limit-docs") { opts.limitDocs = Number(takeValue(i)); i++; } else if (a === "--logFile" || a === "--log-file") { opts.logFile = takeValue(i); i++; } } return opts; } function applyEnvFallbacks(opts) { if (!opts.mongoUri) opts.mongoUri = process.env.MONGO_URI || process.env.GK_MONGO_URI || ""; if (!opts.corpId) { opts.corpId = process.env.TOKEN_CORP_ID || process.env.TARGET_CORP_ID || ""; } if (!opts.oldCorpId) opts.oldCorpId = process.env.OLD_CORP_ID || process.env.SOURCE_CORP_ID || process.env.TARGET_CORP_ID || ""; if (!opts.newCorpId) opts.newCorpId = process.env.NEW_CORP_ID || process.env.TOKEN_CORP_ID || ""; if (!opts.oldPrefix && process.env.OLD_QRCODE_PREFIX) opts.oldPrefix = process.env.OLD_QRCODE_PREFIX; if (!opts.newPrefix && process.env.NEW_QRCODE_PREFIX) opts.newPrefix = process.env.NEW_QRCODE_PREFIX; } function uniqueNonEmptyStrings(values) { const seen = new Set(); const result = []; for (const value of values) { if (!isNonEmptyString(value)) continue; const key = value.trim(); if (seen.has(key)) continue; seen.add(key); result.push(key); } return result; } function getFilterCorpIds(opts) { if (opts.corpIdSpecified) { return uniqueNonEmptyStrings([opts.corpId]); } return uniqueNonEmptyStrings([opts.corpId, opts.oldCorpId, opts.newCorpId]); } function buildCorpFilter(corpIds) { if (!Array.isArray(corpIds) || corpIds.length === 0) { return {}; } if (corpIds.length === 1) { const [corpId] = corpIds; return { $or: [{ corpId }, { corpid: corpId }, { corp_id: corpId }], }; } return { $or: [{ corpId: { $in: corpIds } }, { corpid: { $in: corpIds } }, { corp_id: { $in: corpIds } }], }; } function die(msg) { console.error(msg); process.exit(1); } function formatTimestampForFile(d = new Date()) { const pad = (n) => String(n).padStart(2, "0"); return [ d.getFullYear(), pad(d.getMonth() + 1), pad(d.getDate()), "-", pad(d.getHours()), pad(d.getMinutes()), pad(d.getSeconds()), ].join(""); } function initFileLogger(opts) { const logsDir = path.resolve(process.cwd(), "logs"); if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true }); const logFile = opts.logFile || path.join(logsDir, `replace-team-qrcode-url-${formatTimestampForFile()}.log`); const stream = fs.createWriteStream(logFile, { flags: "a" }); const origLog = console.log; const origErr = console.error; const write = (level, args) => { const line = args .map((arg) => { if (typeof arg === "string") return arg; try { return JSON.stringify(arg); } catch { return String(arg); } }) .join(" "); stream.write(`[${new Date().toISOString()}] [${level}] ${line}\n`); }; console.log = (...args) => { write("INFO", args); origLog(...args); }; console.error = (...args) => { write("ERROR", args); origErr(...args); }; console.log(`[log] writing to ${logFile}`); } function buildMongoUrl(opts) { if (isNonEmptyString(opts.mongoUriEnv) && isNonEmptyString(process.env[opts.mongoUriEnv])) { return process.env[opts.mongoUriEnv]; } if (isNonEmptyString(opts.mongoUri)) { return opts.mongoUri; } const host = opts.mongoHost || process.env.MONGO_HOST || process.env.CONFIG_DB_HOST; const port = opts.mongoPort || process.env.MONGO_PORT || process.env.CONFIG_DB_PORT || "27017"; const username = opts.mongoUser || process.env.MONGO_USER || process.env.CONFIG_DB_USERNAME; const password = opts.mongoPass || process.env.MONGO_PASS || process.env.CONFIG_DB_PASSWORD; const authDb = opts.mongoAuthDb || process.env.MONGO_AUTH_DB || "admin"; if (!host || !username || !password) { return ""; } return `mongodb://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}/${authDb}`; } function replacePrefix(value, oldPrefix, newPrefix) { if (!isNonEmptyString(value) || !isNonEmptyString(oldPrefix) || !isNonEmptyString(newPrefix)) return value; if (!value.startsWith(oldPrefix)) return value; return `${newPrefix}${value.slice(oldPrefix.length)}`; } function replaceUrlCorpId(value, oldCorpId, newCorpId) { if (!isNonEmptyString(value) || !isNonEmptyString(oldCorpId) || !isNonEmptyString(newCorpId)) return value; return value.replace( /([?&]corpId=)([^&#]*)/g, (match, prefix, currentCorpId) => (currentCorpId === oldCorpId ? `${prefix}${newCorpId}` : match) ); } function normalizeQrcodeUrl(value, { oldPrefix, newPrefix, oldCorpId, newCorpId }) { return replaceUrlCorpId(replacePrefix(value, oldPrefix, newPrefix), oldCorpId, newCorpId); } function normalizeArchiveLink(archiveLink, options) { if (!archiveLink || typeof archiveLink !== "object") { return { changed: 0, nextArchiveLink: archiveLink }; } const current = archiveLink.url; const next = normalizeQrcodeUrl(current, options); if (next === current) { return { changed: 0, nextArchiveLink: archiveLink }; } return { changed: 1, nextArchiveLink: { ...archiveLink, url: next }, }; } function normalizeFileList(fileList, options) { if (!Array.isArray(fileList)) { return { changed: 0, nextFileList: fileList }; } let changed = 0; const nextFileList = fileList.map((item) => { if (!item || typeof item !== "object") return item; let nextItem = item; function setItemUrl(key) { const current = item[key]; const next = normalizeQrcodeUrl(current, options); if (next !== current) { changed++; nextItem = nextItem === item ? { ...item } : nextItem; nextItem[key] = next; } } setItemUrl("URL"); setItemUrl("url"); setItemUrl("href"); if (item.file && typeof item.file === "object") { let nextFile = item.file; for (const key of ["url", "URL", "href", "picurl", "imgUrl", "download_url"]) { const current = item.file[key]; const next = normalizeQrcodeUrl(current, options); if (next !== current) { changed++; nextFile = nextFile === item.file ? { ...item.file } : nextFile; nextFile[key] = next; } } if (nextFile !== item.file) { nextItem = nextItem === item ? { ...item } : nextItem; nextItem.file = nextFile; } } return nextItem; }); return { changed, nextFileList }; } function updateTeamDoc(doc, { oldPrefix, newPrefix, oldCorpId, newCorpId }) { const options = { oldPrefix, newPrefix, oldCorpId, newCorpId }; const qrcodes = Array.isArray(doc.qrcodes) ? doc.qrcodes : []; let changed = 0; const nextQrcodes = qrcodes.map((item) => { if (!item || typeof item !== "object") return item; const current = item.qrcode; const next = normalizeQrcodeUrl(current, options); if (next !== current) { changed++; return { ...item, qrcode: next }; } return item; }); const set = {}; if (changed > 0) { set.qrcodes = nextQrcodes; } const groupWelcomeConfig = doc && doc.groupWelcomeConfig && typeof doc.groupWelcomeConfig === "object" ? doc.groupWelcomeConfig : null; if (groupWelcomeConfig) { let nextGroupWelcomeConfig = groupWelcomeConfig; const archiveResult = normalizeArchiveLink(groupWelcomeConfig.archiveLink, options); if (archiveResult.changed > 0) { changed += archiveResult.changed; nextGroupWelcomeConfig = { ...nextGroupWelcomeConfig, archiveLink: archiveResult.nextArchiveLink }; } const fileListResult = normalizeFileList(groupWelcomeConfig.fileList, options); if (fileListResult.changed > 0) { changed += fileListResult.changed; nextGroupWelcomeConfig = { ...nextGroupWelcomeConfig, fileList: fileListResult.nextFileList }; } if (nextGroupWelcomeConfig !== groupWelcomeConfig) { set.groupWelcomeConfig = nextGroupWelcomeConfig; } } return { changed, set }; } function updateStaffQrcodeDoc(doc, { oldPrefix, newPrefix, oldCorpId, newCorpId }) { const options = { oldPrefix, newPrefix, oldCorpId, newCorpId }; let changed = 0; const set = {}; const archiveResult = normalizeArchiveLink(doc && doc.archiveLink, options); if (archiveResult.changed > 0) { changed += archiveResult.changed; set.archiveLink = archiveResult.nextArchiveLink; } const fileListResult = normalizeFileList(doc && doc.fileList, options); if (fileListResult.changed > 0) { changed += fileListResult.changed; set.fileList = fileListResult.nextFileList; } return { changed, set }; } async function processCollectionDocs({ collection, filter, batchDocs, sortById, skipDocs, limitDocs, dryRun, updateDoc, buildUpdate, describeDoc, }) { let cursor = collection.find(filter, { batchSize: batchDocs }); if (sortById) cursor = cursor.sort({ _id: 1 }); if (skipDocs) cursor = cursor.skip(skipDocs); if (limitDocs) cursor = cursor.limit(limitDocs); let scanned = 0; let matchedDocs = 0; let replacedUrls = 0; let batchIndex = 0; while (await cursor.hasNext()) { const batch = []; while (batch.length < batchDocs && (await cursor.hasNext())) { batch.push(await cursor.next()); } if (batch.length === 0) break; batchIndex++; console.log( `[step] ${collection.collectionName} batch#${batchIndex} 读取文档 ${batch.length} 条(已扫描 ${scanned} 条)` ); for (const doc of batch) { scanned++; const { changed, ...payload } = updateDoc(doc); if (changed === 0) continue; matchedDocs++; replacedUrls += changed; console.log(`[match] ${collection.collectionName} ${describeDoc(doc)} replaced=${changed}`); if (!dryRun) { await collection.updateOne({ _id: doc._id }, buildUpdate(payload)); } } } return { scanned, matchedDocs, replacedUrls }; } async function main() { const opts = parseArgs(process.argv); applyEnvFallbacks(opts); initFileLogger(opts); if (opts.help) { console.log(`replace-team-qrcode-url 用法: node replace-team-qrcode-url.js --apply --yes 可选参数: --dbName 数据库名,默认 corp --collection collection 名,默认 team --corpId 仅处理指定机构;未传时默认同时兼容 old/new corpId --oldCorpId URL 参数中的旧 corpId,默认取 OLD_CORP_ID / SOURCE_CORP_ID / TARGET_CORP_ID --newCorpId URL 参数中的新 corpId,默认取 NEW_CORP_ID / TOKEN_CORP_ID --oldPrefix 旧前缀,默认 https://www.youcan365.com/h5/#/ --newPrefix 新前缀,默认 https://crm.gykqyy.com/gkPatientWeChat/#/ --apply --yes 真正写库;默认 dry-run `); return; } opts.batchDocs = safeNumber(opts.batchDocs, 200); opts.skipDocs = Number.isFinite(opts.skipDocs) && opts.skipDocs > 0 ? Math.floor(opts.skipDocs) : 0; opts.limitDocs = Number.isFinite(opts.limitDocs) && opts.limitDocs > 0 ? Math.floor(opts.limitDocs) : 0; if (!opts.dryRun && !opts.yes) { die("该脚本会直接回写原 collection。请确认后加上 --yes 再执行。"); } const mongoUrl = buildMongoUrl(opts); if (!isNonEmptyString(mongoUrl)) { die("缺少 MongoDB 连接信息:请传 --mongoUri,或配置 GK_MONGO_URI / MONGO_URI"); } const client = new MongoClient(mongoUrl, { serverSelectionTimeoutMS: 30000, connectTimeoutMS: 30000, }); console.log("[step] 正在连接 MongoDB..."); await client.connect(); console.log("[step] MongoDB 已连接"); try { const db = client.db(opts.dbName); const teamCol = db.collection(opts.collection); const staffCol = db.collection(opts.staffCollection); const filterCorpIds = getFilterCorpIds(opts); const filter = buildCorpFilter(filterCorpIds); console.log( `[step] collection=${opts.dbName}.${opts.collection} corpIds=${filterCorpIds.length ? filterCorpIds.join(",") : "全部"} mode=${opts.dryRun ? "dry-run" : "apply"}` ); const teamStats = await processCollectionDocs({ collection: teamCol, filter, batchDocs: opts.batchDocs, sortById: opts.sortById, skipDocs: opts.skipDocs, limitDocs: opts.limitDocs, dryRun: opts.dryRun, updateDoc: (doc) => updateTeamDoc(doc, { oldPrefix: opts.oldPrefix, newPrefix: opts.newPrefix, oldCorpId: opts.oldCorpId, newCorpId: opts.newCorpId, }), buildUpdate: ({ set }) => ({ $set: set }), describeDoc: (doc) => `_id=${doc._id} teamId=${doc.teamId || ""} corpId=${doc.corpId || ""}`, }); console.log( `[step] collection=${opts.dbName}.${opts.staffCollection} corpIds=${filterCorpIds.length ? filterCorpIds.join(",") : "全部"} mode=${opts.dryRun ? "dry-run" : "apply"}` ); const staffStats = await processCollectionDocs({ collection: staffCol, filter, batchDocs: opts.batchDocs, sortById: opts.sortById, skipDocs: opts.skipDocs, limitDocs: opts.limitDocs, dryRun: opts.dryRun, updateDoc: (doc) => updateStaffQrcodeDoc(doc, { oldPrefix: opts.oldPrefix, newPrefix: opts.newPrefix, oldCorpId: opts.oldCorpId, newCorpId: opts.newCorpId, }), buildUpdate: ({ set }) => ({ $set: set }), describeDoc: (doc) => `_id=${doc._id} qrcodeId=${doc.qrcodeId || ""} corpId=${doc.corpId || ""}`, }); console.log( `[summary] team: scanned=${teamStats.scanned} matchedDocs=${teamStats.matchedDocs} replacedUrls=${teamStats.replacedUrls} | staff-qrcode: scanned=${staffStats.scanned} matchedDocs=${staffStats.matchedDocs} replacedUrls=${staffStats.replacedUrls} mode=${opts.dryRun ? "dry-run" : "apply"}` ); } finally { await client.close(); } } main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });