更新记录

1.0.0(2026-11-11)

首个版本。

  • 九个 action:ping / check / consume / idem / lock / list / stats / clean / remove
  • 固定窗口限流,条件更新原子扣减配额
  • HMAC-SHA256 签名 + nonce 防重放,容差只认环境变量
  • 幂等键 begin / complete / get / cancel 三段式
  • 带 TTL 与释放凭据的分布式锁,owner 必填
  • 记录集合自带索引,附管理页与分类型统计

平台兼容性

云端兼容性

支付宝云 腾讯云 阿里云
× √ √

uniCloud 接口防护与幂等中间件

零第三方依赖的 uniCloud 云函数中间件:固定窗口限流、HMAC 签名与 nonce 防重放、幂等键去重、轻量分布式锁。一次 check 完成前置校验,附管理页与记录统计。

适合:订单 / 支付 / 表单重复提交、接口被刷、定时任务重复执行、需要给云函数加一道服务端防护、又不想引入 Redis 或其他外部服务的项目。

只依赖 Node 内置 crypto,不引任何 npm 包或扩展库。所有记录只写在使用者自己的服务空间。

特性

  • 固定窗口限流:按业务键计数,并发下用条件更新 where(count <= limit-cost).update(inc(cost)) 原子扣减;返回 remaining 与 retryAfter
  • 防重放:HMAC-SHA256 签名 + 时间戳容差 + nonce 唯一登记,拒绝过期请求与重复 nonce;容差只认环境变量,请求侧改不了;用 timingSafeEqual 比较签名
  • 幂等键:begin → 业务执行 → complete 三段式,重复请求直接返回首次结果;cancel 允许业务失败后重试
  • 轻量分布式锁:acquire / release,带 TTL 自动过期与释放凭据,避免误释放别人的锁
  • 组合校验:check 一次完成「签名防重放 → 限流 → 幂等状态查询」,返回统一的 allowed / reason / result
  • 默认安全:GUARD_TOKEN 未配置时拒绝一切读写;令牌、签名密钥、集合名只从环境变量读,请求侧改不了
  • 不留请求正文:签名只用 body 计算 HMAC,不落库;幂等结果有大小上限;列表接口不返回锁凭据与幂等结果
  • 零依赖:只用 uniCloud.database() 和 Node 内置 crypto
  • 自带管理页:限流自检、记录列表、分类型统计、清理过期、强制删除记录

目录结构

uni_modules/aliang-cloud-guard/
├── package.json                       插件配置(含定价与加密清单,只列 1 个 js)
├── pages_init.json                    自动注册管理页
├── readme.md / changelog.md
├── uniCloud/
│   ├── cloudfunctions/aliang-cloud-guard/
│   │   ├── index.js                   单文件云函数,八个分区
│   │   └── package.json
│   └── database/
│       ├── cloud_guard_records.schema.json
│       └── cloud_guard_records.index.json   三个索引
└── pages/guard/list.vue               管理页

插件内 uniCloud 不带 -aliyun / -tcb 后缀,HBuilderX 会虚拟合并到项目根目录的 uniCloud 中。

云函数刻意做成单文件:付费普通授权版的加密清单按文件配置,多文件结构一旦漏配某个 lib,那个文件会明文留在用户项目里且不会有任何报错。单文件让加密清单只有一项。

记录统一放在一个集合,kind 区分 quota / idem / nonce / lock,只需上传一个 Schema。

五分钟接入

第 0 步(必须先做):在 uniCloud 服务空间的环境变量里配置:

GUARD_TOKEN = 一个足够长的随机字符串
GUARD_SECRET = 另一个随机字符串   # 只有启用签名校验时才需要

未配置 GUARD_TOKEN 时,除 ping 外所有 action 返回 token-required。这是默认安全,不是故障。

  1. 从插件市场导入本插件到 uni-app 项目。
  2. 上传 uniCloud/database/cloud_guard_records.schema.json 到服务空间。
  3. 右键 uniCloud/cloudfunctions/aliang-cloud-guard → 上传部署。
  4. pages_init.json 会自动提示把「接口防护」页注册进 pages.json,确认即可。
  5. 管理页填入 GUARD_TOKEN,点「检查」,再点几次「模拟一次请求」看放行与拒绝。

