npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

bjchy-udg-sdk

v1.8.4

Published

Universal Data Gateway SDK - 跨端统一数据访问(微信小程序 / Web / Node.js),API 兼容微信云开发

Downloads

1,834

Readme

UDG SDK

跨端统一数据访问 SDK(微信小程序 / Web / Node.js ≥18),API 兼容微信云开发 wx.cloud.database(),连接 UDG Gateway 访问 CloudBase / MongoDB / MySQL / Oracle 业务库。零运行时依赖。

本文档即线上 GET {endpoint}/llms.txt(llms.txt 约定,AI 开发工具可直接抓取;/llms-full.txt 为同一内容的兼容别名)。人类可读版:{endpoint}/admin/sdk-guide.html

安装 / 引入

已发布到公开 npm:bjchy-udg-sdk。三端做法一致:

npm install bjchy-udg-sdk

| 环境 | 方式 | |------|------| | Node.js | npm install bjchy-udg-sdk;内部使用全局 fetch(Node ≥18) | | Web(Vue/React) | 经 bundler import udg from 'bjchy-udg-sdk';内部使用 fetch | | 微信小程序 | npm install bjchy-udg-sdk必须在微信开发者工具里「工具 → 构建 npm」;内部自动使用 wx.request(需在小程序后台将 Gateway 域名加入 request 合法域名) |

环境自动识别,三端 API 完全一致。

⚠️ npm install / npm update 只刷新 node_modules,小程序实际运行用的是 miniprogram_npm 目录——每次装完/更新完都要重新点一次「工具 → 构建 npm」,否则代码不生效,容易误以为是 SDK 的 bug。

始终装最新版本,不要固定版本号:SDK 每次改动 push 后都会自动发布新版本,版本号一直往上涨。package.json 里依赖建议写 "bjchy-udg-sdk": "latest",或定期跑 npm update bjchy-udg-sdk 拉最新。

发版(维护者)

sdk/ 有代码改动 push 到 master 时,.githooks/pre-push 自动跑 scripts/publish-sdk.sh自动算出一个比当前 npm 最新版本高一个 patch 的版本号并 npm publish,不需要手动改 sdk/package.jsonversion。这个 version 字段只是仓库里的"基线"——平时不用管,只有想主动打一个语义化更明显的 minor/major 版本时才手动改,改了之后自动发布会以这个更高的号为准。详见 docs/bjchy-udg-sdk-npm-publish.md

新克隆本仓库、要参与 SDK 开发的机器,需执行一次 scripts/setup-hooks.sh 激活 .githooks/

不建议手工把 sdk/src 拷贝进项目——容易漏更新导致新旧字段不一致(例如曾经的登录返回字段从 openid 改名为 openId,手工拷贝的旧副本没跟上,导致小程序端读到 undefined)。统一走 npm install bjchy-udg-sdk

初始化与登录

const udg = require('bjchy-udg-sdk')

const client = udg.init({
  appid: 'wx1234567890abcdef',          // appid / streetKey 二选一:小程序 AppID(租户标识)
  defaultDb: 'main',                    // 可选:覆盖管理后台为该 appid 配置的 defaultDb(database() 不传参时使用)
  token: '<JWT>',                       // 可选:外部已有 token 直入
  timeout: 15000                        // 可选,默认 15s
})

endpoint 可以不传,缺省为正式生产网关 https://pdb.bjchy.gov.cn/udg(导出为 udg.DEFAULT_ENDPOINT)。 只有连其他部署时才需要显式指定——注意不传就会静默连到默认地址:

const client = udg.init({ endpoint: 'https://udg.vule.cn', appid })   // 过渡后备网关

Web 多租户场景:一套前端产物按 URL 路径区分租户(如 /w/hjl),无需硬编码 appid

const streetKey = location.pathname.split('/').pop()   // 从当前 URL 解析出 'hjl'
const client = udg.init({ streetKey })
// 首次 login() / pollScanLogin() 时才会调用 POST /tenant/resolve 解析出 appid(结果本地缓存,同 streetKey 不重复请求)
// 同时传 appid 与 streetKey 时以 appid 为准,忽略 streetKey,不会调用 resolve

登录(六选一),token 缓存 / 自动续期 / 自动重登由 SDK 内部处理:

// ① 小程序:自动 wx.login(需在管理后台为该 appid 配置微信 AppSecret)
const { token, openId } = await client.login()   // openId 同时写入 JWT;非小程序环境无 openId

// ①b web-view 内的 H5:拿不到 wx.login,由宿主小程序传 code 进来(详见下方「web-view 内 H5 的登录」)
const { token, openId } = await client.login({ code })
// 多小程序租户:SDK 会从 UA(或 URL 的 miniAppId/appid)自动带上宿主 appId;仍可显式覆盖
const r = await client.login({ code, miniAppId: 'wx8c6e005797744382' })

// ①c 公众号内打开的 H5:微信网页授权回调把 code 带回本页(详见下方「公众号 H5 的登录与 JSSDK」)
const { token, openId } = await client.login({ mpCode })

// ② Node / 脚本:apiKey 登录(apiKey 按 appid 在管理后台配置,放环境变量)
await client.login({ apiKey: process.env.UDG_API_KEY })

