1643 lines
63 KiB
JavaScript
1643 lines
63 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* convert-wecom-ids-gk
|
||
*
|
||
* 目的:
|
||
* - 把第三方/代开发应用获取的密文 `open_userid` 转为明文 `userid`
|
||
* - 把第三方应用的“服务商 external_userid”转为企业 `external_userid`
|
||
* - 仅写入新表(原表名 + `-gk`),原表只读不改
|
||
*
|
||
* 重要说明(为什么会出现大量 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` 才会写入
|
||
* - 写入 `*-gk` 默认采用 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 candidates = [
|
||
path.resolve(process.cwd(), envFileName),
|
||
path.resolve(__dirname, envFileName),
|
||
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,
|
||
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: [],
|
||
};
|
||
}
|
||
|
||
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 === "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 === "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);
|
||
}
|
||
|
||
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 === "--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 === "--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 === "--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.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.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));
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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 classifyKey(key) {
|
||
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 (k === "userid" || k.endsWith("userid") || k.endsWith("userids")) return "open";
|
||
if (k.includes("leader")) return "open";
|
||
return "unknown";
|
||
}
|
||
|
||
function collectCandidates(doc, { aggressive }) {
|
||
const openUserIds = new Set();
|
||
const externalUserIds = new Set();
|
||
|
||
function walk(value, keyForContext) {
|
||
if (value == null) return;
|
||
if (typeof value === "string") {
|
||
const kind = classifyKey(keyForContext);
|
||
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 (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 }) {
|
||
let openReplaced = 0;
|
||
let externalReplaced = 0;
|
||
function walk(value, keyForContext, parent, parentKey) {
|
||
if (value == null) return;
|
||
if (typeof value === "string") {
|
||
const kind = classifyKey(keyForContext);
|
||
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 (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") {
|
||
const kind = classifyKey(keyForContext);
|
||
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 (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 };
|
||
}
|
||
|
||
async function inferSourceAgentId({ db, targetCorpId }) {
|
||
const corpDoc = await db
|
||
.collection("corp")
|
||
.findOne({ $or: [{ corpId: targetCorpId }, { "auth_corp_info.corpid": targetCorpId }] });
|
||
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 corpDoc = await db
|
||
.collection("corp")
|
||
.findOne({ $or: [{ corpId: targetCorpId }, { "auth_corp_info.corpid": targetCorpId }] });
|
||
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 corpDoc = await db
|
||
.collection("corp")
|
||
.findOne(
|
||
{ $or: [{ corpId: targetCorpId }, { "auth_corp_info.corpid": targetCorpId }] },
|
||
{ 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, targetCorpId) {
|
||
for (const f of corpIdFields) {
|
||
const doc = await collection.findOne({ [f]: targetCorpId }, { 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);
|
||
if (opts.help) {
|
||
console.log(`convert-wecom-ids-gk
|
||
|
||
README: scripts/convert-wecom-ids-gk.README.md
|
||
|
||
必填:
|
||
--dbName <db> 单个数据库名(例如:corp)
|
||
--targetCorpId <corpId> 目标机构 corpId
|
||
--mongoUri <uri> MongoDB 连接(或用 --mongoHost/--mongoUser/--mongoPass)
|
||
--mongoUrl <uri> 同 --mongoUri(兼容别名)
|
||
|
||
可选:
|
||
--collections a,b,c 指定要处理的 collection(例如:corp-member,wechat-friends)
|
||
--preset <name> 使用预设(见 --listPresets)
|
||
--listPresets 列出可用预设
|
||
--openUserIds a,b,c 指定要转换的 open_userid(不跑数据库)
|
||
--externalUserIds a,b,c 指定要转换的 external_userid(不跑数据库)
|
||
--dumpScope 调用 agent/get + user/list_id 打印“可见成员范围”信息
|
||
--scopeOnly 仅打印范围信息后退出(不跑转换)
|
||
--scopeLimit <n> 最多拉取 n 个 userid(dumpScope 用;0 表示不限制,谨慎)
|
||
--checkUserIds a,b,c 检查这些 userid 是否在可见成员列表中(dumpScope 用)
|
||
--reportZh / --zh 额外输出中文统计摘要
|
||
--wecomDebug 输出企微接口请求/响应(包含完整 response)
|
||
--limitDocs <n> 每个 collection 最多处理 n 条(用于只跑部分数据)
|
||
--skipDocs <n> 每个 collection 跳过前 n 条(配合 limit 分段跑)
|
||
--noSortById 不按 _id 排序(默认按 _id 升序,便于分段稳定)
|
||
--insertOnly 仅 insertMany(不建议;报错重跑可能重复插入)
|
||
|
||
企微 token 获取:
|
||
--tokenMode suite 使用 Mongo 中 suite_token + permanent_code 获取 corp token(推荐第三方/代开发场景)
|
||
--tokenMode corpSecret 使用 gettoken(默认;自建应用场景)
|
||
--tokenCorpId <corpId> tokenMode=corpSecret 时必填
|
||
--secret/--secretEnv tokenMode=corpSecret 时必填
|
||
--suiteAccessToken/--suiteAccessTokenEnv tokenMode=suite 时可直接指定 suite_access_token(不依赖 Mongo)
|
||
--permanentCode/--permanentCodeEnv tokenMode=suite 时可直接指定 permanent_code(不依赖 Mongo)
|
||
|
||
写入(危险操作,需要确认):
|
||
--apply --yes 创建/写入 *-gk collection(不改原表)
|
||
|
||
仅扫描:
|
||
--skipApi 不调企微接口(仅 dry-run)
|
||
|
||
企微转换(--skipApi 关闭时需要):
|
||
--tokenCorpId <corpId> gettoken 用的 corpId
|
||
--secret <secret> corpsecret(或 --secretEnv <ENV_NAME>)
|
||
--sourceAgentId <id> 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 scripts/convert-wecom-ids-gk.js --preset gk-suite --collections wechat-friends --limitDocs 200 --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 <ENV_NAME>");
|
||
}
|
||
|
||
if (!opts.dryRun && !opts.yes) {
|
||
die("该脚本会在生产库创建并写入 -gk collection。请确认后加上 --yes 再执行(仍然只会新增 -gk,不会改原表)");
|
||
}
|
||
if (!opts.dryRun && opts.skipApi) {
|
||
die("--skipApi 只能用于 dry-run(apply 模式需要调用企业微信接口进行转换)");
|
||
}
|
||
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;
|
||
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;
|
||
|
||
let sourceAgentId = opts.sourceAgentId;
|
||
if (!sourceAgentId && !opts.skipApi && db) {
|
||
console.log(`[step] 正在从 DB 推断 sourceAgentId...`);
|
||
const inferred = await inferSourceAgentId({ db, targetCorpId: opts.targetCorpId });
|
||
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: opts.targetCorpId });
|
||
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: opts.targetCorpId }) : "");
|
||
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} 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;
|
||
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, opts.targetCorpId);
|
||
if (!corpField) {
|
||
console.log(`[step] [${colName}] 未找到匹配 corpId 字段,跳过`);
|
||
continue;
|
||
}
|
||
console.log(`[step] [${colName}] 命中字段: ${corpField}`);
|
||
matchedCollections++;
|
||
|
||
const toName = `${colName}-gk`;
|
||
const toCol = db.collection(toName);
|
||
const exists = names.includes(toName);
|
||
if (!opts.dryRun) {
|
||
if (exists) {
|
||
const existingCount = await toCol.estimatedDocumentCount().catch(() => null);
|
||
console.log(
|
||
`[${dbName}.${toName}] 已存在${existingCount != null ? `(count≈${existingCount})` : ""}。脚本将采用 upsert 写入,可直接重跑,无需删除表。`
|
||
);
|
||
}
|
||
if (!exists) {
|
||
// 显式创建,避免误写入到同名 collection(同时也能更早发现权限问题)
|
||
console.log(`[step] [${toName}] 正在创建目标 collection...`);
|
||
await db.createCollection(toName);
|
||
console.log(`[step] [${toName}] 创建完成`);
|
||
}
|
||
}
|
||
|
||
const filter = { [corpField]: opts.targetCorpId };
|
||
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 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,
|
||
});
|
||
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);
|
||
await converter.convertServiceExternalUserId(id);
|
||
})
|
||
)
|
||
);
|
||
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 } = applyConvertInPlace(doc, {
|
||
openMap,
|
||
externalMap,
|
||
aggressive: opts.aggressive,
|
||
});
|
||
openReplaced += o;
|
||
externalReplaced += ex;
|
||
}
|
||
}
|
||
|
||
if (!opts.dryRun) {
|
||
const useInsertOnly = !!opts.insertOnly;
|
||
console.log(`[step] [${colName}] batch#${batchIndex} 正在写入 ${batch.length} 条到 ${toName}...`);
|
||
if (useInsertOnly) {
|
||
await toCol.insertMany(batch, { ordered: false });
|
||
} else {
|
||
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} openUnique=${openUnique} externalUnique=${externalUnique}`
|
||
);
|
||
}
|
||
}
|
||
|
||
if (!opts.dryRun) {
|
||
console.log(`[step] [${colName}] 正在复制索引到 ${toName}...`);
|
||
await cloneIndexes({ fromCol, toCol });
|
||
console.log(`[step] [${colName}] 索引复制完成`);
|
||
}
|
||
|
||
console.log(
|
||
`[${dbName}.${colName}] done. processed=${processed} wrote=${opts.dryRun ? 0 : processed} -> ${toName} openUnique=${openUnique} externalUnique=${externalUnique} openReplaced=${openReplaced} externalReplaced=${externalReplaced}`
|
||
);
|
||
|
||
processedTotal += processed;
|
||
openUniqueTotal += openUnique;
|
||
externalUniqueTotal += externalUnique;
|
||
openReplacedTotal += openReplaced;
|
||
externalReplacedTotal += externalReplaced;
|
||
matchedStats.push({
|
||
colName,
|
||
processed,
|
||
openUnique,
|
||
externalUnique,
|
||
openReplaced,
|
||
externalReplaced,
|
||
});
|
||
}
|
||
|
||
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(`- 运行模式:${opts.dryRun ? "dry-run(不落库)" : "apply(写入 -gk 新表)"}`);
|
||
console.log(
|
||
`- 是否调用企微接口:${opts.skipApi ? "否(--skipApi,仅扫描统计,不做转换)" : "是(会做转换并替换字段值)"}`
|
||
);
|
||
console.log(`- 扫描到的 collection 总数:${scannedCollections}`);
|
||
console.log(`- 命中该机构(存在 corpId/corpid/corp_id==targetCorpId)的 collection 数:${matchedCollections}`);
|
||
console.log(`- 命中 collection 内处理的文档总数:${processedTotal}`);
|
||
console.log(
|
||
`- open_userid/疑似 userid 候选计数:${openUniqueTotal}(注意:按每批累计的“批内去重”数量,属于近似统计,不是全局唯一数)`
|
||
);
|
||
console.log(
|
||
`- external_userid 候选计数:${externalUniqueTotal}(同上,近似统计)`
|
||
);
|
||
console.log(
|
||
`- 实际替换(转换成功写回文档)的 open_userid 次数:${openReplacedTotal};external_userid 次数:${externalReplacedTotal}`
|
||
);
|
||
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);
|
||
});
|