环境变量

变量 默认 说明
GUARD_TOKEN 空(必填) 所有 action 的令牌,只从环境变量读
GUARD_SECRET 空 签名校验密钥;未配置时启用签名的请求返回 secret-not-configured
GUARD_COLLECTION cloud_guard_records 记录集合名,只从环境变量读
GUARD_DEFAULT_LIMIT 60 默认窗口内上限
GUARD_DEFAULT_WINDOW_MS 60000 默认窗口长度(毫秒)
GUARD_TOLERANCE_MS 300000 签名时间戳容差
GUARD_IDEM_TTL_MS 86400000 幂等记录保留时长
GUARD_LOCK_TTL_MS 30000 锁默认 TTL
GUARD_MAX_RESULT 8000 幂等结果序列化上限,超出只记长度标记
GUARD_CLEAN_BATCH / GUARD_CLEAN_MAX_BATCHES 100 / 10 清理过期记录的分批参数

除令牌、密钥、集合名外,其余可用单次请求的 data.config 覆盖。

API 参考

云函数名 aliang-cloud-guard。统一返回 { errCode: 0 | 字符串, errMsg, data },失败错误码以 aliang-cloud-guard- 开头。

action 说明 主要参数 需要 token
ping 健康检查,返回配置状态(不含密钥) — 否
check 组合前置校验 key limit windowMs timestamp nonce signature body idemKey 是
consume 单独限流扣配额 key limit windowMs cost 是
idem 幂等键操作 step(begin/complete/get/cancel) idemKey ttlMs result 是
lock 分布式锁 step(acquire/release) name owner(acquire 必填) ttlMs lockToken 是
list 记录列表(不含锁凭据与幂等结果) kind page pageSize 是
stats 分类型计数与过期数 — 是
clean 分批清理过期记录 — 是
remove 按 _id 强制删除一条记录 id 是

最简接入:限流

const res = await uniCloud.callFunction({
  name: 'aliang-cloud-guard',
  data: {
    action: 'check', token: '你的令牌',
    key: 'order:create:u1001', limit: 5, windowMs: 60000
  }
})
const r = res.result.data
if (!r.allowed) {
  return { errCode: 'rate-limited', errMsg: `请 ${Math.ceil(r.retryAfter / 1000)} 秒后重试` }
}
// 正常业务

完整接入:限流 + 幂等

const guard = (action, extra) => uniCloud.callFunction({
  name: 'aliang-cloud-guard',
  data: Object.assign({ action, token: TOKEN }, extra)
}).then(r => r.result)

const pre = await guard('check', { key: `pay:${userId}`, limit: 10, idemKey: orderNo })
if (!pre.data.allowed) {
  if (pre.data.reason === 'idempotent-replay') return pre.data.result   // 直接返回首次结果
  return { errCode: 'busy', errMsg: pre.data.reason }
}

const begun = await guard('idem', { step: 'begin', idemKey: orderNo })
if (!begun.data.acquired) return { errCode: 'in-progress', errMsg: '处理中,请勿重复提交' }

try {
  const result = await doBusiness()
  await guard('idem', { step: 'complete', idemKey: orderNo, result })
  return result
} catch (e) {
  await guard('idem', { step: 'cancel', idemKey: orderNo })   // 失败后允许重试
  throw e
}

防重放签名(服务端到服务端)

签名内容:timestamp + '\n' + nonce + '\n' + body,其中 body 为字符串原样、对象则 JSON.stringify、空则空串。算法 HMAC-SHA256,十六进制输出。

强烈建议把 body 先 JSON.stringify 成字符串再传。 直接传对象时,服务端也会 JSON.stringify,而 JS 对象的键顺序取决于属性插入顺序——两边键顺序不一致就算不出同一个签名,排查起来非常费时间。传字符串可以完全避免这个问题。