// ③ Web 扫码登录(协议兼容 MGAPIV3:log 集合 + scancode/scanned/confirmed 状态机)
const { sessionId } = client.createScanSession()
renderQrCode(client.buildScanQrContent(sessionId))              // 二维码内容,业务渲染成图片
const { userInfo } = await client.pollScanLogin(sessionId)     // 确认后自动保存 token
// 小程序确认端:await client.approveScanLogin({ id, admininfo })(需先 login())

// ③b 跨租户借用登录:借用别的小程序(streetKey='hjl')的扫码流程登录到当前项目自己的 appid
// (小程序端代码完全不用改;目标项目需在管理后台配置 scanLoginTrustedStreetKeys 信任来源,
//  不配置 = 不限制;详见 docs/scan-login-cross-tenant.md)
renderQrCode(client.buildScanQrContent(sessionId, { streetKey: 'hjl' }))
await client.pollScanLogin(sessionId, { viaStreetKey: 'hjl' })   // token.appid 仍是当前项目自己的 appid

// ③c sid 快速登录:A 页扫码确认后跳转 B 页(或深链 / WebView 回调)带 ?sid=sessionId,
// B 页跳过二维码展示,单次校验后直接领 token(详见 docs/scan-login-sid-url.md)
// A 页确认后:location.href = '/login?sid=' + encodeURIComponent(sessionId)
// B 页 mount 时:
const result = await client.tryLoginFromUrl()   // 默认读 URL 查询参数 sid,可选 { viaStreetKey, cleanUrl: true }
if (result.loggedIn) {
  // 已登录:result.token / result.userInfo 可用(token 已由 SDK 缓存),无需展示二维码
} else if (result.reason === 'not_confirmed') {
  // 有 sid 但服务端尚未 confirmed(result.status):按 ③ 正常展示二维码 + pollScanLogin
} // result.reason === 'no_sid'(URL 未带 sid)/ 'error'(网络或接口异常):同样按 ③ 正常走扫码流程

// 单次校验(不处理 URL,自行传 sessionId):
const { status, token } = await client.checkScanLogin(sessionId)

// ④ 外部体系已有 JWT(共用 JWT_SECRET,互通):init({ token }) 直入

辅助方法:isLoggedIn() / getToken() / setToken(t) / logout() / getStreetKey() / client.wx.*(小程序服务端 API,需 login)/ jsapiSign(url?)(公众号 JSSDK 签名,免登录)/ ...

web-view 内 H5 的登录(传 code,不传 token)

小程序 <web-view> 里的 H5 属于 Web 环境,没有 wx.login正确做法是宿主小程序只把微信登录 code 传给 H5,由 H5 自己换 token——code 5 分钟有效且一次性,即使随 URL 落进浏览器历史/日志也基本无害;换回的是完整 token + refreshToken,享受 SDK 的自动续期。

// 小程序中转页:只做一件事,不碰 UDG
const { code } = await wx.login()
wx.navigateTo({ url: `/pages/webview/index?c=${code}` })
//   页面内:<web-view src="https://h5.example.cn/page?c={{code}}" />

// H5 侧:
const client = udg.init({ endpoint, appid })        // appid 必须与宿主小程序一致
await client.login({ code: new URLSearchParams(location.search).get('c') })

注意事项:

  • 不要改传 token:URL 里的长效 JWT 在有效期内可被完全冒充,且 init({ token }) 是 external 模式,SDK 不为它续期、过期即失效。
  • code 由哪个小程序签发,该租户就要关联同一个小程序:网关拿 appid 查到该租户关联的小程序(管理后台配置,未配置则沿用 appid 本身)去调微信 jscode2session,与 code 的签发方不一致会拿到 40013 invalid appid。租户只关联一个小程序时参数不变;关联多个时 SDK 会从 UA / URL 自动带上宿主 miniAppId(也可显式传入,见下节)。
  • 页面刷新login() 幂等,缓存 token 未过期时直接复用,不会再用已废的 code;前提是 H5 每次 init 传的 appid 一致(token 存储键依赖它)。
  • refreshToken 也过期时:H5 拿不到新 code,SDK 的自动重登会返回失败并抛鉴权错误(这是预期行为)。业务兜底:wx.miniProgram.redirectTo 回中转页重取 code。注意 wx.miniProgram.postMessage 不是实时的(小程序只在分享/复制链接/后退/组件销毁时才收到),不能用它通知小程序。
  • CORS:H5 域名需在 Gateway 的跨域白名单里。

