小程序支付后端从零构建:wx.pay 全流程、幂等实践与客户端定位

支付后端从 0 到 1:流程、幂等和那位备胎 提起微信支付,不少后端新同学的第一反应是"这不就是调个 API 嘛"。真上手才发现,光一个异步回调就能把人折腾到怀疑人生:明明用户付了钱,订单状态却一直不更新;回调来了两次,积分发了双份;个人主体小程序连商户号都申请不下来,只能对着文档干瞪眼。 这篇文章把 wx.pay 的后端流程从头到尾拆开:登录拿 openid、统一下单换 prepay_id、二次签名、异步回调验签与解密,再到怎么用乐观锁把幂等做扎实。最后用一点篇幅聊聊"备胎信使"理论——搞明白客户端在支付里到底说了不算什么,很多困惑会迎刃而解。 📌 前置知识:会写 Spring Boot 接口,看得懂 SQL,理解基本的 HTTP 与 JSON。不需要任何支付经验,本文的代码保证从空项目能直接搭起来。 第 1 步 目标说明:这一篇到底讲什么 1.1 为什么写这篇文章 支付是少数几个"看起来简单、出错要命"的领域。某开发者的第一版支付代码只有一百多行,跑起来却发现三个大坑: 客户端调起支付后立刻回调了 success,后端却还没收到微信的异步通知,订单一直挂在"待支付"; 通知重试机制下同一个回调被处理了两次,用户积分翻倍; 本地调得好好的,上线后微信的回调根本进不来——因为内网地址微信访问不了。 这三件事分别对应流程、幂等、回调三个话题,也是本文的主线。提前把这些想明白,能省下大把试错时间。 1.2 小程序开发的"三驾马车" 一个完整的小程序业务,后端主要跟三样东西打交道: 能力 前端 API 后端职责 类比 身份识别 wx.login 用 code 换 openid,建立用户账号 进门刷脸 交易闭环 wx.requestPayment 统一下单、签名、回调处理 柜台结账 消息触达 wx.requestSubscribeMessage + 服务端发送 存 access_token,发订阅消息 售后电话 三者独立又协作:登录建立身份,支付产生交易,订阅消息把交易结果送达用户。 1.3 本文核心议题 围绕上面三驾马车,重点回答四个问题: wx.pay 的完整后端流程是什么?预支付、二次签名、异步回调各是干什么的。 如何保证支付幂等,防止重复扣款、重复加积分? 客户端在支付中到底扮演什么角色?哪些事它说了不算? 个人开发者没有商户资质,怎么照样把后端逻辑练熟? 1.4 阅读本文的收获 读完你会得到一份可以直接抄的 Java 实现:登录接口、统一下单、二次签名、回调处理器,以及一套幂等的三层防御。还会得到一个重要认知:支付后端的核心逻辑与商户号无关,没资质也能先把逻辑写对,拿到商户号只是替换一个 API 地址的事。 ...

十月 23, 2023 · 15 分钟 · 3017 字 · yaomingye

AQS 源码重走:从 Doug Lea 的 CLH 变体到 JDK 21 的完整路径

AQS 源码重走:从「如果让我写」到「原来还可以这样」 某开发者背了一周 AQS 八股:CLH 队列、acquireQueued、shouldParkAfterFailedAcquire……面试官问「AQS 的 prev 和 next 为什么一个可靠一个不可靠?」——答了,但说不出这么设计是为了解决什么问题。面试官一句话戳穿:「你读过源码,但你有没有想过在并发场景下删队列中的一个节点,不这么写会发生什么?」 背源码最大的问题是:你不知道那些代码是在解决什么问题。 所以这篇文章不走寻常路——先问「如果不用 AQS,让我自己写一个锁排队框架,该怎么下手?」然后一步步推演,当你发现「哎这里不安全怎么办」的时候,再引出 Doug Lea 的解法。这个过程会让人惊叹:原来并发代码还可以这样写。 最后,会专门对比 JDK 8 和 JDK 21 两个版本的 AQS,讲清楚 Doug Lea 为什么在 JDK 21 中对核心逻辑做了一次大重构。 🏗️ 从零开始:如果让我实现一个可排队的锁 假设只有 synchronized 和 LockSupport.park/unpark ,让你实现一个「锁没抢到就排队等」的框架,你打算怎么写? 第一次尝试:一个粗暴的实现 // 初版思路 class NaiveLock { volatile int state = 0; // 0=未锁, 1=已锁 Queue<Thread> queue = new ... // 等待队列 void lock() { while (!CAS(&state, 0, 1)) { // 没抢到锁 queue.enqueue(currentThread()); // 入队 park(); // 阻塞 } } void unlock() { state = 0; // 释放锁 Thread t = queue.dequeue(); // 从队列取一个 unpark(t); // 唤醒 } } 看上去好像没问题。但仔细想想,全是坑: ...

