corp-transfer/scripts/replace-team-qrcode-url.js

642 lines
20 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
/**
* 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 <db> 数据库名,默认 corp
--collection <name> collection 名,默认 team
--corpId <corpId> 仅处理指定机构;未传时默认同时兼容 old/new corpId
--oldCorpId <corpId> URL 参数中的旧 corpId默认取 OLD_CORP_ID / SOURCE_CORP_ID / TARGET_CORP_ID
--newCorpId <corpId> URL 参数中的新 corpId默认取 NEW_CORP_ID / TOKEN_CORP_ID
--oldPrefix <url> 旧前缀,默认 https://www.youcan365.com/h5/#/
--newPrefix <url> 新前缀,默认 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);
});