小程序服务端能力(client.wx.*,需先 login()

Gateway 代理 5 个微信服务端 API。客户端不传 appSecret / miniAppId —— 凭据由 Gateway 按 租户 appid → miniProgramId → wxdb.miniprograms 解析(未迁移的租户回落到 legacy 内联 miniAppId/appSecret),详见 docs/wechat-mini-integration.md

| 方法 | 微信 API | 返回 | |------|----------|------| | getPhoneNumber(code) | getuserphonenumber | { phoneNumber, purePhoneNumber, countryCode } | | getWxacode({ path, ... }) | getwxacode | { contentType, base64, width } | | getUnlimitedWxacode({ scene, page?, ... }) | getwxacodeunlimit | { contentType, base64, width } | | scanQrcode({ imgUrl \| base64 }) | cv/img/qrcode | { codeResults: [{ typeName, data, pos }], imgSize } | | sendSubscribeMessage({ content, hint, ... }) | subscribe/send | { sent: true } |

await client.login()

// ① 获取手机号(button open-type="getPhoneNumber" 回调里的 e.detail.code)
const phone = await client.wx.getPhoneNumber(code)

// ② 小程序码:path 可带 query,数量有上限(微信侧 10 万个)
const { base64 } = await client.wx.getWxacode({ path: 'pages/share?id=1', width: 430 })
// 小程序端渲染:<image :src="'data:image/png;base64,' + base64" />

// ③ 不限制的小程序码:数量不限、永久有效,动态参数放 scene(≤32 字符),page 不能带 query
const { base64: qr2 } = await client.wx.getUnlimitedWxacode({
  scene: 'id=1',                    // 小程序端 onLoad(q) 里 decodeURIComponent(q.scene) 取值
  page: 'pages/share/index',
  checkPath: false                  // 页面尚未发布时传 false,否则微信报 41030
})

// ④ 图片里的二维码/条码识别(QR_CODE / 条码 / DataMatrix / PDF417)
await client.wx.scanQrcode({ base64: imageBase64 })      // 图片 ≤2MB
await client.wx.scanQrcode({ imgUrl: 'https://...' })     // 或公网可访问 URL,二选一

// ⑤ 订阅消息:templateId 在管理后台的小程序配置里填,客户端不传
await client.wx.sendSubscribeMessage({
  content: '您的订单已发货',        // → 模板栏位 thing2(通知内容)
  hint: '点击查看详情',             // → 模板栏位 thing4(提示说明)
  page: 'pages/order/detail?id=1',  // 可选,点击卡片跳转
  touser: 'oXXXX'                   // wechat/scan 登录可省略(默认当前用户);apiKey 登录必填
})

支持的登录方式:wechat / scan / apikey;公众号 wechat-mp 与外部 token 会被拒绝(FORBIDDEN)——公众号 openid 与小程序 openid 不是同一体系。

常见报错:appid=xxx 未关联小程序 = 该租户没在管理后台选小程序;该小程序未配置 subscribeTemplateId = 小程序配置里没填模板 ID;scene 含非法字符 = 只允许数字、大小写字母及 !#$&'()*+,/:;=?@-._~

公众号 H5 的登录与 JSSDK

微信公众号(服务号)里打开的 H5,用网页授权拿 openid,再用 code 换 UDG token。公众号凭据配在 Gateway 的 .env(全网关一份),目标租户需在管理后台打开「公众号登录」开关(默认关闭)。

const client = udg.init({ endpoint, appid })

// OAuth 回调阶段:SDK 校验并一次性消费 state,再用 mpCode 登录;context 是授权前保存的业务参数
const callback = await client.mp.loginFromCallback({ withUserInfo: true, cleanUrl: true })
if (callback.handled) {
  console.log(callback.openId, callback.userInfo, callback.context)
} else {
  // 首次进入:SDK 自动获取公众号 AppID、生成随机 state,并保存业务上下文
  await client.mp.redirectToAuthorization({
    redirectUri: location.href.split('#')[0],
    scope: 'snsapi_userinfo',                  // 只要 openid 时改用 snsapi_base
    context: { scanSession, redirect: '/home' }
  })
}

client.mp.getConfig() / createAuthorization() / redirectToAuthorization() / loginFromCallback() 均为无 UI API。state 默认 10 分钟有效、校验后立即删除,可阻止伪造回调与重放。老项目继续使用 login({ mpCode }) 完全兼容,但须自行安全管理 state 和授权前上下文。

注意事项:

  • mpCodecode 不可混用code 是小程序 wx.login 的 code(走 /auth/wxlogin),mpCode 是公众号网页授权的 code(走 /auth/mp/login),两者的 openid 体系也不同。
  • ⚠️ 公众号 openid ≠ 小程序 openid(本项目未启用 unionid)。写入时注入的 _openid 因此分两套,同一张表两端都写时请用 JWT 里的 authTypewechat / wechat-mp)区分来源,不要假设两端能按 _openid 关联到同一个人
  • token 过期:refreshToken 也失效后 SDK 无法自动重登(拿新 code 必须重走授权跳转),业务在捕获鉴权错误时按上面 ① 重新跳一次即可。

公众号扫码登录电脑 Web(无需小程序)

电脑二维码指向项目自己的公众号 H5 确认页;公众号证明手机用户身份,UDG 扫码会话把确认结果交给电脑。用户集合名称和结构由业务决定,不强制存在 userinfo

// 电脑端:一次创建 sessionId、确认 URL、等待任务和 cancel()
const scan = client.createScanLogin({
  confirmUrl: 'https://app.example.cn/user/wxauth',
  timeout: 5 * 60 * 1000,
  onStatus: (status) => console.log(status)
})
renderQrCode(scan.qrContent)
const { userInfo } = await scan.result       // SDK 已保存电脑 token
// 页面销毁或重新生成二维码:scan.cancel() → SCAN_CANCELLED

// 手机确认页:先用 client.mp.* 完成公众号登录并取得/创建业务用户
await client.approveScanLogin({ id: callback.context.scanSession, admininfo: businessUser })

完整可复制模板与数据流见管理后台「公众号扫码登录」操作指引页。

用 JSSDK(wx.config):完整步骤

client.jsapiSign() 无需登录即可调用,返回 wx.config 需要的全部字段。

第 1 步:页面引入微信 JSSDK(UDG SDK 不代劳,也不依赖它)

<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>

