corp-transfer/convert-wecom-ids-gk.js

1643 lines
63 KiB
JavaScript
Raw Normal View History

2026-03-13 17:07:27 +08:00
#!/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);
});