#!/usr/bin/env node /** * convert-wecom-ids-gk * * 目的: * - 把第三方/代开发应用获取的密文 `open_userid` 转为明文 `userid` * - 把第三方应用的“服务商 external_userid”转为企业 `external_userid` * - apply 模式按 `_id` 直接回写原 collection * * 重要说明(为什么会出现大量 invalid): * - `batch/openuserid_to_userid` 只对“第三方/代开发应用产生的密文 open_userid”有效。 * 如果库里存的其实已经是明文 userid(很多都是 `wo...` 形式),或 token/source_agentid 不匹配,会返回 invalid。 * - `externalcontact/from_service_external_userid` 只对“服务商 external_userid”有效。 * 如果库里存的已经是企业 external_userid(也是 `wm...`),会报 40096 invalid,这是正常的:无需转换,直接保留原值。 * - 代开发/第三方场景建议使用 `tokenMode=suite` 获取“授权企业 corp access_token”,不要用自建应用 gettoken。 * - 很多 “invalid” 本质是“可见范围/通讯录权限交集”导致的: * 企微很多接口(包括 open_userid -> userid)只对当前 access_token 所代表的应用“可见范围内的成员”有效。 * 可用 `--dumpScope` 调用 `agent/get` + `user/list_id`(文档 path=96067)查看该 token 在通讯录侧能看到多少成员,用于定位“交集太小”。 * * 安全: * - 默认 dry-run(不落库) * - `--apply --yes` 才会写入 * - apply 模式默认采用 upsert(按 `_id` 覆盖更新),报错后可直接重跑 * * 文档: * - 详细用法见:scripts/convert-wecom-ids-gk.README.md */ const axios = require("axios"); const { MongoClient } = require("mongodb"); const dotenv = require("dotenv"); const fs = require("fs"); const path = require("path"); 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(); const PRESETS = { // 预设不会硬编码任何账号/密码/secret;请通过环境变量注入 // 示例: // export GK_MONGO_URI='mongodb://user:pass@host:27017/admin' // export GK_CORP_SECRET='xxx' "gk-suite": { mongoUriEnv: "GK_MONGO_URI", dbName: "corp", targetCorpId: "wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA", tokenMode: "suite", sourceAgentId: 1000076, }, "gk-corpsecret": { mongoUriEnv: "GK_MONGO_URI", dbName: "corp", targetCorpId: "wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA", tokenMode: "corpSecret", tokenCorpId: "wwe3fb2faa52cf9dfb", secretEnv: "GK_CORP_SECRET", sourceAgentId: 1000076, }, // 小批量测试 wechat-friends(避免一次跑太多) "gk-wechat-sample": { mongoUriEnv: "GK_MONGO_URI", dbName: "corp", targetCorpId: "wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA", tokenMode: "suite", sourceAgentId: 1000076, collections: ["wechat-friends"], limitDocs: 200, }, }; function createDefaultOptions() { return { dbName: "corp", corpIdFields: ["corpId", "corpid", "corp_id"], batchDocs: 200, batchOpenIds: 100, concurrencyExternal: 8, skipDocs: 0, limitDocs: 0, sortById: true, dryRun: true, yes: false, aggressive: false, skipApi: false, help: false, reportZh: false, wecomDebug: true, logFile: "", insertOnly: false, tokenMode: "corpSecret", // corpSecret | suite preset: "", listPresets: false, openUserIds: [], externalUserIds: [], dumpScope: false, scopeOnly: false, scopeLimit: 2000, checkUserIds: [], suiteAccessToken: "", suiteAccessTokenEnv: "", permanentCode: "", permanentCodeEnv: "", mongoUri: "", mongoUriEnv: "", mongoHost: "", mongoPort: "", mongoUser: "", mongoPass: "", mongoAuthDb: "", tokenCorpId: "", secret: "", secretEnv: "", sourceAgentId: 0, collections: [], matchCorpIds: [], useridAsOpenCollections: ["wechat-friends"], }; } function parseArgs(argv) { const args = argv.slice(2); let presetName = ""; let listPresets = false; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === "--listPresets" || a === "--list-presets") listPresets = true; if (a === "--preset" || a === "-p") presetName = args[i + 1] || ""; if (typeof a === "string" && a.includes("=")) { const [k, ...rest] = a.split("="); const key = String(k).replace(/^--/, "").toLowerCase(); const value = rest.join("="); if (key === "preset") presetName = value; } } const defaults = createDefaultOptions(); const preset = presetName ? PRESETS[String(presetName).trim()] : null; if (presetName && !preset) { die(`未知预设: ${presetName}。请先执行 --listPresets 查看可用预设。`); } const opts = { ...defaults, ...(preset ? { ...preset } : null), }; opts.preset = presetName; opts.listPresets = listPresets; function takeValue(i) { if (i + 1 >= args.length) return ""; return args[i + 1]; } function setKeyValue(keyRaw, valueRaw) { const key = String(keyRaw || "").replace(/^--/, "").trim(); const keyLower = key.toLowerCase(); const value = valueRaw; if (!keyLower) return; if (keyLower === "dbname" || keyLower === "db") opts.dbName = value; else if (keyLower === "collections") opts.collections = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); else if (keyLower === "matchcorpids" || keyLower === "match-corp-ids") opts.matchCorpIds = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); else if (keyLower === "targetcorpid") opts.targetCorpId = value; else if (keyLower === "corpidfields") opts.corpIdFields = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); else if (keyLower === "mongouri" || keyLower === "mongourl") opts.mongoUri = value; else if (keyLower === "mongourienv") opts.mongoUriEnv = value; else if (keyLower === "mongohost") opts.mongoHost = value; else if (keyLower === "mongoport") opts.mongoPort = value; else if (keyLower === "mongouser") opts.mongoUser = value; else if (keyLower === "mongopass") opts.mongoPass = value; else if (keyLower === "mongoauthdb") opts.mongoAuthDb = value; else if (keyLower === "tokenmode") opts.tokenMode = value; else if (keyLower === "tokencorpid") opts.tokenCorpId = value; else if (keyLower === "secret") opts.secret = value; else if (keyLower === "secretenv") opts.secretEnv = value; else if (keyLower === "sourceagentid" || keyLower === "source_agentid") opts.sourceAgentId = Number(value); else if (keyLower === "logfile" || keyLower === "log_file") opts.logFile = value; else if (keyLower === "batchdocs") opts.batchDocs = Number(value); else if (keyLower === "batchopenids") opts.batchOpenIds = Number(value); else if (keyLower === "concurrencyexternal") opts.concurrencyExternal = Number(value); else if (keyLower === "skipdocs" || keyLower === "skip-docs") opts.skipDocs = Number(value); else if ( keyLower === "limitdocs" || keyLower === "limit-docs" || keyLower === "limitdoc" || keyLower === "limit-doc" ) opts.limitDocs = Number(value); else if (keyLower === "suiteaccesstoken") opts.suiteAccessToken = value; else if (keyLower === "suiteaccesstokenenv") opts.suiteAccessTokenEnv = value; else if (keyLower === "permanentcode") opts.permanentCode = value; else if (keyLower === "permanentcodeenv") opts.permanentCodeEnv = value; else if (keyLower === "openuserid" || keyLower === "open_userid") opts.openUserIds = [...(opts.openUserIds || []), value].filter(Boolean); else if (keyLower === "openuserids" || keyLower === "open_userids") opts.openUserIds = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); else if (keyLower === "externaluserid" || keyLower === "external_userid") opts.externalUserIds = [...(opts.externalUserIds || []), value].filter(Boolean); else if (keyLower === "externaluserids" || keyLower === "external_userids") opts.externalUserIds = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); else if (keyLower === "useridasopencollections" || keyLower === "userid_as_open_collections") opts.useridAsOpenCollections = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); } 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 === "--reportZh" || a === "--zh") opts.reportZh = true; else if (a === "--wecomDebug" || a === "--wecom-debug") opts.wecomDebug = true; else if (a === "--noWecomDebug" || a === "--no-wecom-debug") opts.wecomDebug = false; else if (a === "--insertOnly" || a === "--insert-only") opts.insertOnly = true; else if (a === "--noSortById" || a === "--no-sort-by-id") opts.sortById = false; else if (a === "--useSuiteToken" || a === "--use-suite-token") opts.tokenMode = "suite"; else if (a === "--apply") opts.dryRun = false; else if (a === "--yes") opts.yes = true; else if (a === "--aggressive") opts.aggressive = true; else if (a === "--skipApi") opts.skipApi = true; else if (a === "--listPresets" || a === "--list-presets") opts.listPresets = true; else if (a === "--dumpScope" || a === "--dump-scope") opts.dumpScope = true; else if (a === "--scopeOnly" || a === "--scope-only") opts.scopeOnly = true; else if (a === "--preset" || a === "-p") { opts.preset = takeValue(i); i++; } else if (a === "--dbName" || a === "--db") { opts.dbName = takeValue(i); i++; } else if (a === "--collections") { opts.collections = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } else if (a === "--matchCorpIds" || a === "--match-corp-ids") { opts.matchCorpIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } else if (a === "--targetCorpId") { opts.targetCorpId = takeValue(i); i++; } else if (a === "--corpIdFields") { opts.corpIdFields = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } else if (a === "--mongoUri") { opts.mongoUri = takeValue(i); i++; } else if (a === "--mongoUrl" || a === "--mongoURL" || a === "--mongo-url") { 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 === "--tokenMode" || a === "--token-mode") { opts.tokenMode = takeValue(i); i++; } else if (a === "--tokenCorpId") { opts.tokenCorpId = takeValue(i); i++; } else if (a === "--secret") { opts.secret = takeValue(i); i++; } else if (a === "--secretEnv") { opts.secretEnv = takeValue(i); i++; } else if (a === "--sourceAgentId") { opts.sourceAgentId = Number(takeValue(i)); i++; } else if (a === "--logFile" || a === "--log-file") { opts.logFile = takeValue(i); i++; } else if (a === "--batchDocs") { opts.batchDocs = Number(takeValue(i)); i++; } else if (a === "--batchOpenIds") { opts.batchOpenIds = Number(takeValue(i)); i++; } else if (a === "--concurrencyExternal") { opts.concurrencyExternal = Number(takeValue(i)); i++; } else if (a === "--skipDocs" || a === "--skip-docs") { opts.skipDocs = Number(takeValue(i)); i++; } else if (a === "--limitDocs" || a === "--limit-docs" || a === "--limitdoc" || a === "--limit-doc") { opts.limitDocs = Number(takeValue(i)); i++; } else if (a === "--suiteAccessToken") { opts.suiteAccessToken = takeValue(i); i++; } else if (a === "--suiteAccessTokenEnv") { opts.suiteAccessTokenEnv = takeValue(i); i++; } else if (a === "--permanentCode") { opts.permanentCode = takeValue(i); i++; } else if (a === "--permanentCodeEnv") { opts.permanentCodeEnv = takeValue(i); i++; } else if (a === "--openUserId" || a === "--openuserid" || a === "--open_userid") { opts.openUserIds = [...(opts.openUserIds || []), takeValue(i)].filter(Boolean); i++; } else if (a === "--openUserIds" || a === "--openuserids" || a === "--open_userids") { opts.openUserIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } else if (a === "--externalUserId" || a === "--externaluserid" || a === "--external_userid") { opts.externalUserIds = [...(opts.externalUserIds || []), takeValue(i)].filter(Boolean); i++; } else if (a === "--externalUserIds" || a === "--externaluserids" || a === "--external_userids") { opts.externalUserIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } else if (a === "--useridAsOpenCollections" || a === "--userid-as-open-collections") { opts.useridAsOpenCollections = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } else if (a === "--scopeLimit" || a === "--scope-limit") { opts.scopeLimit = Number(takeValue(i)); i++; } else if (a === "--checkUserId" || a === "--check-userid") { opts.checkUserIds = [...(opts.checkUserIds || []), takeValue(i)].filter(Boolean); i++; } else if (a === "--checkUserIds" || a === "--check-userids") { opts.checkUserIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); i++; } } return opts; } function die(msg) { console.error(msg); process.exit(1); } 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 applyEnvFallbacks(opts) { if (!opts.mongoUri) opts.mongoUri = process.env.MONGO_URI || process.env.GK_MONGO_URI || ""; if (!opts.dbName && process.env.DB_NAME) opts.dbName = process.env.DB_NAME; if (!opts.targetCorpId && process.env.TARGET_CORP_ID) opts.targetCorpId = process.env.TARGET_CORP_ID; if (!opts.tokenCorpId && process.env.TOKEN_CORP_ID) opts.tokenCorpId = process.env.TOKEN_CORP_ID; if (!opts.secret && process.env.CORP_SECRET) opts.secret = process.env.CORP_SECRET; if (!opts.sourceAgentId && process.env.SOURCE_AGENT_ID) opts.sourceAgentId = Number(process.env.SOURCE_AGENT_ID); if (!opts.logFile && process.env.LOG_FILE) opts.logFile = process.env.LOG_FILE; if ((!opts.collections || opts.collections.length === 0) && process.env.COLLECTIONS) { opts.collections = String(process.env.COLLECTIONS) .split(",") .map((s) => s.trim()) .filter(Boolean); } if ((!opts.openUserIds || opts.openUserIds.length === 0) && process.env.OPEN_USERIDS) { opts.openUserIds = String(process.env.OPEN_USERIDS) .split(",") .map((s) => s.trim()) .filter(Boolean); } if ((!opts.externalUserIds || opts.externalUserIds.length === 0) && process.env.EXTERNAL_USERIDS) { opts.externalUserIds = String(process.env.EXTERNAL_USERIDS) .split(",") .map((s) => s.trim()) .filter(Boolean); } if ((!opts.useridAsOpenCollections || opts.useridAsOpenCollections.length === 0) && process.env.USERID_AS_OPEN_COLLECTIONS) { opts.useridAsOpenCollections = String(process.env.USERID_AS_OPEN_COLLECTIONS) .split(",") .map((s) => s.trim()) .filter(Boolean); } if (!opts.tokenMode && process.env.TOKEN_MODE) opts.tokenMode = process.env.TOKEN_MODE; if (!opts.permanentCode && process.env.PERMANENT_CODE) opts.permanentCode = process.env.PERMANENT_CODE; if (!opts.suiteAccessToken && process.env.SUITE_ACCESS_TOKEN) opts.suiteAccessToken = process.env.SUITE_ACCESS_TOKEN; if (!opts.limitDocs && process.env.LIMIT_DOCS) opts.limitDocs = Number(process.env.LIMIT_DOCS); if (!opts.skipDocs && process.env.SKIP_DOCS) opts.skipDocs = Number(process.env.SKIP_DOCS); } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } 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 maskSensitiveValue(key, value) { const k = String(key || "").toLowerCase(); if (value == null) return value; if (k.includes("secret") || k.includes("token")) { const s = String(value); if (s.length <= 8) return "***"; return `${s.slice(0, 4)}***${s.slice(-4)}`; } if (k.includes("pass")) return "***"; return value; } function sanitizeOptionsForLog(opts) { const out = {}; for (const [k, v] of Object.entries(opts || {})) { out[k] = maskSensitiveValue(k, v); } return out; } function initFileLogger(opts) { const scriptDir = path.dirname(path.resolve(process.argv[1] || __filename)); const logsDir = path.resolve(scriptDir, "logs"); fs.mkdirSync(logsDir, { recursive: true }); const filePath = isNonEmptyString(opts.logFile) ? path.resolve(process.cwd(), opts.logFile) : path.join(logsDir, `transfer-${formatTimestampForFile()}.log`); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, "", "utf8"); const rawStdout = process.stdout.write.bind(process.stdout); const rawStderr = process.stderr.write.bind(process.stderr); const stream = fs.createWriteStream(filePath, { flags: "a", encoding: "utf8" }); const writeLine = (chunk) => { try { stream.write(typeof chunk === "string" ? chunk : chunk.toString("utf8")); } catch { // ignore log file write errors to avoid blocking main flow } }; process.stdout.write = function patchedStdout(chunk, encoding, cb) { writeLine(chunk); return rawStdout(chunk, encoding, cb); }; process.stderr.write = function patchedStderr(chunk, encoding, cb) { writeLine(chunk); return rawStderr(chunk, encoding, cb); }; const argvForLog = process.argv.map((x) => { if (/secret|token/i.test(String(x))) return String(x).replace(/=.*/, "=***"); return x; }); stream.write(`[log] started at ${new Date().toISOString()}\n`); stream.write(`[log] cwd=${process.cwd()}\n`); stream.write(`[log] scriptDir=${scriptDir}\n`); stream.write(`[log] argv=${JSON.stringify(argvForLog)}\n`); stream.write(`[log] options=${JSON.stringify(sanitizeOptionsForLog(opts))}\n`); console.log(`[log] writing to ${filePath}`); return filePath; } class WeComIdConverter { constructor({ tokenCorpId, secret, sourceAgentId, timeoutMs = 20000, debug = true, getAccessToken, }) { this.tokenCorpId = tokenCorpId; this.secret = secret; this.sourceAgentId = sourceAgentId; this.timeoutMs = timeoutMs; this.debug = !!debug; this.getAccessToken = typeof getAccessToken === "function" ? getAccessToken : null; this._accessToken = null; this._accessTokenExpireAt = 0; this._openMap = new Map(); // open_userid -> userid this._externalMap = new Map(); // service external_userid -> corp external_userid this._externalFail = new Map(); // external_userid -> error message this._openInvalid = new Set(); // open_userid that cannot be converted this._externalInvalid = new Set(); // service external_userid that cannot be converted } _maskAccessToken(token) { if (!token) return token; const s = String(token); if (s.length <= 10) return "***"; return `${s.slice(0, 6)}***${s.slice(-4)}`; } async _getAccessToken() { const now = Date.now(); if (this._accessToken && now < this._accessTokenExpireAt - 60_000) { return this._accessToken; } if (this.getAccessToken) { const tokenInfo = await this.getAccessToken(); if (!tokenInfo || !tokenInfo.access_token) throw new Error("getAccessToken() returned empty access_token"); this._accessToken = tokenInfo.access_token; const expiresInSec = tokenInfo.expires_in || 7200; this._accessTokenExpireAt = now + expiresInSec * 1000; return this._accessToken; } const resp = await axios.get("https://qyapi.weixin.qq.com/cgi-bin/gettoken", { params: { corpid: this.tokenCorpId, corpsecret: this.secret }, timeout: this.timeoutMs, }); if (!resp.data || resp.data.errcode !== 0) { throw new Error(`gettoken failed: ${JSON.stringify(resp.data)}`); } this._accessToken = resp.data.access_token; const expiresInSec = resp.data.expires_in || 7200; this._accessTokenExpireAt = now + expiresInSec * 1000; return this._accessToken; } async _wecomRequest({ method, url, params, data, headers, retry = 1 }) { const accessToken = await this._getAccessToken(); const fullUrlMasked = buildUrlWithQuery(url, { access_token: this._maskAccessToken(accessToken), ...(params || {}) }); const reqInfo = { method, url, fullUrl: fullUrlMasked, params: { access_token: this._maskAccessToken(accessToken), ...(params || {}) }, data, headers, }; let resp; try { resp = await axios.request({ method, url, params: { access_token: accessToken, ...(params || {}) }, data, headers, timeout: this.timeoutMs, validateStatus: () => true, }); } catch (e) { if (this.debug) { console.log(`[wecom] ${method} ${url} -> network_error ${e?.message || e}`); console.dir( { request: reqInfo, error: e?.response?.data ? { response: e.response.data, status: e.response.status } : e?.message || e, }, { depth: 12 } ); } throw e; } const body = resp.data; if (this.debug) { console.log(`[wecom] ${method} ${url} -> status=${resp.status} errcode=${body?.errcode ?? "n/a"} ${body?.errmsg || ""}`); console.dir( { request: reqInfo, response: body, http: { status: resp.status, statusText: resp.statusText }, }, { depth: 12 } ); } // access_token invalid/expired if (body && (body.errcode === 40014 || body.errcode === 42001)) { this._accessToken = null; if (retry > 0) return this._wecomRequest({ method, url, params, data, retry: retry - 1 }); } return body; } _formatWecomError({ action, request, response }) { return `${action} failed.\nrequest=${JSON.stringify(request)}\nresponse=${JSON.stringify(response)}`; } async convertOpenUserIds(openUserIds) { const need = []; for (const id of openUserIds) { if (!id) continue; if (this._openMap.has(id)) continue; if (this._openInvalid.has(id)) continue; need.push(id); } if (need.length === 0) return this._openMap; const body = { open_userid_list: need, source_agentid: this.sourceAgentId, }; // 只使用 POST;GET 带 body 在部分客户端/网关下会被丢弃 const req1 = { method: "POST", url: "https://qyapi.weixin.qq.com/cgi-bin/batch/openuserid_to_userid", data: body, headers: { "Content-Type": "application/json" }, }; let res = await this._wecomRequest(req1); // 某些环境下对象 body 可能被错误处理,再兜底用字符串 JSON 发送一次 if (res && res.errcode === 40058) { const req2 = { method: "POST", url: "https://qyapi.weixin.qq.com/cgi-bin/batch/openuserid_to_userid", data: JSON.stringify(body), headers: { "Content-Type": "application/json" }, }; res = await this._wecomRequest(req2); } if (!res || res.errcode !== 0) { const hint = res && res.errcode === 40058 ? `(请求体应包含 open_userid_list 数组;本次 need.length=${need.length},need[0]=${need[0] || ""})` : ""; throw new Error( this._formatWecomError({ action: "openuserid_to_userid", request: { ...req1, data: body }, response: res, }) + hint ); } this._applyOpenConvertResponse(need, res); const invalid = Array.isArray(res.invalid_open_userid_list) ? res.invalid_open_userid_list : []; for (const id of invalid) this._openInvalid.add(id); if (invalid.length === need.length && (!res.userid_list || res.userid_list.length === 0)) { console.log( `[wecom] openuserid_to_userid: 所有 open_userid 均不可转换(invalid=${invalid.length})。通常表示这些值并非第三方 open_userid(可能已经是 userid),或 source_agentid 不匹配。` ); } return this._openMap; } _applyOpenConvertResponse(requestOpenUserIds, res) { const list = res.userid_list || []; if (Array.isArray(list) && list.length > 0 && typeof list[0] === "object") { for (const item of list) { if (!item) continue; const openId = item.open_userid; const userId = item.userid; if (openId && userId) this._openMap.set(openId, userId); } return; } if (Array.isArray(list) && list.length === requestOpenUserIds.length) { for (let i = 0; i < requestOpenUserIds.length; i++) { const openId = requestOpenUserIds[i]; const userId = list[i]; if (openId && userId) this._openMap.set(openId, userId); } } } async convertServiceExternalUserId(externalUserId) { if (!externalUserId) return null; if (this._externalMap.has(externalUserId)) return this._externalMap.get(externalUserId); if (this._externalFail.has(externalUserId)) return null; if (this._externalInvalid.has(externalUserId)) return null; const body = { external_userid: externalUserId, source_agentid: this.sourceAgentId }; try { const req1 = { method: "POST", url: "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/from_service_external_userid", data: body, headers: { "Content-Type": "application/json" }, }; let res = await this._wecomRequest(req1); if (res && res.errcode === 0) { this._externalMap.set(externalUserId, res.external_userid || externalUserId); return this._externalMap.get(externalUserId); } // 这里不再使用 GET(部分环境会丢 body) // 60020: IP not allow;这种情况继续跑没有意义,直接中止 if (res && res.errcode === 60020) { throw new Error( `WeCom API IP 限制(errcode=60020)。请在企业微信后台/白名单允许的服务器上运行该脚本。原始信息: ${res.errmsg}` ); } // 40096: invalid external userid // 常见原因:传入的 external_userid 已经是“企业 external_userid”(无需转换),或不是该 source_agentid 下产生的“服务商 external_userid” // 该情况不应中断整体处理:保留原值,记录为 invalid 并跳过后续重试 if (res && res.errcode === 40096) { this._externalInvalid.add(externalUserId); this._externalFail.set(externalUserId, JSON.stringify(res)); console.log( `[wecom] from_service_external_userid: external_userid 不可转换(errcode=40096)。将保留原值并跳过重试。external_userid=${externalUserId}` ); return null; } // 60011: no privilege // 常见原因:当前应用缺少外部联系人相关权限或可见范围不足 // 该情况按“单条失败可跳过”处理,避免整批中断 if (res && res.errcode === 60011) { this._externalFail.set(externalUserId, JSON.stringify(res)); console.log( `[wecom] from_service_external_userid: 权限不足(errcode=60011)。将跳过该 external_userid 并继续。external_userid=${externalUserId}` ); return null; } const msg = this._formatWecomError({ action: "from_service_external_userid", request: { ...req1, data: { ...body, external_userid: externalUserId } }, response: res, }); this._externalFail.set(externalUserId, JSON.stringify(res)); throw new Error(msg); } catch (e) { const msg = e?.response?.data ? JSON.stringify(e.response.data) : e?.message || String(e); this._externalFail.set(externalUserId, msg); throw e; } } async getAgentInfo(agentId = this.sourceAgentId) { if (!agentId) throw new Error("getAgentInfo: missing agentId"); const req = { method: "GET", url: "https://qyapi.weixin.qq.com/cgi-bin/agent/get", params: { agentid: Number(agentId) }, }; return this._wecomRequest(req); } async listUserIds({ limit = 1000, cursor = "" } = {}) { // 文档:获取成员ID列表(path=96067),用于验证通讯录可见范围 const body = {}; if (cursor) body.cursor = cursor; if (limit) body.limit = Number(limit); const req = { method: "POST", url: "https://qyapi.weixin.qq.com/cgi-bin/user/list_id", data: body, headers: { "Content-Type": "application/json" }, }; return this._wecomRequest(req); } async simpleListUsers({ departmentId = 1, fetchChild = 1 } = {}) { // 兜底接口:/cgi-bin/user/simplelist(按部门拉取) const req = { method: "GET", url: "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist", params: { department_id: Number(departmentId), fetch_child: Number(fetchChild) }, }; return this._wecomRequest(req); } } function isPlainObject(v) { if (!v || typeof v !== "object") return false; if (Array.isArray(v)) return false; const tag = Object.prototype.toString.call(v); return tag === "[object Object]"; } const OPEN_USERID_LIKE = /^wo[a-zA-Z0-9_-]{10,}$/; const EXTERNAL_USERID_LIKE = /^[wm][a-zA-Z0-9_-]{10,}$/; function buildUrlWithQuery(baseUrl, query) { try { const u = new URL(baseUrl); const q = query || {}; for (const [k, v] of Object.entries(q)) { if (v === undefined || v === null || v === "") continue; u.searchParams.set(k, String(v)); } return u.toString(); } catch { return baseUrl; } } function shouldTreatUseridAsOpen({ key, collectionName, useridAsOpenCollections }) { const k = String(key || "").toLowerCase(); if (k !== "userid") return false; const collection = String(collectionName || "").trim(); if (!collection) return false; return Array.isArray(useridAsOpenCollections) && useridAsOpenCollections.includes(collection); } function classifyKey(key, { collectionName, useridAsOpenCollections } = {}) { const k = String(key || "").toLowerCase(); if (!k) return "unknown"; if (k.includes("external_userid") || k.includes("externaluserid") || k === "external_userid") return "external"; if (k.includes("open_userid") || k.includes("openuserid")) return "open"; if (shouldTreatUseridAsOpen({ key, collectionName, useridAsOpenCollections })) return "open"; if (k === "userid" || k.endsWith("userid") || k.endsWith("userids")) return "skip"; return "unknown"; } function normalizeKeyName(key) { return String(key || "") .trim() .toLowerCase() .replace(/[_\-\s]/g, ""); } function isCorpFieldKey(key, corpIdFields) { const normalized = normalizeKeyName(key); if (!normalized) return false; return (corpIdFields || []).some((field) => normalizeKeyName(field) === normalized); } function collectCandidates(doc, { aggressive, collectionName, useridAsOpenCollections }) { const openUserIds = new Set(); const externalUserIds = new Set(); function walk(value, keyForContext) { if (value == null) return; if (typeof value === "string") { const kind = classifyKey(keyForContext, { collectionName, useridAsOpenCollections, }); if (kind === "open") { if (OPEN_USERID_LIKE.test(value)) openUserIds.add(value); } else if (kind === "external") { if (EXTERNAL_USERID_LIKE.test(value) || OPEN_USERID_LIKE.test(value)) externalUserIds.add(value); } else if (kind === "skip") { return; } else if (aggressive) { // 兜底:遇到疑似 id 的字符串,先尝试按 open_userid 处理;失败的会在后续按 external_userid 处理(由调用方决定) if (OPEN_USERID_LIKE.test(value)) openUserIds.add(value); else if (EXTERNAL_USERID_LIKE.test(value)) externalUserIds.add(value); } return; } if (Array.isArray(value)) { for (const item of value) walk(item, keyForContext); return; } if (isPlainObject(value)) { for (const [k, v] of Object.entries(value)) walk(v, k); } } walk(doc, ""); return { openUserIds, externalUserIds }; } function applyConvertInPlace(doc, { openMap, externalMap, aggressive, replacementCorpId, corpIdFields, collectionName, useridAsOpenCollections }) { let openReplaced = 0; let externalReplaced = 0; let corpReplaced = 0; let wecomMetaRemoved = 0; function stripWecomMeta(node) { if (node == null) return; if (Array.isArray(node)) { for (const item of node) stripWecomMeta(item); return; } if (!isPlainObject(node)) return; if (Object.prototype.hasOwnProperty.call(node, "errcode")) { delete node.errcode; wecomMetaRemoved++; } if (Object.prototype.hasOwnProperty.call(node, "errmsg")) { delete node.errmsg; wecomMetaRemoved++; } for (const v of Object.values(node)) stripWecomMeta(v); } stripWecomMeta(doc); function walk(value, keyForContext, parent, parentKey) { if (value == null) return; if (typeof value === "string") { if (isCorpFieldKey(keyForContext, corpIdFields) && isNonEmptyString(replacementCorpId) && value !== replacementCorpId) { parent[parentKey] = replacementCorpId; corpReplaced++; return; } const kind = classifyKey(keyForContext, { collectionName, useridAsOpenCollections, }); if (kind === "open") { if (openMap.has(value)) { const next = openMap.get(value); if (next !== value) { parent[parentKey] = next; openReplaced++; } } } else if (kind === "external") { if (externalMap.has(value)) { const next = externalMap.get(value); if (next !== value) { parent[parentKey] = next; externalReplaced++; } } } else if (kind === "skip") { return; } else if (aggressive) { if (openMap.has(value)) { const next = openMap.get(value); if (next !== value) { parent[parentKey] = next; openReplaced++; } } else if (externalMap.has(value)) { const next = externalMap.get(value); if (next !== value) { parent[parentKey] = next; externalReplaced++; } } } return; } if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const item = value[i]; if (typeof item === "string") { if (isCorpFieldKey(keyForContext, corpIdFields) && isNonEmptyString(replacementCorpId) && item !== replacementCorpId) { value[i] = replacementCorpId; corpReplaced++; continue; } const kind = classifyKey(keyForContext, { collectionName, useridAsOpenCollections, }); if (kind === "open") { if (openMap.has(item)) { const next = openMap.get(item); if (next !== item) { value[i] = next; openReplaced++; } } } else if (kind === "external") { if (externalMap.has(item)) { const next = externalMap.get(item); if (next !== item) { value[i] = next; externalReplaced++; } } } else if (kind === "skip") { continue; } else if (aggressive) { if (openMap.has(item)) { const next = openMap.get(item); if (next !== item) { value[i] = next; openReplaced++; } } else if (externalMap.has(item)) { const next = externalMap.get(item); if (next !== item) { value[i] = next; externalReplaced++; } } } } else { walk(item, keyForContext, value, i); } } return; } if (isPlainObject(value)) { for (const [k, v] of Object.entries(value)) walk(v, k, value, k); } } if (isPlainObject(doc)) { for (const [k, v] of Object.entries(doc)) walk(v, k, doc, k); } return { openReplaced, externalReplaced, corpReplaced, wecomMetaRemoved }; } async function inferSourceAgentId({ db, targetCorpId }) { const matchCorpIds = Array.from(new Set((Array.isArray(targetCorpId) ? targetCorpId : [targetCorpId]).filter(Boolean))); const corpDoc = await db .collection("corp") .findOne({ $or: [ { corpId: { $in: matchCorpIds } }, { corpid: { $in: matchCorpIds } }, { corp_id: { $in: matchCorpIds } }, { "auth_corp_info.corpid": { $in: matchCorpIds } }, ], }); const agents = corpDoc?.auth_info?.agent; if (Array.isArray(agents) && agents.length > 0 && agents[0]?.agentid) { return Number(agents[0].agentid); } return null; } async function getThirdPartyAgents({ db, targetCorpId }) { const matchCorpIds = Array.from(new Set((Array.isArray(targetCorpId) ? targetCorpId : [targetCorpId]).filter(Boolean))); const corpDoc = await db .collection("corp") .findOne({ $or: [ { corpId: { $in: matchCorpIds } }, { corpid: { $in: matchCorpIds } }, { corp_id: { $in: matchCorpIds } }, { "auth_corp_info.corpid": { $in: matchCorpIds } }, ], }); const agents = corpDoc?.auth_info?.agent; if (!Array.isArray(agents)) return []; return agents .filter(Boolean) .map((a) => ({ agentid: a.agentid, name: a.name, auth_mode: a.auth_mode, is_customized_app: a.is_customized_app })) .filter((a) => Number.isFinite(Number(a.agentid))); } function normalizeTokenMode(mode) { const m = String(mode || "").trim(); if (!m) return "corpSecret"; if (m === "corpSecret" || m === "corpsecret") return "corpSecret"; if (m === "suite" || m === "suiteToken" || m === "suitetoken") return "suite"; return m; } async function getSuiteTokenFromDb(db) { const doc = await db.collection("weComToken").findOne({ type: "suiteToken" }); return doc?.suite_token || ""; } async function getPermanentCodeFromDb({ db, targetCorpId }) { const matchCorpIds = Array.from(new Set((Array.isArray(targetCorpId) ? targetCorpId : [targetCorpId]).filter(Boolean))); const corpDoc = await db .collection("corp") .findOne( { $or: [ { corpId: { $in: matchCorpIds } }, { corpid: { $in: matchCorpIds } }, { corp_id: { $in: matchCorpIds } }, { "auth_corp_info.corpid": { $in: matchCorpIds } }, ], }, { projection: { permanent_code: 1, corpId: 1, auth_corp_info: 1 } } ); return corpDoc?.permanent_code || ""; } async function getCorpTokenBySuite({ suiteAccessToken, authCorpId, permanentCode, timeoutMs = 20000 }) { const url = `https://qyapi.weixin.qq.com/cgi-bin/service/get_corp_token`; const resp = await axios.request({ method: "POST", url, params: { suite_access_token: suiteAccessToken }, data: { auth_corpid: authCorpId, permanent_code: permanentCode }, headers: { "Content-Type": "application/json" }, timeout: timeoutMs, validateStatus: () => true, }); const body = resp.data; if (!body || body.errcode !== 0) { throw new Error(`get_corp_token failed: ${JSON.stringify(body)}`); } return body; } async function runDirectIdConvert({ converter, openUserIds, externalUserIds }) { const out = { open_userid: [], external_userid: [] }; const openIds = Array.from(new Set((openUserIds || []).filter(Boolean))); if (openIds.length > 0) { await converter.convertOpenUserIds(openIds); out.open_userid = openIds.map((id) => ({ open_userid: id, userid: converter._openMap.get(id) || null, status: converter._openMap.has(id) ? "ok" : converter._openInvalid.has(id) ? "invalid" : "unknown", })); } const extIds = Array.from(new Set((externalUserIds || []).filter(Boolean))); if (extIds.length > 0) { for (const id of extIds) { try { await converter.convertServiceExternalUserId(id); } catch (_) { // 错误信息已由 wecomDebug 输出;这里继续处理下一个 } } out.external_userid = extIds.map((id) => ({ service_external_userid: id, external_userid: converter._externalMap.get(id) || null, status: converter._externalMap.has(id) ? "ok" : converter._externalInvalid.has(id) ? "invalid" : converter._externalFail.has(id) ? "error" : "unknown", })); } console.log("\nDirect convert result:"); console.log(JSON.stringify(out, null, 2)); } function extractUserIdsFromListIdResponse(res) { const ids = []; if (!res || typeof res !== "object") return { ids, nextCursor: "" }; // 常见返回:{ dept_user: [{ userid, department: [...] }, ...], next_cursor: "..." } if (Array.isArray(res.dept_user)) { for (const item of res.dept_user) { if (!item) continue; if (typeof item === "string") ids.push(item); else if (typeof item === "object" && item.userid) ids.push(String(item.userid)); } } // 兼容其他可能字段 if (Array.isArray(res.userlist)) { for (const item of res.userlist) { if (!item) continue; if (typeof item === "string") ids.push(item); else if (typeof item === "object" && item.userid) ids.push(String(item.userid)); } } if (Array.isArray(res.userid_list)) { for (const item of res.userid_list) { if (!item) continue; if (typeof item === "string") ids.push(item); else if (typeof item === "object" && item.userid) ids.push(String(item.userid)); } } const nextCursor = res.next_cursor || res.nextCursor || res.cursor || ""; return { ids, nextCursor: String(nextCursor || "") }; } async function dumpWeComScope({ converter, scopeLimit, checkUserIds }) { console.log("\n[scope] 开始检查通讯录可见范围(agent/get + user/list_id)..."); // 1) agent/get:应用信息 + 可见范围配置(allow_user/allow_party/allow_tag) try { const agent = await converter.getAgentInfo(); if (agent && agent.errcode === 0) { const allowUser = agent.allow_user?.user || []; const allowParty = agent.allow_party?.partyid || []; const allowTag = agent.allow_tag?.tagid || []; console.log( `[scope] agent/get ok: agentid=${converter.sourceAgentId} name=${agent.name || ""} allow_user=${allowUser.length} allow_party=${allowParty.length} allow_tag=${allowTag.length}` ); } else { console.log(`[scope] agent/get not ok: ${JSON.stringify(agent)}`); } } catch (e) { console.log(`[scope] agent/get error: ${e?.message || e}`); } // 2) user/list_id:按游标拉取“当前 token 能看到的成员 userid 列表” const max = Number.isFinite(scopeLimit) && scopeLimit >= 0 ? scopeLimit : 2000; const perPage = 1000; let cursor = ""; const set = new Set(); let pages = 0; while (true) { pages++; if (pages > 200) { console.log("[scope] user/list_id 页数过多,已停止(防止无限循环)。如需全量请显式设置更小 scopeLimit 或检查返回的 next_cursor。"); break; } const res = await converter.listUserIds({ limit: perPage, cursor }); if (!res || res.errcode !== 0) { console.log(`[scope] user/list_id not ok: ${JSON.stringify(res)}`); console.log("[scope] 将尝试兜底接口 user/simplelist(按 department_id=1, fetch_child=1)..."); try { const simple = await converter.simpleListUsers({ departmentId: 1, fetchChild: 1 }); if (simple && simple.errcode === 0) { const { ids } = extractUserIdsFromListIdResponse(simple); for (const id of ids) { if (!id) continue; set.add(id); if (max > 0 && set.size >= max) break; } console.log(`[scope] user/simplelist ok: fetched=${set.size}${max > 0 && set.size >= max ? "(已到 scopeLimit)" : ""}`); } else { console.log(`[scope] user/simplelist not ok: ${JSON.stringify(simple)}`); } } catch (e) { console.log(`[scope] user/simplelist error: ${e?.message || e}`); } break; } const { ids, nextCursor } = extractUserIdsFromListIdResponse(res); for (const id of ids) { if (!id) continue; set.add(id); if (max > 0 && set.size >= max) break; } cursor = nextCursor || ""; if ((max > 0 && set.size >= max) || !cursor) break; } const all = Array.from(set); const sample = all.slice(0, 20); console.log(`[scope] user/list_id ok: fetched=${all.length}${max > 0 && all.length >= max ? "(已到 scopeLimit)" : ""}`); if (sample.length > 0) console.log(`[scope] sample userids: ${sample.join(", ")}`); const checks = Array.from(new Set((checkUserIds || []).filter(Boolean))); if (checks.length > 0) { const visible = []; const notVisible = []; const s = new Set(all); for (const id of checks) (s.has(id) ? visible : notVisible).push(id); console.log(`[scope] checkUserIds visible=${visible.length} notVisible=${notVisible.length}`); if (notVisible.length > 0) console.log(`[scope] notVisible: ${notVisible.join(", ")}`); } console.log( "[scope] 说明:这里拿到的 userid 列表,是“当前 access_token 对应的应用,在通讯录侧可见的成员集合”。如果这个集合很小,open_userid -> userid 很可能大面积 invalid。" ); } async function listCollections(db) { const cols = await db.listCollections().toArray(); return cols.map((c) => c.name); } async function pickCorpFilterField(collection, corpIdFields, matchCorpIds) { const corpIds = Array.from(new Set((Array.isArray(matchCorpIds) ? matchCorpIds : [matchCorpIds]).filter(Boolean))); for (const f of corpIdFields) { if (corpIds.length === 0) continue; const doc = await collection.findOne({ [f]: { $in: corpIds } }, { projection: { _id: 1 } }); if (doc) return f; } return null; } function createLimiter(maxConcurrency) { const max = safeNumber(maxConcurrency, 8); let active = 0; const queue = []; const runNext = () => { if (active >= max) return; const item = queue.shift(); if (!item) return; active++; Promise.resolve() .then(item.fn) .then((v) => item.resolve(v), (e) => item.reject(e)) .finally(() => { active--; runNext(); }); }; return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); runNext(); }); } async function cloneIndexes({ fromCol, toCol }) { const indexes = await fromCol.indexes(); const existing = await toCol.indexes().catch(() => []); const existingNames = new Set(existing.map((i) => i?.name).filter(Boolean)); for (const idx of indexes) { if (!idx || idx.name === "_id_") continue; if (existingNames.has(idx.name)) continue; const { key, name, unique, sparse, expireAfterSeconds, partialFilterExpression, collation } = idx; const options = { name }; if (unique) options.unique = true; if (sparse) options.sparse = true; if (typeof expireAfterSeconds === "number") options.expireAfterSeconds = expireAfterSeconds; if (partialFilterExpression) options.partialFilterExpression = partialFilterExpression; if (collation) options.collation = collation; try { await toCol.createIndex(key, options); } catch (e) { // 允许重复运行时索引已存在/冲突的情况:不中断主流程 console.log(`[index] skip createIndex name=${name}: ${e?.message || e}`); } } } async function main() { const opts = parseArgs(process.argv); applyEnvFallbacks(opts); initFileLogger(opts); if (opts.help) { console.log(`convert-wecom-ids-gk README: scripts/convert-wecom-ids-gk.README.md 必填: --dbName 单个数据库名(例如:corp) --targetCorpId 目标机构 corpId --mongoUri MongoDB 连接(或用 --mongoHost/--mongoUser/--mongoPass) --mongoUrl 同 --mongoUri(兼容别名) 可选: --collections a,b,c 指定要处理的 collection(例如:corp-member,wechat-friends) --matchCorpIds a,b,c 指定额外用于匹配文档/机构信息的 corpId(默认会自动包含 targetCorpId 和 tokenCorpId) --useridAsOpenCollections a,b 将指定 collection 中的 userid 视为 open_userid 进行转换 --preset 使用预设(见 --listPresets) --listPresets 列出可用预设 --openUserIds a,b,c 指定要转换的 open_userid(不跑数据库) --externalUserIds a,b,c 指定要转换的 external_userid(不跑数据库) --dumpScope 调用 agent/get + user/list_id 打印“可见成员范围”信息 --scopeOnly 仅打印范围信息后退出(不跑转换) --scopeLimit 最多拉取 n 个 userid(dumpScope 用;0 表示不限制,谨慎) --checkUserIds a,b,c 检查这些 userid 是否在可见成员列表中(dumpScope 用) --reportZh / --zh 额外输出中文统计摘要 --wecomDebug 输出企微接口请求/响应(包含完整 response) --logFile 导出完整运行日志到文件(默认 logs/transfer-时间.log) --limitDocs 每个 collection 最多处理 n 条(用于只跑部分数据) --skipDocs 每个 collection 跳过前 n 条(配合 limit 分段跑) --noSortById 不按 _id 排序(默认按 _id 升序,便于分段稳定) --insertOnly 已不再支持(apply 模式固定按 _id upsert 回写原表) 企微 token 获取: --tokenMode suite 使用 Mongo 中 suite_token + permanent_code 获取 corp token(推荐第三方/代开发场景) --tokenMode corpSecret 使用 gettoken(默认;自建应用场景) --tokenCorpId tokenMode=corpSecret 时必填 --secret/--secretEnv tokenMode=corpSecret 时必填 --suiteAccessToken/--suiteAccessTokenEnv tokenMode=suite 时可直接指定 suite_access_token(不依赖 Mongo) --permanentCode/--permanentCodeEnv tokenMode=suite 时可直接指定 permanent_code(不依赖 Mongo) 写入(危险操作,需要确认): --apply --yes 按 _id 回写原 collection(危险操作) 仅扫描: --skipApi 不调企微接口(仅 dry-run) 企微转换(--skipApi 关闭时需要): --tokenCorpId gettoken 用的 corpId --secret corpsecret(或 --secretEnv ) --sourceAgentId source_agentid `); return; } if (opts.listPresets) { console.log("可用预设:"); for (const [name, cfg] of Object.entries(PRESETS)) { const safeCfg = { ...cfg }; // 避免打印敏感字段(预设本身也不应包含) delete safeCfg.secret; console.log(`- ${name}: ${JSON.stringify(safeCfg)}`); } console.log("\n用法示例:"); console.log(" node transfer.js --apply --yes"); return; } opts.batchDocs = safeNumber(opts.batchDocs, 200); opts.batchOpenIds = safeNumber(opts.batchOpenIds, 100); opts.concurrencyExternal = safeNumber(opts.concurrencyExternal, 8); 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; opts.scopeLimit = Number.isFinite(opts.scopeLimit) && opts.scopeLimit >= 0 ? Math.floor(opts.scopeLimit) : 2000; if (opts.scopeOnly) opts.dumpScope = true; if (!opts.targetCorpId) die("缺少 --targetCorpId"); opts.tokenMode = normalizeTokenMode(opts.tokenMode); if (!opts.skipApi && opts.tokenMode !== "suite" && !opts.tokenCorpId) { die("缺少 --tokenCorpId(用于获取 access_token)"); } if (!isNonEmptyString(opts.dbName)) die("缺少 --dbName(用于指定单个数据库名)"); if (String(opts.dbName).includes(",")) die("--dbName 仅支持单个数据库名,请不要传逗号分隔"); const secret = opts.skipApi || opts.tokenMode === "suite" ? null : opts.secret || (opts.secretEnv ? process.env[opts.secretEnv] : null); if (!opts.skipApi && opts.tokenMode !== "suite" && !secret) { die("缺少 secret:请传 --secret 或 --secretEnv "); } if (!opts.dryRun && !opts.yes) { die("该脚本会按 _id 直接回写原 collection。请确认后加上 --yes 再执行。"); } if (!opts.dryRun && opts.skipApi) { die("--skipApi 只能用于 dry-run(apply 模式需要调用企业微信接口进行转换)"); } if (!opts.dryRun && opts.insertOnly) { die("--insertOnly 已不支持:apply 模式现在固定按 _id upsert 回写原表。"); } if (opts.dumpScope && opts.skipApi) { die("--dumpScope/--scopeOnly 需要调用企微接口,请不要加 --skipApi"); } const directMode = Array.isArray(opts.openUserIds) && opts.openUserIds.length > 0 || Array.isArray(opts.externalUserIds) && opts.externalUserIds.length > 0; let mongoUrl = opts.mongoUri; if (!isNonEmptyString(mongoUrl) && isNonEmptyString(opts.mongoUriEnv) && isNonEmptyString(process.env[opts.mongoUriEnv])) { mongoUrl = process.env[opts.mongoUriEnv]; } if (directMode && opts.tokenMode !== "suite") { // 仅做企微转换且不使用 suite token 时,可不提供 Mongo(不跑数据库) if (!isNonEmptyString(mongoUrl)) { mongoUrl = null; } } if (!isNonEmptyString(mongoUrl)) { if (directMode && opts.tokenMode !== "suite") { // no mongo needed } else { 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) { die( "缺少 MongoDB 连接信息:请传 --mongoUri,或传 --mongoHost/--mongoUser/--mongoPass(可选 --mongoPort/--mongoAuthDb)" ); } mongoUrl = `mongodb://${encodeURIComponent(username)}:${encodeURIComponent( password )}@${host}:${port}/${authDb}`; } } const client = mongoUrl ? new MongoClient(mongoUrl, { serverSelectionTimeoutMS: 30000, connectTimeoutMS: 30000, }) : null; if (client) { console.log(`[step] 正在连接 MongoDB...`); await client.connect(); console.log(`[step] MongoDB 已连接`); } try { const dbName = opts.dbName; const db = client ? client.db(dbName) : null; const replacementCorpId = opts.tokenCorpId || opts.targetCorpId; const effectiveMatchCorpIds = Array.from( new Set([opts.targetCorpId, replacementCorpId, ...(opts.matchCorpIds || [])].filter(Boolean)) ); console.log(`[step] corpId 匹配集合: ${effectiveMatchCorpIds.join(", ")}`); console.log(`[step] 正在列举 collection...`); const names = db ? await listCollections(db) : []; console.log(`[step] collection 列表(共 ${names.length} 个): ${names.join(", ") || "(空)"}`); const selected = opts.collections?.length ? opts.collections : names; console.log( `[step] 本次执行范围: ${opts.collections?.length ? `仅指定 collection -> ${selected.join(", ")}` : "未指定 --collections,将遍历该库全部 collection"}` ); let sourceAgentId = opts.sourceAgentId; if (!sourceAgentId && !opts.skipApi && db) { console.log(`[step] 正在从 DB 推断 sourceAgentId...`); const inferred = await inferSourceAgentId({ db, targetCorpId: effectiveMatchCorpIds }); if (inferred) sourceAgentId = inferred; console.log(`[step] sourceAgentId 推断结果: ${inferred ?? "未找到"}`); } if (!opts.skipApi && (!sourceAgentId || !Number.isFinite(sourceAgentId))) { die("缺少 source_agentid:请传 --sourceAgentId(或确保 corp collection 中能推断 auth_info.agent[0].agentid)"); } if (!opts.skipApi && db) { console.log(`[step] 正在查询第三方/代开发应用列表...`); const agents = await getThirdPartyAgents({ db, targetCorpId: effectiveMatchCorpIds }); console.log(`[step] 应用列表: ${agents.map((a) => `${a.agentid}(${a.name || ""})`).join("、") || "(空)"}`); if (agents.length > 0) { const agentIds = agents.map((a) => Number(a.agentid)); if (opts.sourceAgentId && !agentIds.includes(Number(opts.sourceAgentId))) { console.log( `[db=${dbName}] 注意:你传入的 --sourceAgentId=${opts.sourceAgentId} 不在该机构的第三方/代开发应用列表中:` + agents.map((a) => `${a.agentid}${a.name ? `(${a.name})` : ""}`).join("、") + `。如果报 errcode=846000,请改用列表中的 agentid,或不传 --sourceAgentId 让脚本自动推断。` ); } } } let getAccessToken = null; if (!opts.skipApi && opts.tokenMode === "suite") { console.log(`[step] tokenMode=suite,正在获取 suiteAccessToken...`); const suiteAccessToken = opts.suiteAccessToken || (opts.suiteAccessTokenEnv ? process.env[opts.suiteAccessTokenEnv] : "") || (db ? await getSuiteTokenFromDb(db) : ""); if (!suiteAccessToken) { die( `tokenMode=suite 需要 suite_access_token。可通过 --suiteAccessToken/--suiteAccessTokenEnv 指定,或提供 Mongo 让脚本从 corp.weComToken(type=suiteToken) 读取。` ); } console.log(`[step] suiteAccessToken 已获取(长度=${suiteAccessToken?.length ?? 0}),正在获取 permanentCode...`); const permanentCode = opts.permanentCode || (opts.permanentCodeEnv ? process.env[opts.permanentCodeEnv] : "") || (db ? await getPermanentCodeFromDb({ db, targetCorpId: effectiveMatchCorpIds }) : ""); if (!permanentCode) { die( `tokenMode=suite 需要 permanent_code。可通过 --permanentCode/--permanentCodeEnv 指定,或提供 Mongo 让脚本从 corp.corp 读取。` ); } console.log(`[step] permanentCode 已获取,suite token 准备就绪`); let cachedCorpToken = null; let cachedExpireAt = 0; getAccessToken = async () => { const now = Date.now(); if (cachedCorpToken && now < cachedExpireAt - 60_000) { return { access_token: cachedCorpToken, expires_in: Math.floor((cachedExpireAt - now) / 1000) }; } console.log(`[step] 正在调用 get_corp_token 获取授权企业 access_token...`); const res = await getCorpTokenBySuite({ suiteAccessToken, authCorpId: opts.targetCorpId, permanentCode, }); cachedCorpToken = res.access_token; const expiresInSec = res.expires_in || 7200; cachedExpireAt = now + expiresInSec * 1000; console.log(`[step] get_corp_token 成功,expires_in=${expiresInSec}s`); return { access_token: cachedCorpToken, expires_in: expiresInSec }; }; } const converter = opts.skipApi ? null : new WeComIdConverter({ tokenCorpId: opts.tokenCorpId, secret, sourceAgentId, debug: opts.wecomDebug, getAccessToken, }); console.log( `[db=${dbName}] targetCorpId=${opts.targetCorpId} matchCorpIds=${effectiveMatchCorpIds.join("|")} mode=${opts.dryRun ? "dry-run" : "apply"}${opts.skipApi ? " skipApi=true" : ` tokenMode=${opts.tokenMode} sourceAgentId=${sourceAgentId}`}` ); if (opts.dumpScope) { if (!converter) die("--dumpScope 需要调用企微接口(请不要加 --skipApi)"); await dumpWeComScope({ converter, scopeLimit: opts.scopeLimit, checkUserIds: opts.checkUserIds }); if (opts.scopeOnly) return; } if (directMode) { if (!converter) die("direct convert 模式需要调用企微接口,请不要加 --skipApi"); await runDirectIdConvert({ converter, openUserIds: opts.openUserIds, externalUserIds: opts.externalUserIds, }); return; } if (!db) { die("未提供 MongoDB 连接,无法扫描/写入 collection。若只想转换指定 ID,请使用 --openUserIds / --externalUserIds。"); } if (selected.length === 0) { console.log(`[db=${dbName}] 没有找到任何 collection(或无权限列出 collection)。`); return; } let scannedCollections = 0; let matchedCollections = 0; let missingCollections = 0; let processedTotal = 0; let openUniqueTotal = 0; let externalUniqueTotal = 0; let openReplacedTotal = 0; let externalReplacedTotal = 0; let externalErrorTotal = 0; let corpReplacedTotal = 0; let wecomMetaRemovedTotal = 0; const matchedStats = []; for (const colName of selected) { if (!opts.collections?.length && colName.endsWith("-gk")) continue; scannedCollections++; if (opts.collections?.length && !names.includes(colName)) { missingCollections++; console.log(`[${dbName}.${colName}] collection 不存在(或无权限看到),将跳过。`); continue; } const fromCol = db.collection(colName); console.log(`[step] [${colName}] 正在查找 corpId 字段(候选: ${opts.corpIdFields.join(", ")})...`); const corpField = await pickCorpFilterField(fromCol, opts.corpIdFields, effectiveMatchCorpIds); if (!corpField) { console.log(`[step] [${colName}] 未找到匹配 corpId 字段,跳过`); continue; } console.log(`[step] [${colName}] 命中字段: ${corpField}`); matchedCollections++; const toName = colName; const toCol = fromCol; if (!opts.dryRun) { const existingCount = await toCol.estimatedDocumentCount().catch(() => null); console.log( `[${dbName}.${toName}] 将直接回写原 collection${existingCount != null ? `(count≈${existingCount})` : ""},写入方式为按 _id upsert。` ); } const filter = { [corpField]: { $in: effectiveMatchCorpIds } }; let cursor = fromCol.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 processed = 0; let openReplaced = 0; let externalReplaced = 0; let externalError = 0; let corpReplaced = 0; let wecomMetaRemoved = 0; let openUnique = 0; let externalUnique = 0; const limiter = createLimiter(opts.concurrencyExternal); let batchIndex = 0; console.log(`[step] [${colName}] 开始遍历文档(batchDocs=${opts.batchDocs} limitDocs=${opts.limitDocs || "无限制"} skipDocs=${opts.skipDocs})...`); 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] [${colName}] batch#${batchIndex} 读取文档 ${batch.length} 条(已处理 ${processed} 条)`); const openSet = new Set(); const externalSet = new Set(); for (const doc of batch) { const { openUserIds, externalUserIds } = collectCandidates(doc, { aggressive: opts.aggressive, collectionName: colName, useridAsOpenCollections: opts.useridAsOpenCollections, }); for (const x of openUserIds) openSet.add(x); for (const x of externalUserIds) externalSet.add(x); } openUnique += openSet.size; externalUnique += externalSet.size; if (converter) { // open_userid -> userid(批量) const openNeed = Array.from(openSet); console.log(`[step] [${colName}] batch#${batchIndex} 转换 open_userid: 候选 ${openNeed.length} 个`); for (let i = 0; i < openNeed.length; i += opts.batchOpenIds) { const chunk = openNeed.slice(i, i + opts.batchOpenIds); if (chunk.length === 0) continue; console.log(`[step] [${colName}] 调用 openuserid_to_userid chunk ${i / opts.batchOpenIds + 1}(${chunk.length} 个)...`); await converter.convertOpenUserIds(chunk); console.log(`[step] [${colName}] openuserid_to_userid 返回`); } // aggressive 兜底:对未能按 open_userid 转换成功的候选值,再尝试按 external_userid 转换 if (opts.aggressive) { const openMapNow = converter._openMap; for (const id of openSet) { if (!openMapNow.has(id) && OPEN_USERID_LIKE.test(id)) externalSet.add(id); } } // service external_userid -> corp external_userid(单个) const externalNeed = Array.from(externalSet); console.log(`[step] [${colName}] batch#${batchIndex} 转换 external_userid: 候选 ${externalNeed.length} 个`); await Promise.all( externalNeed.map((id) => limiter(async () => { // 轻微延时,避免瞬时打爆接口 await sleep(10); try { await converter.convertServiceExternalUserId(id); } catch (e) { externalError++; } }) ) ); if (externalError > 0) { console.log( `[step] [${colName}] batch#${batchIndex} external_userid 转换出现 ${externalError} 条错误,已跳过继续。` ); } console.log(`[step] [${colName}] batch#${batchIndex} external_userid 转换完成`); const openMap = converter._openMap; const externalMap = converter._externalMap; for (const doc of batch) { const { openReplaced: o, externalReplaced: ex, corpReplaced: corp, wecomMetaRemoved: meta } = applyConvertInPlace(doc, { openMap, externalMap, aggressive: opts.aggressive, replacementCorpId, corpIdFields: opts.corpIdFields, collectionName: colName, useridAsOpenCollections: opts.useridAsOpenCollections, }); openReplaced += o; externalReplaced += ex; corpReplaced += corp; wecomMetaRemoved += meta; } } else { for (const doc of batch) { const { corpReplaced: corp, wecomMetaRemoved: meta } = applyConvertInPlace(doc, { openMap: new Map(), externalMap: new Map(), aggressive: opts.aggressive, replacementCorpId, corpIdFields: opts.corpIdFields, collectionName: colName, useridAsOpenCollections: opts.useridAsOpenCollections, }); corpReplaced += corp; wecomMetaRemoved += meta; } } if (!opts.dryRun) { console.log(`[step] [${colName}] batch#${batchIndex} 正在写入 ${batch.length} 条到 ${toName}...`); const ops = batch.map((d) => ({ replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true }, })); await toCol.bulkWrite(ops, { ordered: false }); console.log(`[step] [${colName}] batch#${batchIndex} 写入完成`); } processed += batch.length; if (processed % (opts.batchDocs * 10) === 0) { console.log( `[${dbName}.${colName}] processed=${processed} openReplaced=${openReplaced} externalReplaced=${externalReplaced} externalError=${externalError} corpReplaced=${corpReplaced} wecomMetaRemoved=${wecomMetaRemoved} openUnique=${openUnique} externalUnique=${externalUnique}` ); } } console.log( `[${dbName}.${colName}] done. processed=${processed} wrote=${opts.dryRun ? 0 : processed} -> ${toName}(source) openUnique=${openUnique} externalUnique=${externalUnique} openReplaced=${openReplaced} externalReplaced=${externalReplaced} externalError=${externalError} corpReplaced=${corpReplaced} wecomMetaRemoved=${wecomMetaRemoved}` ); processedTotal += processed; openUniqueTotal += openUnique; externalUniqueTotal += externalUnique; openReplacedTotal += openReplaced; externalReplacedTotal += externalReplaced; externalErrorTotal += externalError; corpReplacedTotal += corpReplaced; wecomMetaRemovedTotal += wecomMetaRemoved; matchedStats.push({ colName, processed, openUnique, externalUnique, openReplaced, externalReplaced, externalError, corpReplaced, wecomMetaRemoved, }); } console.log( `[db=${dbName}] summary: scannedCollections=${scannedCollections} matchedCollections=${matchedCollections}${missingCollections ? ` missingCollections=${missingCollections}` : ""}` ); if (matchedCollections === 0) { console.log( `提示:--dbName 是“数据库名”,不是 collection 名。\n例如要处理 corp 数据库里的 corp-member 表,请用:\n --dbName corp --collections corp-member` ); } if (opts.reportZh) { const topProcessed = [...matchedStats] .sort((a, b) => b.processed - a.processed) .slice(0, 5) .map((x) => `${x.colName}(${x.processed})`) .join("、"); const topExternal = [...matchedStats] .sort((a, b) => b.externalUnique - a.externalUnique) .slice(0, 5) .map((x) => `${x.colName}(${x.externalUnique})`) .join("、"); console.log("\n中文统计摘要:"); console.log(`- 数据库:${dbName}`); console.log(`- 目标机构 corpId:${opts.targetCorpId}`); console.log(`- 实际匹配 corpId 集合:${effectiveMatchCorpIds.join("、")}`); console.log(`- 运行模式:${opts.dryRun ? "dry-run(不落库)" : "apply(回写原 collection)"}`); console.log( `- 是否调用企微接口:${opts.skipApi ? "否(--skipApi,仅扫描统计,不做转换)" : "是(会做转换并替换字段值)"}` ); console.log(`- 扫描到的 collection 总数:${scannedCollections}`); console.log(`- 命中 collection 数(存在 corpId/corpid/corp_id 命中匹配集合):${matchedCollections}`); console.log(`- 命中 collection 内处理的文档总数:${processedTotal}`); console.log( `- open_userid/疑似 userid 候选计数:${openUniqueTotal}(注意:按每批累计的“批内去重”数量,属于近似统计,不是全局唯一数)` ); console.log( `- external_userid 候选计数:${externalUniqueTotal}(同上,近似统计)` ); console.log( `- 实际替换(转换成功写回文档)的 open_userid 次数:${openReplacedTotal};external_userid 次数:${externalReplacedTotal};external 转换错误次数:${externalErrorTotal};corpId/corpid/corp_id 次数:${corpReplacedTotal};移除 wecom 元字段(errcode/errmsg)次数:${wecomMetaRemovedTotal}` ); if (matchedCollections > 0) { console.log(`- 文档量最多的前 5 个 collection:${topProcessed || "无"}`); console.log(`- external_userid 候选最多的前 5 个 collection:${topExternal || "无"}`); } console.log(""); } } finally { if (client) await client.close(); } } main().catch((e) => { const msg = e?.response?.data ? JSON.stringify(e.response.data) : e?.message || String(e); console.error(msg); process.exit(1); });