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

476 lines
15 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 中旧前缀替换为新前缀
* - 扫描 corp.staff-qrcode collection
* - 将 staff-qrcode.archiveLink.url 中旧前缀替换为新前缀
*
* 安全:
* - 默认 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: "",
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;
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);
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.TARGET_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 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 updateTeamDoc(doc, { oldPrefix, newPrefix }) {
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 = replacePrefix(current, oldPrefix, newPrefix);
if (next !== current) {
changed++;
return { ...item, qrcode: next };
}
return item;
});
return { changed, nextQrcodes };
}
function updateStaffQrcodeDoc(doc, { oldPrefix, newPrefix }) {
const archiveLink =
doc && doc.archiveLink && typeof doc.archiveLink === "object"
? doc.archiveLink
: null;
if (!archiveLink) {
return { changed: 0, nextArchiveLink: archiveLink };
}
const current = archiveLink.url;
const next = replacePrefix(current, oldPrefix, newPrefix);
if (next === current) {
return { changed: 0, nextArchiveLink: archiveLink };
}
return {
changed: 1,
nextArchiveLink: { ...archiveLink, url: next },
};
}
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> 仅处理指定机构
--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 filter = {};
if (isNonEmptyString(opts.corpId)) filter.corpId = opts.corpId;
console.log(
`[step] collection=${opts.dbName}.${opts.collection} corpId=${opts.corpId || "全部"} 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,
}),
buildUpdate: ({ nextQrcodes }) => ({ $set: { qrcodes: nextQrcodes } }),
describeDoc: (doc) =>
`_id=${doc._id} teamId=${doc.teamId || ""} corpId=${doc.corpId || ""}`,
});
console.log(
`[step] collection=${opts.dbName}.${opts.staffCollection} corpId=${opts.corpId || "全部"} 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,
}),
buildUpdate: ({ nextArchiveLink }) => ({
$set: { archiveLink: nextArchiveLink },
}),
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);
});