支付服务那些坑:从先落库到分表预案,一笔钱背后的设计决策

支付服务:每个"为什么"背后都是真金白银 为什么支付服务和普通业务完全不一样 写业务代码,最常见的是 CRUD。增删改查写熟了,觉得什么服务都差不多——直到被分配写支付服务。 支付和普通业务有本质区别:普通业务操作的是信息,支付操作的是钱。信息写错了能改,钱出去了就是真金白银的损失。更扎心的是,支付服务里每一个看似"怎么做都行"的设计决策,背后都藏着一个"做错了会怎样"的财务事故。 这一篇不讲支付怎么接入第三方(那是另一篇的活),专门讲设计决策:先落库还是先调渠道、支付单和退款单要不要分开、前端该直连支付还是走订单、状态机怎么拆、将来分库分表怎么留预案。这些决策不写明白,代码写对了也是悬的——哪天线上出了对不上账的事故,回头看全是今天的"小事"。 设计决策 1:先落库支付单,还是先调渠道 prepay? 踩坑现场 第一次写支付创建接口的人,几乎都会纠结这个顺序。有人觉得"先调渠道拿参数,再落库,这样能确认渠道成功"——听着有道理,其实是财务黑洞的开端。 为什么必须"先落库、后调渠道" 插入 pay_order 失败?→ 直接抛异常终止,永不调渠道 插入 pay_order 成功?→ 调渠道 prepay → 拿拉起参数 → 返回前端 这笔顺序是强同步串行的,理由有三个: ① pay_order 是"我方要收这笔钱"的唯一凭证。必须先记账,再去碰第三方。渠道 prepay 失败、渠道宕机时,本地已有待支付单——可重试、可追溯、可对账。反过来,渠道调好了本地啥都没有,这笔支付意图就丢了。 ② 反向顺序是财务黑洞。先调渠道拿参数、再落库,万一落库失败(DB 故障、唯一键冲突、事务回滚),渠道侧已经有一笔预支付交易、本地没有单。用户真拿着参数付了款,渠道回调过来本地无单可匹配——用户钱付了,我方账上没收,直接资金风险。 ③ prepay 本身不扣款。 alipay.trade.app.pay 只是"下单拿拉起参数",用户还没付款。所以先落库后调渠道,即使渠道失败也没有资金损失,重试即可。 顺带一个容易踩的长事务坑 创建接口标了 @Transactional ,prepay 这个外部 HTTP 调用被包进了本地事务——prepay 慢(外部网络)会长时间占用数据库连接,高并发下单时是隐患。严谨做法是把 prepay 移出事务: 事务 A:insert pay_order(本地,快) 无事务:调渠道 prepay(外部,慢) 事务 B:prepay 失败则更新 pay_order 状态 顺序本身不变,但别让外部调用拖住数据库事务。 设计决策 2:支付单和退款单,为什么分成两张表? 踩坑现场 看表结构时容易嘀咕:支付单和退款单字段挺像的——都有金额、订单号、渠道、用户、时间。为什么不合成一张表,用个"方向"字段区分? 为什么必须分开 ① 一对多是硬约束。一笔支付可以多次退款:买 399 退一件 99,再退一件 100——支付单只有一笔(399),退款单有两笔(99+100)。退款独立成表才能记录多次退款历史,塞进支付单就毁了。 ② 状态机本质不同。支付单管"收钱":待支付 → 已支付 → 关闭/失败;退款单管"退钱":待处理 → 处理中 → 成功/失败 + 审核流。两个状态机混在一张表必然打架。 ...

十月 29, 2023 · 5 分钟 · 858 字 · yaomingye

支付系统设计评审:一次增量讨论补足的五个缺陷