第 2 步:取签名并 wx.config务必写 wx.error,否则配置失败时页面毫无提示,只能靠猜:

const client = udg.init({ endpoint, appid })

const cfg = await client.jsapiSign()   // { appId, timestamp, nonceStr, signature, url }
wx.config({
  debug: false,                        // 联调期可设 true:每一步都会 alert,最快定位问题
  appId: cfg.appId,
  timestamp: cfg.timestamp,
  nonceStr: cfg.nonceStr,
  signature: cfg.signature,
  jsApiList: ['chooseImage', 'getLocation', 'scanQRCode']   // 用到的接口必须逐个列出
})

wx.ready(() => {
  // 配置成功。只有到这里之后才能调用 wx.chooseImage / wx.getLocation 等
  wx.getLocation({ type: 'gcj02', success: (r) => console.log(r.latitude, r.longitude) })
})

wx.error((res) => {
  // 配置失败。res.errMsg 形如 'config:invalid signature',对照下面的排错表
  console.error('JSSDK 配置失败:', res.errMsg)
})

第 3 步:调用具体接口时,失败信息在各接口自己的 fail 回调里(res.errMsg),与 wx.error 是两回事。

JSSDK / 公众号登录排错

先看错误是谁报的wx.errorerrMsg 是微信报的;jsapiSign() / login({ mpCode }) 抛出的 UdgRequestError(含 errorCodemessage)是 UDG 网关报的。

| 报错 | 谁报的 | 原因与处理 | | --- | --- | --- | | config:invalid signature | 微信 | 签名用的 url 与微信记录的当前页面 URL 不一致。优先确认你用的是 jsapiSign() 的默认值(页面首次加载时的地址)——SPA / iOS 上 location.href 已被 pushState 改过,自己传当前 href 必然对不上。其次确认 url 已去掉 # 及其后部分,且协议/域名/路径/query 一个字符都没差(多一个末尾斜杠也不行) | | config:invalid url domain | 微信 | 页面域名没加到公众号后台的「JS 接口安全域名」。到公众号后台 → 设置与开发 → 公众号设置 → 功能设置里添加,并把校验文件放到域名根目录可访问 | | config:permission denied | 微信 | 该公众号没有此接口权限(未认证服务号权限受限),或接口名拼错 | | 调接口时 xxx:fail, the permission value is offline verifying | 微信 | jsApiList 里没声明这个接口,或没等 wx.ready 就调用了 | | 微信接口返回错误:40001 invalid credential | 微信(经 UDG 转出) | 网关缓存的公众号 access_token 被别处刷新踢掉了(同一公众号被多个服务共用时会发生)。网关会自动作废缓存重取并重试一次,正常情况下你看不到这个错误;若仍抛出,说明重试后依然失败,需要检查网关的 WX_MP_SECRET 是否与公众号后台一致 | | 微信接口返回错误(cgi-bin/token):40164 invalid ip xxx, not in whitelist | 微信(经 UDG 转出) | 网关所在服务器的出口 IP 不在公众号后台的「IP 白名单」里。这是独立于域名的一道闸门:公众号后台 → 设置与开发 → 基本配置 → IP 白名单,把报错里那个 IP 加进去。只影响 jsapiSign()(它要 access_token),login({ mpCode }) 走网页授权接口、不受 IP 白名单限制 | | 域名 xxx 不在 JSAPI 白名单内(WX_MP_JSAPI_HOSTS) | UDG | 网关的 .envWX_MP_JSAPI_HOSTS 没包含该域名。这是精确匹配 host,子域不会自动放行a.example.cn 不因 example.cn 在名单里而通过)。需要运维在 gateway/.env 追加该域名(逗号分隔)后 pm2 restart udg-gateway | | url 必须是 https | UDG | 公众号 JS 接口安全域名只支持 https,本地 http 调试拿不到签名(可在真机上用正式域名联调) | | 网关未配置微信公众号(请设置 WX_MP_APPID / WX_MP_SECRET) | UDG | 网关侧公众号凭据没配。运维在 gateway/.env 填好后重启 | | 网关未配置 JSAPI 域名白名单 | UDG | WX_MP_JSAPI_HOSTS 为空。留空不等于放开,是一律拒绝 | | appid=xxx 未开启公众号登录 | UDG | 目标租户的开关没打开:管理后台 → 租户配置 → 「公众号网页授权登录」 | | 微信未返回 openid(code 可能已使用或已过期) | UDG | code 一次性、5 分钟有效。页面刷新时 SDK 会命中缓存 token 不再用旧 code;若确需重新登录,要重走一次授权跳转(见上面 ①) | | 授权跳转时页面提示 redirect_uri 参数错误 | 微信 | 页面域名没加到公众号后台的「网页授权域名」(与 JS 接口安全域名是两个独立的设置项,都要配) |

联调技巧:wx.config({ debug: true }) 会把每步结果 alert 出来;签名对不上时先把 cfg.url 打印出来,和微信实际认的 URL 逐字符比对——绝大多数 invalid signature 都是这一处差异。

openId 约定:JWT 内统一字段名为 openId(小程序 wx.login、公众号网页授权、网页扫码、外部 MGAPIV3 token 均如此)。小程序 login() 与公众号 login({ mpCode }) 成功时响应含 openId;扫码登录的 openId 在 JWT 中,业务侧一般从 userInfo 或解码 token 读取。