时间戳容差由环境变量 GUARD_TOLERANCE_MS 决定(默认 5 分钟),请求里的 toleranceMs 会被忽略。这是故意的:容差不在签名内容里,如果允许请求覆盖,截获一个签名请求后把它改大就能绕过 nonce 记录。

const crypto = require('crypto')
const timestamp = Date.now()
const nonce = crypto.randomBytes(12).toString('hex')
const body = JSON.stringify({ orderNo: 'NO1', amount: 100 })
const signature = crypto.createHmac('sha256', GUARD_SECRET)
  .update(`${timestamp}\n${nonce}\n${body}`).digest('hex')

await uniCloud.callFunction({
  name: 'aliang-cloud-guard',
  data: {
    action: 'check', token: GUARD_TOKEN, key: 'server:pay',
    timestamp, nonce, signature, body
  }
})

客户端到云函数的场景建议只用限流 + 幂等:把 GUARD_SECRET 放进前端等于没有密钥。签名校验适用于服务端互调或受信任的后端。

分布式锁

const a = await guard('lock', { step: 'acquire', name: 'daily-settlement', owner: 'cron-1', ttlMs: 60000 })
if (!a.data.acquired) return { errCode: 'locked' }
try {
  await runSettlement()
} finally {
  await guard('lock', { step: 'release', name: 'daily-settlement', lockToken: a.data.lockToken })
}

acquire 时 owner 必须传(例如 pod-1、cron-daily)。它是重入判断与排障的依据,留空会直接返回 invalid-param —— 早期版本允许留空,结果两个都不传 owner 的调用方能互相接管同一把锁。

token 是保留的鉴权字段,业务参数请勿占用。 锁的释放凭据刻意叫 lockToken,与鉴权令牌分开;若业务上确实需要一个叫 token 的参数,请放进 body 或改名,否则会覆盖鉴权令牌导致返回 unauthorized。

错误码

errCode 含义
...-token-required 服务空间没配 GUARD_TOKEN
...-unauthorized 令牌不对
...-invalid-param 缺少 key / 未知 step / 参数不合法
...-invalid-action action 不认识
...-collection-missing Schema 没上传
...-nonce-store-failed nonce 登记时数据库报错(不是重放,按 message 排查)
...-internal 其他异常

check 被拒绝时 errCode 仍为 0(这是业务判定,不是调用失败),具体原因看 data.reason: rate-limited / idempotent-replay / in-progress / secret-not-configured / invalid-timestamp / invalid-nonce / timestamp-expired / bad-signature / nonce-replay。

安全与隐私

  • GUARD_TOKEN / GUARD_SECRET / 集合名只从环境变量读取,data.config 覆盖不了。
  • 业务键只做长度与控制字符约束,不参与数据库命令拼接。
  • 记录集合 permission 全为 false,前端无法直连,只能走云函数。
  • 请求正文只用于计算 HMAC,不写入数据库;nonce 记录只存键与过期时间。
  • 幂等结果有大小上限,超限只保留长度标记;list 不返回 token 与 result 字段。
  • 管理页只应放在后台,不要把令牌写进前端代码或暴露给终端用户。
  • 扣减与窗口切换都走条件更新(where(条件).update()),并发下不会出现「同时判定窗口过期、各自把计数覆写成 1」导致的超发。
  • 返回值里的 used / remaining 是按「更新前读到的计数 + 本次 cost」估算的。并发时可能偏小一点,但判定用的是数据库条件更新,不会因此放行超额请求。需要精确剩余量请自己按 limit 与响应时间算。
  • 固定窗口本身的边界突刺(窗口首尾各放行一次 limit)是算法特性,不是并发问题。防刷要求严格时把窗口改小或叠加网关限流。