一份支付设计方案,是怎么被审出五个洞的 某开发者在设计一套统一支付服务。初版方案自认为考虑周全:支付订单表、退款表、渠道配置、对账批次,表格一张比一张漂亮。然后跟组里人过了一遍设计,被一个问题接一个问题地问到改稿——每一问都补出一个之前没想透的缺陷。 这篇就把这次增量讨论里补上的五个点记下来。它们不是"支付系统特有的冷知识",而是任何一个会分库分表、会对账、要复用的系统都可能踩的坑。 场景:一个要复用的统一支付服务 先交代背景。这套支付服务的目标是: 从"模拟支付"改造成真实支付——聚合支付宝、微信支付、微信小程序支付,未来还可能接更多国内渠道 拥有自己的数据库(此前完全没有,支付数据存在别处) 带一套每日对账系统——拉取渠道对账单、解析、批量入库、逐笔对比、算差异 日后作为独立支付服务接入其他项目(商城只是第一个业务方) 正是"要复用"和"要对账"这两点,引出了下面五个洞。 洞一:对账的逐笔对比写了 JOIN 初版对账设计里,逐笔对比是这么写的: -- 渠道账单临时表 LEFT JOIN 本地支付订单表 SELECT t.trade_no, t.amount, o.pay_amount, ... FROM recon_temp t LEFT JOIN pay_order o ON t.trade_no = o.merchant_order_no; 乍看没毛病——两张表同库、有公共键。但评审的人问了一句:"pay_order 分库分表之后,这个 JOIN 还能跑吗?" 不能。跨表 JOIN 需要分片键能路由到同一分片,而 recon_temp 和 pay_order 的分片键不同(一个按批次、一个按用户),JOIN 会退化成全分片广播扫描——每一个分片都扫一遍再合并,数据量一大就是灾难,更别说分片规则一变直接报错。 📌 前置知识:分库分表后,跨表 JOIN 要保证两张表的行落在同一个分片(同分片键)才能路由;分片键不一致时只能广播到所有分片再内存合并。 改法:双批查询 + 内存撮合。 flowchart TD A["渠道对账单文件"] -->|"解析"| B["recon_temp 当日明细"] C["pay_order 支付订单表"] -->|"按渠道×时间窗查询"| D["当日支付单子集"] B -->|"批查加载"| E["渠道侧内存 Map"] D -->|"批查加载"| F["平台侧内存 Map"] E -->|"按 merchant_order_no 匹配"| G["逐笔撮合"] F -->|"按 merchant_order_no 匹配"| G G --> H["命中 / 仅渠道 / 仅平台"] H --> I["差异写 recon_result"] classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:2px,color:#ede9fe,font-weight:bold; class A,D process class B,C,F data class G,E condition class H,I process 两个单表查询各自按自己的分片键路由( recon_temp 按 batch_no 、 pay_order 按 user_id ),各自只取撮合需要的列,在内存里建 HashMap 按 merchant_order_no 精确匹配。全程无跨表 JOIN——这就是"无联表"原则,也是后续分库分表的前提条件。 ...

十月 27, 2023 · 3 分钟 · 621 字 · 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

Spring Cloud 微服务接入 BFF 聚合层:从一个混乱的项目重构说起