十月 21, 2023 · 20 分钟 · 4200 字 · yaomingye

HashMap 源码十八拷:从 put/get 到红黑树,八股文背后的 JDK 设计逻辑

打开 HashMap.java,从第 1 行读到第 2587 行 某开发者背了三天八股,面试官问「HashMap 怎么定位桶的」——「计算 hashCode,扰动,然后 (n-1) & hash 」。面试官点头又问:「那为什么要扰动,直接 (n-1) & hashCode 不行吗?」——卡住了。 其实 HashMap.java 的开头注释里写得很明白:Because the table uses power-of-two masking, sets of hashes that vary only in bits above the current mask will always collide. 因为用的是 2 的幂掩码,高位不同的 key 会撞。这才是设计动机,不是「某大牛说 XOR 一下好」。 本文换个路子,打开 JDK 21 的 java.util.HashMap (2587 行),从上到下、按源码书写顺序走一遍。每遇到一个常量、一个方法、一个分支,不只说它是什么,说它为什么是它。 第一站:类声明和那篇著名的注释 // HashMap.java:139 public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { 类签名平平无奇,真正的宝藏从第 145 行开始——一段大几百字的 Implementation notes。这大概是 Java 标准库里含金量最高的注释之一,建议每个读源码的人在这停个十分钟。 ...

十月 19, 2023 · 16 分钟 · 3203 字 · yaomingye

从语法迷雾到内存物理本质:C 指针 / Java 引用 / Go 接口 / C++ 类的底层统一模型

语法迷雾之下,物理内存里只有「数值」与「地址」 一、导言:被抽象概念包围的困惑 某开发者学了三年 Java,突然被扔去写 C——struct 是什么? *p 又是什么? void * 是什么东西?回头再看 Go,interface 怎么不用 implements ?再翻翻 C++ 源码,一个 class 里又是 virtual 又是 this——每个语言都有一套自己的"数据创造论",把内存的本质层层包裹起来。 Java 说「一切皆对象」,C 说「一切皆指针」,Go 说「用组合不要继承」,C++ 说「我全都要」。刚入门的读者站在这些口号中间,CPU 和 RAM 到底是怎么看待这些概念的? 破局点只有一句话:CPU 和 RAM 根本不懂面向对象。物理内存里永远只有两样东西——「数值」与「地址」。 int 是数值,指针是地址,对象的字段是数值和地址的排列组合,接口变量是两个地址凑一对。所有语言的语法特性,最终在内存里都还原为这个二元模型。 flowchart LR subgraph APP["应用层"] JAVA["Java: 对象/引用"] CPP["C++: class/虚表"] GO["Go: struct/interface"] C["C: struct/指针"] end subgraph COMPILER["编译器/Runtime"] LANG["语法糖脱糖"] LAYOUT["内存布局计算"] VTABLE["虚函数表生成"] end subgraph HARDWARE["物理层"] VAL["数值"] ADDR["地址"] end JAVA & CPP & GO & C -->|"编译/解释"| COMPILER COMPILER -->|"最终形态"| HARDWARE classDef appStyle fill:#2d2522,stroke:#ea580c,stroke-width:2px,color:#f8fafc; classDef compStyle fill:#1e293b,stroke:#0284c7,stroke-width:2px,color:#f8fafc; classDef hwStyle fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0; class JAVA,CPP,GO,C appStyle; class COMPILER compStyle; class VAL,ADDR hwStyle; 这篇文章的任务:把 C、C++、Java、Go 的语法糖一颗颗剥开,露出底下那个统一的物理内存画卷。 ...