容量与限制

  • 所有记录共用一个集合,索引已放在 uniCloud/database/cloud_guard_records.index.json(kind+create_time、create_time、expire_time),随 Schema 一起上传即可,不用手动去控制台建。
  • 一定要配定时清理,否则集合会一直长。给云函数加定时触发器,config 只填 cron 表达式:

    "triggers": [
    { "name": "guardClean", "type": "timer", "config": "0 0 2 * * * *" }
    ]

    触发器里不要也不能填参数 —— 定时触发时云函数收到的 event 只有 Time / TriggerName / Type,传不了自定义 JSON。插件已经识别 Type === 'Timer' 并自动执行 clean,且只放行动作 clean 这一个,不需要也不接受令牌。

  • 幂等需要业务方在成功后显式调用 complete;如果业务进程在 begin 之后崩溃且没调 cancel,该键会等到 TTL 过期才释放,请按业务容忍度设置 GUARD_IDEM_TTL_MS。
  • 同一个 key 的 limit / windowMs 以首次请求为准,后续请求传新值不会生效(记录里存的是第一次的那套)。要改请换 key,或在管理页删掉该条记录。
  • 一次 check 最坏会有 3~4 次数据库往返,云函数超时已显式设为 10 秒。如果用的是腾讯云个人版/入门版套餐(2025-08-31 起超时固定 3 秒),高并发下建议只用 consume 或 idem 单动作,别把四个能力全串在一次 check 里。
  • 本插件不提供验证码、设备指纹、WAF 等能力,定位是云函数侧的服务端中间件,不替代网关防护。

常见问题

Q:报 token-required / unauthorized。 A:没配 GUARD_TOKEN 或管理页填的不一致。用 action: 'ping' 确认配置状态。

Q:启用签名后报 secret-not-configured。 A:服务空间没配 GUARD_SECRET。不打算用签名就别传 signature / nonce / timestamp。

Q:报 nonce-replay 但我确定只发了一次。 A:同一个 nonce 只能用一次,重试请换新的 nonce;另外检查时间戳是否在容差内。

Q:check 返回 in-progress。 A:同一个 idemKey 已有一次 begin 还没 complete。等业务完成,或确认失败后调 cancel。

Q:限流偶尔多放行一次。 A:固定窗口在切换瞬间有极小竞态,属于已知取舍。需要严格限流请叠加网关策略。

Q:报 collection-missing。 A:DB Schema 没上传,上传后重新部署云函数。

Q:lock acquire 返回 invalid-param,提示必须传 owner。 A:owner 是必填的。它不是装饰字段,是用来判断「同一持有者重入」的;留空会让任意调用方都能接管同一把锁。

Q:传了 toleranceMs 但不生效。 A:容差只从环境变量 GUARD_TOLERANCE_MS 读。改成请求可控会让防重放失效,所以刻意不支持。

Q:idem begin 一直返回 in-progress,但我确认上次已经跑完(或早就超时了)。 A:1.0.0 修过这个问题。旧版本在记录过期但还没被 clean 删掉时会永久卡住;现在会条件接管过期记录。如果你的版本早于 1.0.0,删掉该条 idem: 记录即可。

Q:Schema 里 permission 全是 false,会不会导致云函数写不进去? A:不会。uniCloud 的 DB Schema(含权限与字段校验)只在 JQL 下生效,本插件用的是云函数原生数据库接口,不受约束。上传 Schema 的目的是建集合、在控制台留字段说明。

版本要求

  • HBuilderX 4.71+,uniCloud 阿里云 / 腾讯云服务空间
  • JavaScript 普通云函数(CommonJS),需要 Node 内置 crypto(云函数环境默认提供)
  • 付费普通授权版按服务空间 spaceId 绑定,提供 7 天试用

授权

购买后按 DCloud 插件市场的标准授权条款使用,可用于自己的任意商业项目,项目数量不限。普通授权版不含源码,请勿反编译或二次分发。

隐私、权限声明

1. 本插件需要申请的系统权限列表:

无

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

本插件把限流计数、幂等键状态、nonce 与分布式锁记录写入使用者自己的 uniCloud 服务空间集合(默认 cloud_guard_records),字段包含业务键、类型、计数、状态、过期时间、锁持有者与释放凭据。签名校验只使用调用方传入的 body 计算 HMAC,不存储请求正文;幂等结果默认做大小限制。不向任何第三方服务器发送数据,不内置统计或上报 SDK。必须通过 GUARD_TOKEN 才能读写。

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

无

暂无用户评论。