电商结算系统核心架构设计

417 阅读6分钟

废话不多,全是干货。

引言

结算系统在电商技术体系中属于交易后处理中枢,主要负责资金的清结算。上游系统一般是交易支付、营销中心,以及其他有结算需求的业务系统,下游是资金系统、账务系统。

对于有更多种场景的业务诉求,比如需要支持电商、机酒、联盟等等,结算系统平台化是一个选择,更是一种趋势。面对多种多样的结算诉求,对结算账期和任务调度进行统一管理势在必行。

结算平台

下图是结算流程的极简示意图,黄色部分是本文重点介绍的「账期引擎」所处的位置。

结算流程极简示意图

账期引擎位于结算系统中游,向上承接受理核心发来的结算单据和需求,向下驱动结算核心发起结算。

账期引擎

要管理账期,首先要明确账期是什么。

专业领域的账期,指时间跨度,比如按月结算,1号发生的交易,在下个月1号进行付款,这中间的时间长度为1个月。广义上的账期,更关注时间范围,比如月结,指在这个月内发生的所有交易,合并成1笔账单进行付款,常见的像对公快递业务,一个月出一个账单,信用卡也是一个周期内出一个账单。

系统实现上几乎都以广义账期为主,下文提到的账期均为广义账期的概念,不再赘述。

结算方式

有周期结和逐笔结,逐笔结顾名思义就是一笔一结,高业务量的情况下实际上应用比较少,常见的场景如结算调整会使用这种方式。周期结是将固定时间段内的交易归集后按固定周期结算,例如月结是以一个自然月为一个周期,每月月末出账账单,次月2号付款。

常见的账期如下:

账期类型定义与计算方式示例说明
月结按自然月周期结算,次月固定日期付款(如次月5日)某超市与供应商约定:账期:自然月(1日-31日)付款日:次月5日支付上月货款
周结按自然周(周一到周日)结算,次周固定日期付款(如次周二)外卖平台与骑手约定:账期:每周一至周日付款日:次周二结算上周佣金
D+7以自然日为起点,7天后付款装修工程合同约定:账期:工程验收合格日(D)起7天付款日:D+7日支付尾款
T+7以交易完成日为起点,7天后付款跨境电商规则:账期:客户确认收货日(T)起7天付款日:T+7日向商家结算货款

账期 领域核心设计

收账主流程

收账流程图

账期单

账期单用来实现对结算需求进行统一管理,从数据层面直接驱动结算。

public class BillingPeriod {
    private Long id;                 // 账期单唯一标识
    private String userId;           // 商家ID(分片键)
    private PeriodType periodType;   // 枚举值:NATURAL_MONTH/CUSTOM_WEEK
    private BillType billType;       // 账单类型
    private String settleBatchNo;    // 结算批次号
    private Status status;           // 状态机:OPEN -> PROCESSING -> CLOSED
    // 账期时间戳
    private LocalDateTime periodStart;  // 精确到秒的账期起点
    private LocalDateTime periodEnd;    // 包含边界的截止时点
    // 关键业务时间戳
    private LocalDateTime closedTime;   // 关账截止(不可修改标记)
    private LocalDateTime billingTime;  // 出账生效时间
}

账期单状态机

账期单状态机

账期计算器

用来计算账期单的账期开始时间、截止时间、关账时间和出账时间。根据不同的账期类型创建账期策略,通过账期计算器工厂对外提供服务。

账期计算器类图

/**
账期计算器
*/
public interface PeriodCalculator {
    PeriodType getPeriodType();

    default BillType getBillType() {
        return null;
    }

    LocalDateTime getPeriodStart(LocalDateTime date, TimeZone timeZone);
    LocalDateTime getPeriodEnd(LocalDateTime date, TimeZone timeZone);
    LocalDateTime getCloseTime(LocalDateTime date, TimeZone timeZone);
    LocalDateTime getBillingTime(LocalDateTime date, TimeZone timeZone);
}

以下代码是通过DeepSeek自动生成,强到没边,强到离谱,可以直接拿来用!

import java.time.*;
import java.time.temporal.TemporalAdjusters;
import java.util.TimeZone;

/**
自然月账期计算器实现类
所有时间计算均基于指定时区处理
*/
public class MonthlyPeriodCalculator implements PeriodCalculator {

