发票中心架构设计

0 阅读20分钟

做技术架构之前,得先把业务和用户场景搞清楚。发票这块业务,不看用户怎么用、不看有哪些关键用例,上来就画架构图,画出来的东西大概率和业务对不上。这篇文章的写作思路是:先说清楚需求是什么、用户怎么用发票,再聊领域知识,最后才是架构设计。

需求与关键用例

一个做连锁店业务的品牌,线上有小程序、支付宝、美团外卖、周边商城、自由卡、会员卡,每个渠道的订单都能开发票。发票这个需求看起来简单,就是用户买了东西要开票,但实际展开之后复杂度不小。

用户自助开票

用户在小程序或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-baiwangHTTP + 签名微信XML/支付宝JSON同步+异步轮询
航信base-hangxinHTTP + 签名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