废话不多,全是干货。
引言
结算系统在电商技术体系中属于交易后处理中枢,主要负责资金的清结算。上游系统一般是交易支付、营销中心,以及其他有结算需求的业务系统,下游是资金系统、账务系统。
对于有更多种场景的业务诉求,比如需要支持电商、机酒、联盟等等,结算系统平台化是一个选择,更是一种趋势。面对多种多样的结算诉求,对结算账期和任务调度进行统一管理势在必行。
结算平台
下图是结算流程的极简示意图,黄色部分是本文重点介绍的「账期引擎」所处的位置。
账期引擎位于结算系统中游,向上承接受理核心发来的结算单据和需求,向下驱动结算核心发起结算。
账期引擎
要管理账期,首先要明确账期是什么。
专业领域的账期,指时间跨度,比如按月结算,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万就要出账结算,那就需要配合动态账期计算器来编排实现另一种收账策略。
责任链的实践参考:使用责任链模式重构计费