十月 17, 2023 · 6 分钟 · 1191 字 · yaomingye

内存管理的破局者:虚拟地址、物理地址与页表——从哲学追问到公式推导

虚拟地址、物理地址与页表:从懵圈到通透只差这篇文章 一、起源:一个看似简单的哲学追问 1.1 初始疑惑:虚拟地址和物理地址一样,也是面向 CPU 的编号吗? 一个常见的困惑:虚拟地址(Virtual Address,VA,程序看到的地址)和物理地址(Physical Address,PA,硬件真正使用的地址)都是数字,那它们到底有什么区别?某位开发者曾盯着 printf("%p", ptr) 打印出的十六进制数,天真地以为这个数字就是内存条上的物理位置——直到他发现进程的虚拟内存空间比物理 RAM 还大,才意识到事情没那么简单。 核心区别在于它们服务的 CPU 阶段不同: VA(虚拟地址):面向程序与 MMU(Memory Management Unit,内存管理单元)。CPU 在执行 LOAD 指令时,发出的第一个请求就是 VA。程序中的每一个指针、每一个变量地址,都是在 VA 空间中定义的。 PA(物理地址):面向 RAM 与内存控制器。经过 MMU 翻译之后,VA 变成 PA,才是内存条上真正的硬件编号。内存控制器只认 PA,它不关心也不理解虚拟化这件事。 来看一段最直观的 C 代码: #include <stdio.h> #include <stdlib.h> int main() { int x = 42; int *ptr = &x; // ptr 的值是一个虚拟地址,不是物理地址 printf("变量 x 的虚拟地址: %p\n", (void *)ptr); printf("变量 x 的值: %d\n", *ptr); return 0; } ptr 里存储的地址是 VA。当 printf 读取 *ptr 时,CPU 发出 LOAD [ptr] 指令,硬件自动经过 MMU 翻译为 PA 之后才去 RAM 中取值。整个过程对程序员透明——这也是为什么很多人长期把 VA 误当作「内存的真实坐标」。 ...

十月 15, 2023 · 7 分钟 · 1369 字 · yaomingye

RocketMQ 存储模型硬核拆解:NameServer → CommitLog → ConsumeQueue 三层架构精析

RocketMQ 存储模型拆解:别再拿它当 BlockingQueue 用了 0. 引言:一个 Javaer 的认知崩塌 每一个刚接触 RocketMQ 的 Java 开发者,大概都会经历一次认知崩塌: 翻开 RocketMQ 源码,找遍所有 package,也找不到一个像 BlockingQueue 那样的 Queue 实现。作为一个"消息队列"(Message Queue),它的 Queue 到底在哪里?如果找不到 Queue 对象,消息是怎么"入队"和"出队"的? 答案很残酷,但也很优雅:RocketMQ 根本没有什么 Queue 数据结构。 RocketMQ 的 Queue 是一个文件夹——硬盘上的文件夹。消息不是"入队",而是 append 到磁盘文件末尾。消费者不是"出队",而是从磁盘文件读取一段定长的字节数组。 整个 RocketMQ,本质上就是一套精心设计的磁盘文件操作方案。 所有的分布式消息特性——消息重试、死信队列、消息积压、限流熔断——都是在这套磁盘文件模型上玩出的花样。理解了 RocketMQ 的文件长什么样、文件之间怎么关联,你就理解了 RocketMQ 的一切。 这篇从最底层开始,一层层往上拆,共七层: NameServer(路由层)——只管路标,不存消息 CommitLog(存储层)——所有消息顺序写入的大文件 ConsumeQueue(索引层)——被误称为队列的定长指针数组 Topic 参数(运维配置)——目录数量和权限的配置映射 高级特性——基于文件模型的策略实现 开发视角——你的代码在操作什么 总结——一张物理模型图覆盖所有概念 1. 第一层:NameServer(路由层) NameServer 是 RocketMQ 的路由层。它是一个轻量级的注册中心——只维护 Topic 到 Broker 地址的映射关系,不存储任何消息数据。 传统模式 在 RocketMQ 的传统模式下,NameServer 内存中维护着两张核心映射表: BrokerData:Topic 名称 → Broker IP 列表。消费者拿到这个列表,就知道该从哪台机器拉数据。 QueueData:Queue 数量 + 权限设置。Broker 上报时携带每个 Topic 配置了多少 Queue。 生产者发消息时先问 NameServer:“TopicA 在哪台机器上?有几个 Queue?” NameServer 返回 Broker 地址列表,生产者选择一个 Broker 发过去。消费者同理。 ...

