做技术架构之前,得先把业务和用户场景搞清楚。发票这块业务,不看用户怎么用、不看有哪些关键用例,上来就画架构图,画出来的东西大概率和业务对不上。这篇文章的写作思路是:先说清楚需求是什么、用户怎么用发票,再聊领域知识,最后才是架构设计。
需求与关键用例
一个做连锁店业务的品牌,线上有小程序、支付宝、美团外卖、周边商城、自由卡、会员卡,每个渠道的订单都能开发票。发票这个需求看起来简单,就是用户买了东西要开票,但实际展开之后复杂度不小。
用户自助开票
用户在小程序或APP里,对已完成的订单发起开票请求。这是最常见的用例。用户选择订单、填写抬头、提交,系统异步完成开票,用户后续查看开票结果。
发票助手批量开票
发票助手是面向用户的C端入口,用户在这里完成从选单到开票的全部操作。
进入发票助手后,首页按订单类型分类展示:门店单、美团单、周边单、自由卡单、会员卡单,各自有独立的待开票列表和已开票列表。用户选择一种订单类型后,看到该类型下所有可开票的订单,勾选多个订单后一次性提交开票申请。这就是批量开票的入口。
批量开票有一个前提:同一批次的订单必须属于同一个开票公司主体。如果用户勾选的订单跨越了不同的公司主体(比如两个门店对应不同的税务渠道),发票中心会按公司主体自动拆分成多个批次,用户不需要关心这个细节。
发票助手的交互流程是:选订单类型 → 查看待开票列表 → 勾选订单 → 预览开票详情(金额、抬头) → 提交申请 → 等待开票结果 → 查看开票记录。预览环节会实时计算开票金额,用户确认后才真正提交。
开票成功后,发票助手提供几个后续操作:查看发票PDF、重发邮件、微信卡包插卡。这些操作都走发票中心的统一查询接口,发票助手本身不存发票数据。
退款后自动红冲
订单发生退款时,如果这笔订单已经开过蓝票,系统需要自动开一张等额的红票把蓝票冲掉。用户不感知这个过程,但这是发票业务里必须覆盖的用例。
后台手工开票
运营人员在后台直接操作标记,不走税务平台。这种用例量不大,但业务上必须支持。
订单已开票标记
开票完成后,订单系统需要标记这笔订单已开发票。用户在订单列表和订单详情里能看到开票状态。退款服务也需要知道订单是否已开票,来决定是否触发红冲。
发票抬头管理
用户可以维护多个发票抬头(个人或企业),开票时从已有抬头里选择,也可以临时填写新抬头。企业抬头支持从税务平台的发票云查询,用户不用手动输入税号等信息。
微信卡包与支付宝发票
微信来源的开票请求,开票成功后要往用户微信卡包里插卡。支付宝来源的开票请求,开票成功后要走支付宝的发票回传接口。两个平台的对接方式完全不同,但都是用户侧的强需求。
发票业务的领域知识
了解了需求和用例之后,再看发票领域的业务知识点,就知道每个知识点对应的是哪个用例。
发票类型与状态
发票分两种:蓝票和红票。蓝票是正常开出去的发票,红票是冲销蓝票用的。当订单发生退款,原来的蓝票需要被红冲,即开一张等额的红票把原来的蓝票冲掉。
一张发票从开出到终结,状态流转如下:未开票 → 开票中 → 已开票/开票失败。已开票的蓝票在订单退款时会被红冲,状态变为被红冲。还有两种特殊状态:部分开票(一张蓝票只开出了部分金额)和超时取消。
开票渠道与来源
开票渠道指的是对接的税务服务商。一般来说都是:百望和航信。不同渠道的通信协议、请求格式、回调机制都不一样,需要通过策略模式来路由。
开票来源指的是用户从哪个平台发起开票请求:微信或支付宝。来源不同,后续的回调处理逻辑也不同。微信开票成功后要往用户微信卡包里插卡,支付宝开票成功后要走支付宝的发票回传接口。
订单类型
发票面向的订单有六种:门店单、美团单、周边订单、自由卡订单、会员卡订单、虚拟订单。每种订单的金额计算逻辑不同,退款判断逻辑不同,公司主体映射也不同。这是发票业务复杂度的主要来源。
开票方式
四种开票方式:用户自助开票、后台手工开票、系统自助开票、发票助手开票。手工开票是运营人员在后台直接操作标记,不走税务平台。发票助手开票走完整的开票流程但入口在发票助手模块。
开票前置校验
包含四个校验环节:
订单校验(ORDER_COUNT):查询订单是否真实存在、是否已支付、是否已取消。美团单和虚拟订单有独立的校验分支。自由卡退款订单不允许开发票。全额退款的订单不允许开发票。
金额校验(ORDER_TOTAL_MONEY):计算实际可开票金额。要扣减已退款金额,要处理组合支付(中台支付)的特殊取值逻辑,要减去拼单红包金额。美团单要额外扣减配送费。金额小于等于0则拒绝开票。
重复开票校验(IS_OPEN):检查订单是否已经开过票。
公司主体校验(COMPANY):根据订单所属门店查出发票公司主体。一个开票批次里的所有订单必须属于同一个公司主体。校验公司主体对应的税务库存是否充足。
金额计算逻辑
开票金额的计算是发票业务里最细致的部分:
组合支付场景下,优先开税率低的商品。按税率从低到高排序商品,逐个分配开票金额,直到总额等于订单实付金额。最后一个商品的金额通过差额抹平,避免精度丢失。
包装费和配送费单独处理,各自用一条固定的税务编码和SKU来开票。配送费和包装费的税务信息存在系统变量表里,运行时可配置。
退款金额要减掉。中台支付的退款计算方式和其他订单不同,要取原支付金额和实付减退款金额中的较小值。
回调处理
两个渠道的回调格式各不相同。微信回调是XML格式,解析后处理授权和开票确认。支付宝回调是JSON格式,需要验签后回传开票结果给支付宝。航信回调包含发票代码、号码、PDF地址等信息。
红冲流程
订单发生退款时触发红冲。流程是:查找该订单对应的已开票蓝票 → 构建红票请求(引用蓝票的代码和号码) → 向税务平台提交红票申请。如果税务平台是异步接口,通过MQ轮询红票结果。红冲成功后,蓝票状态更新为被红冲。
发票抬头管理
用户可以维护多个发票抬头(个人或企业),包括名称、税号、地址、电话、开户银行、银行账号。企业抬头还支持从发票云查询(百望提供的接口)。
公司主体管理
开票公司主体是发票的出票方信息,包括公司名称、税号、地址、电话、开户行、开票人、收款人、复核人、税务库存告警手机号等。一个公司主体对应一个税务渠道(百望或航信),有单张发票限额配置。门店和公司主体之间是映射关系,同一个门店的订单只能由一个公司主体开票。
发票中心的领域模型
发票领域有四个核心概念:
发票申请(InvoiceApply):用户或系统发起的开票请求,包含订单信息、抬头信息、联系方式。这是发票领域的入口,也是最核心的。一个申请持有开票请求从进入到完成的全部状态。
发票(Invoice):一张真实的发票,包含发票代码、号码、金额、税额、开票日期、PDF地址。一个申请可能产生多张发票(拆票场景)。
发票抬头(InvoiceTitle):用户维护的开票抬头信息,个人或企业,跨订单复用。
开票主体(InvoiceCompany):出票方的公司信息,决定了走哪个税务渠道、用哪个税号开票。
发票领域模型速查表
| 概念 | 职责 | 关键字段 | 生命周期 |
|---|---|---|---|
| InvoiceApply | 开票申请 | 申请ID、订单号列表、抬头、来源 | 创建 → 校验 → 开票中 → 完成/失败 |
| Invoice | 真实发票 | 发票代码、号码、金额、税额、PDF地址 | 开票中 → 已开票/被红冲 |
| InvoiceTitle | 开票抬头 | 名称、税号、地址、银行账号 | 持久化,跨申请复用 |
| InvoiceCompany | 开票主体 | 公司名称、税号、渠道、限额 | 持久化,按门店映射 |
发票中心的服务定位
service-invoice是一个独立的发票中心服务,职责是:接收来自各个订单服务的开票请求,执行开票校验、金额计算、税务平台对接、结果回调处理,对外提供统一的发票API。
它不关心订单是从哪个渠道来的。订单服务传过来的是门店单或者周边单,对发票中心来说都是一样的开票请求,只是订单类型字段不同。
发票中心内部依赖两个税务平台网关服务:base-baiwang负责和百望通信,base-hangxin负责和航信通信。这两个服务只做HTTP转发和加解密,不碰业务逻辑,相当于税务平台的反腐败层。发票中心通过Dubbo RPC调用它们,通信细节全部封装在网关内部。
对外接口设计
发票中心对外提供四类Dubbo接口:
开票接口:接收开票请求(订单号列表、抬头信息、开票来源),返回开票申请ID。校验和金额计算在内部完成,调用方不需要关心。
红冲接口:接收订单号,查找对应的蓝票并提交红冲请求。退款服务在处理完退款后调用此接口。
查询接口:按开票申请ID查询发票详情,按用户ID查询开票历史,按订单号查询开票状态。
抬头管理接口:增删改查发票抬头,查询云抬头。
发票中心对外接口一览
| 接口类型 | 核心方法 | 入参 | 出参 | 调用方 |
|---|---|---|---|---|
| 开票 | applyInvoice | 订单号列表、抬头、来源 | 申请ID | 各订单服务 |
| 红冲 | redInvoice | 订单号 | 无 | 退款服务 |
| 查询 | queryByApplyId | 申请ID | 发票详情 | 前端/运营 |
| 查询 | queryByOrderNo | 订单号 | 开票状态 | 各订单服务 |
| 查询 | queryByUserId | 用户ID | 开票历史 | 前端 |
| 抬头 | saveTitle / queryTitle | 抬头信息 | 抬头列表 | 前端 |
订单服务如何对接
订单服务不再持有发票逻辑,只做两件事:
申请开票时,构造一个开票请求DTO(包含订单号、金额、商品明细、门店ID),通过Dubbo RPC调用发票中心。金额计算、校验、税务平台对接全部交给发票中心。
退款完成时,调用发票中心的红冲接口。不需要自己查蓝票、构造红票请求。
发票操作本身是低频的(用户不会每秒都在开发票),网络开销可以忽略。换来的收益是:新增订单类型时只需构造DTO,不用写任何开票逻辑。
开票流程的主线
开票流程的主线是:创建申请 → 校验 → 金额计算 → 选择渠道 → 提交开票 → 轮询结果 → 处理回调 → 更新状态。
这条主线通过InvoiceApplyService编排,每个步骤委托给专门的组件:
校验 → InvoiceValidationService
金额计算 → InvoiceAmountCalculator
渠道选择 → InvoiceChannelRouter
回调处理 → InvoiceCallbackHandler
申请的创建由InvoiceApplyFactory负责,它接收外部传来的DTO,执行校验后创建申请对象。校验逻辑用模板方法模式:定义校验步骤的执行顺序,每种订单类型的校验差异通过策略接口注入。
税务渠道的抽象
渠道抽象接口用策略模式设计,新增渠道只需要实现InvoiceChannelService接口:
public interface InvoiceChannelService {
// 开蓝票
ChannelResult openBlueInvoice(BlueInvoiceRequest request);
// 开红票
ChannelResult openRedInvoice(RedInvoiceRequest request);
// 查询发票
ChannelResult queryInvoice(String fpqqlsh);
// 是否异步
boolean isAsync();
}
百望的实现委托给base-baiwang的Dubbo调用,航信的实现委托给base-hangxin的Dubbo调用。发票中心不关心渠道的通信细节,只关心渠道返回的业务结果。
渠道差异速查表
| 渠道 | 网关服务 | 通信协议 | 回调方式 | 异步模式 |
|---|---|---|---|---|
| 百望 | base-baiwang | HTTP + 签名 | 微信XML/支付宝JSON | 同步+异步轮询 |
| 航信 | base-hangxin | HTTP + 签名 | HTTP回调 | 异步回调+轮询 |
订单类型的策略隔离
不同订单类型的差异通过策略接口隔离:
public interface InvoiceOrderStrategy {
// 查询订单信息
OrderInfo queryOrders(List<String> orderNos, Long userId);
// 计算开票金额
BigDecimal calculateAmount(OrderInfo orderInfo);
// 获取公司主体
Integer getCompanyByShop(Integer shopId);
}
门店单、美团单、周边单、自由卡单各自实现这个接口。发票中心的主流程不出现if-else的订单类型分支,而是通过策略注册表来分发。新增一种订单类型,只需要注册一个新的策略实现。
订单类型策略差异表
| 订单类型 | 金额计算特殊点 | 退款判断 | 公司主体来源 |
|---|---|---|---|
| 门店单 | 按税率从低到高分配 | 正常退款扣减 | 门店映射 |
| 美团单 | 额外扣减配送费 | 正常退款扣减 | 门店映射 |
| 周边单 | 独立税率计算 | 独立退款逻辑 | 独立映射 |
| 自由卡 | 退款单不允许开票 | 全额退款禁开 | 门店映射 |
回调与后续动作
回调处理统一入口,按渠道类型分发:
微信回调:解析XML,处理授权信息,触发开票。
支付宝回调:验签,更新发票状态,回传结果给支付宝。
航信回调:解析发票信息,触发结果查询。
所有回调处理完成后,发布一个InvoiceCompletedEvent。邮件发送、微信卡包插卡、PDF转图片等后续动作作为事件订阅者,按需执行。这种事件驱动的方式让后续动作的增减不影响核心流程,发邮件、插卡、转图片任何一个环节出问题也不影响发票状态的更新。
税务平台的容错处理
对接税务平台,稳定性是绕不开的问题。百望和航信都是第三方服务,调用超时、返回异常、网络抖动,在生产环境里都遇到过。代码里对这类问题的处理方式可以归纳为三层。
第一层是调用方的异常捕获与重试。向税务平台提交开票请求时,如果抛出异常(超时、网络错误),不会直接标记为失败,而是先把发票状态设为开票中,同时向MQ投递一条延迟查询消息。等延迟时间到了之后,定时任务去税务平台查询这笔开票请求的结果。如果查询到成功,状态正常更新;如果仍在处理中,继续投递延迟消息轮询;如果查询到失败,才标记为开票失败。这种做法的好处是:第三方超时不等于开票失败,可能请求已经到了税务平台正在处理,只是响应没回来。贸然标记失败会导致重复开票。
第二层是Redis异常的降级。开票流程里用Redis做分布式锁和缓存,markOrderInvoice方法用Redisson锁保证同一笔开票请求不会被并发处理。如果Redis超时(RedisTimeoutException),代码不会把发票标记为失败,而是保持开票中状态,等下一次轮询来兜底。这是合理的降级策略:Redis不可用不应该导致发票状态错误。
第三层是未知异常的兜底。对于无法识别的异常(比如数据库访问异常、序列化错误),代码把发票标记为失败,记录异常信息到remarks字段。这类异常通常不是税务平台的问题,重试没有意义,直接失败比反复重试更合理。
税务平台容错策略速查表
| 异常类型 | 处理方式 | 发票状态 | 原因 |
|---|---|---|---|
| 第三方超时/网络异常 | 投递MQ延迟查询 | 开票中 | 请求可能已到达,不能标记失败 |
| Redis超时 | 保持现状,等轮询兜底 | 开票中 | Redis不可用不应影响发票状态 |
| 业务校验异常 | 标记失败 | 开票失败 | 校验不通过,重试无意义 |
| 未知异常 | 标记失败 | 开票失败 | 无法识别,重试风险大 |
与订单服务的状态同步
发票中心和订单服务之间的状态同步,核心问题就一个:订单系统怎么知道某笔订单已经开过票了?
在发票中心的架构下,这个同步通过两种机制来实现。
第一种是开票成功后主动通知。发票中心完成开票后,发布InvoiceCompletedEvent。订单服务作为事件订阅者,收到通知后更新订单的开票状态字段(invoiceStatus)。这个通知走MQ,不需要同步调用,订单服务更新失败也不会影响发票中心的流程。
第二种是订单服务查询时实时获取。订单列表和订单详情接口在返回数据时,需要带上开票状态。做法是订单服务通过Dubbo RPC调用发票中心的queryByOrderNo接口,批量查询一批订单的开票状态,然后填充到订单VO里返回给前端。这里有个优化点:可以在订单服务侧做短时间缓存(比如1分钟),避免每次查订单列表都调发票中心。
两个机制互补:主动通知保证状态及时更新,实时查询保证即使通知丢失也能查到最新状态。不依赖单一通道,就不会出现订单显示未开票但实际已经开票的问题。
异步流程的设计
发票中心内部维护四个MQ队列:
蓝票开票队列:提交开票请求后,延迟一定时间触发蓝票结果查询。如果查询时仍在开票中,会重新投递延迟消息继续轮询。
发票查询队列:用于查询开票结果,包括蓝票和红票。
邮件发送队列:开票成功后异步发送邮件给用户。
图片转换队列:将PDF格式的发票转换为图片格式,用于前端展示。
开票结果轮询从延迟队列改为定时任务扫描。延迟队列的问题是每条消息都是独立的,无法批量处理。改成定时任务每分钟扫描开票中的记录,批量查询结果,减少对税务平台的请求次数。
邮件发送和PDF转图片做成可选的后续动作。开票成功后发布领域事件,订阅方根据配置决定是否发送邮件、是否转图片。
小结
发票业务有一个特点:看起来是订单的附属功能,实际上有自己完整的生命周期。从抬头管理到开票校验,从金额计算到税务平台对接,从蓝票到红冲,从PDF生成到微信卡包,每一步都有独立的业务规则。给发票建一个独立的服务,不是在制造更多的微服务,而是把一块有完整领域边界的东西放到它该在的位置。
发票中心架构设计的核心思路是两条线:向外用策略模式隔离变化,对内用领域模型收拢职责。订单类型的差异用InvoiceOrderStrategy隔离,税务渠道的差异用InvoiceChannelService隔离,后续动作的差异用领域事件隔离。变化被隔离之后,核心流程就是一条干净的主线:创建申请、校验、计算、开票、回调、更新状态。
容错和状态同步是发票中心上线后真正考验工程质量的地方。第三方平台超时不能直接标失败,要用轮询兜底;Redis挂了不能影响发票状态,要保持开票中等轮询来收场;订单的开票状态不能只靠推通知,还要有拉查询做兜底。这些设计不是过度防御,是跟第三方平台打过交道之后的经验:你控制不了的东西,就得假设它会出问题,然后保证出问题之后状态不会错。
发票中心的设计原则:变化用策略隔离,职责用领域收拢。
最近在知乎出了
- 「应付6000万会员的秒杀系统专栏」
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
- 「应付亿级用户规模的支付系统代码实战」
专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:
- 老码头的技术浮生录
它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」
当前星球里免费看的专栏是:
- 「应付6000万会员的秒杀系统专栏」
- 「几亿用户,百万并发的C端商品系统实战」
- 「技术团队DDD领域驱动设计三年落地实战」
- 「应付亿级用户规模的支付系统代码实战」
知识星球内后续将推出20+个付费专栏,覆盖电商全链路:
| 选购线 | 用户会员营销线 | 中后台 |
|---|---|---|
| 购物车服务 | 营销系统 | 订单系统 |
| 商品服务 | 用户系统 | 支付系统 |
| 菜单服务 | 结算服务 |
从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。
我的知乎账号:
- SamDeepThinking