From c95657dbb1e262cf0c2e21f0bd386d804374046d Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Wed, 8 Apr 2026 17:46:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20replace-qrcode-url?= =?UTF-8?q?=20=E8=84=9A=E6=9C=AC=E5=B9=B6=E6=9B=B4=E6=96=B0=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- esbuild.config.js | 2 +- package.json | 3 +- replace-qrcode-url.js | 7 + scripts/replace-team-qrcode-url.js | 392 +++++++++++++++++++++++++++++ 4 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 replace-qrcode-url.js create mode 100644 scripts/replace-team-qrcode-url.js diff --git a/esbuild.config.js b/esbuild.config.js index c51631a..ba320e0 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -41,7 +41,7 @@ function getEmbeddedEnvBanner() { cleanDist(); build({ - entryPoints: ["./transfer.js", "./corpid.js"], + entryPoints: ["./transfer.js", "./corpid.js", "./replace-qrcode-url.js"], outdir: "./dist", entryNames: "[name]", platform: "node", diff --git a/package.json b/package.json index 32e1432..5dd023e 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "build": "node esbuild.config.js", "package": "node esbuild.config.js", "start": "node dist/transfer.js", - "corpid": "node corpid.js" + "corpid": "node corpid.js", + "replace-qrcode-url": "node dist/replace-qrcode-url.js" }, "dependencies": { "axios": "^1.7.0", diff --git a/replace-qrcode-url.js b/replace-qrcode-url.js new file mode 100644 index 0000000..9b8debe --- /dev/null +++ b/replace-qrcode-url.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +if (!process.env.NODE_ENV && !process.env.ENV_FILE && !process.env.DOTENV_CONFIG_PATH) { + process.env.NODE_ENV = "gk"; +} + +require("./scripts/replace-team-qrcode-url"); diff --git a/scripts/replace-team-qrcode-url.js b/scripts/replace-team-qrcode-url.js new file mode 100644 index 0000000..cd5ab92 --- /dev/null +++ b/scripts/replace-team-qrcode-url.js @@ -0,0 +1,392 @@ +#!/usr/bin/env node +/** + * replace-team-qrcode-url + * + * 目的: + * - 扫描 corp.team collection + * - 将 team.qrcodes[].qrcode 中旧前缀替换为新前缀 + * + * 安全: + * - 默认 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", + 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 === "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 === "--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 && process.env.TARGET_CORP_ID) opts.corpId = process.env.TARGET_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 }; +} + +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 仅处理指定机构 + --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 col = db.collection(opts.collection); + 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"}`); + let cursor = col.find(filter, { batchSize: opts.batchDocs }); + if (opts.sortById) cursor = cursor.sort({ _id: 1 }); + if (opts.skipDocs) cursor = cursor.skip(opts.skipDocs); + if (opts.limitDocs) cursor = cursor.limit(opts.limitDocs); + + let scanned = 0; + let matchedDocs = 0; + let replacedUrls = 0; + let batchIndex = 0; + + while (await cursor.hasNext()) { + const batch = []; + while (batch.length < opts.batchDocs && (await cursor.hasNext())) { + batch.push(await cursor.next()); + } + if (batch.length === 0) break; + batchIndex++; + console.log(`[step] batch#${batchIndex} 读取文档 ${batch.length} 条(已扫描 ${scanned} 条)`); + + for (const doc of batch) { + scanned++; + const { changed, nextQrcodes } = updateTeamDoc(doc, { + oldPrefix: opts.oldPrefix, + newPrefix: opts.newPrefix, + }); + if (changed === 0) continue; + matchedDocs++; + replacedUrls += changed; + console.log(`[match] _id=${doc._id} teamId=${doc.teamId || ""} corpId=${doc.corpId || ""} replaced=${changed}`); + + if (!opts.dryRun) { + await col.updateOne( + { _id: doc._id }, + { $set: { qrcodes: nextQrcodes } } + ); + } + } + } + + console.log( + `[summary] scanned=${scanned} matchedDocs=${matchedDocs} replacedUrls=${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); +});