bjchy-udg-sdk
v1.8.4
Published
Universal Data Gateway SDK - 跨端统一数据访问(微信小程序 / Web / Node.js),API 兼容微信云开发
Downloads
1,834
Maintainers
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.json 的 version。这个 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 和授权前上下文。
注意事项:
mpCode与code不可混用:code是小程序wx.login的 code(走/auth/wxlogin),mpCode是公众号网页授权的 code(走/auth/mp/login),两者的 openid 体系也不同。- ⚠️ 公众号 openid ≠ 小程序 openid(本项目未启用 unionid)。写入时注入的
_openid因此分两套,同一张表两端都写时请用 JWT 里的authType(wechat/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.error 的 errMsg 是微信报的;jsapiSign() / login({ mpCode }) 抛出的 UdgRequestError(含 errorCode、message)是 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 | 网关的 .env 里 WX_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.getAccountInfoSync,login({ 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.md;dbKey 路由思路的历史版本见 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_INCREMENT 的 insertId;非自增主键表回落到 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 → _OPENID → WX_OPENID → USER_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()收到的事件不再携带文档内容,只给documentIds与truncated: 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),并发追加不会互相覆盖。
刻意没有复用 _.push:push 在文档库是往数组追加元素,同名不同义会让同一份代码在不同数据源上静默产生两种行为,且没有对应的可逆操作。文档库要追加数组元素仍然用 _.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_OPERATOR、TENANT_NOT_FOUND、DB_NOT_FOUND、READONLY_DATABASE、APPEND_ONLY_TABLE、TABLE_ACCESS_DENIED、FORBIDDEN、TIMEOUT 外,还包括:TYPE_MISMATCH、INVALID_NUMBER、INVALID_DATE、VALUE_TOO_LONG、VALUE_OUT_OF_RANGE、REQUIRED_FIELD_MISSING(均为 400),以及 DUPLICATE_KEY、CONSTRAINT_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-Key,action=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(否则报错并告知集合内文档数)。
