首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
21
篇与
» 小程序
的结果
2026-06-23
小程序自动化测试与 CI/CD 实战:miniprogram-simulate、automator 与流水线搭建
小程序自动化测试与 CI/CD 实战:miniprogram-simulate、automator 与流水线搭建"每次发版前手工点一遍所有页面"——小程序项目超过 20 个页面后,人工回归就是一场酷刑,而且一定漏测。这篇介绍微信官方的两大测试利器(miniprogram-simulate 组件测试、miniprogram-automator 端到端测试)和基于 miniprogram-ci 的自动化发布流水线,把发版从"手工活"变成"流水线作业"。一、测试金字塔:小程序版 / E2E 测试 \ automator(真机/工具自动化) / 业务流程回归 \ 核心链路:登录、下单、支付 /---------------\ / 页面测试 \ Page 级别,模拟交互 /-------------------\ / 组件测试 \ miniprogram-simulate(单组件渲染+断言) /-----------------------\ / 纯函数/工具类单元测试 \ Jest(无渲染依赖的逻辑) /---------------------------\比例原则:单元测试多而快,E2E 测试少而稳。全用 E2E 会又慢又脆,全用单元测试覆盖不了真实渲染。二、组件测试:miniprogram-simulate2.1 安装与配置npm install --save-dev miniprogram-simulate jest// package.json { "scripts": { "test": "jest" }, "jest": { "testEnvironment": "jsdom" } }2.2 测试一个组件假设有个 price-tag 组件,props 传分转换为元展示:// test/price-tag.test.js const simulate = require('miniprogram-simulate') const path = require('path') test('price-tag 渲染价格', () => { const id = simulate.load(path.join(__dirname, '../components/price-tag/index'), { // 处理 usingComponents 里的自定义组件引用 rootPath: path.join(__dirname, '../') }) const comp = simulate.render(id, { price: 9900, // 分 currency: '¥' }) const parent = document.createElement('parent-wrapper') comp.attach(parent) // 断言渲染结果 expect(comp.querySelector('.price').dom.innerHTML).toContain('99') expect(comp.querySelector('.symbol').dom.innerHTML).toBe('¥') // 修改数据触发更新 comp.setData({ price: 1050 }) expect(comp.dom.innerHTML).toContain('10.5') })2.3 测试组件事件test('点击触发 buy 事件', () => { const id = simulate.load('/components/goods-card/index') const comp = simulate.render(id, { goods: mockGoods }) // 监听组件自定义事件 let received = null comp.addEventListener('buy', (e) => { received = e.detail }) // 模拟点击 comp.querySelector('.buy-btn').dispatchEvent('tap') return simulate.sleep(10).then(() => { expect(received).toEqual({ goodsId: 'g1', count: 1 }) }) })适用边界:simulate 是在 jsdom 里模拟小程序运行时,wxs、部分原生组件(map/canvas/web-view)渲染不了。这类组件用 automator 的真机/工具链路测。三、端到端测试:miniprogram-automator3.1 启动与连接npm install --save-dev miniprogram-automator// test/e2e/login.e2e.js const automator = require('miniprogram-automator') describe('登录流程', () => { let miniProgram, page beforeAll(async () => { miniProgram = await automator.launch({ cliPath: 'D:/software/wechat-web-devtools/cli.bat', // 开发者工具 CLI 路径 projectPath: 'D:/work/my-miniprogram', // 项目路径 // 需要开发者工具开启「服务端口」(设置→安全) }) await miniProgram.reLaunch('/pages/login/login') page = await miniProgram.currentPage() }, 60000) afterAll(async () => { await miniProgram.close() }) })3.2 页面元素操作与断言test('微信授权登录成功后跳转首页', async () => { // 获取元素 const btn = await page.$('.login-btn') expect(await btn.text()).toBe('微信一键登录') // mock 授权弹窗(真机上无法自动点授权,mock wx API 是 E2E 的常规手段) await miniProgram.mockWxMethod('getSetting', { authSetting: { 'scope.userInfo': true } }) await miniProgram.mockWxMethod('login', { code: 'mock-code' }) // 点击 await btn.tap() // 等待页面跳转(轮询获取当前页面路径) await miniProgram.pageWaitFor('/pages/index/index') const newPage = await miniProgram.currentPage() expect(newPage.path).toBe('pages/index/index') // 断言页面数据 const data = await newPage.data() expect(data.userInfo.nickName).toBeTruthy() })3.3 mock 能力是 E2E 的灵魂真机自动化测不了微信授权弹窗、支付密码输入这类系统级交互,mockWxMethod 是官方给的口子:// mock 掉支付,专注测试支付成功后的业务流转 await miniProgram.mockWxMethod('requestPayment', { errMsg: 'requestPayment:ok' }) // mock 网络请求,构造稳定测试环境 await miniProgram.mockWxMethod('request', { data: { code: 0, data: mockOrder } }) // ...测试结束后恢复 await miniProgram.restoreWxMethod('request')策略:mock 边界 API(登录/支付/授权),不 mock 业务接口——业务接口走测试环境真实数据,E2E 才有回归价值。3.4 截图对比(可选)// 关键页面截图,接入像素对比工具(如 pixelmatch)做视觉回归 await page.screenshot({ path: `snapshots/${page.path.replace(/\//g, '_')}.png` })四、发布流水线:miniprogram-ci手动点"上传"发版的问题:谁传的、什么时候传的、传的什么代码全靠自觉。ci 工具让发版代码化。4.1 准备密钥小程序后台 → 开发管理 → 开发设置 → 小程序代码上传,生成上传密钥(IP 白名单建议配置)。npm install --save-dev miniprogram-ci4.2 上传脚本// scripts/upload.js const ci = require('miniprogram-ci') const path = require('path') async function upload({ version, desc }) { const project = new ci.Project({ appid: process.env.WX_APPID, type: 'miniProgram', projectPath: path.resolve(__dirname, '../dist'), privateKeyPath: path.resolve(__dirname, './private.key'), ignores: ['node_modules/**/*'] }) const result = await ci.upload({ project, version, // 版本号,如 1.4.2 desc, // 版本描述 setting: { es6: true, minify: true, // 压缩 autoPrefixWXSS: true }, // 机器人编号 1-30:不同流水线用不同机器人,后台显示区分来源 robot: process.env.CI_ROBOT || 1 }) console.log('上传成功', result) } upload({ version: process.env.VERSION, desc: process.env.DESC })4.3 预览二维码(测试分发)const qrcode = await ci.preview({ project, desc: '提测版本', qrcodeFormat: 'image', qrcodeOutputDest: './preview.jpg' }) // 把 preview.jpg 推到测试群,测试同学扫码即测4.4 完整流水线(GitHub Actions 示例)# .github/workflows/release.yml name: miniprogram-release on: push: tags: ['v*'] jobs: test-then-upload: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: '20' } - name: 安装依赖 run: npm ci - name: 单元测试 + 组件测试 run: npm test -- --coverage - name: 构建 run: npm run build - name: 上传体验版 env: PRIVATE_KEY: ${{ secrets.WX_PRIVATE_KEY }} VERSION: ${{ github.ref_name }} run: | echo "$PRIVATE_KEY" > scripts/private.key node scripts/upload.js # 私钥用 GitHub Secrets 存,永远不进仓库 - name: 通知测试群 run: curl -X POST ${{ secrets.WEBHOOK }} -d '{"msg":"体验版已更新: ${{ github.ref_name }}"}'打 tag 触发 → 跑测试 → 构建产物 → 上传体验版 → 群通知领测。全流程 5 分钟,人工介入为零,正式版发布只是最后一步"提交审核"由人在后台点一下确认。五、测试策略落地建议项目阶段投入策略0-5 个页面的新项目只测纯函数(Jest),先把工具函数测稳10+ 页面,开始多人协作引入组件测试,公共组件(请求弹层/卡片)全覆盖20+ 页面,有交易链路E2E 覆盖核心链路(登录/下单/支付),流水线强制卡点多端多项目沉淀测试脚手架,统一 mock 数据管理别追求覆盖率数字:90% 覆盖率但全是无断言的快照测试,不如 40% 覆盖率但每条都测在交易关键路径上。六、避坑清单坑现象解法automator 连不上工具launch 超时开发者工具开启服务端口,关多个实例E2E 时好时坏等待缺失用 pageWaitFor 轮询代替 sleep真机授权弹窗卡死自动化无法点击mockWxMethod 跳过系统弹窗simulate 渲染不出组件使用了 wxs/原生组件该组件改用 automator 测上传报 40125密钥或 IP 白名单问题检查 privateKeyPath 与后台 IP 白名单流水线密钥泄漏私钥提交到仓库用 CI 平台 Secrets,脚本里生成robot 冲突多流水线互相顶掉不同环境配不同 robot 编号写在最后小程序工程化的终局形态:git push 打 tag → 测试自动跑 → 体验版自动传 → 二维码自动进群 → 人只做两件事:看测试报告,点提交审核。前期搭流水线要花两三天,但从第一次"发版救火"开始,这些投入就开始指数级回本。测试代码不是负担,是团队交付速度的复利资产。
2026年06月23日
203 阅读
0 评论
0 点赞
2026-06-19
小程序埋点与性能监控体系:从 wx.getPerformance 到线上告警
小程序埋点与性能监控体系:从 wx.getPerformance 到线上告警"线上有点卡"——这种反馈没法排查:哪个页面、哪个环节、什么机型、什么网络?没有监控体系的小程序,性能问题全靠猜。这篇搭一套完整的可观测方案:性能指标采集、业务埋点规范、异常捕获、上报通道和告警闭环。一、监控什么:指标体系1.1 性能指标(技术侧)指标采集方式关注点启动耗时wx.getPerformance下载/注入/首屏渲染分段路由切换耗时手动打点onShow 到可交互setData 耗时wx.getPerformance + 手动单次通信量和频率请求耗时拦截器打点P95 接口慢查询白屏率/错误率异常捕获稳定性红线1.2 业务指标(产品侧)漏斗模型:曝光 → 点击 → 提交 → 支付成功。技术指标告诉你"哪里慢",业务指标告诉你"哪里漏"。二、性能指标采集2.1 wx.getPerformance// app.js onLaunch 里启动观察者 const perf = wx.getPerformance() const observer = perf.createObserver((entryList) => { entryList.getEntries().forEach(entry => { switch (entry.name) { case 'appLaunch': // 启动总耗时 report('perf_launch', { duration: entry.duration, path: entry.path, scene: entry.scene // 进入场景值,很关键 }) break case 'firstRender': // 首次渲染 report('perf_first_render', { duration: entry.duration }) break case 'evaluateScript': // 逻辑层注入 report('perf_evaluate', { duration: entry.duration }) break } }) }) observer.observe({ entryTypes: ['navigation', 'render', 'script'] })启动耗时的正确拆解:appLaunch = 代码包下载 + 代码注入 + 首次渲染 + 首屏接口。分别打点才能定位瓶颈:下载慢是包体积问题,注入慢是主包代码量问题,渲染慢是首屏 setData 数据量问题,接口慢是后端问题——处方不同,诊断必须分开。2.2 页面级打点// utils/perf-track.js function trackPageShow(page) { page._showAt = Date.now() } function trackPageReady(page) { const cost = Date.now() - page._showAt report(`perf_page_${page.route}`, { cost }) } // 劫持 Page 构造器统一埋点(一次接入全局生效) const originPage = Page Page = function (config) { const { onShow, onReady } = config config.onShow = function () { trackPageShow(this) onShow && onShow.call(this) } config.onReady = function () { trackPageReady(this) onReady && onReady.call(this) } originPage(config) }2.3 setData 监控高频 setData 是性能杀手,全局埋点监控:// 劫持 setData,记录每次调用数据量 function wrapSetData(page) { const origin = page.setData.bind(page) page.setData = function (data, callback) { const size = JSON.stringify(data).length if (size > 100 * 1024) { // 单次超 100KB 告警 report('perf_setdata_oversize', { page: page.route, size, keys: Object.keys(data).slice(0, 10) }) } const start = Date.now() origin(data, () => { callback && callback() report('perf_setdata', { page: page.route, size, cost: Date.now() - start }) }) } }三、业务埋点规范3.1 事件模型统一 schema,别每个页面自由发挥:// 埋点 = 谁 + 在哪 + 做了什么 + 结果如何 track('order_submit', { page: 'pages/order/confirm', // 页面路径 biz_params: { // 业务参数 goodsCount: 3, amount: 99.0, couponUsed: true }, result: 'success', // success / fail failReason: '' // 失败原因 })3.2 曝光埋点的坑列表项曝光不能用 onShow(整页级别太粗),用 IntersectionObserver:// 组件 attach 时注册观察器,进入视口才算曝光 Component({ lifetimes: { attached() { this._observer = this.createIntersectionObserver() this._observer.relativeToViewport({ bottom: 0 }).observe('.card', (res) => { if (res.intersectionRatio > 0.5 && !this._exposed) { this._exposed = true // 只曝光一次 track('goods_expose', { id: this.data.item.id }) this._observer.disconnect() // 观察完就断开,省性能 } }) }, detached() { this._observer && this._observer.disconnect() } } })3.3 漏斗分析把关键路径事件串起来,服务端按 session_id 聚合:session: abc123 10:00:01 home_banner_expose 10:00:05 home_banner_click 10:00:12 goods_detail_view 10:00:40 cart_add 10:01:15 order_submit (success) 10:01:30 pay_success转化率 = 后一环节人数 / 前一环节人数。掉率异常的环节,就是产品和技术共同盯的靶子。四、异常捕获4.1 JS 错误// app.js App({ onError(err) { report('error_js', { message: err.message, stack: err.stack.slice(0, 2000), // 截断,别把上报接口自己打爆 route: getCurrentPageRoute(), version: APP_VERSION // 版本号必须带,定位灰度问题 }) } })4.2 Promise 拒绝// onError 捕获不到未处理的 Promise rejection App({ onUnhandledRejection(res) { report('error_promise', { reason: String(res.reason).slice(0, 1000) }) } })4.3 请求失败请求封装层统一捕获(业务错误码 + 网络错误分类上报):function request(options) { return new Promise((resolve, reject) => { wx.request({ ...options, success: (res) => { if (res.statusCode >= 500) { report('error_http_5xx', { url: options.url, code: res.statusCode }) } resolve(res) }, fail: (err) => { report('error_network', { url: options.url, msg: err.errMsg }) reject(err) } }) }) }JSError 聚合:上报时对 stack 做 fingerprint(取函数名+文件名哈希),服务端按 fingerprint 聚合数量,否则同一个错误刷出几万条没法看。五、上报通道设计5.1 批量 + 采样// utils/report.js const queue = [] let timer = null function report(event, data) { // 采样:非错误类事件 30% 采样率足够 if (!event.startsWith('error') && Math.random() > 0.3) return queue.push({ event, data, ts: Date.now(), sessionId: getApp().globalData.sessionId, openid: getApp().globalData.openid || '', systemInfo: getApp().globalData.systemInfo, // 机型/系统/基础库,启动时采集一次缓存 version: APP_VERSION }) // 攒 10 条或 5 秒批量发 if (queue.length >= 10 || timer) return timer = setTimeout(flush, 5000) } function flush() { timer = null if (!queue.length) return const batch = queue.splice(0, 20) wx.request({ url: 'https://track.example.com/batch', method: 'POST', data: { events: batch }, fail: () => { // 失败回队,最多重试一次,别无限堆积 if (queue.length < 200) queue.unshift(...batch) } }) }设计要点:批量上报:逐条上报浪费请求数,也容易被限流错误类全量、行为类采样:监控成本和有效性平衡体积上限:队列封顶,极端情况下丢弃最旧数据,防止内存堆积onHide 时强制 flush:切后台立刻发送,别等定时器5.2 上报域名要求域名必须在微信后台配置 request 合法域名,且用独立子域名(track.xxx.com)——与业务接口隔离,统计流量波动不影响业务,业务接口故障也不影响监控数据回收。六、告警闭环采集只是开始,告警才产生价值:规则阈值动作JSError 突增5 分钟内同 fingerprint > 100立即电话/短信接口 5xx 率> 1% 持续 5 分钟群告警启动 P90> 5s 持续 30 分钟群告警支付成功率环比跌 10%立即电话告警分级是关键:全是紧急告警等于没有告警。资损类电话叫醒,体验类群消息,趋势类日报周报。七、避坑清单坑现象解法埋点代码入侵业务改个埋点改十处劫持 Page/请求层统一埋点上报打爆自己错误风暴连环上报fingerprint 聚合 + 队列上限没带版本号无法定位是哪个版本的问题全事件带 APP_VERSION曝光重复统计滚动来回算多次_exposed 标记一次性白名单域名忘了配上报静默失败上报 fail 打本地日志只看均值长尾用户被平均看分位数 P90/P95写在最后监控体系的建设顺序建议:先异常(保命)→ 再性能(体检)→ 最后业务(增长)。异常监控上线第一天就能发现存量 bug;性能基线建立后,每次发版有数据回归;业务漏斗则是产品迭代的弹药库。三步都不复杂,难的是坚持让"数据说话"成为团队习惯。
2026年06月19日
131 阅读
0 评论
0 点赞
2026-06-13
小程序接口安全攻防:签名、防重放、防刷与数据加密实战
小程序接口安全攻防:签名、防重放、防刷与数据加密实战小程序代码可以被反编译、请求可以被抓包、接口可以被脚本刷——这是做接口安全设计的前提假设。这篇按"攻击者视角"过一遍小程序接口的常见攻防手段,从请求签名到风控埋点,给出一套可落地的纵深防御方案。一、先明确攻击面拿到一个小程序包(unwxapkg 解包是公开技术),攻击者能做什么:读源码:所有前端逻辑可见,包括写死的密钥、接口地址、加密逻辑改请求:Charles/mitmproxy 抓包改包,绕过前端校验直接打接口重放请求:把合法请求原样重发(领券接口重发 100 次)脚本刷量:用抓到的协议写脚本,批量注册、批量抢券、爬数据核心结论:任何只放在前端的安全逻辑都等于明文。前端安全措施的定位是"提高破解成本",真正的防线必须在服务端。二、第一层:请求签名2.1 签名方案sign = HMAC-SHA256(secret, method + path + timestamp + nonce + body摘要)问题来了:secret 放哪?放前端代码里会被反编译提取。务实的做法是不追求绝对保密,而是动态化:// 登录后,服务端按设备指纹+时间窗口下发动态签名密钥 // 前端存内存(不落盘),每次请求计算签名 const crypto = require('./crypto') // 轻量 hmac 实现 function signRequest(method, path, body) { const timestamp = Date.now().toString() const nonce = randomStr(16) const message = [method, path, timestamp, nonce, hashBody(body)].join('\n') const sign = crypto.hmacSha256(getSessionSecret(), message) return { timestamp, nonce, sign } }2.2 服务端验签// Node.js 中间件 async function verifySign(req, res, next) { const { 'x-timestamp': ts, 'x-nonce': nonce, 'x-sign': sign } = req.headers // 1. 时间窗口校验(±5分钟),防长期重放 if (Math.abs(Date.now() - Number(ts)) > 5 * 60 * 1000) { return res.status(401).json({ code: 'SIGN_EXPIRED' }) } // 2. nonce 一次性校验:redis SETNX + TTL const isNew = await redis.set(`nonce:${nonce}`, 1, 'NX', 'EX', 300) if (!isNew) { return res.status(401).json({ code: 'REPLAY_ATTACK' }) } // 3. 按用户会话密钥重算签名比对 const expected = hmacSha256(req.sessionSecret, buildMessage(req)) if (expected !== sign) { return res.status(401).json({ code: 'SIGN_INVALID' }) } next() }密钥下发设计:签名密钥在登录时由服务端生成(绑定 openid + 设备 + 过期时间),存 Redis。密钥本身不下发到可持久化的存储,前端只存内存变量,冷启动重新走登录换取。这样即使被反编译拿到加密算法,拿不到实时密钥。三、第二层:防重放与防刷3.1 时间戳 + nonce 双保险只校验时间戳:5 分钟窗口内照样可以重放。只校验 nonce:nonce 表无限膨胀。两者结合:时间窗口内的 nonce 记 Redis(TTL = 窗口长度),窗口外的直接拒绝。3.2 幂等令牌(业务防重)关键操作(下单、领券、提现)加幂等令牌:进入下单页 → POST /idempotent-token → 服务端生成 token 存 Redis(5分钟有效,一次性) 提交订单 → 携带 token → 服务端 GETDEL 原子消费 → 消费成功:处理业务 → 消费失败(token 不存在/已用):拒绝,返回"请勿重复提交"const token = await redis.getdel(`idem:${clientToken}`) if (!token) return res.status(409).json({ code: 'DUPLICATE' }) // token 消费成功才继续业务比签名更硬:幂等令牌在业务层防重放,签名防不了"同一用户不同 nonce 重放同一业务"。3.3 频率限制多维度限流(Redis + 令牌桶/滑动窗口):async function rateLimit(req) { const rules = [ { key: `rl:ip:${req.ip}`, limit: 100, window: 60 }, // IP 维度 { key: `rl:uid:${req.openid}`, limit: 30, window: 60 }, // 用户维度 { key: `rl:uid-api:${req.openid}:${req.path}`, limit: 5, window: 60 } // 用户+接口 ] for (const r of rules) { const cnt = await redis.incr(r.key) if (cnt === 1) await redis.expire(r.key, r.window) if (cnt > r.limit) return { blocked: true, rule: r.key } } return { blocked: false } }梯度惩罚:超限不直接封禁(误伤共享 IP),而是返回 429 + 递增惩罚时长(30s → 5min → 1h),并在风控后台打标。3.4 行为验证高频价值接口(抢券、秒杀)前置行为验证:微信提供的 wx.checkSession 只是登录态检查,不是人机验证自建方案:滑块/点选验证码,配合行为埋点(操作时长、滑动轨迹的贝塞尔曲线特征、页面停留时间)综合评分简单有效的一招:接口必须携带页面埋点产生的行为链 ID,脚本直调接口没有行为链,直接拒绝四、第三层:数据加密传输HTTPS 之外再加一层应用层加密,主要防抓包工具明文查看(HTTPS 抓包在用户信任证书的前提下是明文的):// 前端:AES 加密业务数据 const key = getSessionKey() // 登录协商的会话密钥 const encrypted = aesEncrypt(JSON.stringify(data), key) wx.request({ url: api, data: { payload: encrypted }, // 密文外层再走签名 // ... }) // 后端:对称解密 const data = JSON.parse(aesDecrypt(req.body.payload, sessionKey))会话密钥协商:登录成功后服务端生成 32 字节随机密钥,用 openid 绑定存 Redis,返回给前端存内存。抓包者拿到的是密文+无法解密的密钥。加密的边界认知:这层防的是"偷看数据",防不了"拿会话重放"——重放要靠前面的签名+nonce+幂等令牌。四层各司其职。五、第四层:服务端业务兜底前面所有手段都可能被绕过(逆向能力强的大厂黑产团队有的是),服务端业务校验是最后防线:价格不信任前端:下单只传商品 ID 和数量,价格从数据库取——这条老规矩依然有人犯错库存原子扣减:UPDATE stock SET n = n - 1 WHERE id = ? AND n > 0,affected rows = 0 就售罄风控规则引擎:单用户单日领券上限、同设备指纹多账号识别、异常时段集中下单告警敏感操作二次核验:提现、改手机号等操作要求短信/生物识别二次验证六、纵深防御全景图┌─ L1 传输层:HTTPS + 证书锁定(防被动窃听) ├─ L2 签名层:动态密钥 + timestamp + nonce(防篡改、防重放) ├─ L3 业务层:幂等令牌 + 限流 + 行为验证(防刷量) ├─ L4 数据层:应用层 AES(防抓包明文) └─ L5 兜底层:服务端价格/库存/权限校验 + 风控(防一切绕过)攻击者要攻破你的系统,需要逐层突破;而每加一层,攻击成本都是指数级上升。绝大多数刷单攻击在 L2/L3 就被挡掉了,坚持打到 L5 的对手,靠风控人工处置。七、避坑清单坑现象解法secret 硬编码在前端反编译直接提取登录后动态下发,内存持有只校验时间戳不校验 nonce窗口期内重放成功双校验 + Redis SETNX前端传价格/库存改包 0.01 元下单服务端查库取价限流只按 IP公司/校园网集体误伤IP+用户+设备指纹多维加密了没验签抓包改密文照样打加密和签名是两件事,都要幂等令牌可复用并发重复提交GETDEL 原子消费429 直接封 IP黑产换 IP 池绕过梯度惩罚 + 设备指纹识别写在最后接口安全没有银弹,本质是一场成本博弈:你的防御成本要让攻击者的破解成本高于收益。普通业务做好"动态密钥签名 + 防重放 + 服务端兜底校验"三板斧,就能挡掉 99% 的脚本小子;剩余 1% 的职业黑产,交给风控规则和人工运营。别追求理论上的绝对安全,追求性价比足够高的纵深防御。
2026年06月13日
243 阅读
0 评论
0 点赞
2026-06-13
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势不想买服务器、不想做登录鉴权、不想配 HTTPS 证书——云开发(TCB)把这些全包了。但"免运维"不等于"无脑用",云开发的冷启动、数据库权限模型、计费陷阱都有讲究。这篇按真实项目经验整理云开发的正确打开方式。一、云开发是什么传统架构:小程序 → HTTPS 服务器(自己买、自己运维、自己备案域名)→ MySQL/Redis。云开发架构:小程序 → 云函数(无服务器,微信鉴权自动透传)→ 云数据库(文档型,小程序端可直接查询)+ 云存储。三个开箱即用的能力:免鉴权调用:云函数天然知道调用者 openid,wx.cloud.callFunction 不需要自己实现登录态小程序直连数据库:数据库权限规则控制读写粒度,简单 CRUD 不用写后端云存储直传:图片视频直传,无需走服务器中转适用判断:中小型工具类、内容类小程序(日活几千到几万量级)用云开发能省一个人力;强事务、复杂查询、高并发秒杀类业务,老实上自建后端。二、云函数2.1 基础结构// cloudfunctions/createOrder/index.js const cloud = require('wx-server-sdk') cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) const db = cloud.database() exports.main = async (event, context) => { // 关键:不需要自己解 token,直接拿 openid const { OPENID, UNIONID, APPID } = cloud.getWXContext() const { goodsId, count } = event // 参数校验:云函数暴露在公网,event 里任何字段都不可信 if (!Number.isInteger(count) || count < 1 || count > 99) { return { code: 400, msg: 'invalid count' } } const res = await db.collection('orders').add({ data: { _openid: OPENID, goodsId, count, status: 'created', createdAt: db.serverDate() } }) return { code: 0, orderId: res._id } }2.2 数据库权限规则:安全的边界核心认知:小程序端代码是可以被反编译的。你写在 db.collection('orders').where(...) 里的查询,任何人都能改。安全只能靠权限规则兜底。云数据库四种基础权限:权限读写仅创建者可读写本人本人所有人可读,仅创建者可写所有本人仅创建者可读写(管理端可读)本人本人所有用户不可读写云函数云函数进阶:自定义安全规则(JSON 声明式):{ "read": "auth.openid == resource._openid || get(`database.roles.${auth.openid}`).role == 'admin'", "write": "auth.openid == resource._openid" }这条规则实现了:本人可读写自己的文档,admin 角色可读所有。任何涉及金额、权限、敏感数据的操作,一律收进云函数(云函数走的是管理员权限,不受规则限制),前端直查只用于展示类数据。2.3 冷启动云函数实例闲置后回收,再次调用要冷启动(拉镜像、初始化 runtime),耗时 1~3 秒。缓解手段:// 1. 依赖收进包体:wx-server-sdk 是大头,按需引入 // 2. 全局复用连接:把初始化放到函数体外面 const cloud = require('wx-server-sdk') cloud.init() // 模块加载时执行,冷启动后所有请求复用 const db = cloud.database() exports.main = async (event) => { /* ... */ }// config.json:配置预置并发(云开发控制台或函数配置) { "triggers": [], "layers": [] }控制台开启预置并发实例可彻底消除冷启动(额外计费),支付下单等核心链路建议开启。三、云数据库3.1 文档型思维类 MongoDB 的文档模型,没有 join,关联靠冗余或多次查询:// 订单列表:直接冗余商品快照,避免列表页 N+1 查询 const orders = await db.collection('orders') .where({ _openid: OPENID, status: 'paid' }) .orderBy('createdAt', 'desc') .skip((page - 1) * 20) .limit(20) .get()设计原则:列表页要展示什么,就冗余什么。订单里的商品标题、价格、缩略图做成快照存进订单文档,详情页再查全量。3.2 事务涉及多个文档一致性的操作必须用事务(仅云函数端支持):const transaction = await db.startTransaction() try { const goods = await transaction.collection('goods').doc(goodsId).get() if (goods.data.stock < count) throw new Error('库存不足') await transaction.collection('goods').doc(goodsId).update({ data: { stock: _.inc(-count) } // 原子自减,避免并发超卖 }) await transaction.collection('orders').add({ data: orderDoc }) await transaction.commit() } catch (e) { await transaction.rollback() throw e }小程序端直查没有事务,这是"扣库存必须进云函数"的根本原因。3.3 索引单字段查询超过一定量级后必须建索引(控制台 → 数据库 → 索引管理)。复合查询用组合索引,注意字段顺序(把等值条件字段放前面、范围/排序字段放后面):// 订单列表查询 where({_openid, status}).orderBy(createdAt desc) 的组合索引 { "name": "openid_status_created", "unique": false, "keys": [ { "name": "_openid", "direction": "1" }, { "name": "status", "direction": "1" }, { "name": "createdAt", "direction": "-1" } ] }没有索引的大集合查询会直接报错或超时,上线前用 explain 验证一遍。四、云存储4.1 上传// 小程序端直传 wx.cloud.uploadFile({ cloudPath: `avatars/${OPENID}-${Date.now()}.jpg`, // 路径自己拼,注意唯一 filePath: tempFilePath, // 本地临时文件 success: res => { // res.fileID: cloud://xxx —— 存这个!不要存临时链接 } })必须存 fileID 而不是临时 URL:cloud:// 开头的 fileID 可以通过 wx.cloud.getTempFileURL 换成带签名的临时链接(默认 2 小时有效);存死了 URL 过期就是裂图。4.2 图片处理临时链接可以拼 OSS 风格的图片处理参数,缩略图不用自己生成:const { fileList } = await wx.cloud.getTempFileURL({ fileList: [fileID] }) const url = fileList[0].tempFileURL // 拼接缩放参数 const thumb = `${url}&imageMogr2/thumbnail/300x300`列表页用缩略图、详情页用原图,流量和加载速度双赢。五、定时触发器运营态需求(每日结算、数据同步、优惠券过期)用定时触发器,免服务器 crontab:// cloudfunctions/dailySettle/config.json { "triggers": [ { "name": "dailySettle", "type": "timer", "config": "0 30 2 * * * *" // 每天凌晨 2:30 } ] }// 函数内判断触发来源 exports.main = async (event) => { if (event.TriggerName) { // 定时触发,无 OPENID 上下文 await settleYesterday() } else { // 用户调用 } }六、计费与成本控制云开发按量付费(有免费额度),三个计费大头:云函数调用次数 + 运行时长:高频轮询接口最烧钱,用数据库 watch(实时推送)替代轮询数据库读写次数:小程序端直查 + 分页加载做不好,读次数暴涨CDN 流量:图片直接原图全量输出是流量刺客,必须上缩略参数成本红线自查:日活 1 万的小程序,如果月账单超过几百块,大概率是"该进云函数的没进、该建索引的没建、该用缩略图的没缩"三宗罪之一。七、避坑清单坑现象解法前端直写数据库被刷数据被恶意篡改写操作收进云函数,规则只开读存了临时 URL图片过两天裂图只存 fileID,用时换临时链列表页 N+1 查询首页加载 3 秒+文档冗余快照字段并发超卖库存变负数云函数事务 + _.inc 原子操作函数冷启动慢首次点击卡 2 秒预置并发 + 模块级初始化大集合无索引查询查询报错/超时控制台建组合索引event 参数直接入库注入脏数据云函数内白名单校验写在最后云开发的甜点区是"快速验证的中小型业务"——一套前端 + 少量云函数就能上线,验证失败损失极小。规模上去后(复杂事务、数据分析、多端复用),再平滑迁移到自建后端也不迟:云数据库导出 JSON、云函数逻辑改写成 HTTP 接口,工作量可控。选型不是信仰,是成本和阶段的匹配。
2026年06月13日
256 阅读
0 评论
0 点赞
2026-06-06
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款支付是小程序商业化的最后一公里,也是出问题最致命的一环——少一个验签步骤就是资损,回调处理不幂等就是对账灾难。这篇以 Node.js 后端为例,把 JSAPI 支付的下单、调起、回调、退款全链路和每个环节的坑讲透。一、整体链路总览用户点击"支付" → 小程序端: wx.login 换 openid(通常登录时已拿到) → 后端: 统一下单 API(携 openid、金额、商户订单号) → 微信返回 prepay_id → 后端: 用 prepay_id 二次签名,返回支付参数 → 小程序端: wx.requestPayment 调起支付 → 用户完成支付 → 微信异步回调 notify_url(后端验签 + 更新订单) → 兜底: 支付结果主动查询(查单)关键认知:支付状态以微信回调(或主动查单)为准,前端 wx.requestPayment 的 success 回调只代表"用户完成了支付操作",不能作为入账依据。二、后端统一下单2.1 准备工作商户号 mch_id + API v3 密钥(微信支付商户平台申请)证书私钥(apiclient_key.pem)+ 证书序列号小程序 appid 需与商户号绑定2.2 下单代码(API v3)// pay/service.js const crypto = require('crypto') const axios = require('axios') async function createOrder({ orderId, amount, openid, description }) { const body = { appid: process.env.WX_APPID, mchid: process.env.WX_MCHID, description, // 商品描述 out_trade_no: orderId, // 商户订单号,唯一 notify_url: 'https://api.example.com/pay/notify', amount: { total: amount, // 单位:分!不是元! currency: 'CNY' }, payer: { openid } // JSAPI 支付必须传 openid } const res = await wxpayV3('POST', '/v3/pay/transactions/jsapi', body) return res.prepay_id }第一个高频坑:金额单位是分。前端传元后端忘了乘 100,用户 1 分钱买走 99 元商品,事后哭都来不及。建议在 API 层做一层显式转换,并在数据库订单表同时存 amount_yuan 和 amount_fen 方便对账。2.3 生成小程序支付参数拿到 prepay_id 后,需要再签一次名才能给前端调起:function buildPayParams(prepayId) { const timeStamp = String(Math.floor(Date.now() / 1000)) const nonceStr = crypto.randomBytes(16).toString('hex') const appId = process.env.WX_APPID const pkg = `prepay_id=${prepayId}` // 签名串:appId\ntimeStamp\nnonceStr\npackage\n const message = `${appId}\n${timeStamp}\n${nonceStr}\n${pkg}\n` const signature = crypto.createSign('RSA-SHA256') .update(message) .sign(fs.readFileSync('./cert/apiclient_key.pem'), 'base64') return { timeStamp, nonceStr, package: pkg, signType: 'RSA', paySign: signature } }前端拿到的就是这五个字段,原封不动传给 wx.requestPayment。三、小程序端调起支付// 前端 async function pay(orderId) { // 1. 调后端下单接口拿支付参数 const { timeStamp, nonceStr, package: pkg, signType, paySign } = await api.createPayOrder(orderId) // 2. 调起支付 return new Promise((resolve, reject) => { wx.requestPayment({ timeStamp, nonceStr, package: pkg, signType, paySign, success: resolve, // 仅代表用户操作完成 fail: reject // ERR_USER_CANCEL 用户取消 / 其他失败 }) }) }用户体验设计:fail 回调里区分 errMsg 含 cancel 的情况——用户主动取消不用弹错误提示,保持静默或轻提示"已取消支付";其他失败才引导重试。四、回调:验签 + 解密 + 幂等这是整个支付链路最容易出事故的环节。4.1 验签微信回调请求头带 Wechatpay-Signature,必须用微信支付平台证书验签,防止伪造回调:const { verifySign } = require('./wxpay-verify') router.post('/pay/notify', express.raw({ type: '*/*' }), async (req, res) => { const headers = { timestamp: req.headers['wechatpay-timestamp'], nonce: req.headers['wechatpay-nonce'], signature: req.headers['wechatpay-signature'], serial: req.headers['wechatpay-serial'] } // 1. 验签(验的是「时间戳\n随机串\n请求体\n」) const valid = verifySign(headers, req.body.toString()) if (!valid) { return res.status(401).json({ code: 'FAIL', message: '验签失败' }) } // ... })4.2 解密资源对象回调 body 里的订单信息是 AES-256-GCM 加密的,用 API v3 密钥解密:function decryptResource(ciphertext, associatedData, nonce) { const buf = Buffer.from(ciphertext, 'base64') const authTag = buf.subarray(buf.length - 16) const data = buf.subbuf(0, buf.length - 16) || buf.subarray(0, buf.length - 16) const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(process.env.WX_V3_KEY), Buffer.from(nonce)) decipher.setAuthTag(authTag) decipher.setAAD(Buffer.from(associatedData)) return JSON.parse( Buffer.concat([decipher.update(data), decipher.final()]).toString() ) }解密出来的就是订单详情:out_trade_no、transaction_id(微信支付单号)、trade_state(SUCCESS/REFUND/CLOSED...)。4.3 幂等处理(对账灾难的防火墙)微信回调会重试多次(网络异常、你返回非 200 都会触发),代码必须幂等:// 数据库唯一约束 + 状态机 const affected = await db.query( `UPDATE orders SET status = 'paid', transaction_id = ?, paid_at = NOW() WHERE order_no = ? AND status = 'unpaid'`, // 关键:只允许 unpaid -> paid [transaction_id, out_trade_no] ) if (affected === 0) { // 已处理过(重复回调)或状态不对,直接返回成功,别让微信重试 return res.json({ code: 'SUCCESS' }) } // 幂等后再做副作用:发券、发消息、记账…… await fulfillOrder(out_trade_no)返回规范:处理成功返回 HTTP 200 + {"code":"SUCCESS"};失败返回 4xx/5xx,微信会按退避策略重试(15s/15s/30s/3m/10m...最多 24 小时)。五、查单兜底回调可能丢失(服务重启、网络抖动)。前端支付成功后主动查一次,未支付订单定时任务轮询:// 查单接口 async function queryOrder(orderId) { const res = await wxpayV3('GET', `/v3/pay/transactions/out-trade-no/${orderId}?mchid=${process.env.WX_MCHID}`) return res.trade_state // SUCCESS / NOTPAY / CLOSED / REFUND ... } // 定时任务:每 5 分钟扫一次 30 分钟前创建还未支付回调的订单 // 连续 N 次 NOTPAY 后主动关单,防止库存被长期锁死关单时机:限时优惠单、库存类订单超时未支付要主动调 /v3/pay/transactions/out-trade-no/{id}/close 关单,否则用户可能 24 小时后还按旧价格支付成功。六、退款退款是独立的一套链路,坑比支付还多:async function refund({ orderId, refundNo, refundAmount, totalAmount, reason }) { const body = { out_trade_no: orderId, out_refund_no: refundNo, // 商户退款单号,唯一!幂等依据 reason, amount: { refund: refundAmount, // 分 total: totalAmount, // 原订单总额 currency: 'CNY' } } return wxpayV3('POST', '/v3/refund/domestic/refunds', body) }退款三原则:out_refund_no 必须唯一且可重复提交(同号重复提交不会二次退款,天然幂等)退款回调 /v3/refund/domestic/refunds 的 notify 同样要验签、解密、幂等退款状态有中间态(PROCESSING),钱到账有延迟,用户余额退回通常秒级,银行卡 1-3 天——客服话术要提前准备七、对账每日下载对账单做三方核对:// 商户平台每日生成对账单,API 下载 const res = await wxpayV3('GET', `/v3/bill/fundflowbill?bill_date=20260822&account_type=BASIC`) // 逐笔核对:本地订单表 vs 微信账单 // 差异场景:本地 paid 但账单没有(掉单)、账单有但本地 unpaid(漏处理回调)对账脚本跑不通的支付系统都是裸奔。建议每日跑一次 + 告警群通知差异单。八、避坑清单坑后果解法金额传了元资损下单前统一 ×100,双字段存库前端 success 当入账依据掉单假象只信回调/查单回调不验签被伪造回调刷单平台证书验签回调不幂等重复发货状态机 + affected rows 判断回调处理失败只返回 500微信疯狂重试失败入重试队列,先回 200out_refund_no 随机生成重复退款基于订单号+序号生成证书过期没监控全渠道支付瘫痪证书到期前 30 天告警写在最后支付系统的代码量其实不大,难点全在异常路径:回调丢失、重复通知、用户中途杀进程、退款卡在中间态。设计时把每一步都当成"随时会失败"来写——验签是底线,幂等是纪律,查单是兜底,对账是审计。四层防御做齐,才能安心睡个好觉。
2026年06月06日
266 阅读
0 评论
0 点赞
1
2
3
4
5
0:00