corp-transfer/convert-wecom-ids-gk.js
2026-03-13 17:07:27 +08:00

1643 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 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,
};
// 只使用 POSTGET 带 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 个 useriddumpScope 用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-runapply 模式需要调用企业微信接口进行转换)");
}
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);
});