⚠️ 小程序 openid 是按微信小程序(miniAppId)划分的,不是按 UDG 租户划分:多个 UDG 租户若配了同一个 miniAppId,同一个微信用户在这些租户里换回的 openid 是同一个值(可用于跨租户按 openid 关联同一自然人);配不同 miniAppId 则 openid 不同、不能跨租户直接对人。

一个租户关联多个小程序

一套小程序代码部署到 N 个 appId、但数据只有一份时(多街道 / 多门店同款小程序),不必为每个小程序建一个租户:在管理后台的租户配置里把这 N 个小程序都勾进「其它小程序」,各自用自己的 wx.login() 登录到同一个租户。

const client = udg.init({ endpoint, appid: 'wxxcx-oracle' })   // N 个小程序共用同一个租户 appid
await client.login()   // SDK 自动带上当前小程序的真实 appId,网关据此选对 AppSecret
  • 小程序内login() 自动从 wx.getAccountInfoSync() 取当前 appId 上报,业务代码不用改,也不能自己传。
  • web-view 内的 H5:拿不到 wx.getAccountInfoSynclogin({ code }) 会从 UA(miniProgram/wx…)或 URL 的 miniAppId / appid 自动探测宿主并上报;仍可显式传 miniAppId 覆盖。探测不到时网关只能用默认小程序(与 code 签发方不一致时报 40013)。
  • 上报的 miniAppId 只能在该租户已关联的集合内选择;传了没关联的会直接报 appid=… 未关联小程序 …不会悄悄回落到默认小程序(这样"后台漏勾了一个"能一眼看出来,而不是表现成微信的 40013)。
  • ⚠️ 这 N 个小程序之间 openid 不互通(openid 按 miniAppId 划分)。同一自然人在各小程序里 _openid 不同,不要_openid 跨小程序关联到同一个人。需要跨端对人请用手机号等业务标识。
  • 租户只关联一个小程序时,以上全部无感知,行为与之前完全一致。

envId / mongoDatabase / AppSecret 由服务端从 wxdb.config 读取,客户端不传、不可传。

同一 appid 多数据源(一个 client、一份 token,用 dbKey 切换;管理后台里租户的 bindings[]dbKey 路由到数据源注册中心里的某个数据源,详见 docs/datasource-registry-and-schema-dictionary.mddbKey 路由思路的历史版本见 docs/archive/multi-database-dbkey.md):

const client = udg.init({ endpoint, appid })
await client.login()

const cloudDb = client.database()        // 不传 → 用租户 defaultDb
const mongoDb = client.database('mongo') // 显式 dbKey
const mapsDb = client.database('maps')   // 多租户共享的只读公共库(access: readonly)

查询

const _ = db.command

// 链式查询(与 wx.cloud 一致)
const { data } = await db.collection('users')
  .where({ status: 1, age: _.gte(18).and(_.lt(60)) })
  .field({ name: true, age: true })
  .orderBy('age', 'desc')
  .skip(20)
  .limit(20)
  .get()

// 计数
const { total } = await db.collection('users').where({ status: 1 }).count()

// 正则(两种写法均可)
db.collection('users').where({ name: new db.RegExp({ regexp: '^陈', options: 'i' }) }).get()
db.collection('users').where({ name: /^陈/i }).get()

// 按 _id
const { data: [doc] } = await db.collection('users').doc(id).get()

支持的查询指令:eq neq gt gte lt lte in nin exists size all elemMatch mod and or nor not;也可直接写 MongoDB 风格 filter(如 { age: { $gte: 18 } })。不支持的运算符 Gateway 直接报错。

doc(id) 在 Oracle / MySQL 上同样可用

关系表没有 _id 列。Gateway 会把 doc(id)_id 条件自动翻译成该表真实的主键列(主键从数据字典读取并缓存,无需配置),get / update / remove / set 四个操作一致:

// 表 YYC_RYXX 主键是 RYID → 实际执行 WHERE RYID = 10086
await db.collection('YYC_RYXX').doc('10086').get()

主键值的类型按列类型归一:NUMBER 主键传字符串 '10086' 会转成数字(命中索引),VARCHAR2 主键传 '007' 保持字符串不会被转成 7。两种情况用不了 doc(id),都会报 INVALID_PARAM 并提示改用 where复合主键表无主键的表或视图

值的类型不用自己对齐(关系库)

Gateway 按列类型归一 where 条件与写入值{ status: 1 }{ status: '1' } 等价,写代码不用记哪列是什么类型:

await db.collection('WXCONTACT').where({ wxid: 0 }).count()    // 与 where({ wxid: '0' }) 完全一致
await db.collection('CHARGING').add({ data: { total: 4, price: 0.5103 } })   // 写进 VARCHAR2 列不会变成 .5103
  • 字符列(VARCHAR2 / CHAR / CLOB / TEXT …)收到 JS 数字 → 转字符串。 这条是必须的:Oracle 遇到 WHERE 字符列 = 数字 会把整列 TO_NUMBER 后再比, 列里只要有一行非数字文本就 ORA-01722,即使不报错该列索引也失效(MySQL 不报错,但同样退化成全表扫)。
  • 数字列(NUMBER / INT …)收到纯数字字符串 → 转数字(命中索引)。只在往返无损时转: '007'、超出双精度的长数字 id 保持原样。
  • 不动Date、布尔、null、数组 / 嵌套对象,以及数据字典里查不到的列名(视图别名、聚合别名、写错的列名)。
  • 不动的运算符$exists$regex / $options$type / $size / $mod,以及 $inc / $mul(值必须保持数值)。
  • 覆盖范围:where / count / doc(id) / update / remove / add / set,以及 aggregate 里的 $match 阶段。
  • 列类型来自数据字典,带 10 分钟缓存;文档库(MongoDB / CloudBase)不做任何归一(那里类型是数据的一部分)。