    @Override
    public PeriodType getPeriodType() {
        return PeriodType.MONTHLY;
    }

    /**
     * 计算账期开始时间(自然月首日00:00:00)
     * @param date 基准时间(通常为业务发生时间)
     * @param timeZone 业务发生时区
     * @return 带时区信息的账期开始时间(转换为UTC时间返回)
     */
    @Override
    public LocalDateTime getPeriodStart(LocalDateTime date, TimeZone timeZone) {
        // 转换为带时区的时间对象
        ZonedDateTime zonedDate = date.atZone(timeZone.toZoneId());
        // 调整为当月第一天零点(保持原时区)
        ZonedDateTime start = zonedDate.with(TemporalAdjusters.firstDayOfMonth())
                                     .with(LocalTime.MIN);
        // 转换为统一存储的UTC时间
        return start.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
    }

    /**
     * 计算账期结束时间(自然月最后一日24:00:00)
     * 技术上等同于次月首日00:00:00的临界点
     */
    @Override
    public LocalDateTime getPeriodEnd(LocalDateTime date, TimeZone timeZone) {
        ZonedDateTime zonedDate = date.atZone(timeZone.toZoneId());
        // 次月首日零点(当前时区时间)
        ZonedDateTime end = zonedDate.with(TemporalAdjusters.firstDayOfNextMonth())
                                   .with(LocalTime.MIN);
        return end.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
    }

    /**
     * 计算关账时间(次月1日02:00:00)
     * 预留2小时缓冲期处理期末业务
     */
    @Override
    public LocalDateTime getCloseTime(LocalDateTime date, TimeZone timeZone) {
        ZonedDateTime zonedDate = date.atZone(timeZone.toZoneId());
        // 次月1日02:00(当前时区时间)
        ZonedDateTime closeTime = zonedDate.with(TemporalAdjusters.firstDayOfNextMonth())
                                         .with(LocalTime.of(2, 0));
        return closeTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
    }

    /**
     * 计算出账时间(次月1日24:00:00)
     * 实际等同于次月2日00:00:00的UTC时间
     */
    @Override
    public LocalDateTime getBillingTime(LocalDateTime date, TimeZone timeZone) {
        ZonedDateTime zonedDate = date.atZone(timeZone.toZoneId());
        // 次月2日零点(当前时区时间)
        ZonedDateTime billingTime = zonedDate.with(TemporalAdjusters.firstDayOfNextMonth())
                                           .plusDays(1)
                                           .with(LocalTime.MIN);
        return billingTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime();
    }

    // 测试用例(使用时区敏感验证)
    public static void main(String[] args) {
        MonthlyPeriodCalculator calculator = new MonthlyPeriodCalculator();
        testTimeZone(calculator, "Asia/Shanghai", LocalDateTime.of(2024,2,15,14,30));
        testTimeZone(calculator, "America/New_York", LocalDateTime.of(2024,2,28,23,59));
    }

    private static void testTimeZone(MonthlyPeriodCalculator calc, String zoneId, LocalDateTime testTime) {
        TimeZone tz = TimeZone.getTimeZone(zoneId);
        System.out.println("\n时区测试: " + zoneId);
        System.out.println("测试基准时间: " + testTime + " [" + zoneId + "]");
        System.out.println("开始时间: " + calc.getPeriodStart(testTime, tz) + " UTC");
        System.out.println("结束时间: " + calc.getPeriodEnd(testTime, tz) + " UTC");
        System.out.println("关账时间: " + calc.getCloseTime(testTime, tz) + " UTC");
        System.out.println("出账时间: " + calc.getBillingTime(testTime, tz) + " UTC");
    }
}

收账

收账就是把结算需求收进来,为后续通过结算任务创建账单做准备,主要通过责任链实现对流程的编排和解耦。通常情况收账时会按照用户、账单类型、币种、账期等要素把结算需求收入到不同的账期单内,但是也有一些业务有其特殊的要求,需要进行定制化的实现处理器和流程,比如某些商家要求待结算金额满足100万就要出账结算,那就需要配合动态账期计算器来编排实现另一种收账策略。

收账类图

责任链的实践参考:使用责任链模式重构计费