十月 13, 2023 · 7 分钟 · 1408 字 · yaomingye

从 Java/Go 到 React:一个后端程序员的 TypeScript 受难与破壁实录(附 Flutter 做中间翻译器)

从 Java/Go 到 React,顺便拉 Flutter 垫背 一、前言:为什么后端程序员学 React 想骂人? 某后端组有天接了个需求:用 React + TypeScript 写个管理后台。组里人均三年 Spring Boot 或 Go Gin 经验,前端认知停留在 jQuery 版本。一开始想的是"TS 不就是带类型的 JS 嘛,有类型就不慌"——结果打开第一个 React 教程就傻了:函数组件、Hooks、闭包陷阱、依赖数组、JSX 里嵌逻辑……这哪是前端,这分明是另一个世界。 后端思维高度固化:类继承、接口实现、线程阻塞、强类型、反射——这些概念在 Spring 和 Go 里是护城河,在 React 里是全都没用的东西。类?函数组件不需要。接口?TS 的结构化类型不需要显式 implements。线程?JS 单线程事件循环,根本没有多线程。 Flutter 被拉进来做"中间翻译器":Dart 语法像 Java,但框架思维像 React。先写 Flutter 再写 React,会发现很多映射:Widget 树 >= 虚拟 DOM,setState >= useState,initState/dispose >= useEffect。把 Flutter 当"桥梁",Java/Go 当"起点",React 就没那么陌生。 为什么不直接对比?因为 Java 和 TS 差距太大——标称类型 vs 结构类型,多线程阻塞 vs 单线程事件循环。中间垫一个 Flutter(标称类型 + 单线程异步 + 声明式 UI),过渡就平滑了。 ...

十月 11, 2023 · 16 分钟 · 3256 字 · yaomingye

今日日报:Redis Lua 原子脚本与三段式库存模型——从商品表拆出独立库存微服务

拆库存服务:库存不是商品的附属品 某天盯着商品表的字段列表,发现 quantity、remain_quantity、sale_count 这三个东西怎么看怎么和 name、price、cover_url 不是一家人。name 改了不频繁,库存每秒都在扣——高频写和低频读挤在同一行,互相锁着玩。 决定拆。新建了一个 mall-inventory 微服务,独立数据库 cloud_mall_inventory,三张表:inventory(主库存)、inventory_batch(批次追踪)、inventory_log(变动流水)。 最头疼的问题:扣库存的一致性 库存扣减最怕两个事:超卖和半截崩溃。 第一个做法是两条 Redis 命令: redisUtil.increment(key, -quantity); // 扣 available redisUtil.increment(frozenKey, quantity); // 加 frozen 问题很明显——第一条执行完、第二条还没跑的时候,机器崩了怎么办?available 扣了但 frozen 没加,库存"凭空消失"了。 解法是 Lua 脚本,把两条操作打包发给 Redis: local qty = -tonumber(ARGV[1]) local avail = redis.call('INCRBY', KEYS[1], qty) if avail < 0 then redis.call('INCRBY', KEYS[1], -qty) return -1 end redis.call('INCRBY', KEYS[2], -qty) return avail - qty Redis 内部保证整个脚本一次性原子执行,不存在中间状态。Spring Data Redis 的 StringRedisTemplate.execute(script, keys, args) 直接调用就行。 三段式库存模型 完整链路改成了"冻结 → 确定 → 释放": 下单 → frozen +1, available -1(冻结) ├─ 支付成功 → frozen -1, sale_count +1(确认扣减) └─ 超时/取消 → frozen -1, available +1(释放) 之前是下单直接扣 remain_quantity,30 分钟后超时取消还要回滚。但回滚依赖 MQ 消息,MQ 挂了库存就永远不恢复了。新模型不存在这个问题——「可用库存」只负责「卖」,frozen 只负责「锁」,职责拆开,逻辑自洽。 ...