Oracle 表名 / 列名大小写

Oracle 数据字典里的表名、列名都是大写,SQL 中不加引号的标识符也会被数据库折叠为大写。Gateway 已统一把 Oracle 数据源的表名归一为大写,所以 collection('yyc_ryxx')collection('YYC_RYXX') 完全等价,也不会再因为大小写与后台的表级权限配置对不上而被拒。列名同理由 Oracle 自行折叠,where({ ryid: 1 }) 能命中 RYID 列。

(MySQL 表名在 Linux 上大小写敏感,不做归一,请与建表时保持一致。)

视图(Oracle)

Oracle 视图与表一样可以查询,管理后台的数据源表清单里会以 kind=view 一并列出,可正常配置到租户的白名单 / 例外里。视图只读:没有主键,doc(id) / set 会报 INVALID_PARAM,写入会被数据库拒绝。

写入

// 单条 → { _id }(关系库返回业务主键值,不是 ROWID)
const { _id } = await db.collection('users').add({ data: { name: 'alice', createdAt: new Date() } })

// 批量(≤5000 条,一次事务:要么全部成功,要么全部回退)→ { ids }
const { ids } = await db.collection('users').add({ data: [{...}, {...}] })

// 更新(where 匹配全部;doc(id) 单条)
await db.collection('users').where({ status: 0 }).update({ data: { status: 1 } })
await db.collection('users').doc(id).update({ data: { score: _.inc(10), tags: _.push(['new']) } })

// 删除(filter 必须非空,防整表误删)
await db.collection('users').where({ status: 9 }).remove()   // → { stats: { removed } }
await db.collection('users').doc(id).remove()

// set(upsert):指定 id,有则更新、无则新增 → { _id, stats: { created, updated } }
await db.collection('users').doc('u10086').set({ data: { name: 'alice', status: 1 } })
await db.collection('users').set({ id: 'u10086', data: { name: 'alice' } })
// MySQL / Oracle 也可显式传主键列名(Gateway 自动查表主键校验)
await db.collection('users').set({ user_id: '10086', data: { name: 'alice' } })

add 返回什么 id

add 返回的 _id / ids业务主键值,可以直接拿去 doc(id)

| 数据源 | 返回值 | |--------|--------| | CloudBase / MongoDB | 新文档的 _id | | Oracle | 主键列的值:data 里带了主键就回该值,没带(序列 / 触发器 / IDENTITY 生成)则 RETURNING <主键列> 取回 | | MySQL | AUTO_INCREMENTinsertId;非自增主键表回落到 data 里的主键值 |

只有无主键表才回落到 Oracle 的 ROWID / MySQL 的空串。复合主键表也拿不到有意义的单值 id(同样回落),需要主键请自行从写入的 data 里取。

