From 8ebd8d2af3f07c266cd2cfcec3e78751c18579b1 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Fri, 13 Mar 2026 17:07:27 +0800 Subject: [PATCH] first commit --- .env | 12 + .env.example | 23 + .gitignore | 23 + convert-wecom-ids-gk.README.md | 273 ++++++ convert-wecom-ids-gk.js | 1642 ++++++++++++++++++++++++++++++++ esbuild.config.js | 24 + package-lock.json | 924 ++++++++++++++++++ package.json | 17 + 8 files changed, 2938 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 convert-wecom-ids-gk.README.md create mode 100644 convert-wecom-ids-gk.js create mode 100644 esbuild.config.js create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.env b/.env new file mode 100644 index 0000000..8d7511a --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +MONGO_URI=mongodb://root:password@127.0.0.1:27017/admin +DB_NAME=corp +COLLECTIONS=corp-member +TARGET_CORP_ID=wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA + +TOKEN_MODE=corpSecret +TOKEN_CORP_ID=wwa54dfba0b5441ef1 +CORP_SECRET=6TFkETZWRMbATFhJZAp44SrQGdIFnSxMRlljW_zAmDA +SOURCE_AGENT_ID=1000076 + +LIMIT_DOCS=50 +SKIP_DOCS=0 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a30308a --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Mongo +MONGO_URI=mongodb://root:password@127.0.0.1:27017/admin +DB_NAME=corp +COLLECTIONS=corp-member +TARGET_CORP_ID=wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA + +# WeCom token (corpSecret mode) +TOKEN_MODE=corpSecret +TOKEN_CORP_ID=wwa54dfba0b5441ef1 +CORP_SECRET=replace_with_your_secret +SOURCE_AGENT_ID=1000076 + +# Optional controls +LIMIT_DOCS=50 +SKIP_DOCS=0 + +# Direct convert mode (optional) +OPEN_USERIDS= +EXTERNAL_USERIDS= + +# Suite mode optional values +SUITE_ACCESS_TOKEN= +PERMANENT_CODE= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..38cac0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules/ +dist/ + +!.env.example + +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +coverage/ +.nyc_output/ + +.DS_Store +Thumbs.db + +.idea/ +.vscode/ + +tmp/ +temp/ +*.tmp diff --git a/convert-wecom-ids-gk.README.md b/convert-wecom-ids-gk.README.md new file mode 100644 index 0000000..46d295c --- /dev/null +++ b/convert-wecom-ids-gk.README.md @@ -0,0 +1,273 @@ +# convert-wecom-ids-gk + +把第三方应用获取的: + +- `external_userid`(服务商 external_userid) +- `open_userid`(密文 open_userid) + +转换为自建应用/企业内部可用的: + +- `external_userid`(企业 external_userid) +- `userid`(明文 userid) + +并把转换后的数据写入新的 MongoDB collection(原名 + `-gk` 后缀,如 `corp-member-gk`)。 + +安全说明: + +- 默认 dry-run:不创建、不写入任何 collection +- 只有 `--apply --yes` 才会写入 +- 永远不会更新原 collection(只读原表) +- 写入 `*-gk` 时默认使用 **upsert**(按 `_id` 覆盖更新),因此报错后可直接重跑,一般不需要删表 + +--- + +## 依赖与运行方式 + +- Node.js(建议 18+) +- 依赖:`mongodb`、`axios`(项目内已安装) + +在目录 `c:\code\yk\ykt\ytk-customer-service` 下运行: + +```powershell +cd c:\code\yk\ykt\ytk-customer-service +node scripts/convert-wecom-ids-gk.js --help +``` + +脚本不读取任何 `.env` 文件;Mongo/企微参数全部通过命令行参数或环境变量提供。 + +注:脚本文件为 UTF-8 编码;在 Windows PowerShell 里如果直接 `Get-Content` 看到中文乱码,请用 `Get-Content -Encoding UTF8 scripts/convert-wecom-ids-gk.js` 查看。 + +--- + +## 企业微信接口(脚本内部调用) + +- 获取 `access_token`:`/cgi-bin/gettoken` +- open_userid -> userid:`/cgi-bin/batch/openuserid_to_userid` +- 服务商 external_userid -> 企业 external_userid:`/cgi-bin/externalcontact/from_service_external_userid` + +若遇到 `errcode=60020 not allow to access from your ip`,需要把运行机器出口 IP 加到企业微信白名单后再执行。 + +补充:文档里很多地址写成 `.../xxx?access_token=ACCESS_TOKEN`。脚本里使用 `axios` 的 `params` 传参,最终效果仍然是拼到 URL query string(日志里会以 `fullUrl` 字段打印出来,并对 token 做脱敏)。 + +--- + +## 典型用法 + +### 1) dry-run(推荐先跑) + +只扫描统计,不写入任何 `*-gk` 表: + +```powershell +node scripts/convert-wecom-ids-gk.js ` + --mongoUri "mongodb://:@:27017/admin" ` + --dbName corp ` + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA ` + --skipApi +``` + +Linux / macOS(bash/zsh): + +```bash +node scripts/convert-wecom-ids-gk.js \ + --mongoUri "mongodb://:@:27017/admin" \ + --dbName corp \ + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA \ + --skipApi +``` + +### 2) 实际写入 `*-gk`(只新增,不改原表) + +PowerShell: + +```powershell +$env:WECOM_SECRET='你的corpsecret' + +node scripts/convert-wecom-ids-gk.js ` + --apply --yes ` + --mongoUri "mongodb://:@:27017/admin" ` + --dbName corp ` + --collections wechat-friends ` + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA ` + --sourceAgentId 1000076 +``` + +Linux / macOS(bash/zsh): + +```bash +export WECOM_SECRET='你的corpsecret' + +node scripts/convert-wecom-ids-gk.js \ + --apply --yes \ + --mongoUri "mongodb://:@:27017/admin" \ + --dbName corp \ + --collections wechat-friends \ + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA \ + --sourceAgentId 1000076 +``` + +### 3) 只跑指定 collection(便于测试) + +```bash +node scripts/convert-wecom-ids-gk.js \ + --mongoUri "mongodb://:@:27017/admin" \ + --dbName corp \ + --collections wechat-friends,corp-member \ + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA \ + --skipApi +``` + +### 4) wechat 表数据太多:只跑一部分 + +只跑 `wechat-friends` 前 1000 条(默认按 `_id` 升序,便于稳定分段): + +```bash +node scripts/convert-wecom-ids-gk.js \ + --apply --yes \ + --mongoUri "mongodb://:@:27017/admin" \ + --dbName corp \ + --collections wechat-friends \ + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA \ + --limitDocs 1000 +``` + +分段跑下一段(跳过前 1000 条,再跑 1000 条): + +```bash +node scripts/convert-wecom-ids-gk.js \ + --apply --yes \ + --mongoUri "mongodb://:@:27017/admin" \ + --dbName corp \ + --collections wechat-friends \ + --targetCorpId wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA \ + --skipDocs 1000 \ + --limitDocs 1000 +``` + +--- + +## 可见范围/成员列表排查(推荐) + +当你发现 `open_userid -> userid` 大面积 `invalid_open_userid_list` 时,最常见原因不是“参数写错”,而是:当前 `access_token` 所代表的应用,在通讯录侧可见的成员范围很小(可见范围/权限交集)。 + +脚本提供 `--dumpScope`,会调用: + +- `agent/get`:查看应用的 allow_user/allow_party/allow_tag +- `user/list_id`(文档 path=96067):拉取“当前 token 可见的成员 userid 列表” + +只做排查、不跑转换: + +```bash +node scripts/convert-wecom-ids-gk.js \ + --preset gk-suite \ + --dumpScope --scopeOnly +``` + +检查某些 userid 是否在可见范围内: + +```bash +node scripts/convert-wecom-ids-gk.js \ + --preset gk-suite \ + --dumpScope --scopeOnly \ + --checkUserIds 23090101,23090102 +``` + +## 参数说明 + +### MongoDB(任选一种方式) + +- `--mongoUri ""`(或别名 `--mongoUrl ""`) +- 或拆分参数(也支持对应环境变量兜底): + - `--mongoHost`(或 `MONGO_HOST` / `CONFIG_DB_HOST`) + - `--mongoPort`(或 `MONGO_PORT` / `CONFIG_DB_PORT`,默认 `27017`) + - `--mongoUser`(或 `MONGO_USER` / `CONFIG_DB_USERNAME`) + - `--mongoPass`(或 `MONGO_PASS` / `CONFIG_DB_PASSWORD`) + - `--mongoAuthDb`(或 `MONGO_AUTH_DB`,默认 `admin`) + +### 必填/常用 + +- `--dbName `:只处理单个数据库(例如 `corp`;注意这是“数据库名”,不是 collection 名) +- `--targetCorpId `:目标机构 corpId +- `--collections a,b,c`:只处理指定 collection(可选;例如 `corp-member,wechat-friends`;不传则扫描该库所有 collection) + +### 指定 ID 转换(不跑数据库) + +如果你只想把指定的 `open_userid`/`external_userid` 转换出来用于验证(不扫描 Mongo,也不写入 `*-gk`),可以用: + +```bash +node scripts/convert-wecom-ids-gk.js \ + --tokenMode corpSecret \ + --tokenCorpId \ + --secret "" \ + --sourceAgentId 1000076 \ + --openUserIds woXXXX,woYYYY \ + --externalUserIds wmXXXX,wmYYYY +``` + +tokenMode=suite 也支持直接提供 token 所需参数(无需 Mongo): + +```bash +node scripts/convert-wecom-ids-gk.js \ + --tokenMode suite \ + --suiteAccessToken "" \ + --permanentCode "" \ + --targetCorpId \ + --sourceAgentId 1000076 \ + --openUserIds woXXXX +``` + +### 只跑部分数据(单表分段) + +- `--limitDocs `:每个 collection 最多处理 n 条 +- `--skipDocs `:每个 collection 跳过前 n 条 +- `--noSortById`:不按 `_id` 排序(默认按 `_id` 升序,分段更稳定) + +### 企微转换相关(`--skipApi` 关闭时需要) + +- access_token 获取方式(2 选 1): + - `--tokenMode suite`(推荐):使用第三方应用的 suite_token + 目标机构 permanent_code 获取该机构的 corp access_token + - 依赖:MongoDB 的 `corp.weComToken(type=suiteToken).suite_token`、以及 `corp.corp.permanent_code` + - 无需传 `--tokenCorpId/--secret` + - `--tokenMode corpSecret`(默认):使用 `gettoken`(仅适用于自建应用/企业内部应用场景) + - `--tokenCorpId `:用于 `gettoken` 的 corpId + - `--secret `:corpsecret + - `--secretEnv `:从环境变量读取 corpsecret(推荐) +- `--sourceAgentId `:第三方应用的 `source_agentid` + - 不传时脚本会尝试从 `corp` collection 的 `auth_info.agent[0].agentid` 推断(推断不到会报错) + +### 写入与安全开关 + +- `--apply`:执行写入(不传则 dry-run) +- `--yes`:`--apply` 时必须显式确认(避免误操作) +- `--insertOnly`:仅使用 `insertMany`(不建议;报错重跑容易重复插入或因重复 `_id` 报错) +- `--skipApi`:只扫描统计,不调企业微信接口(仅 dry-run 可用) + +### 扫描/性能参数(可选) + +- `--corpIdFields corpId,corpid,corp_id`:用于判断该 collection 是否属于目标 corp +- `--batchDocs 200`:Mongo 游标批大小 +- `--batchOpenIds 100`:`open_userid` 批量转换每次请求数量 +- `--concurrencyExternal 8`:external_userid 单条转换并发数 +- `--aggressive`:更激进地识别字段/字符串(可能增加 API 调用量) + +### 企微接口日志 + +- 默认会输出每一次企微接口调用的完整响应(成功/失败都会输出) +- 如需关闭:`--noWecomDebug` + +--- + +## 预设(减少命令行参数) + +脚本内置了少量预设(不包含任何明文账号/密码/secret),用法: + +```bash +node scripts/convert-wecom-ids-gk.js --listPresets +node scripts/convert-wecom-ids-gk.js --preset gk-suite --collections wechat-friends --limitDocs 200 --apply --yes +``` + +建议在 Linux 上把敏感信息放环境变量里: + +```bash +export GK_MONGO_URI='mongodb://:@:27017/admin' +export GK_CORP_SECRET='你的corpsecret' # 仅 tokenMode=corpSecret 时需要 +``` diff --git a/convert-wecom-ids-gk.js b/convert-wecom-ids-gk.js new file mode 100644 index 0000000..62a7a6a --- /dev/null +++ b/convert-wecom-ids-gk.js @@ -0,0 +1,1642 @@ +#!/usr/bin/env node +/** + * convert-wecom-ids-gk + * + * 目的: + * - 把第三方/代开发应用获取的密文 `open_userid` 转为明文 `userid` + * - 把第三方应用的“服务商 external_userid”转为企业 `external_userid` + * - 仅写入新表(原表名 + `-gk`),原表只读不改 + * + * 重要说明(为什么会出现大量 invalid): + * - `batch/openuserid_to_userid` 只对“第三方/代开发应用产生的密文 open_userid”有效。 + * 如果库里存的其实已经是明文 userid(很多都是 `wo...` 形式),或 token/source_agentid 不匹配,会返回 invalid。 + * - `externalcontact/from_service_external_userid` 只对“服务商 external_userid”有效。 + * 如果库里存的已经是企业 external_userid(也是 `wm...`),会报 40096 invalid,这是正常的:无需转换,直接保留原值。 + * - 代开发/第三方场景建议使用 `tokenMode=suite` 获取“授权企业 corp access_token”,不要用自建应用 gettoken。 + * - 很多 “invalid” 本质是“可见范围/通讯录权限交集”导致的: + * 企微很多接口(包括 open_userid -> userid)只对当前 access_token 所代表的应用“可见范围内的成员”有效。 + * 可用 `--dumpScope` 调用 `agent/get` + `user/list_id`(文档 path=96067)查看该 token 在通讯录侧能看到多少成员,用于定位“交集太小”。 + * + * 安全: + * - 默认 dry-run(不落库) + * - `--apply --yes` 才会写入 + * - 写入 `*-gk` 默认采用 upsert(按 `_id` 覆盖更新),报错后可直接重跑,一般无需删表 + * + * 文档: + * - 详细用法见:scripts/convert-wecom-ids-gk.README.md + */ + +const axios = require("axios"); +const { MongoClient } = require("mongodb"); +const dotenv = require("dotenv"); +const fs = require("fs"); +const path = require("path"); + +function loadDotEnv() { + const nodeEnv = process.env.NODE_ENV || "development"; + const envFileName = `.env.${nodeEnv}`; + const candidates = [ + path.resolve(process.cwd(), envFileName), + path.resolve(__dirname, envFileName), + path.resolve(process.cwd(), ".env"), + path.resolve(__dirname, ".env"), + ]; + const picked = candidates.find((p) => { + try { + return fs.existsSync(p); + } catch { + return false; + } + }); + + if (picked) { + dotenv.config({ path: picked }); + console.log(`[env] loaded: ${picked}`); + return; + } + dotenv.config(); +} + +loadDotEnv(); + +const PRESETS = { + // 预设不会硬编码任何账号/密码/secret;请通过环境变量注入 + // 示例: + // export GK_MONGO_URI='mongodb://user:pass@host:27017/admin' + // export GK_CORP_SECRET='xxx' + "gk-suite": { + mongoUriEnv: "GK_MONGO_URI", + dbName: "corp", + targetCorpId: "wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA", + tokenMode: "suite", + sourceAgentId: 1000076, + }, + "gk-corpsecret": { + mongoUriEnv: "GK_MONGO_URI", + dbName: "corp", + targetCorpId: "wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA", + tokenMode: "corpSecret", + tokenCorpId: "wwe3fb2faa52cf9dfb", + secretEnv: "GK_CORP_SECRET", + sourceAgentId: 1000076, + }, + // 小批量测试 wechat-friends(避免一次跑太多) + "gk-wechat-sample": { + mongoUriEnv: "GK_MONGO_URI", + dbName: "corp", + targetCorpId: "wpLgjyawAAeRkCPQMp9-z5q-xEzK64nA", + tokenMode: "suite", + sourceAgentId: 1000076, + collections: ["wechat-friends"], + limitDocs: 200, + }, +}; + +function createDefaultOptions() { + return { + dbName: "corp", + corpIdFields: ["corpId", "corpid", "corp_id"], + batchDocs: 200, + batchOpenIds: 100, + concurrencyExternal: 8, + skipDocs: 0, + limitDocs: 0, + sortById: true, + dryRun: true, + yes: false, + aggressive: false, + skipApi: false, + help: false, + reportZh: false, + wecomDebug: true, + insertOnly: false, + tokenMode: "corpSecret", // corpSecret | suite + preset: "", + listPresets: false, + openUserIds: [], + externalUserIds: [], + dumpScope: false, + scopeOnly: false, + scopeLimit: 2000, + checkUserIds: [], + suiteAccessToken: "", + suiteAccessTokenEnv: "", + permanentCode: "", + permanentCodeEnv: "", + mongoUri: "", + mongoUriEnv: "", + mongoHost: "", + mongoPort: "", + mongoUser: "", + mongoPass: "", + mongoAuthDb: "", + tokenCorpId: "", + secret: "", + secretEnv: "", + sourceAgentId: 0, + collections: [], + }; +} + +function parseArgs(argv) { + const args = argv.slice(2); + + let presetName = ""; + let listPresets = false; + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--listPresets" || a === "--list-presets") listPresets = true; + if (a === "--preset" || a === "-p") presetName = args[i + 1] || ""; + if (typeof a === "string" && a.includes("=")) { + const [k, ...rest] = a.split("="); + const key = String(k).replace(/^--/, "").toLowerCase(); + const value = rest.join("="); + if (key === "preset") presetName = value; + } + } + + const defaults = createDefaultOptions(); + const preset = presetName ? PRESETS[String(presetName).trim()] : null; + if (presetName && !preset) { + die(`未知预设: ${presetName}。请先执行 --listPresets 查看可用预设。`); + } + + const opts = { + ...defaults, + ...(preset ? { ...preset } : null), + }; + opts.preset = presetName; + opts.listPresets = listPresets; + + function takeValue(i) { + if (i + 1 >= args.length) return ""; + return args[i + 1]; + } + + function setKeyValue(keyRaw, valueRaw) { + const key = String(keyRaw || "").replace(/^--/, "").trim(); + const keyLower = key.toLowerCase(); + const value = valueRaw; + if (!keyLower) return; + if (keyLower === "dbname" || keyLower === "db") opts.dbName = value; + else if (keyLower === "collections") + opts.collections = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); + else if (keyLower === "targetcorpid") opts.targetCorpId = value; + else if (keyLower === "corpidfields") + opts.corpIdFields = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); + else if (keyLower === "mongouri" || keyLower === "mongourl") opts.mongoUri = value; + else if (keyLower === "mongourienv") opts.mongoUriEnv = value; + else if (keyLower === "mongohost") opts.mongoHost = value; + else if (keyLower === "mongoport") opts.mongoPort = value; + else if (keyLower === "mongouser") opts.mongoUser = value; + else if (keyLower === "mongopass") opts.mongoPass = value; + else if (keyLower === "mongoauthdb") opts.mongoAuthDb = value; + else if (keyLower === "tokenmode") opts.tokenMode = value; + else if (keyLower === "tokencorpid") opts.tokenCorpId = value; + else if (keyLower === "secret") opts.secret = value; + else if (keyLower === "secretenv") opts.secretEnv = value; + else if (keyLower === "sourceagentid" || keyLower === "source_agentid") opts.sourceAgentId = Number(value); + else if (keyLower === "batchdocs") opts.batchDocs = Number(value); + else if (keyLower === "batchopenids") opts.batchOpenIds = Number(value); + else if (keyLower === "concurrencyexternal") opts.concurrencyExternal = Number(value); + else if (keyLower === "skipdocs" || keyLower === "skip-docs") opts.skipDocs = Number(value); + else if ( + keyLower === "limitdocs" || + keyLower === "limit-docs" || + keyLower === "limitdoc" || + keyLower === "limit-doc" + ) + opts.limitDocs = Number(value); + else if (keyLower === "suiteaccesstoken") opts.suiteAccessToken = value; + else if (keyLower === "suiteaccesstokenenv") opts.suiteAccessTokenEnv = value; + else if (keyLower === "permanentcode") opts.permanentCode = value; + else if (keyLower === "permanentcodeenv") opts.permanentCodeEnv = value; + else if (keyLower === "openuserid" || keyLower === "open_userid") + opts.openUserIds = [...(opts.openUserIds || []), value].filter(Boolean); + else if (keyLower === "openuserids" || keyLower === "open_userids") + opts.openUserIds = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); + else if (keyLower === "externaluserid" || keyLower === "external_userid") + opts.externalUserIds = [...(opts.externalUserIds || []), value].filter(Boolean); + else if (keyLower === "externaluserids" || keyLower === "external_userids") + opts.externalUserIds = String(value || "").split(",").map((s) => s.trim()).filter(Boolean); + } + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (!a) continue; + if (a === "--") continue; + if (typeof a === "string" && a.includes("=")) { + const [k, ...rest] = a.split("="); + setKeyValue(k, rest.join("=")); + continue; + } + + if (a === "--help" || a === "-h") opts.help = true; + else if (a === "--reportZh" || a === "--zh") opts.reportZh = true; + else if (a === "--wecomDebug" || a === "--wecom-debug") opts.wecomDebug = true; + else if (a === "--noWecomDebug" || a === "--no-wecom-debug") opts.wecomDebug = false; + else if (a === "--insertOnly" || a === "--insert-only") opts.insertOnly = true; + else if (a === "--noSortById" || a === "--no-sort-by-id") opts.sortById = false; + else if (a === "--useSuiteToken" || a === "--use-suite-token") opts.tokenMode = "suite"; + else if (a === "--apply") opts.dryRun = false; + else if (a === "--yes") opts.yes = true; + else if (a === "--aggressive") opts.aggressive = true; + else if (a === "--skipApi") opts.skipApi = true; + else if (a === "--listPresets" || a === "--list-presets") opts.listPresets = true; + else if (a === "--dumpScope" || a === "--dump-scope") opts.dumpScope = true; + else if (a === "--scopeOnly" || a === "--scope-only") opts.scopeOnly = true; + else if (a === "--preset" || a === "-p") { + opts.preset = takeValue(i); + i++; + } else if (a === "--dbName" || a === "--db") { + opts.dbName = takeValue(i); + i++; + } else if (a === "--collections") { + opts.collections = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); + i++; + } else if (a === "--targetCorpId") { + opts.targetCorpId = takeValue(i); + i++; + } else if (a === "--corpIdFields") { + opts.corpIdFields = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); + i++; + } else if (a === "--mongoUri") { + opts.mongoUri = takeValue(i); + i++; + } else if (a === "--mongoUrl" || a === "--mongoURL" || a === "--mongo-url") { + opts.mongoUri = takeValue(i); + i++; + } else if (a === "--mongoUriEnv") { + opts.mongoUriEnv = takeValue(i); + i++; + } else if (a === "--mongoHost") { + opts.mongoHost = takeValue(i); + i++; + } else if (a === "--mongoPort") { + opts.mongoPort = takeValue(i); + i++; + } else if (a === "--mongoUser") { + opts.mongoUser = takeValue(i); + i++; + } else if (a === "--mongoPass") { + opts.mongoPass = takeValue(i); + i++; + } else if (a === "--mongoAuthDb") { + opts.mongoAuthDb = takeValue(i); + i++; + } else if (a === "--tokenMode" || a === "--token-mode") { + opts.tokenMode = takeValue(i); + i++; + } else if (a === "--tokenCorpId") { + opts.tokenCorpId = takeValue(i); + i++; + } else if (a === "--secret") { + opts.secret = takeValue(i); + i++; + } else if (a === "--secretEnv") { + opts.secretEnv = takeValue(i); + i++; + } else if (a === "--sourceAgentId") { + opts.sourceAgentId = Number(takeValue(i)); + i++; + } else if (a === "--batchDocs") { + opts.batchDocs = Number(takeValue(i)); + i++; + } else if (a === "--batchOpenIds") { + opts.batchOpenIds = Number(takeValue(i)); + i++; + } else if (a === "--concurrencyExternal") { + opts.concurrencyExternal = Number(takeValue(i)); + i++; + } else if (a === "--skipDocs" || a === "--skip-docs") { + opts.skipDocs = Number(takeValue(i)); + i++; + } else if (a === "--limitDocs" || a === "--limit-docs" || a === "--limitdoc" || a === "--limit-doc") { + opts.limitDocs = Number(takeValue(i)); + i++; + } else if (a === "--suiteAccessToken") { + opts.suiteAccessToken = takeValue(i); + i++; + } else if (a === "--suiteAccessTokenEnv") { + opts.suiteAccessTokenEnv = takeValue(i); + i++; + } else if (a === "--permanentCode") { + opts.permanentCode = takeValue(i); + i++; + } else if (a === "--permanentCodeEnv") { + opts.permanentCodeEnv = takeValue(i); + i++; + } else if (a === "--openUserId" || a === "--openuserid" || a === "--open_userid") { + opts.openUserIds = [...(opts.openUserIds || []), takeValue(i)].filter(Boolean); + i++; + } else if (a === "--openUserIds" || a === "--openuserids" || a === "--open_userids") { + opts.openUserIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); + i++; + } else if (a === "--externalUserId" || a === "--externaluserid" || a === "--external_userid") { + opts.externalUserIds = [...(opts.externalUserIds || []), takeValue(i)].filter(Boolean); + i++; + } else if (a === "--externalUserIds" || a === "--externaluserids" || a === "--external_userids") { + opts.externalUserIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); + i++; + } else if (a === "--scopeLimit" || a === "--scope-limit") { + opts.scopeLimit = Number(takeValue(i)); + i++; + } else if (a === "--checkUserId" || a === "--check-userid") { + opts.checkUserIds = [...(opts.checkUserIds || []), takeValue(i)].filter(Boolean); + i++; + } else if (a === "--checkUserIds" || a === "--check-userids") { + opts.checkUserIds = String(takeValue(i) || "").split(",").map((s) => s.trim()).filter(Boolean); + i++; + } + } + + return opts; +} + +function die(msg) { + console.error(msg); + process.exit(1); +} + +function isNonEmptyString(v) { + return typeof v === "string" && v.trim().length > 0; +} + +function safeNumber(n, fallback) { + if (!Number.isFinite(n) || n <= 0) return fallback; + return n; +} + +function applyEnvFallbacks(opts) { + if (!opts.mongoUri) opts.mongoUri = process.env.MONGO_URI || process.env.GK_MONGO_URI || ""; + if (!opts.dbName && process.env.DB_NAME) opts.dbName = process.env.DB_NAME; + if (!opts.targetCorpId && process.env.TARGET_CORP_ID) opts.targetCorpId = process.env.TARGET_CORP_ID; + if (!opts.tokenCorpId && process.env.TOKEN_CORP_ID) opts.tokenCorpId = process.env.TOKEN_CORP_ID; + if (!opts.secret && process.env.CORP_SECRET) opts.secret = process.env.CORP_SECRET; + if (!opts.sourceAgentId && process.env.SOURCE_AGENT_ID) opts.sourceAgentId = Number(process.env.SOURCE_AGENT_ID); + + if ((!opts.collections || opts.collections.length === 0) && process.env.COLLECTIONS) { + opts.collections = String(process.env.COLLECTIONS) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + if ((!opts.openUserIds || opts.openUserIds.length === 0) && process.env.OPEN_USERIDS) { + opts.openUserIds = String(process.env.OPEN_USERIDS) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + if ((!opts.externalUserIds || opts.externalUserIds.length === 0) && process.env.EXTERNAL_USERIDS) { + opts.externalUserIds = String(process.env.EXTERNAL_USERIDS) + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + if (!opts.tokenMode && process.env.TOKEN_MODE) opts.tokenMode = process.env.TOKEN_MODE; + if (!opts.permanentCode && process.env.PERMANENT_CODE) opts.permanentCode = process.env.PERMANENT_CODE; + if (!opts.suiteAccessToken && process.env.SUITE_ACCESS_TOKEN) opts.suiteAccessToken = process.env.SUITE_ACCESS_TOKEN; + if (!opts.limitDocs && process.env.LIMIT_DOCS) opts.limitDocs = Number(process.env.LIMIT_DOCS); + if (!opts.skipDocs && process.env.SKIP_DOCS) opts.skipDocs = Number(process.env.SKIP_DOCS); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} + +class WeComIdConverter { + constructor({ + tokenCorpId, + secret, + sourceAgentId, + timeoutMs = 20000, + debug = true, + getAccessToken, + }) { + this.tokenCorpId = tokenCorpId; + this.secret = secret; + this.sourceAgentId = sourceAgentId; + this.timeoutMs = timeoutMs; + this.debug = !!debug; + this.getAccessToken = typeof getAccessToken === "function" ? getAccessToken : null; + + this._accessToken = null; + this._accessTokenExpireAt = 0; + + this._openMap = new Map(); // open_userid -> userid + this._externalMap = new Map(); // service external_userid -> corp external_userid + this._externalFail = new Map(); // external_userid -> error message + this._openInvalid = new Set(); // open_userid that cannot be converted + this._externalInvalid = new Set(); // service external_userid that cannot be converted + } + + _maskAccessToken(token) { + if (!token) return token; + const s = String(token); + if (s.length <= 10) return "***"; + return `${s.slice(0, 6)}***${s.slice(-4)}`; + } + + async _getAccessToken() { + const now = Date.now(); + if (this._accessToken && now < this._accessTokenExpireAt - 60_000) { + return this._accessToken; + } + + if (this.getAccessToken) { + const tokenInfo = await this.getAccessToken(); + if (!tokenInfo || !tokenInfo.access_token) throw new Error("getAccessToken() returned empty access_token"); + this._accessToken = tokenInfo.access_token; + const expiresInSec = tokenInfo.expires_in || 7200; + this._accessTokenExpireAt = now + expiresInSec * 1000; + return this._accessToken; + } + + const resp = await axios.get("https://qyapi.weixin.qq.com/cgi-bin/gettoken", { + params: { corpid: this.tokenCorpId, corpsecret: this.secret }, + timeout: this.timeoutMs, + }); + + if (!resp.data || resp.data.errcode !== 0) { + throw new Error(`gettoken failed: ${JSON.stringify(resp.data)}`); + } + + this._accessToken = resp.data.access_token; + const expiresInSec = resp.data.expires_in || 7200; + this._accessTokenExpireAt = now + expiresInSec * 1000; + return this._accessToken; + } + + async _wecomRequest({ method, url, params, data, headers, retry = 1 }) { + const accessToken = await this._getAccessToken(); + const fullUrlMasked = buildUrlWithQuery(url, { access_token: this._maskAccessToken(accessToken), ...(params || {}) }); + const reqInfo = { + method, + url, + fullUrl: fullUrlMasked, + params: { access_token: this._maskAccessToken(accessToken), ...(params || {}) }, + data, + headers, + }; + let resp; + try { + resp = await axios.request({ + method, + url, + params: { access_token: accessToken, ...(params || {}) }, + data, + headers, + timeout: this.timeoutMs, + validateStatus: () => true, + }); + } catch (e) { + if (this.debug) { + console.log(`[wecom] ${method} ${url} -> network_error ${e?.message || e}`); + console.dir( + { + request: reqInfo, + error: e?.response?.data ? { response: e.response.data, status: e.response.status } : e?.message || e, + }, + { depth: 12 } + ); + } + throw e; + } + + const body = resp.data; + if (this.debug) { + console.log(`[wecom] ${method} ${url} -> status=${resp.status} errcode=${body?.errcode ?? "n/a"} ${body?.errmsg || ""}`); + console.dir( + { + request: reqInfo, + response: body, + http: { status: resp.status, statusText: resp.statusText }, + }, + { depth: 12 } + ); + } + + // access_token invalid/expired + if (body && (body.errcode === 40014 || body.errcode === 42001)) { + this._accessToken = null; + if (retry > 0) return this._wecomRequest({ method, url, params, data, retry: retry - 1 }); + } + + return body; + } + + _formatWecomError({ action, request, response }) { + return `${action} failed.\nrequest=${JSON.stringify(request)}\nresponse=${JSON.stringify(response)}`; + } + + async convertOpenUserIds(openUserIds) { + const need = []; + for (const id of openUserIds) { + if (!id) continue; + if (this._openMap.has(id)) continue; + if (this._openInvalid.has(id)) continue; + need.push(id); + } + if (need.length === 0) return this._openMap; + + const body = { + open_userid_list: need, + source_agentid: this.sourceAgentId, + }; + + // 只使用 POST;GET 带 body 在部分客户端/网关下会被丢弃 + const req1 = { + method: "POST", + url: "https://qyapi.weixin.qq.com/cgi-bin/batch/openuserid_to_userid", + data: body, + headers: { "Content-Type": "application/json" }, + }; + let res = await this._wecomRequest(req1); + + // 某些环境下对象 body 可能被错误处理,再兜底用字符串 JSON 发送一次 + if (res && res.errcode === 40058) { + const req2 = { + method: "POST", + url: "https://qyapi.weixin.qq.com/cgi-bin/batch/openuserid_to_userid", + data: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }; + res = await this._wecomRequest(req2); + } + if (!res || res.errcode !== 0) { + const hint = + res && res.errcode === 40058 + ? `(请求体应包含 open_userid_list 数组;本次 need.length=${need.length},need[0]=${need[0] || ""})` + : ""; + throw new Error( + this._formatWecomError({ + action: "openuserid_to_userid", + request: { ...req1, data: body }, + response: res, + }) + hint + ); + } + this._applyOpenConvertResponse(need, res); + const invalid = Array.isArray(res.invalid_open_userid_list) ? res.invalid_open_userid_list : []; + for (const id of invalid) this._openInvalid.add(id); + if (invalid.length === need.length && (!res.userid_list || res.userid_list.length === 0)) { + console.log( + `[wecom] openuserid_to_userid: 所有 open_userid 均不可转换(invalid=${invalid.length})。通常表示这些值并非第三方 open_userid(可能已经是 userid),或 source_agentid 不匹配。` + ); + } + return this._openMap; + } + + _applyOpenConvertResponse(requestOpenUserIds, res) { + const list = res.userid_list || []; + if (Array.isArray(list) && list.length > 0 && typeof list[0] === "object") { + for (const item of list) { + if (!item) continue; + const openId = item.open_userid; + const userId = item.userid; + if (openId && userId) this._openMap.set(openId, userId); + } + return; + } + + if (Array.isArray(list) && list.length === requestOpenUserIds.length) { + for (let i = 0; i < requestOpenUserIds.length; i++) { + const openId = requestOpenUserIds[i]; + const userId = list[i]; + if (openId && userId) this._openMap.set(openId, userId); + } + } + } + + async convertServiceExternalUserId(externalUserId) { + if (!externalUserId) return null; + if (this._externalMap.has(externalUserId)) return this._externalMap.get(externalUserId); + if (this._externalFail.has(externalUserId)) return null; + if (this._externalInvalid.has(externalUserId)) return null; + + const body = { external_userid: externalUserId, source_agentid: this.sourceAgentId }; + + try { + const req1 = { + method: "POST", + url: "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/from_service_external_userid", + data: body, + headers: { "Content-Type": "application/json" }, + }; + let res = await this._wecomRequest(req1); + if (res && res.errcode === 0) { + this._externalMap.set(externalUserId, res.external_userid || externalUserId); + return this._externalMap.get(externalUserId); + } + + // 这里不再使用 GET(部分环境会丢 body) + + // 60020: IP not allow;这种情况继续跑没有意义,直接中止 + if (res && res.errcode === 60020) { + throw new Error( + `WeCom API IP 限制(errcode=60020)。请在企业微信后台/白名单允许的服务器上运行该脚本。原始信息: ${res.errmsg}` + ); + } + + // 40096: invalid external userid + // 常见原因:传入的 external_userid 已经是“企业 external_userid”(无需转换),或不是该 source_agentid 下产生的“服务商 external_userid” + // 该情况不应中断整体处理:保留原值,记录为 invalid 并跳过后续重试 + if (res && res.errcode === 40096) { + this._externalInvalid.add(externalUserId); + this._externalFail.set(externalUserId, JSON.stringify(res)); + console.log( + `[wecom] from_service_external_userid: external_userid 不可转换(errcode=40096)。将保留原值并跳过重试。external_userid=${externalUserId}` + ); + return null; + } + + const msg = this._formatWecomError({ + action: "from_service_external_userid", + request: { ...req1, data: { ...body, external_userid: externalUserId } }, + response: res, + }); + this._externalFail.set(externalUserId, JSON.stringify(res)); + throw new Error(msg); + } catch (e) { + const msg = e?.response?.data ? JSON.stringify(e.response.data) : e?.message || String(e); + this._externalFail.set(externalUserId, msg); + throw e; + } + } + + async getAgentInfo(agentId = this.sourceAgentId) { + if (!agentId) throw new Error("getAgentInfo: missing agentId"); + const req = { + method: "GET", + url: "https://qyapi.weixin.qq.com/cgi-bin/agent/get", + params: { agentid: Number(agentId) }, + }; + return this._wecomRequest(req); + } + + async listUserIds({ limit = 1000, cursor = "" } = {}) { + // 文档:获取成员ID列表(path=96067),用于验证通讯录可见范围 + const body = {}; + if (cursor) body.cursor = cursor; + if (limit) body.limit = Number(limit); + const req = { + method: "POST", + url: "https://qyapi.weixin.qq.com/cgi-bin/user/list_id", + data: body, + headers: { "Content-Type": "application/json" }, + }; + return this._wecomRequest(req); + } + + async simpleListUsers({ departmentId = 1, fetchChild = 1 } = {}) { + // 兜底接口:/cgi-bin/user/simplelist(按部门拉取) + const req = { + method: "GET", + url: "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist", + params: { department_id: Number(departmentId), fetch_child: Number(fetchChild) }, + }; + return this._wecomRequest(req); + } +} + +function isPlainObject(v) { + if (!v || typeof v !== "object") return false; + if (Array.isArray(v)) return false; + const tag = Object.prototype.toString.call(v); + return tag === "[object Object]"; +} + +const OPEN_USERID_LIKE = /^wo[a-zA-Z0-9_-]{10,}$/; +const EXTERNAL_USERID_LIKE = /^[wm][a-zA-Z0-9_-]{10,}$/; + +function buildUrlWithQuery(baseUrl, query) { + try { + const u = new URL(baseUrl); + const q = query || {}; + for (const [k, v] of Object.entries(q)) { + if (v === undefined || v === null || v === "") continue; + u.searchParams.set(k, String(v)); + } + return u.toString(); + } catch { + return baseUrl; + } +} + +function classifyKey(key) { + const k = String(key || "").toLowerCase(); + if (!k) return "unknown"; + if (k.includes("external_userid") || k.includes("externaluserid") || k === "external_userid") + return "external"; + if (k.includes("open_userid") || k.includes("openuserid")) return "open"; + if (k === "userid" || k.endsWith("userid") || k.endsWith("userids")) return "open"; + if (k.includes("leader")) return "open"; + return "unknown"; +} + +function collectCandidates(doc, { aggressive }) { + const openUserIds = new Set(); + const externalUserIds = new Set(); + + function walk(value, keyForContext) { + if (value == null) return; + if (typeof value === "string") { + const kind = classifyKey(keyForContext); + if (kind === "open") { + if (OPEN_USERID_LIKE.test(value)) openUserIds.add(value); + } else if (kind === "external") { + if (EXTERNAL_USERID_LIKE.test(value) || OPEN_USERID_LIKE.test(value)) externalUserIds.add(value); + } else if (aggressive) { + // 兜底:遇到疑似 id 的字符串,先尝试按 open_userid 处理;失败的会在后续按 external_userid 处理(由调用方决定) + if (OPEN_USERID_LIKE.test(value)) openUserIds.add(value); + else if (EXTERNAL_USERID_LIKE.test(value)) externalUserIds.add(value); + } + return; + } + if (Array.isArray(value)) { + for (const item of value) walk(item, keyForContext); + return; + } + if (isPlainObject(value)) { + for (const [k, v] of Object.entries(value)) walk(v, k); + } + } + + walk(doc, ""); + return { openUserIds, externalUserIds }; +} + +function applyConvertInPlace(doc, { openMap, externalMap, aggressive }) { + let openReplaced = 0; + let externalReplaced = 0; + function walk(value, keyForContext, parent, parentKey) { + if (value == null) return; + if (typeof value === "string") { + const kind = classifyKey(keyForContext); + if (kind === "open") { + if (openMap.has(value)) { + const next = openMap.get(value); + if (next !== value) { + parent[parentKey] = next; + openReplaced++; + } + } + } else if (kind === "external") { + if (externalMap.has(value)) { + const next = externalMap.get(value); + if (next !== value) { + parent[parentKey] = next; + externalReplaced++; + } + } + } else if (aggressive) { + if (openMap.has(value)) { + const next = openMap.get(value); + if (next !== value) { + parent[parentKey] = next; + openReplaced++; + } + } else if (externalMap.has(value)) { + const next = externalMap.get(value); + if (next !== value) { + parent[parentKey] = next; + externalReplaced++; + } + } + } + return; + } + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const item = value[i]; + if (typeof item === "string") { + const kind = classifyKey(keyForContext); + if (kind === "open") { + if (openMap.has(item)) { + const next = openMap.get(item); + if (next !== item) { + value[i] = next; + openReplaced++; + } + } + } else if (kind === "external") { + if (externalMap.has(item)) { + const next = externalMap.get(item); + if (next !== item) { + value[i] = next; + externalReplaced++; + } + } + } else if (aggressive) { + if (openMap.has(item)) { + const next = openMap.get(item); + if (next !== item) { + value[i] = next; + openReplaced++; + } + } else if (externalMap.has(item)) { + const next = externalMap.get(item); + if (next !== item) { + value[i] = next; + externalReplaced++; + } + } + } + } else { + walk(item, keyForContext, value, i); + } + } + return; + } + if (isPlainObject(value)) { + for (const [k, v] of Object.entries(value)) walk(v, k, value, k); + } + } + + if (isPlainObject(doc)) { + for (const [k, v] of Object.entries(doc)) walk(v, k, doc, k); + } + return { openReplaced, externalReplaced }; +} + +async function inferSourceAgentId({ db, targetCorpId }) { + const corpDoc = await db + .collection("corp") + .findOne({ $or: [{ corpId: targetCorpId }, { "auth_corp_info.corpid": targetCorpId }] }); + const agents = corpDoc?.auth_info?.agent; + if (Array.isArray(agents) && agents.length > 0 && agents[0]?.agentid) { + return Number(agents[0].agentid); + } + return null; +} + +async function getThirdPartyAgents({ db, targetCorpId }) { + const corpDoc = await db + .collection("corp") + .findOne({ $or: [{ corpId: targetCorpId }, { "auth_corp_info.corpid": targetCorpId }] }); + const agents = corpDoc?.auth_info?.agent; + if (!Array.isArray(agents)) return []; + return agents + .filter(Boolean) + .map((a) => ({ agentid: a.agentid, name: a.name, auth_mode: a.auth_mode, is_customized_app: a.is_customized_app })) + .filter((a) => Number.isFinite(Number(a.agentid))); +} + +function normalizeTokenMode(mode) { + const m = String(mode || "").trim(); + if (!m) return "corpSecret"; + if (m === "corpSecret" || m === "corpsecret") return "corpSecret"; + if (m === "suite" || m === "suiteToken" || m === "suitetoken") return "suite"; + return m; +} + +async function getSuiteTokenFromDb(db) { + const doc = await db.collection("weComToken").findOne({ type: "suiteToken" }); + return doc?.suite_token || ""; +} + +async function getPermanentCodeFromDb({ db, targetCorpId }) { + const corpDoc = await db + .collection("corp") + .findOne( + { $or: [{ corpId: targetCorpId }, { "auth_corp_info.corpid": targetCorpId }] }, + { projection: { permanent_code: 1, corpId: 1, auth_corp_info: 1 } } + ); + return corpDoc?.permanent_code || ""; +} + +async function getCorpTokenBySuite({ suiteAccessToken, authCorpId, permanentCode, timeoutMs = 20000 }) { + const url = `https://qyapi.weixin.qq.com/cgi-bin/service/get_corp_token`; + const resp = await axios.request({ + method: "POST", + url, + params: { suite_access_token: suiteAccessToken }, + data: { auth_corpid: authCorpId, permanent_code: permanentCode }, + headers: { "Content-Type": "application/json" }, + timeout: timeoutMs, + validateStatus: () => true, + }); + const body = resp.data; + if (!body || body.errcode !== 0) { + throw new Error(`get_corp_token failed: ${JSON.stringify(body)}`); + } + return body; +} + +async function runDirectIdConvert({ converter, openUserIds, externalUserIds }) { + const out = { open_userid: [], external_userid: [] }; + + const openIds = Array.from(new Set((openUserIds || []).filter(Boolean))); + if (openIds.length > 0) { + await converter.convertOpenUserIds(openIds); + out.open_userid = openIds.map((id) => ({ + open_userid: id, + userid: converter._openMap.get(id) || null, + status: converter._openMap.has(id) ? "ok" : converter._openInvalid.has(id) ? "invalid" : "unknown", + })); + } + + const extIds = Array.from(new Set((externalUserIds || []).filter(Boolean))); + if (extIds.length > 0) { + for (const id of extIds) { + try { + await converter.convertServiceExternalUserId(id); + } catch (_) { + // 错误信息已由 wecomDebug 输出;这里继续处理下一个 + } + } + out.external_userid = extIds.map((id) => ({ + service_external_userid: id, + external_userid: converter._externalMap.get(id) || null, + status: converter._externalMap.has(id) + ? "ok" + : converter._externalInvalid.has(id) + ? "invalid" + : converter._externalFail.has(id) + ? "error" + : "unknown", + })); + } + + console.log("\nDirect convert result:"); + console.log(JSON.stringify(out, null, 2)); +} + +function extractUserIdsFromListIdResponse(res) { + const ids = []; + if (!res || typeof res !== "object") return { ids, nextCursor: "" }; + + // 常见返回:{ dept_user: [{ userid, department: [...] }, ...], next_cursor: "..." } + if (Array.isArray(res.dept_user)) { + for (const item of res.dept_user) { + if (!item) continue; + if (typeof item === "string") ids.push(item); + else if (typeof item === "object" && item.userid) ids.push(String(item.userid)); + } + } + + // 兼容其他可能字段 + if (Array.isArray(res.userlist)) { + for (const item of res.userlist) { + if (!item) continue; + if (typeof item === "string") ids.push(item); + else if (typeof item === "object" && item.userid) ids.push(String(item.userid)); + } + } + if (Array.isArray(res.userid_list)) { + for (const item of res.userid_list) { + if (!item) continue; + if (typeof item === "string") ids.push(item); + else if (typeof item === "object" && item.userid) ids.push(String(item.userid)); + } + } + + const nextCursor = res.next_cursor || res.nextCursor || res.cursor || ""; + return { ids, nextCursor: String(nextCursor || "") }; +} + +async function dumpWeComScope({ converter, scopeLimit, checkUserIds }) { + console.log("\n[scope] 开始检查通讯录可见范围(agent/get + user/list_id)..."); + + // 1) agent/get:应用信息 + 可见范围配置(allow_user/allow_party/allow_tag) + try { + const agent = await converter.getAgentInfo(); + if (agent && agent.errcode === 0) { + const allowUser = agent.allow_user?.user || []; + const allowParty = agent.allow_party?.partyid || []; + const allowTag = agent.allow_tag?.tagid || []; + console.log( + `[scope] agent/get ok: agentid=${converter.sourceAgentId} name=${agent.name || ""} allow_user=${allowUser.length} allow_party=${allowParty.length} allow_tag=${allowTag.length}` + ); + } else { + console.log(`[scope] agent/get not ok: ${JSON.stringify(agent)}`); + } + } catch (e) { + console.log(`[scope] agent/get error: ${e?.message || e}`); + } + + // 2) user/list_id:按游标拉取“当前 token 能看到的成员 userid 列表” + const max = Number.isFinite(scopeLimit) && scopeLimit >= 0 ? scopeLimit : 2000; + const perPage = 1000; + let cursor = ""; + const set = new Set(); + let pages = 0; + + while (true) { + pages++; + if (pages > 200) { + console.log("[scope] user/list_id 页数过多,已停止(防止无限循环)。如需全量请显式设置更小 scopeLimit 或检查返回的 next_cursor。"); + break; + } + const res = await converter.listUserIds({ limit: perPage, cursor }); + if (!res || res.errcode !== 0) { + console.log(`[scope] user/list_id not ok: ${JSON.stringify(res)}`); + console.log("[scope] 将尝试兜底接口 user/simplelist(按 department_id=1, fetch_child=1)..."); + try { + const simple = await converter.simpleListUsers({ departmentId: 1, fetchChild: 1 }); + if (simple && simple.errcode === 0) { + const { ids } = extractUserIdsFromListIdResponse(simple); + for (const id of ids) { + if (!id) continue; + set.add(id); + if (max > 0 && set.size >= max) break; + } + console.log(`[scope] user/simplelist ok: fetched=${set.size}${max > 0 && set.size >= max ? "(已到 scopeLimit)" : ""}`); + } else { + console.log(`[scope] user/simplelist not ok: ${JSON.stringify(simple)}`); + } + } catch (e) { + console.log(`[scope] user/simplelist error: ${e?.message || e}`); + } + break; + } + const { ids, nextCursor } = extractUserIdsFromListIdResponse(res); + for (const id of ids) { + if (!id) continue; + set.add(id); + if (max > 0 && set.size >= max) break; + } + cursor = nextCursor || ""; + if ((max > 0 && set.size >= max) || !cursor) break; + } + + const all = Array.from(set); + const sample = all.slice(0, 20); + console.log(`[scope] user/list_id ok: fetched=${all.length}${max > 0 && all.length >= max ? "(已到 scopeLimit)" : ""}`); + if (sample.length > 0) console.log(`[scope] sample userids: ${sample.join(", ")}`); + + const checks = Array.from(new Set((checkUserIds || []).filter(Boolean))); + if (checks.length > 0) { + const visible = []; + const notVisible = []; + const s = new Set(all); + for (const id of checks) (s.has(id) ? visible : notVisible).push(id); + console.log(`[scope] checkUserIds visible=${visible.length} notVisible=${notVisible.length}`); + if (notVisible.length > 0) console.log(`[scope] notVisible: ${notVisible.join(", ")}`); + } + + console.log( + "[scope] 说明:这里拿到的 userid 列表,是“当前 access_token 对应的应用,在通讯录侧可见的成员集合”。如果这个集合很小,open_userid -> userid 很可能大面积 invalid。" + ); +} + +async function listCollections(db) { + const cols = await db.listCollections().toArray(); + return cols.map((c) => c.name); +} + +async function pickCorpFilterField(collection, corpIdFields, targetCorpId) { + for (const f of corpIdFields) { + const doc = await collection.findOne({ [f]: targetCorpId }, { projection: { _id: 1 } }); + if (doc) return f; + } + return null; +} + +function createLimiter(maxConcurrency) { + const max = safeNumber(maxConcurrency, 8); + let active = 0; + const queue = []; + const runNext = () => { + if (active >= max) return; + const item = queue.shift(); + if (!item) return; + active++; + Promise.resolve() + .then(item.fn) + .then((v) => item.resolve(v), (e) => item.reject(e)) + .finally(() => { + active--; + runNext(); + }); + }; + return (fn) => + new Promise((resolve, reject) => { + queue.push({ fn, resolve, reject }); + runNext(); + }); +} + +async function cloneIndexes({ fromCol, toCol }) { + const indexes = await fromCol.indexes(); + const existing = await toCol.indexes().catch(() => []); + const existingNames = new Set(existing.map((i) => i?.name).filter(Boolean)); + for (const idx of indexes) { + if (!idx || idx.name === "_id_") continue; + if (existingNames.has(idx.name)) continue; + const { key, name, unique, sparse, expireAfterSeconds, partialFilterExpression, collation } = + idx; + const options = { name }; + if (unique) options.unique = true; + if (sparse) options.sparse = true; + if (typeof expireAfterSeconds === "number") options.expireAfterSeconds = expireAfterSeconds; + if (partialFilterExpression) options.partialFilterExpression = partialFilterExpression; + if (collation) options.collation = collation; + try { + await toCol.createIndex(key, options); + } catch (e) { + // 允许重复运行时索引已存在/冲突的情况:不中断主流程 + console.log(`[index] skip createIndex name=${name}: ${e?.message || e}`); + } + } +} + +async function main() { + const opts = parseArgs(process.argv); + applyEnvFallbacks(opts); + if (opts.help) { + console.log(`convert-wecom-ids-gk + +README: scripts/convert-wecom-ids-gk.README.md + +必填: + --dbName 单个数据库名(例如:corp) + --targetCorpId 目标机构 corpId + --mongoUri MongoDB 连接(或用 --mongoHost/--mongoUser/--mongoPass) + --mongoUrl 同 --mongoUri(兼容别名) + +可选: + --collections a,b,c 指定要处理的 collection(例如:corp-member,wechat-friends) + --preset 使用预设(见 --listPresets) + --listPresets 列出可用预设 + --openUserIds a,b,c 指定要转换的 open_userid(不跑数据库) + --externalUserIds a,b,c 指定要转换的 external_userid(不跑数据库) + --dumpScope 调用 agent/get + user/list_id 打印“可见成员范围”信息 + --scopeOnly 仅打印范围信息后退出(不跑转换) + --scopeLimit 最多拉取 n 个 userid(dumpScope 用;0 表示不限制,谨慎) + --checkUserIds a,b,c 检查这些 userid 是否在可见成员列表中(dumpScope 用) + --reportZh / --zh 额外输出中文统计摘要 + --wecomDebug 输出企微接口请求/响应(包含完整 response) + --limitDocs 每个 collection 最多处理 n 条(用于只跑部分数据) + --skipDocs 每个 collection 跳过前 n 条(配合 limit 分段跑) + --noSortById 不按 _id 排序(默认按 _id 升序,便于分段稳定) + --insertOnly 仅 insertMany(不建议;报错重跑可能重复插入) + +企微 token 获取: + --tokenMode suite 使用 Mongo 中 suite_token + permanent_code 获取 corp token(推荐第三方/代开发场景) + --tokenMode corpSecret 使用 gettoken(默认;自建应用场景) + --tokenCorpId tokenMode=corpSecret 时必填 + --secret/--secretEnv tokenMode=corpSecret 时必填 + --suiteAccessToken/--suiteAccessTokenEnv tokenMode=suite 时可直接指定 suite_access_token(不依赖 Mongo) + --permanentCode/--permanentCodeEnv tokenMode=suite 时可直接指定 permanent_code(不依赖 Mongo) + +写入(危险操作,需要确认): + --apply --yes 创建/写入 *-gk collection(不改原表) + +仅扫描: + --skipApi 不调企微接口(仅 dry-run) + +企微转换(--skipApi 关闭时需要): + --tokenCorpId gettoken 用的 corpId + --secret corpsecret(或 --secretEnv ) + --sourceAgentId source_agentid +`); + return; + } + + if (opts.listPresets) { + console.log("可用预设:"); + for (const [name, cfg] of Object.entries(PRESETS)) { + const safeCfg = { ...cfg }; + // 避免打印敏感字段(预设本身也不应包含) + delete safeCfg.secret; + console.log(`- ${name}: ${JSON.stringify(safeCfg)}`); + } + console.log("\n用法示例:"); + console.log(" node 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 "); + } + + if (!opts.dryRun && !opts.yes) { + die("该脚本会在生产库创建并写入 -gk collection。请确认后加上 --yes 再执行(仍然只会新增 -gk,不会改原表)"); + } + if (!opts.dryRun && opts.skipApi) { + die("--skipApi 只能用于 dry-run(apply 模式需要调用企业微信接口进行转换)"); + } + if (opts.dumpScope && opts.skipApi) { + die("--dumpScope/--scopeOnly 需要调用企微接口,请不要加 --skipApi"); + } + + const directMode = Array.isArray(opts.openUserIds) && opts.openUserIds.length > 0 || + Array.isArray(opts.externalUserIds) && opts.externalUserIds.length > 0; + + let mongoUrl = opts.mongoUri; + if (!isNonEmptyString(mongoUrl) && isNonEmptyString(opts.mongoUriEnv) && isNonEmptyString(process.env[opts.mongoUriEnv])) { + mongoUrl = process.env[opts.mongoUriEnv]; + } + if (directMode && opts.tokenMode !== "suite") { + // 仅做企微转换且不使用 suite token 时,可不提供 Mongo(不跑数据库) + if (!isNonEmptyString(mongoUrl)) { + mongoUrl = null; + } + } + if (!isNonEmptyString(mongoUrl)) { + if (directMode && opts.tokenMode !== "suite") { + // no mongo needed + } else { + const host = opts.mongoHost || process.env.MONGO_HOST || process.env.CONFIG_DB_HOST; + const port = opts.mongoPort || process.env.MONGO_PORT || process.env.CONFIG_DB_PORT || "27017"; + const username = opts.mongoUser || process.env.MONGO_USER || process.env.CONFIG_DB_USERNAME; + const password = opts.mongoPass || process.env.MONGO_PASS || process.env.CONFIG_DB_PASSWORD; + const authDb = opts.mongoAuthDb || process.env.MONGO_AUTH_DB || "admin"; + if (!host || !username || !password) { + die( + "缺少 MongoDB 连接信息:请传 --mongoUri,或传 --mongoHost/--mongoUser/--mongoPass(可选 --mongoPort/--mongoAuthDb)" + ); + } + mongoUrl = `mongodb://${encodeURIComponent(username)}:${encodeURIComponent( + password + )}@${host}:${port}/${authDb}`; + } + } + + const client = mongoUrl + ? new MongoClient(mongoUrl, { + serverSelectionTimeoutMS: 30000, + connectTimeoutMS: 30000, + }) + : null; + + if (client) { + console.log(`[step] 正在连接 MongoDB...`); + await client.connect(); + console.log(`[step] MongoDB 已连接`); + } + try { + const dbName = opts.dbName; + const db = client ? client.db(dbName) : null; + console.log(`[step] 正在列举 collection...`); + const names = db ? await listCollections(db) : []; + console.log(`[step] collection 列表(共 ${names.length} 个): ${names.join(", ") || "(空)"}`); + const selected = opts.collections?.length ? opts.collections : names; + + let sourceAgentId = opts.sourceAgentId; + if (!sourceAgentId && !opts.skipApi && db) { + console.log(`[step] 正在从 DB 推断 sourceAgentId...`); + const inferred = await inferSourceAgentId({ db, targetCorpId: opts.targetCorpId }); + if (inferred) sourceAgentId = inferred; + console.log(`[step] sourceAgentId 推断结果: ${inferred ?? "未找到"}`); + } + if (!opts.skipApi && (!sourceAgentId || !Number.isFinite(sourceAgentId))) { + die("缺少 source_agentid:请传 --sourceAgentId(或确保 corp collection 中能推断 auth_info.agent[0].agentid)"); + } + + if (!opts.skipApi && db) { + console.log(`[step] 正在查询第三方/代开发应用列表...`); + const agents = await getThirdPartyAgents({ db, targetCorpId: opts.targetCorpId }); + console.log(`[step] 应用列表: ${agents.map((a) => `${a.agentid}(${a.name || ""})`).join("、") || "(空)"}`); + if (agents.length > 0) { + const agentIds = agents.map((a) => Number(a.agentid)); + if (opts.sourceAgentId && !agentIds.includes(Number(opts.sourceAgentId))) { + console.log( + `[db=${dbName}] 注意:你传入的 --sourceAgentId=${opts.sourceAgentId} 不在该机构的第三方/代开发应用列表中:` + + agents.map((a) => `${a.agentid}${a.name ? `(${a.name})` : ""}`).join("、") + + `。如果报 errcode=846000,请改用列表中的 agentid,或不传 --sourceAgentId 让脚本自动推断。` + ); + } + } + } + + let getAccessToken = null; + if (!opts.skipApi && opts.tokenMode === "suite") { + console.log(`[step] tokenMode=suite,正在获取 suiteAccessToken...`); + const suiteAccessToken = + opts.suiteAccessToken || + (opts.suiteAccessTokenEnv ? process.env[opts.suiteAccessTokenEnv] : "") || + (db ? await getSuiteTokenFromDb(db) : ""); + if (!suiteAccessToken) { + die( + `tokenMode=suite 需要 suite_access_token。可通过 --suiteAccessToken/--suiteAccessTokenEnv 指定,或提供 Mongo 让脚本从 corp.weComToken(type=suiteToken) 读取。` + ); + } + console.log(`[step] suiteAccessToken 已获取(长度=${suiteAccessToken?.length ?? 0}),正在获取 permanentCode...`); + const permanentCode = + opts.permanentCode || + (opts.permanentCodeEnv ? process.env[opts.permanentCodeEnv] : "") || + (db ? await getPermanentCodeFromDb({ db, targetCorpId: opts.targetCorpId }) : ""); + if (!permanentCode) { + die( + `tokenMode=suite 需要 permanent_code。可通过 --permanentCode/--permanentCodeEnv 指定,或提供 Mongo 让脚本从 corp.corp 读取。` + ); + } + console.log(`[step] permanentCode 已获取,suite token 准备就绪`); + let cachedCorpToken = null; + let cachedExpireAt = 0; + getAccessToken = async () => { + const now = Date.now(); + if (cachedCorpToken && now < cachedExpireAt - 60_000) { + return { access_token: cachedCorpToken, expires_in: Math.floor((cachedExpireAt - now) / 1000) }; + } + console.log(`[step] 正在调用 get_corp_token 获取授权企业 access_token...`); + const res = await getCorpTokenBySuite({ + suiteAccessToken, + authCorpId: opts.targetCorpId, + permanentCode, + }); + cachedCorpToken = res.access_token; + const expiresInSec = res.expires_in || 7200; + cachedExpireAt = now + expiresInSec * 1000; + console.log(`[step] get_corp_token 成功,expires_in=${expiresInSec}s`); + return { access_token: cachedCorpToken, expires_in: expiresInSec }; + }; + } + + const converter = opts.skipApi + ? null + : new WeComIdConverter({ + tokenCorpId: opts.tokenCorpId, + secret, + sourceAgentId, + debug: opts.wecomDebug, + getAccessToken, + }); + + console.log( + `[db=${dbName}] targetCorpId=${opts.targetCorpId} mode=${opts.dryRun ? "dry-run" : "apply"}${opts.skipApi ? " skipApi=true" : ` tokenMode=${opts.tokenMode} sourceAgentId=${sourceAgentId}`}` + ); + + if (opts.dumpScope) { + if (!converter) die("--dumpScope 需要调用企微接口(请不要加 --skipApi)"); + await dumpWeComScope({ converter, scopeLimit: opts.scopeLimit, checkUserIds: opts.checkUserIds }); + if (opts.scopeOnly) return; + } + + if (directMode) { + if (!converter) die("direct convert 模式需要调用企微接口,请不要加 --skipApi"); + await runDirectIdConvert({ + converter, + openUserIds: opts.openUserIds, + externalUserIds: opts.externalUserIds, + }); + return; + } + + if (!db) { + die("未提供 MongoDB 连接,无法扫描/写入 collection。若只想转换指定 ID,请使用 --openUserIds / --externalUserIds。"); + } + + if (selected.length === 0) { + console.log(`[db=${dbName}] 没有找到任何 collection(或无权限列出 collection)。`); + return; + } + + let scannedCollections = 0; + let matchedCollections = 0; + let missingCollections = 0; + let processedTotal = 0; + let openUniqueTotal = 0; + let externalUniqueTotal = 0; + let openReplacedTotal = 0; + let externalReplacedTotal = 0; + const matchedStats = []; + + for (const colName of selected) { + if (!opts.collections?.length && colName.endsWith("-gk")) continue; + scannedCollections++; + if (opts.collections?.length && !names.includes(colName)) { + missingCollections++; + console.log(`[${dbName}.${colName}] collection 不存在(或无权限看到),将跳过。`); + continue; + } + const fromCol = db.collection(colName); + console.log(`[step] [${colName}] 正在查找 corpId 字段(候选: ${opts.corpIdFields.join(", ")})...`); + const corpField = await pickCorpFilterField(fromCol, opts.corpIdFields, opts.targetCorpId); + if (!corpField) { + console.log(`[step] [${colName}] 未找到匹配 corpId 字段,跳过`); + continue; + } + console.log(`[step] [${colName}] 命中字段: ${corpField}`); + matchedCollections++; + + const toName = `${colName}-gk`; + const toCol = db.collection(toName); + const exists = names.includes(toName); + if (!opts.dryRun) { + if (exists) { + const existingCount = await toCol.estimatedDocumentCount().catch(() => null); + console.log( + `[${dbName}.${toName}] 已存在${existingCount != null ? `(count≈${existingCount})` : ""}。脚本将采用 upsert 写入,可直接重跑,无需删除表。` + ); + } + if (!exists) { + // 显式创建,避免误写入到同名 collection(同时也能更早发现权限问题) + console.log(`[step] [${toName}] 正在创建目标 collection...`); + await db.createCollection(toName); + console.log(`[step] [${toName}] 创建完成`); + } + } + + const filter = { [corpField]: opts.targetCorpId }; + let cursor = fromCol.find(filter, { batchSize: opts.batchDocs }); + if (opts.sortById) cursor = cursor.sort({ _id: 1 }); + if (opts.skipDocs) cursor = cursor.skip(opts.skipDocs); + if (opts.limitDocs) cursor = cursor.limit(opts.limitDocs); + + let processed = 0; + let openReplaced = 0; + let externalReplaced = 0; + let openUnique = 0; + let externalUnique = 0; + const limiter = createLimiter(opts.concurrencyExternal); + let batchIndex = 0; + + console.log(`[step] [${colName}] 开始遍历文档(batchDocs=${opts.batchDocs} limitDocs=${opts.limitDocs || "无限制"} skipDocs=${opts.skipDocs})...`); + while (await cursor.hasNext()) { + const batch = []; + while (batch.length < opts.batchDocs && (await cursor.hasNext())) { + batch.push(await cursor.next()); + } + if (batch.length === 0) break; + batchIndex++; + console.log(`[step] [${colName}] batch#${batchIndex} 读取文档 ${batch.length} 条(已处理 ${processed} 条)`); + + const openSet = new Set(); + const externalSet = new Set(); + for (const doc of batch) { + const { openUserIds, externalUserIds } = collectCandidates(doc, { + aggressive: opts.aggressive, + }); + for (const x of openUserIds) openSet.add(x); + for (const x of externalUserIds) externalSet.add(x); + } + openUnique += openSet.size; + externalUnique += externalSet.size; + + if (converter) { + // open_userid -> userid(批量) + const openNeed = Array.from(openSet); + console.log(`[step] [${colName}] batch#${batchIndex} 转换 open_userid: 候选 ${openNeed.length} 个`); + for (let i = 0; i < openNeed.length; i += opts.batchOpenIds) { + const chunk = openNeed.slice(i, i + opts.batchOpenIds); + if (chunk.length === 0) continue; + console.log(`[step] [${colName}] 调用 openuserid_to_userid chunk ${i / opts.batchOpenIds + 1}(${chunk.length} 个)...`); + await converter.convertOpenUserIds(chunk); + console.log(`[step] [${colName}] openuserid_to_userid 返回`); + } + + // aggressive 兜底:对未能按 open_userid 转换成功的候选值,再尝试按 external_userid 转换 + if (opts.aggressive) { + const openMapNow = converter._openMap; + for (const id of openSet) { + if (!openMapNow.has(id) && OPEN_USERID_LIKE.test(id)) externalSet.add(id); + } + } + + // service external_userid -> corp external_userid(单个) + const externalNeed = Array.from(externalSet); + console.log(`[step] [${colName}] batch#${batchIndex} 转换 external_userid: 候选 ${externalNeed.length} 个`); + await Promise.all( + externalNeed.map((id) => + limiter(async () => { + // 轻微延时,避免瞬时打爆接口 + await sleep(10); + await converter.convertServiceExternalUserId(id); + }) + ) + ); + console.log(`[step] [${colName}] batch#${batchIndex} external_userid 转换完成`); + + const openMap = converter._openMap; + const externalMap = converter._externalMap; + + for (const doc of batch) { + const { openReplaced: o, externalReplaced: ex } = applyConvertInPlace(doc, { + openMap, + externalMap, + aggressive: opts.aggressive, + }); + openReplaced += o; + externalReplaced += ex; + } + } + + if (!opts.dryRun) { + const useInsertOnly = !!opts.insertOnly; + console.log(`[step] [${colName}] batch#${batchIndex} 正在写入 ${batch.length} 条到 ${toName}...`); + if (useInsertOnly) { + await toCol.insertMany(batch, { ordered: false }); + } else { + const ops = batch.map((d) => ({ + replaceOne: { filter: { _id: d._id }, replacement: d, upsert: true }, + })); + await toCol.bulkWrite(ops, { ordered: false }); + } + console.log(`[step] [${colName}] batch#${batchIndex} 写入完成`); + } + + processed += batch.length; + if (processed % (opts.batchDocs * 10) === 0) { + console.log( + `[${dbName}.${colName}] processed=${processed} openReplaced=${openReplaced} externalReplaced=${externalReplaced} openUnique=${openUnique} externalUnique=${externalUnique}` + ); + } + } + + if (!opts.dryRun) { + console.log(`[step] [${colName}] 正在复制索引到 ${toName}...`); + await cloneIndexes({ fromCol, toCol }); + console.log(`[step] [${colName}] 索引复制完成`); + } + + console.log( + `[${dbName}.${colName}] done. processed=${processed} wrote=${opts.dryRun ? 0 : processed} -> ${toName} openUnique=${openUnique} externalUnique=${externalUnique} openReplaced=${openReplaced} externalReplaced=${externalReplaced}` + ); + + processedTotal += processed; + openUniqueTotal += openUnique; + externalUniqueTotal += externalUnique; + openReplacedTotal += openReplaced; + externalReplacedTotal += externalReplaced; + matchedStats.push({ + colName, + processed, + openUnique, + externalUnique, + openReplaced, + externalReplaced, + }); + } + + console.log( + `[db=${dbName}] summary: scannedCollections=${scannedCollections} matchedCollections=${matchedCollections}${missingCollections ? ` missingCollections=${missingCollections}` : ""}` + ); + if (matchedCollections === 0) { + console.log( + `提示:--dbName 是“数据库名”,不是 collection 名。\n例如要处理 corp 数据库里的 corp-member 表,请用:\n --dbName corp --collections corp-member` + ); + } + + if (opts.reportZh) { + const topProcessed = [...matchedStats] + .sort((a, b) => b.processed - a.processed) + .slice(0, 5) + .map((x) => `${x.colName}(${x.processed})`) + .join("、"); + const topExternal = [...matchedStats] + .sort((a, b) => b.externalUnique - a.externalUnique) + .slice(0, 5) + .map((x) => `${x.colName}(${x.externalUnique})`) + .join("、"); + + console.log("\n中文统计摘要:"); + console.log(`- 数据库:${dbName}`); + console.log(`- 目标机构 corpId:${opts.targetCorpId}`); + console.log(`- 运行模式:${opts.dryRun ? "dry-run(不落库)" : "apply(写入 -gk 新表)"}`); + console.log( + `- 是否调用企微接口:${opts.skipApi ? "否(--skipApi,仅扫描统计,不做转换)" : "是(会做转换并替换字段值)"}` + ); + console.log(`- 扫描到的 collection 总数:${scannedCollections}`); + console.log(`- 命中该机构(存在 corpId/corpid/corp_id==targetCorpId)的 collection 数:${matchedCollections}`); + console.log(`- 命中 collection 内处理的文档总数:${processedTotal}`); + console.log( + `- open_userid/疑似 userid 候选计数:${openUniqueTotal}(注意:按每批累计的“批内去重”数量,属于近似统计,不是全局唯一数)` + ); + console.log( + `- external_userid 候选计数:${externalUniqueTotal}(同上,近似统计)` + ); + console.log( + `- 实际替换(转换成功写回文档)的 open_userid 次数:${openReplacedTotal};external_userid 次数:${externalReplacedTotal}` + ); + if (matchedCollections > 0) { + console.log(`- 文档量最多的前 5 个 collection:${topProcessed || "无"}`); + console.log(`- external_userid 候选最多的前 5 个 collection:${topExternal || "无"}`); + } + console.log(""); + } + } finally { + if (client) await client.close(); + } +} + +main().catch((e) => { + const msg = e?.response?.data ? JSON.stringify(e.response.data) : e?.message || String(e); + console.error(msg); + process.exit(1); +}); diff --git a/esbuild.config.js b/esbuild.config.js new file mode 100644 index 0000000..ea39b2b --- /dev/null +++ b/esbuild.config.js @@ -0,0 +1,24 @@ +const { build } = require("esbuild"); + +// mongodb 的可选原生模块:打包时标记为 external,运行时不需要 +const mongodbOptionals = [ + "kerberos", + "mongodb-client-encryption", + "@mongodb-js/zstd", + "@aws-sdk/credential-providers", + "snappy", + "gcp-metadata", + "aws4", + "socks", +]; + +build({ + entryPoints: ["./convert-wecom-ids-gk.js"], + outfile: "./dist/bundle.js", + platform: "node", + bundle: true, + minify: false, + sourcemap: false, + target: ["node18"], + external: mongodbOptionals, +}).catch(() => process.exit(1)); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..423bd73 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,924 @@ +{ + "name": "convert-wecom-ids-gk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "convert-wecom-ids-gk", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.0", + "dotenv": "^16.4.7", + "mongodb": "^6.10.0" + }, + "devDependencies": { + "esbuild": "^0.24.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmmirror.com/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmmirror.com/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmmirror.com/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mongodb": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/mongodb/-/mongodb-6.21.0.tgz", + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.2" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.3.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b70386b --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "convert-wecom-ids-gk", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "node esbuild.config.js", + "start": "node dist/bundle.js" + }, + "dependencies": { + "axios": "^1.7.0", + "dotenv": "^16.4.7", + "mongodb": "^6.10.0" + }, + "devDependencies": { + "esbuild": "^0.24.2" + } +}