七月 9, 2023 · 2 分钟 · 237 字 · yaomingye

BFF 层接口规范化踩坑记:从 Object 到 DTO 的全面改造

BFF 重构:接口规范化流水账 从 “Object e” 和 “Map c” 说起 项目里有一个 BFF 层,最初的写法长这样: @PostMapping("/xxx/insert") public ApiResult<Integer> insert(@RequestBody Object e) { ... } @PostMapping("/xxx/page") public ApiResult<ResponsePageEntity<?>> page(@RequestBody Map c) { ... } 看起来很省事对吧?一个 Object 通吃所有入参,一个 Map 搞定所有查询条件。但问题在于——Swagger 上完全看不到请求体结构,前端看着文档只能看到 {},根本不知道要传什么字段。 于是这轮干了一件脏活累活:把 BFF 层所有接口的 @RequestBody Object 和 @RequestBody Map 全换成了具体的 DTO 类。总共涉及约 50 处改动,覆盖了认证、用户、商品、订单、营销等全部模块。 改完之后的效果: @PostMapping("/xxx/insert") public ApiResult<Integer> insert(@RequestBody XxxDTO entity) { ... } @PostMapping("/xxx/page") public ApiResult<ResponsePageEntity<?>> page(@RequestBody XxxConditionDTO c) { ... } 前端看 Swagger 终于能看到每个字段的名、类型、枚举值、示例——不需要反复在群里问"这个接口传什么"了。 ...

七月 5, 2023 · 2 分钟 · 334 字 · yaomingye

admin-bff 接口全面就绪 + 前端功能规划定稿 + 安全统一

今日工作 1. admin-bff 接口全面检查与补齐 前端功能规划定稿后,逐一比对前端目录树与 admin-bff 实际接口,发现 1 处缺失(商品图片接口),已补齐。 补齐内容: ProductPhotoFeignClient(新建)→ mall-product-client AdminProductExtraController 新增 productPhoto 5 个 CRUD 接口 最终 admin-bff 共 36 个接口,覆盖前端全部页面。这是前端开发可以直接对着写的接口清单。 2. 前端功能规划定稿 docs/30-admin前端功能规划.md 经过多轮讨论最终定稿。核心原则: 页面按角色可见: 角色 可见页面 超级管理员 全部(系统管理/商品/订单/营销/基础数据/评价) 运营部 商品管理/营销/首页/基础数据/评价 客服部 订单管理/评价 财务部 订单管理(只读金额) 不开发的前端页面: 菜单管理、角色管理、部门管理、岗位管理、字典管理、定时任务——这些由开发维护 DB,不出现在前端。 权限关系简化为:部门 + 岗位 → 角色 → 菜单,超级管理员只需要在用户管理里选部门/岗位,权限自动带出。 3. RBAC 数据初始化 向 cloud_mall_admin 数据库写入预设数据: 部门:运营部、客服部、财务部 角色:超级管理员(已有)、运营(ops)、客服(service)、财务(finance) 岗位表(auth_job)待预设,后续补上。 4. 商品上下架字段补全 发现 product 表没有上下架字段——整个商品系统没有上架/下架的概念。已修复: ALTER TABLE product ADD COLUMN status tinyint(1) DEFAULT 1 COMMENT '上下架状态 1:上架 0:下架'; ProductEntity 同步新增 status 字段,文档补充商品列表支持按状态筛选。 ...

七月 3, 2023 · 1 分钟 · 200 字 · yaomingye
Cat Radio