set:按 id 更新或新增(四类数据源统一,合并语义

set 解决的是"这条记录可能存在也可能不存在,我要把它写成这样"——不用先查再决定 add 还是 update

只写 data 里出现的字段,没提到的字段原样保留(四类数据源一致):

// 库里已有:{ _id: 'u1', name: 'alice', age: 30, city: '北京' }
await db.collection('users').doc('u1').set({ data: { age: 31 } })
// 结果:   { _id: 'u1', name: 'alice', age: 31, city: '北京' }   ← name/city 都还在

⚠️ 与微信云开发的 doc().set() 不同:微信云那个是整文档覆盖(上例会把 name、city 抹掉)。 UDG 统一为合并语义,因为它更接近实际业务需要,也和 MySQL/Oracle 的 upsert 行为一致。 确实要"整文档替换"时,请显式 remove() 后再 add()

| 写法 | 适用 | 说明 | |------|------|------| | .doc(id).set({ data }) | 全部 | 最常用,与 doc(id).update() 的区别是记录不存在时会新建 | | .set({ id, data }) | 全部 | 通用 id,不必知道关系表主键列叫什么 | | .set({ user_id: v, data }) | MySQL / Oracle | 显式指定主键列名;与 id 同时传且值不一致会报错 |

返回 { _id, stats: { created: 0|1, updated: 0|1 }, errMsg }created=1 表示这次是新建,updated=1 表示命中了已有记录。

各数据源实现:MongoDB updateOne($set) + upsert;CloudBase 先 update 合并、记录不存在再创建;MySQL INSERT ... ON DUPLICATE KEY UPDATE;Oracle 事务内先查再 INSERT/UPDATE。关系库的主键自动从数据字典读取(无需配置),复合主键表不支持 set(会报 INVALID_PARAM)。

其他约束:data 必须是非空对象;关系库的 data 除主键外至少要有一个字段;append 只增表拒绝 set(与 update 一致,返回 APPEND_ONLY_TABLE)。

用户登录(非 apiKey)时,Gateway 在 add / set 时自动写入调用者的 openid(取自 token 的 openId);apiKey 登录不注入。写哪一列按数据源分:

| 数据源 | 写入列 | 说明 | |--------|--------|------| | CloudBase / MongoDB | _openid | 恒注入,覆盖客户端传入值 | | Oracle / MySQL | 表里实际存在的 openid 列 | 按 OPENID_OPENIDWX_OPENIDUSER_OPENID 顺序探测,命中第一个就写;表里一个都没有则静默跳过,不报错 |

关系库建表时把该列命名为 OPENID 即可自动获得归属标记,无需任何配置。两点注意:

  • data 里显式给了该列的值就不覆盖(与文档库不同,这里以调用方传的为准)。
  • Oracle 不加引号的标识符不能以下划线开头,所以列名请用 OPENID 而非 _OPENID(后者必须建成带引号的 "_OPENID",SDK 侧读写都要跟着加引号,不推荐)。

批量写入的事务保证(≤5000 条)

传数组时 Gateway 在一个事务里整批提交:任一条失败,整批都不落库,不会出现"前 800 条写进去了、第 801 条报错"的半截状态。四类数据源都是如此(MongoDB / CloudBase 用事务包住批量插入,MySQL / Oracle 本来就在事务内逐条 INSERT)。

try {
  const { ids } = await db.collection('coupon').add({ data: bigArray })   // bigArray.length <= 5000
  // 走到这里 = 5000 条全部落库,ids 与入参一一对应
} catch (e) {
  // 走到这里 = 一条都没落库,直接整批重试即可,不必先查"写进去几条"
}

注意事项:

  • 超过 5000 条自行分批,每批仍各自是一个事务(跨批没有全局事务,批与批之间可能一批成功一批失败)。
  • 请求体大小才是真正的瓶颈:网关限 20MB,其上游 nginx 也须放开(本项目 pdb.bjchy.gov.cn/udg 已配 20MB)。5000 条 × 400 字节 ≈ 2MB。413 报错就是撞到了这道闸门。
  • 大批量请调大超时udg.init({ endpoint, appid, timeout: 60000 })。SDK 默认 15s,弱网上传几 MB 可能不够;客户端超时不等于服务端回滚——服务端可能仍在提交,超时后应先查再重试,别盲目重发。
  • MongoDB 数据源需要副本集(本项目 rs0 满足)。非副本集实例会直接报错而不是退化成部分写入。
  • 批量 > 200 条时,SSE watch() 收到的事件不再携带文档内容,只给 documentIdstruncated: true(避免把订阅端打爆),需要内容请自行重查。

更新指令:set remove inc mul push pop shift unshift addToSet pull pullAll appendText;也可传原生 update 文档(顶层全 $ 键时直传,如 { $set: {...}, $inc: {...} })。

_.appendText:文本追加(仅 Oracle / MySQL)

关系表没有数组列,_.push 在 Oracle / MySQL 上会明确报 UNSUPPORTED_OPERATOR。要往一个文本列尾部追加内容用 _.appendText——把新内容拼到原值后面,用 | 分隔;原值为空(NULL 或空串)则直接写入,不留前导分隔符:

// REMARK 原值 '已受理'  →  '已受理|已办结'
await db.collection('YYC_SQ').doc(id).update({ data: { REMARK: _.appendText('已办结') } })

// 一次追加多段(等价于追加 'a|b')
await db.collection('YYC_SQ').doc(id).update({ data: { REMARK: _.appendText(['a', 'b']) } })

约束:值必须是标量(字符串/数字),null、数组字面量、嵌套对象都会报 INVALID_PARAM;分隔符固定为 |,不可配置;这是原子的 SQL 更新CASE WHEN ... || / IF ... CONCAT),并发追加不会互相覆盖。

刻意没有复用 _.pushpush 在文档库是往数组追加元素,同名不同义会让同一份代码在不同数据源上静默产生两种行为,且没有对应的可逆操作。文档库要追加数组元素仍然用 _.push

聚合(重点能力)

const { list } = await db.collection('orders').aggregate()
  .match({ year: 2026, status: 'paid' })
  .group({ _id: '$type', total: { $sum: '$amount' }, n: { $sum: 1 } })
  .sort({ total: -1 })
  .limit(10)
  .end()

支持 stage:match group project sort limit skip count lookup unwind addFields replaceRoot sample sortByCount。stage 内表达式使用 MongoDB 原生写法($sum / $cond / $dateToString …),各数据源写法一致;MySQL / Oracle 支持其中核心子集(match group project addFields sort limit skip count),矩阵外直接报错。

实时订阅(SSE)

const watcher = db.collection('logs')
  .where({ status: 'waiting' })          // 可选:带条件订阅
  .watch({
    onChange: (evt) => { /* insert / update / delete 变更事件 */ },
    onError: (err) => { /* 断线等错误(内部已自动重连) */ }
  })
watcher.close()                           // 退出页面时关闭

基于 SSE:经 Gateway 的全部写入(四类数据源)都会推送;外部直连写入在 MongoDB(Change Stream)/ CloudBase(原生 watch)上补充监听,MySQL / Oracle 暂不支持外部监听。onChange 首个 watch-status 事件的 external 字段指示外部监听状态(active | degraded | unsupported)。

运行环境:浏览器 EventSource / Node ≥ 18;微信小程序不支持(抛 ENV_NOT_SUPPORTED,请用轮询 / 定时 get 代替)。