当你的单体项目被拆成微服务,前端第一个崩溃 某天接手了一个从老单体拆出来的微服务电商项目。技术栈倒是很"大厂"——Spring Cloud Alibaba、Nacos、Sentinel、RocketMQ、ShardingSphere,你能想到的全塞上了。 但前端同事过来敲门的时候,事情就不太对劲了。 “咱这项目一共几个文档地址?” “9 个。"(每个后端服务一个 Knife4j 页面) “那我要调一个登录接口,该看哪个服务的文档?” “……好问题。” 这就是典型的微服务拆了,但没完全拆——后端确实拆成了 9 个独立服务,可前端仍然需要知道每个服务的地址、每个接口的路径、每个返回的字段含义。而且很多接口其实需要前端自己拼数据:登录完了再查一遍用户信息、再查一遍菜单权限、再查一遍角色列表。 前端不是在写业务,是在做 API 聚合。 BFF:不是新概念,但能解决真问题 BFF(Backend For Frontend)的核心思路很简单:每个前端都有一个专属的后端入口,这个入口干三件事: 聚合 — 把多个后端服务的数据合并成前端需要的一站式响应 裁剪 — 只返回前端真正需要的字段,不裸奔整个数据库实体 隔离 — 后端再怎么拆、再怎么重构,前端代码不用动 架构上看起来就是中间多了一层: flowchart LR subgraph CLIENT["📱 前端"] WEB(["管理后台 Web"]) APP(["移动端 小程序"]) end subgraph GW["🚪 网关层"] GATEWAY[Spring Cloud Gateway\nJWT · CORS · Sentinel] end subgraph BFF["🎯 BFF 聚合层"] ADMIN_BFF["mall-admin-api\n管理后台 BFF\n端口 8090"] MOBILE_BFF["mall-mobile-api\n移动端 BFF\n端口 8091"] end subgraph BACKEND["⚙️ 业务微服务"] AUTH[mall-auth-api] BASIC[mall-basic-api] PRODUCT[mall-product-api] ORDER[mall-order-api] MARKETING[mall-marketing-api] OTHERS[其余 4 个服务...] end WEB -->|"/api/admin/**"| GATEWAY APP -->|"/api/mobile/**"| GATEWAY GATEWAY --> ADMIN_BFF GATEWAY --> MOBILE_BFF ADMIN_BFF -->|Feign 调用| BACKEND MOBILE_BFF -->|Feign 调用| BACKEND classDef bffFill fill:#2e1065,stroke:#a855f7,stroke-width:2.5px,color:#f8fafc,font-weight:bold; class ADMIN_BFF,MOBILE_BFF bffFill; 为什么要加这一层?直接转发不行吗? 接手时项目就已经有两个"BFF 模块"了—— mall-mobile-api 和 mall-admin-api 。但打开一看,里面就一个 ForwardController ,用 RestTemplate + LoadBalancerClient 把所有请求原封不动转发到后端服务: ...

三月 12, 2023 · 4 分钟 · 739 字 · yaomingye

微服务拆分套路拆解:BFF 服务于前端的法则与六大拆分原则,以电商为例

拆分微服务,先搞懂这七条法则 BFF 不是新概念,但翻车率极高 当你决定从单体拆微服务,问得最多的问题往往是:“前端到底该调哪个服务?” 见过太多次这种场景——前端对着十几个 API 接口陷入选择困难症:一个商品详情页要调 5 个服务才能拼完整,首页要调 8 个。于是前端自己写了个"聚合层",但没有服务端治理能力,比单体时代还乱。 BFF(Backend For Frontend,为前端服务的后端) 就是来解决这个的。它不是简单在前面加个代理,而是有明确的拆分法则。 BFF 拆分三法则 按客户端维度切分 不同端消费场景天然不同: 移动端 BFF:接口瘦、响应快、流量敏感,需要数据压缩和裁剪 Web 端 BFF:数据全、可交互多,可能需要 SSE 之类推送能力 小程序/第三方 BFF:安全校验严密,接口格式受平台约束 某团队早期把移动和 Web 共用一个 BFF,结果移动端要的"轻量接口"和 Web 端要的"完整数据"打架,BFF 越写越臃肿,成了一个"新型大单体"。 核心法则:一个端一个 BFF 实例。 代码可以复用,但部署实例要独立,避免互相影响。 BFF 只做编排,不做业务 BFF 层最容易踩的坑是"顺手把业务逻辑也写了"。 它的职责边界非常清晰: 该做的:接口聚合、数据裁剪、字段格式化、请求路由、Token 校验 不该做的:优惠计算、库存扣减、订单校验、风控规则 BFF 是服务员,不是厨师。厨师在后厨(业务服务),服务员只负责拼盘上菜。 关注点分离——BFF 不做跨服务事务 BFF 同时调了订单服务和库存服务,发现库存扣减成功但订单创建失败——这时候 BFF 能回滚吗?不能。BFF 层没有分布式事务能力。 碰到需要事务强一致的场景,BFF 必须把这个"烫手山芋"扔给下游的编排服务(比如用 Saga 模式),别自己在 BFF 层 try-catch 补偿。 flowchart LR subgraph Client["📱 客户端层"] WEB(["Web App"]) APP(["移动 App"]) MINI(["小程序"]) end subgraph BFF["🔀 BFF 层"] WB[Web BFF\n内容聚合+认证] MB[Mobile BFF\n数据裁剪+压缩] XB[三方 BFF\n签名校验+格式转换] end subgraph Biz["⚙️ 业务服务层"] BS[商品服务] CS[购物车服务] OS[订单服务] US[用户服务] PS[支付服务] end subgraph Store["💾 数据层"] DB[(MySQL)] CACHE[(Redis)] ES[(Elasticsearch)] end WEB -->|HTTP| WB APP -->|HTTP| MB MINI -->|HTTP| XB WB -->|RPC| BS & CS & OS & US & PS MB -->|RPC| BS & CS & US XB -->|RPC| OS & PS BS & CS & OS & US & PS --> Store classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2.5px,color:#fce7f3,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef bff fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; class WEB,APP,MINI startEnd; class WB,MB,XB bff; class BS,CS,OS,US,PS process; class DB,CACHE,ES data; 业务能力拆分——最直觉的切法 最简单的拆分方式:按业务功能划分。电商天然就能分成商品、订单、用户、支付、库存这些模块。每个服务对应一个业务域,内部有独立数据库,对外暴露接口。 ...

二月 18, 2023 · 4 分钟 · 682 字 · yaomingye

微服务支付系统全景:从接入第三方到对账结算,一笔钱走完的九九八十一难

一笔钱在微服务里到底怎么走的 为什么支付是微服务里最难啃的骨头 做业务开发,碰到的最常见代码可能就是 CRUD。增删改查写熟了,觉得微服务也不过如此——直到某天被分配了支付模块。 支付和普通业务有本质区别:普通业务操作的是"信息",支付操作的是"钱"。写错一行代码,信息可以修,钱出去了就是真金白银的损失。更麻烦的是,支付不是自己一个服务就能搞定的事——要接微信、要接支付宝、可能还要接银联、接 Stripe。每家渠道的接口风格不同,回调机制不同,对账方式也不同。上游还有订单系统在等支付结果,下游有会计系统等着入账。 flowchart LR %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold; RISK1["[钱出去了\n回不来]"] RISK2["[重复支付\n多扣款]"] RISK3["[回调丢失\n订单卡死]"] RISK4["[对账不平\n财务追杀]"] RISK5["[渠道故障\n全站瘫痪]"] CORE["支付系统\n核心矛盾:\n复杂 × 高风险 × 强一致性"] RISK1 --> CORE RISK2 --> CORE RISK3 --> CORE RISK4 --> CORE RISK5 --> CORE class RISK1,RISK2,RISK3,RISK4,RISK5 reject; class CORE highlight; 把这些复杂度拆开来看,一个支付系统本质上要解决五个问题: 怎么收——对接各种支付渠道,屏蔽渠道差异 怎么记——每笔钱的来龙去脉都要有据可查 怎么验——回调确认钱真的到了,不是"用户说付了就算付了" 怎么对——自己的账和渠道的账对得上 怎么退——钱能收就能退,但不能退多了,也不能重复退 下面逐个拆解。 支付系统的"五脏六腑":模块全景图 在动手写代码之前,先搞清楚一笔钱在系统里要经过哪些模块。 flowchart TD %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; subgraph FRONT["接入层"] GATE["[支付网关\n路由/验签/限流/协议转换]"] end subgraph CORE_MOD["核心支付域"] ORDER["[支付订单服务\n订单创建/查询/状态流转]"] CHANNEL["[支付渠道服务\n渠道抽象/路由/适配器]"] CALLBACK["[回调处理服务\n异步通知/幂等/重试]"] REFUND["[退款服务\n退款申请/审核/执行]"] end subgraph BILLING["清算对账域"] RECON["[对账服务\nT+1对账/差异处理/长款短款]"] SETTLE["[结算服务\n分账/手续费/入账]"] end subgraph INFRA["基础设施"] MQ["[消息队列\n异步解耦/重试]"] IDEM["[幂等表\n防重支付/防重回调]"] LGR["[流水表\n不可变审计日志]"] end FRONT --> CORE_MOD CORE_MOD --> BILLING INFRA -.-> CORE_MOD INFRA -.-> BILLING class GATE highlight; class ORDER,CHANNEL,CALLBACK,REFUND process; class RECON,SETTLE data; class MQ,IDEM,LGR process; 每个模块解决一类问题: ...

二月 14, 2023 · 6 分钟 · 1167 字 · yaomingye

DDD 重构实战——什么时候该用 DDD?什么时候 MVC 就够了?

DDD vs MVC:如何选择? 📖 前置阅读:本文假设读者已理解 DDD 的核心概念(实体/值对象/聚合根/限界上下文)和战术代码模板(四层架构/Repository/Domain Service)。如果还不熟悉,建议先阅读 DDD 本质 和 DDD 战术落地。 一、⚡ DDD 这么好——是不是所有服务都要重构一遍? 看完前两篇——概念清楚了——代码模板也有了——冲动上来了: "先把所有微服务用 DDD 重构一遍!" ① user-service → DDD ② order-service → DDD ③ product-service → DDD ④ account-service → DDD ⑤ inventory-service → DDD → 加班 2 个月——重构了一堆——代码没更好——反而更复杂了 DDD 不是银弹——不是所有代码都值得用 DDD。这篇的核心就是告诉你:什么该改、什么不改、改到什么程度。 二、🔍 诊断——我们现有的三个服务——各自是什么情况 2.1 user-service——经典 MVC——不改 // user-service——现有结构 controller/ └─ UserController.java @RestController——GET/POST/PUT service/ └─ UserService.java 简单的增删改查 + 缓存操作 mapper/ └─ UserMapper.java MyBatis——selectById/insert/update model/ └─ User.java 15 个字段——getter/setter // UserService 最复杂的方法——也就 20 行 @Service public class UserService { @Autowired private UserMapper userMapper; @Autowired private RedisTemplate<String, User> redisTemplate; public User getUser(Long userId) { String cacheKey = "user:" + userId; User cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) return cached; User user = userMapper.selectById(userId); if (user != null) redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES); return user; } public void updateUser(User user) { user.setUpdatedAt(LocalDateTime.now()); userMapper.updateById(user); redisTemplate.delete("user:" + user.getId()); // 失效缓存 } } 判断——不需要 DDD: ...

十二月 23, 2022 · 13 分钟 · 2614 字 · yaomingye

DDD 战术落地——代码怎么写

DDD 代码怎么写? 📖 前置阅读:本文假设读者已理解实体、值对象、聚合根、限界上下文、领域事件的核心概念。如果还不熟悉,建议先阅读 DDD 本质——领域驱动设计的核心概念。 一、⚡ 概念都懂了——但代码从哪个 package 开始建? 上一篇搞清楚了实体和值对象的区别、聚合根是"一致性边界"——但回到 IDE 中: 现有项目结构(MVC——三层): controller/ ├─ OrderController.java service/ ├─ OrderService.java (3000 行——上帝类) mapper/ ├─ OrderMapper.java ├─ UserMapper.java ← 跨表调用——OrderMapper 也调 UserMapper ├─ ProductMapper.java ← 跨表调用 model/ ├─ Order.java ← 只有 getter/setter——贫血 ├─ User.java ├─ Product.java 问题——现在要改成 DDD——应该怎么建目录?Repository 放哪?Domain Service 放哪? 这篇就是答案——从目录结构开始——到每一层的代码——完整的落地模板。 二、📂 项目结构——DDD 四层架构 2.1 四层——不是"三层 + 一层" 传统 MVC 三层: Controller → Service → Mapper → Service 层无限膨胀——3000 行——什么都往里塞 DDD 四层: interfaces(接口层) → 接收请求、返回响应——薄薄一层 application(应用层) → 编排业务流程——调 Repository、发事件——没有业务逻辑 domain(领域层) → 业务逻辑——聚合根、值对象、Repository 接口、领域事件 infrastructure(基础设施层)→ 技术实现——Repository 实现、数据库访问、MQ 发送 order-service/ ├── interfaces/ ← ① 接口层 │ ├── rest/ │ │ └── OrderController.java # HTTP 接口——接受请求——转给 application 层 │ ├── dto/ │ │ ├── CreateOrderRequest.java # 入参 DTO │ │ └── OrderResponse.java # 出参 DTO │ └── mq/ │ └── OrderEventListener.java # MQ 消息消费——转到 application 层 │ ├── application/ ← ② 应用层 │ ├── OrderApplicationService.java # 编排——调 Repository + 发事件——不包含业务逻辑 │ ├── command/ │ │ └── CreateOrderCommand.java # 应用层自己的命令对象——DTO 转换后的内部对象 │ └── event/ │ └── OrderEventPublisher.java # 事件发布接口——实现在 infrastructure │ ├── domain/ ← ③ 领域层——核心——不依赖任何外部框架 │ ├── model/ │ │ ├── aggregate/ │ │ │ └── Order.java # 聚合根 │ │ ├── entity/ │ │ │ └── OrderItem.java # 聚合内部实体 │ │ ├── valueobject/ │ │ │ ├── Money.java # 值对象——金额 │ │ │ ├── Address.java # 值对象——地址 │ │ │ └── OrderStatus.java # 枚举——订单状态 │ │ └── event/ │ │ ├── OrderCreatedEvent.java # 领域事件 │ │ └── OrderPaidEvent.java │ ├── repository/ │ │ └── OrderRepository.java # Repository 接口——只有接口——没有实现 │ └── service/ │ ├── OrderDomainService.java # 领域服务——跨聚合的逻辑 │ └── PricingService.java # 领域服务——价格计算策略 │ └── infrastructure/ ← ④ 基础设施层 ├── persistence/ │ ├── OrderRepositoryImpl.java # Repository 实现——调 JPA/MyBatis │ ├── mapper/ │ │ ├── OrderMapper.java # MyBatis Mapper │ │ └── OrderItemMapper.java │ └── converter/ │ └── OrderConverter.java # DO ↔ Domain 对象转换 ├── messaging/ │ └── RocketMQEventPublisher.java # 事件发布实现——发到 RocketMQ └── external/ └── UserServiceAdapter.java # 防腐层——隔离外部 User 服务 依赖方向——只能是单向的: ...

十二月 22, 2022 · 12 分钟 · 2499 字 · yaomingye

DDD 本质——领域驱动设计的核心概念

搞懂 DDD 的核心概念 一、⚡ 一个下单方法 800 行——你知道拆不开是因为什么吗? 先看一段熟悉的代码——我们所有微服务的 Controller/Service 大概都长这样: @Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private UserMapper userMapper; @Autowired private ProductMapper productMapper; @Autowired private InventoryMapper inventoryMapper; public Order createOrder(CreateOrderRequest request) { // ① 查用户——有没有被封号 User user = userMapper.selectById(request.getUserId()); if (user == null || user.getStatus() == UserStatus.BANNED) { throw new BusinessException("用户不存在或已封号"); } // ② 查商品——库存够不够 BigDecimal totalAmount = BigDecimal.ZERO; List<OrderItem> items = new ArrayList<>(); for (CreateOrderItemRequest itemReq : request.getItems()) { Product product = productMapper.selectById(itemReq.getProductId()); if (product == null || product.getStatus() != ProductStatus.ON_SALE) { throw new BusinessException("商品 " + itemReq.getProductId() + " 不可售"); } if (product.getStock() < itemReq.getQuantity()) { throw new BusinessException("商品 " + itemReq.getProductId() + " 库存不足"); } // ③ 扣库存——直接在 Service 里 UPDATE product.setStock(product.getStock() - itemReq.getQuantity()); productMapper.updateById(product); totalAmount = totalAmount.add(product.getPrice() .multiply(BigDecimal.valueOf(itemReq.getQuantity()))); items.add(new OrderItem(itemReq.getProductId(), itemReq.getQuantity(), product.getPrice())); } // ④ 扣余额——直接操作 Account 表 Account account = accountMapper.selectByUserId(request.getUserId()); if (account.getBalance().compareTo(totalAmount) < 0) { throw new BusinessException("余额不足"); } account.setBalance(account.getBalance().subtract(totalAmount)); accountMapper.updateById(account); // ⑤ 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.PENDING_PAY); order.setItems(items); orderMapper.insert(order); // ⑥ 发通知——MQ rocketMQTemplate.syncSend("order-created", order); return order; } } 问题不是代码长——问题是:你想加一个"首单 9 折"的功能——该加在哪? ...

十二月 21, 2022 · 12 分钟 · 2357 字 · yaomingye
Cat Radio