扫码登录专用:client.subscribeScanLogin(sessionId)——SSE 推送确认结果(亚秒级),SSE 不可用时自动降级为 pollScanLogin 轮询,返回值语义一致;两者均支持 opts.signal 取消。新项目可直接使用组合 API createScanLogin(),返回 { sessionId, qrContent, result, cancel }

运行时自查表结构

POST {endpoint}/api/describe
Authorization: Bearer <token>
body: { "db": "<dbKey>", "collection": "<表名>" }

返回该表的字段/类型/中文说明/枚举样本(来自管理后台维护的数据字典);也可在管理后台「导出开发文档」得到完整 Markdown 文档包。

配套 MCP Server(UDG 仓库 mcp/ 目录):只读工具 udg_list_collections(列可访问的表)、udg_describe_collection(查表结构)、udg_query / udg_aggregate(只读试查询)、udg_nl_query(自然语言查询),环境变量 UDG_ENDPOINT / UDG_APPID / UDG_APIKEY,适合接入 Claude Code / Cursor 等 AI 开发工具。

服务端时间(防本机时钟篡改)

Gateway 的每个响应都带 timestamp,SDK 据此被动维护本地时钟偏差。凡是"是否已截止""是否已过期"这类判断,一律用 client.now() 而不是 new Date() —— 用户改本机时间不会影响结果。

client.now()              // → Date,服务端校正后的当前时间
client.serverTimeOffset() // → 毫秒,本地相对服务端的偏差(正数 = 本地慢)
client.lastTimeSyncAt()   // → 最近一次对时的本地时刻(毫秒时间戳),0 = 从未对时
await client.syncTime()   // → 主动对时(GET /health,无需登录),返回偏差

// 典型用法:判断活动是否还在报名期
if (client.now() < new Date(activity.bookEnd)) { /* 可以报名 */ }

发生过**任意一次请求(含 login()、含失败的请求)**之后 now() 即为可信时间,常规业务无需显式对时。仅当"页面刚打开、还没发过任何请求就要判断时效"时才需要先 await client.syncTime()。从未对过时会退化为本地时间(不抛错)。

基准取请求前后本地时间的中点,抵消约一半网络往返;剩余误差在毫秒~百毫秒量级,而本能力要防的是"改本机时间"这种小时量级的偏差。

注意与 db.serverDate() 的区别:后者是写入数据时的日期值,V1 以客户端时间近似;需要不可伪造的写入时间请在 Gateway 侧配 beforeWrite.autoTimestamps 钩子。

返回与错误

返回兼容微信云开发:get → {data}count → {total}aggregate → {list}add → {_id}/{ids}set → {_id, stats:{created,updated}}update → {stats:{updated}}remove → {stats:{removed}}

失败抛统一错误对象:{ errorCode, errorMessage, errorDetails?, requestId, timestamp }errorDetails 是可选的结构化诊断,关系库数据错误会包含 dataSource/collection/field/databaseCode/reason 及可取得的长度或约束信息;不包含 SQL、原始值、连接信息或堆栈。

常见错误码除 UNSUPPORTED_OPERATORTENANT_NOT_FOUNDDB_NOT_FOUNDREADONLY_DATABASEAPPEND_ONLY_TABLETABLE_ACCESS_DENIEDFORBIDDENTIMEOUT 外,还包括:TYPE_MISMATCHINVALID_NUMBERINVALID_DATEVALUE_TOO_LONGVALUE_OUT_OF_RANGEREQUIRED_FIELD_MISSING(均为 400),以及 DUPLICATE_KEYCONSTRAINT_VIOLATION(409)。可修正的数据错误使用 4xx,确保生产反代不会把详细 JSON 替换为 HTML。

说明

  • 日期与关系库类型兼容:SDK 与 Gateway 以 { $date: 毫秒 } 传输 Date。关系库按实际列类型保存:日期列保持日期,数字/字符列保存毫秒值;布尔写数字/字符列转 1/0;带时区的完整 ISO 8601 或 13 位毫秒时间戳写日期列会转 Date。不猜 10 位秒时间戳,不自动 JSON 化对象/数组,不截断或舍入;不存在字段仍严格报错
  • db.serverDate() V1 以客户端时间近似;需要可信的“当前时间”用 client.now(),需要不可伪造的“写入时间”用 Gateway 的 beforeWrite.autoTimestamps 钩子
  • distinct / 文件上传:V1 不提供
  • 集合是首次写入时自动创建的(CloudBase 端也一样,Gateway 会代建并放开集合权限)
  • 建/删索引、删集合:SDK 不提供(属 DDL 运维动作)。用 Gateway 的 Admin 面接口 POST /api/execute + 请求头 X-Admin-Keyaction = createIndex / dropIndex / listIndexes / dropCollection / createCollection,仅 mongodb / cloudbase 数据源可用。例:
curl -X POST https://udg.vule.cn/api/execute -H 'X-Admin-Key: <ADMIN_KEY>' -H 'Content-Type: application/json' \
  -d '{"action":"createIndex","appid":"bwds","db":"hjl","collection":"events","keys":{"street":1,"createdAt":-1},"unique":true}'

keys 也可写成 [{"name":"street","direction":1}];方向支持 1/-1/2d/2dsphere/text/hashed;不传 name 时按 MongoDB 惯例生成索引名;重复创建幂等。dropCollection 删非空集合必须显式带 force:true(否则报错并告知集合内文档数)。