财务对账系统设计与实现:支付宝、微信、银行流水全渠道对账
前言
在电商、金融等业务场景中,对账系统是保障资金安全的核心环节。本文将详细介绍如何设计和实现一套支持支付宝、微信、银行等多渠道的财务对账系统,涵盖系统架构、核心算法、代码实现等内容。
一、对账系统概述
1.1 什么是对账
对账是指将企业内部的交易记录与外部渠道(支付宝、微信、银行等)的流水记录进行核对,确保双方数据一致的过程。
lua
+------------------------------------------------------------------+
| 对账核心流程 |
+------------------------------------------------------------------+
| |
| 内部系统 外部渠道 |
| +------------------+ +------------------+ |
| | 交易订单表 | | 支付宝账单 | |
| | 支付流水表 | 对账核心 | 微信账单 | |
| | 退款记录表 | <--------------> | 银行流水 | |
| +------------------+ | 自定义流水 | |
| +------------------+ |
| | |
| v |
| +------------------+ |
| | 对账结果 | |
| +------------------+ |
| | - 对账成功 | |
| | - 本地有渠道无 | |
| | - 渠道有本地无 | |
| | - 金额不一致 | |
| +------------------+ |
| |
+------------------------------------------------------------------+
1.2 对账的目的
- 资金安全:及时发现异常交易,防止资金损失
- 账务准确:确保财务报表数据准确
- 差错处理:快速定位和处理对账差异
- 风险控制:发现潜在的欺诈和风险
1.3 对账差异类型
lua
+------------------------------------------------------------------+
| 对账差异分类 |
+------------------------------------------------------------------+
| |
| +--------------------+------------------------------------+ |
| | 差异类型 | 描述 | |
| +--------------------+------------------------------------+ |
| | 本地多(长款) | 本地有记录,渠道无记录 | |
| +--------------------+------------------------------------+ |
| | 渠道多(短款) | 渠道有记录,本地无记录 | |
| +--------------------+------------------------------------+ |
| | 金额不一致 | 双方都有记录,但金额不同 | |
| +--------------------+------------------------------------+ |
| | 状态不一致 | 双方金额相同,但交易状态不同 | |
| +--------------------+------------------------------------+ |
| | 时间差异 | 交易时间跨天导致的差异 | |
| +--------------------+------------------------------------+ |
| |
+------------------------------------------------------------------+
二、系统架构设计
2.1 整体架构
lua
+------------------------------------------------------------------------+
| 对账系统整体架构 |
+------------------------------------------------------------------------+
| |
| +-------------------+ +-------------------+ +------------------+ |
| | 定时任务 | | 手动触发 | | 文件上传 | |
| +--------+----------+ +--------+----------+ +--------+---------+ |
| | | | |
| +------------------------+------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+
| | 对账调度中心 |
| +---------------------------------------------------------------------+
| | |
| +------------------------+------------------------+ |
| | | | |
| v v v |
| +----------------+ +----------------+ +----------------+ |
| | 支付宝对账模块 | | 微信对账模块 | | 银行对账模块 | |
| +----------------+ +----------------+ +----------------+ |
| | | | |
| +------------------------+------------------------+ |
| | |
| v |
| +---------------------------------------------------------------------+
| | 对账引擎核心 |
| | +-------------+ +-------------+ +-------------+ +-------------+ |
| | | 文件解析器 | | 数据清洗 | | 匹配算法 | | 差异处理 | |
| | +-------------+ +-------------+ +-------------+ +-------------+ |
| +---------------------------------------------------------------------+
| | |
| v |
| +---------------------------------------------------------------------+
| | 数据存储层 |
| | +-------------+ +-------------+ +-------------+ +-------------+ |
| | | 对账任务表 | | 渠道流水表 | | 对账结果表 | | 差异记录表 | |
| | +-------------+ +-------------+ +-------------+ +-------------+ |
| +---------------------------------------------------------------------+
| | |
| v |
| +---------------------------------------------------------------------+
| | 监控告警层 |
| | +------------------+ +------------------+ +-------------------+ |
| | | 对账进度监控 | | 差异率告警 | | 异常通知 | |
| | +------------------+ +------------------+ +-------------------+ |
| +---------------------------------------------------------------------+
| |
+------------------------------------------------------------------------+
2.2 核心模块说明
diff
+------------------------------------------------------------------+
| 核心模块职责 |
+------------------------------------------------------------------+
| |
| 对账调度中心: |
| - 管理对账任务的创建、调度、执行 |
| - 支持定时对账和手动触发 |
| - 任务状态管理和失败重试 |
| |
| 文件解析器: |
| - 支持 CSV、Excel、TXT 等格式 |
| - 适配不同渠道的账单格式 |
| - 大文件分片解析 |
| |
| 匹配算法: |
| - 单字段匹配(订单号) |
| - 多字段联合匹配 |
| - 模糊匹配和容差匹配 |
| |
| 差异处理: |
| - 差异分类和标记 |
| - 自动处理规则 |
| - 人工审核流程 |
| |
+------------------------------------------------------------------+
三、数据库设计
3.1 核心表结构
sql
-- 1. 对账任务表
CREATE TABLE `recon_task` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
`task_no` VARCHAR(32) NOT NULL COMMENT '任务编号',
`channel_type` VARCHAR(20) NOT NULL COMMENT '渠道类型:ALIPAY/WECHAT/BANK/CUSTOM',
`recon_date` DATE NOT NULL COMMENT '对账日期',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-待处理,1-处理中,2-已完成,3-失败',
`total_count` INT DEFAULT 0 COMMENT '总记录数',
`success_count` INT DEFAULT 0 COMMENT '对账成功数',
`fail_count` INT DEFAULT 0 COMMENT '对账失败数',
`diff_count` INT DEFAULT 0 COMMENT '差异数',
`file_path` VARCHAR(500) COMMENT '账单文件路径',
`error_msg` VARCHAR(1000) COMMENT '错误信息',
`start_time` DATETIME COMMENT '开始时间',
`end_time` DATETIME COMMENT '结束时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_task_no` (`task_no`),
KEY `idx_channel_date` (`channel_type`, `recon_date`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账任务表';
-- 2. 渠道流水表(支付宝)
CREATE TABLE `channel_bill_alipay` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '任务ID',
`trade_no` VARCHAR(64) NOT NULL COMMENT '支付宝交易号',
`out_trade_no` VARCHAR(64) COMMENT '商户订单号',
`trade_type` VARCHAR(20) COMMENT '交易类型',
`trade_status` VARCHAR(20) COMMENT '交易状态',
`amount` DECIMAL(12,2) NOT NULL COMMENT '交易金额',
`fee` DECIMAL(12,2) DEFAULT 0 COMMENT '手续费',
`trade_time` DATETIME COMMENT '交易时间',
`subject` VARCHAR(256) COMMENT '商品名称',
`recon_status` TINYINT DEFAULT 0 COMMENT '对账状态:0-待对账,1-已对账,2-差异',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_task_id` (`task_id`),
KEY `idx_trade_no` (`trade_no`),
KEY `idx_out_trade_no` (`out_trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付宝账单流水';
-- 3. 渠道流水表(微信)
CREATE TABLE `channel_bill_wechat` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '任务ID',
`transaction_id` VARCHAR(64) NOT NULL COMMENT '微信支付订单号',
`out_trade_no` VARCHAR(64) COMMENT '商户订单号',
`trade_type` VARCHAR(20) COMMENT '交易类型',
`trade_state` VARCHAR(20) COMMENT '交易状态',
`amount` DECIMAL(12,2) NOT NULL COMMENT '交易金额',
`fee` DECIMAL(12,2) DEFAULT 0 COMMENT '手续费',
`trade_time` DATETIME COMMENT '交易时间',
`body` VARCHAR(256) COMMENT '商品描述',
`recon_status` TINYINT DEFAULT 0 COMMENT '对账状态',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_task_id` (`task_id`),
KEY `idx_transaction_id` (`transaction_id`),
KEY `idx_out_trade_no` (`out_trade_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信账单流水';
-- 4. 渠道流水表(银行)
CREATE TABLE `channel_bill_bank` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '任务ID',
`bank_serial_no` VARCHAR(64) NOT NULL COMMENT '银行流水号',
`merchant_order_no` VARCHAR(64) COMMENT '商户订单号',
`trans_type` VARCHAR(20) COMMENT '交易类型:DEBIT/CREDIT',
`trans_status` VARCHAR(20) COMMENT '交易状态',
`amount` DECIMAL(12,2) NOT NULL COMMENT '交易金额',
`fee` DECIMAL(12,2) DEFAULT 0 COMMENT '手续费',
`trans_time` DATETIME COMMENT '交易时间',
`remark` VARCHAR(256) COMMENT '备注',
`account_no` VARCHAR(32) COMMENT '账户号',
`account_name` VARCHAR(64) COMMENT '账户名',
`recon_status` TINYINT DEFAULT 0 COMMENT '对账状态',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_task_id` (`task_id`),
KEY `idx_bank_serial_no` (`bank_serial_no`),
KEY `idx_merchant_order_no` (`merchant_order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='银行账单流水';
-- 5. 自定义渠道流水表
CREATE TABLE `channel_bill_custom` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '任务ID',
`channel_code` VARCHAR(32) NOT NULL COMMENT '渠道编码',
`channel_trade_no` VARCHAR(64) NOT NULL COMMENT '渠道交易号',
`merchant_order_no` VARCHAR(64) COMMENT '商户订单号',
`trade_type` VARCHAR(20) COMMENT '交易类型',
`trade_status` VARCHAR(20) COMMENT '交易状态',
`amount` DECIMAL(12,2) NOT NULL COMMENT '交易金额',
`fee` DECIMAL(12,2) DEFAULT 0 COMMENT '手续费',
`trade_time` DATETIME COMMENT '交易时间',
`ext_data` JSON COMMENT '扩展数据',
`recon_status` TINYINT DEFAULT 0 COMMENT '对账状态',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_task_id` (`task_id`),
KEY `idx_channel_trade_no` (`channel_code`, `channel_trade_no`),
KEY `idx_merchant_order_no` (`merchant_order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自定义渠道流水';
-- 6. 本地支付流水表
CREATE TABLE `local_payment_record` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`order_no` VARCHAR(64) NOT NULL COMMENT '商户订单号',
`channel_type` VARCHAR(20) NOT NULL COMMENT '支付渠道',
`channel_trade_no` VARCHAR(64) COMMENT '渠道交易号',
`pay_type` VARCHAR(20) COMMENT '支付类型',
`pay_status` TINYINT NOT NULL COMMENT '支付状态:0-待支付,1-支付成功,2-支付失败,3-已退款',
`amount` DECIMAL(12,2) NOT NULL COMMENT '支付金额',
`fee` DECIMAL(12,2) DEFAULT 0 COMMENT '手续费',
`pay_time` DATETIME COMMENT '支付时间',
`user_id` BIGINT COMMENT '用户ID',
`product_name` VARCHAR(256) COMMENT '商品名称',
`recon_status` TINYINT DEFAULT 0 COMMENT '对账状态:0-待对账,1-已对账,2-差异',
`recon_time` DATETIME COMMENT '对账时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_channel_trade_no` (`channel_type`, `channel_trade_no`),
KEY `idx_pay_time` (`pay_time`),
KEY `idx_recon_status` (`recon_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地支付流水表';
-- 7. 对账结果表
CREATE TABLE `recon_result` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`task_id` BIGINT NOT NULL COMMENT '任务ID',
`order_no` VARCHAR(64) COMMENT '商户订单号',
`channel_trade_no` VARCHAR(64) COMMENT '渠道交易号',
`result_type` TINYINT NOT NULL COMMENT '结果类型:1-匹配成功,2-本地多,3-渠道多,4-金额不一致,5-状态不一致',
`local_amount` DECIMAL(12,2) COMMENT '本地金额',
`channel_amount` DECIMAL(12,2) COMMENT '渠道金额',
`diff_amount` DECIMAL(12,2) COMMENT '差异金额',
`local_status` VARCHAR(20) COMMENT '本地状态',
`channel_status` VARCHAR(20) COMMENT '渠道状态',
`handle_status` TINYINT DEFAULT 0 COMMENT '处理状态:0-待处理,1-已处理,2-忽略',
`handle_remark` VARCHAR(500) COMMENT '处理备注',
`handler` VARCHAR(64) COMMENT '处理人',
`handle_time` DATETIME COMMENT '处理时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_task_id` (`task_id`),
KEY `idx_order_no` (`order_no`),
KEY `idx_result_type` (`result_type`),
KEY `idx_handle_status` (`handle_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账结果表';
-- 8. 渠道配置表
CREATE TABLE `recon_channel_config` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`channel_code` VARCHAR(32) NOT NULL COMMENT '渠道编码',
`channel_name` VARCHAR(64) NOT NULL COMMENT '渠道名称',
`file_type` VARCHAR(20) COMMENT '文件类型:CSV/EXCEL/TXT',
`file_encoding` VARCHAR(20) DEFAULT 'UTF-8' COMMENT '文件编码',
`field_mapping` JSON COMMENT '字段映射配置',
`parser_class` VARCHAR(256) COMMENT '解析器类名',
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_channel_code` (`channel_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道配置表';
3.2 E-R 关系图
lua
+------------------------------------------------------------------+
| E-R 关系图 |
+------------------------------------------------------------------+
| |
| +-------------+ 1:N +-------------------+ |
| | recon_task |---------------->| channel_bill_xxx | |
| +-------------+ +-------------------+ |
| | |
| | 1:N |
| v |
| +-------------+ +---------------------+ |
| | recon_result|<--------------->| local_payment_record| |
| +-------------+ 关联 +---------------------+ |
| |
| +---------------------+ |
| | recon_channel_config| 渠道配置(独立) |
| +---------------------+ |
| |
+------------------------------------------------------------------+
四、核心代码实现
4.1 基础实体类
java
/**
* 渠道类型枚举
*/
public enum ChannelType {
ALIPAY("ALIPAY", "支付宝"),
WECHAT("WECHAT", "微信支付"),
BANK("BANK", "银行"),
CUSTOM("CUSTOM", "自定义渠道");
private final String code;
private final String desc;
ChannelType(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getCode() { return code; }
public String getDesc() { return desc; }
}
/**
* 对账结果类型枚举
*/
public enum ReconResultType {
MATCHED(1, "匹配成功"),
LOCAL_MORE(2, "本地多(长款)"),
CHANNEL_MORE(3, "渠道多(短款)"),
AMOUNT_MISMATCH(4, "金额不一致"),
STATUS_MISMATCH(5, "状态不一致");
private final int code;
private final String desc;
ReconResultType(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() { return code; }
public String getDesc() { return desc; }
}
/**
* 对账任务状态枚举
*/
public enum TaskStatus {
PENDING(0, "待处理"),
PROCESSING(1, "处理中"),
COMPLETED(2, "已完成"),
FAILED(3, "失败");
private final int code;
private final String desc;
TaskStatus(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() { return code; }
public String getDesc() { return desc; }
}
java
/**
* 对账任务实体
*/
@Data
@TableName("recon_task")
public class ReconTask {
@TableId(type = IdType.AUTO)
private Long id;
private String taskNo;
private String channelType;
private LocalDate reconDate;
private Integer status;
private Integer totalCount;
private Integer successCount;
private Integer failCount;
private Integer diffCount;
private String filePath;
private String errorMsg;
private LocalDateTime startTime;
private LocalDateTime endTime;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
/**
* 统一渠道账单数据模型
*/
@Data
public class ChannelBillDTO {
private String channelTradeNo; // 渠道交易号
private String merchantOrderNo; // 商户订单号
private String tradeType; // 交易类型
private String tradeStatus; // 交易状态
private BigDecimal amount; // 交易金额
private BigDecimal fee; // 手续费
private LocalDateTime tradeTime; // 交易时间
private String remark; // 备注
private Map<String, Object> extData;// 扩展数据
}
/**
* 本地支付记录实体
*/
@Data
@TableName("local_payment_record")
public class LocalPaymentRecord {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo;
private String channelType;
private String channelTradeNo;
private String payType;
private Integer payStatus;
private BigDecimal amount;
private BigDecimal fee;
private LocalDateTime payTime;
private Long userId;
private String productName;
private Integer reconStatus;
private LocalDateTime reconTime;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
/**
* 对账结果实体
*/
@Data
@TableName("recon_result")
public class ReconResult {
@TableId(type = IdType.AUTO)
private Long id;
private Long taskId;
private String orderNo;
private String channelTradeNo;
private Integer resultType;
private BigDecimal localAmount;
private BigDecimal channelAmount;
private BigDecimal diffAmount;
private String localStatus;
private String channelStatus;
private Integer handleStatus;
private String handleRemark;
private String handler;
private LocalDateTime handleTime;
private LocalDateTime createTime;
}
4.2 文件解析器
java
/**
* 账单解析器接口
*/
public interface BillParser {
/**
* 获取支持的渠道类型
*/
ChannelType getChannelType();
/**
* 解析账单文件
*/
List<ChannelBillDTO> parse(InputStream inputStream, String fileName) throws Exception;
/**
* 是否支持该文件
*/
boolean supports(String fileName);
}
/**
* 支付宝账单解析器
*/
@Slf4j
@Component
public class AlipayBillParser implements BillParser {
@Override
public ChannelType getChannelType() {
return ChannelType.ALIPAY;
}
@Override
public boolean supports(String fileName) {
return fileName != null && fileName.contains("alipay");
}
@Override
public List<ChannelBillDTO> parse(InputStream inputStream, String fileName) throws Exception {
List<ChannelBillDTO> billList = new ArrayList<>();
// 支付宝账单通常是 CSV 格式,GBK 编码
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, "GBK"))) {
String line;
boolean isDataSection = false;
int lineNum = 0;
while ((line = reader.readLine()) != null) {
lineNum++;
// 跳过账单头部信息(支付宝账单前几行是汇总信息)
if (line.startsWith("支付宝交易号")) {
isDataSection = true;
continue;
}
// 跳过汇总行
if (line.startsWith("#") || line.trim().isEmpty()) {
continue;
}
if (!isDataSection) {
continue;
}
try {
ChannelBillDTO bill = parseLine(line);
if (bill != null) {
billList.add(bill);
}
} catch (Exception e) {
log.warn("解析支付宝账单第{}行失败: {}", lineNum, e.getMessage());
}
}
}
log.info("支付宝账单解析完成,共{}条记录", billList.size());
return billList;
}
private ChannelBillDTO parseLine(String line) {
// 支付宝账单字段:支付宝交易号,商户订单号,交易类型,交易状态,金额,手续费,交易时间...
String[] fields = line.split(",");
if (fields.length < 7) {
return null;
}
ChannelBillDTO bill = new ChannelBillDTO();
bill.setChannelTradeNo(fields[0].trim());
bill.setMerchantOrderNo(fields[1].trim());
bill.setTradeType(fields[2].trim());
bill.setTradeStatus(fields[3].trim());
bill.setAmount(new BigDecimal(fields[4].trim()));
bill.setFee(new BigDecimal(fields[5].trim()));
// 解析时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
bill.setTradeTime(LocalDateTime.parse(fields[6].trim(), formatter));
return bill;
}
}
/**
* 微信账单解析器
*/
@Slf4j
@Component
public class WechatBillParser implements BillParser {
@Override
public ChannelType getChannelType() {
return ChannelType.WECHAT;
}
@Override
public boolean supports(String fileName) {
return fileName != null && fileName.contains("wechat");
}
@Override
public List<ChannelBillDTO> parse(InputStream inputStream, String fileName) throws Exception {
List<ChannelBillDTO> billList = new ArrayList<>();
// 微信账单是 CSV 格式,UTF-8 编码
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
boolean isDataSection = false;
int lineNum = 0;
while ((line = reader.readLine()) != null) {
lineNum++;
// 微信账单以"交易时间"开头的是表头
if (line.startsWith("交易时间")) {
isDataSection = true;
continue;
}
// 跳过汇总部分
if (line.startsWith("总交易单数") || line.trim().isEmpty()) {
isDataSection = false;
continue;
}
if (!isDataSection) {
continue;
}
try {
ChannelBillDTO bill = parseLine(line);
if (bill != null) {
billList.add(bill);
}
} catch (Exception e) {
log.warn("解析微信账单第{}行失败: {}", lineNum, e.getMessage());
}
}
}
log.info("微信账单解析完成,共{}条记录", billList.size());
return billList;
}
private ChannelBillDTO parseLine(String line) {
// 微信账单字段:交易时间,公众账号ID,商户号,子商户号,设备号,微信订单号,商户订单号,用户标识,交易类型,交易状态,付款银行,货币种类,总金额,企业红包金额,微信退款单号,商户退款单号,退款金额,企业红包退款金额,退款类型,退款状态,商品名称,商户数据包,手续费,费率
String[] fields = line.split(",");
if (fields.length < 15) {
return null;
}
ChannelBillDTO bill = new ChannelBillDTO();
// 去除反引号(微信账单中数字字段带反引号)
bill.setChannelTradeNo(cleanField(fields[5]));
bill.setMerchantOrderNo(cleanField(fields[6]));
bill.setTradeType(cleanField(fields[8]));
bill.setTradeStatus(cleanField(fields[9]));
// 金额字段需要去除 ¥ 符号
String amountStr = cleanField(fields[12]).replace("¥", "");
bill.setAmount(new BigDecimal(amountStr));
// 手续费
if (fields.length > 22) {
String feeStr = cleanField(fields[22]).replace("¥", "");
bill.setFee(new BigDecimal(feeStr));
}
// 解析时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
bill.setTradeTime(LocalDateTime.parse(cleanField(fields[0]), formatter));
return bill;
}
private String cleanField(String field) {
return field.replace("`", "").trim();
}
}
/**
* 银行账单解析器(Excel 格式)
*/
@Slf4j
@Component
public class BankBillParser implements BillParser {
@Override
public ChannelType getChannelType() {
return ChannelType.BANK;
}
@Override
public boolean supports(String fileName) {
return fileName != null &&
(fileName.endsWith(".xlsx") || fileName.endsWith(".xls"));
}
@Override
public List<ChannelBillDTO> parse(InputStream inputStream, String fileName) throws Exception {
List<ChannelBillDTO> billList = new ArrayList<>();
// 使用 EasyExcel 解析
EasyExcel.read(inputStream, BankBillExcelDTO.class, new ReadListener<BankBillExcelDTO>() {
@Override
public void invoke(BankBillExcelDTO data, AnalysisContext context) {
ChannelBillDTO bill = convertToChannelBill(data);
if (bill != null) {
billList.add(bill);
}
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
log.info("银行账单解析完成,共{}条记录", billList.size());
}
}).sheet().doRead();
return billList;
}
private ChannelBillDTO convertToChannelBill(BankBillExcelDTO excelDTO) {
ChannelBillDTO bill = new ChannelBillDTO();
bill.setChannelTradeNo(excelDTO.getBankSerialNo());
bill.setMerchantOrderNo(excelDTO.getMerchantOrderNo());
bill.setTradeType(excelDTO.getTransType());
bill.setTradeStatus(excelDTO.getTransStatus());
bill.setAmount(excelDTO.getAmount());
bill.setFee(excelDTO.getFee());
bill.setTradeTime(excelDTO.getTransTime());
bill.setRemark(excelDTO.getRemark());
return bill;
}
/**
* 银行账单 Excel DTO
*/
@Data
public static class BankBillExcelDTO {
@ExcelProperty("流水号")
private String bankSerialNo;
@ExcelProperty("商户订单号")
private String merchantOrderNo;
@ExcelProperty("交易类型")
private String transType;
@ExcelProperty("交易状态")
private String transStatus;
@ExcelProperty("交易金额")
private BigDecimal amount;
@ExcelProperty("手续费")
private BigDecimal fee;
@ExcelProperty("交易时间")
private LocalDateTime transTime;
@ExcelProperty("备注")
private String remark;
}
}
/**
* 自定义渠道解析器工厂
*/
@Slf4j
@Component
public class CustomBillParserFactory {
@Autowired
private ReconChannelConfigMapper channelConfigMapper;
/**
* 根据渠道配置动态解析账单
*/
public List<ChannelBillDTO> parse(String channelCode, InputStream inputStream) throws Exception {
ReconChannelConfig config = channelConfigMapper.selectByChannelCode(channelCode);
if (config == null) {
throw new RuntimeException("渠道配置不存在: " + channelCode);
}
// 解析字段映射配置
Map<String, String> fieldMapping = JSON.parseObject(
config.getFieldMapping(), new TypeReference<Map<String, String>>() {});
List<ChannelBillDTO> billList = new ArrayList<>();
// 根据文件类型解析
if ("CSV".equalsIgnoreCase(config.getFileType())) {
billList = parseCsv(inputStream, config.getFileEncoding(), fieldMapping);
} else if ("EXCEL".equalsIgnoreCase(config.getFileType())) {
billList = parseExcel(inputStream, fieldMapping);
}
return billList;
}
private List<ChannelBillDTO> parseCsv(InputStream inputStream, String encoding,
Map<String, String> fieldMapping) throws Exception {
List<ChannelBillDTO> billList = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, encoding))) {
// 读取表头
String headerLine = reader.readLine();
String[] headers = headerLine.split(",");
// 构建列索引映射
Map<String, Integer> columnIndex = new HashMap<>();
for (int i = 0; i < headers.length; i++) {
columnIndex.put(headers[i].trim(), i);
}
String line;
while ((line = reader.readLine()) != null) {
if (line.trim().isEmpty()) continue;
String[] fields = line.split(",");
ChannelBillDTO bill = new ChannelBillDTO();
// 根据字段映射配置填充数据
for (Map.Entry<String, String> entry : fieldMapping.entrySet()) {
String targetField = entry.getKey(); // ChannelBillDTO 字段名
String sourceColumn = entry.getValue(); // 源文件列名
Integer idx = columnIndex.get(sourceColumn);
if (idx != null && idx < fields.length) {
setFieldValue(bill, targetField, fields[idx].trim());
}
}
billList.add(bill);
}
}
return billList;
}
private void setFieldValue(ChannelBillDTO bill, String fieldName, String value) {
switch (fieldName) {
case "channelTradeNo":
bill.setChannelTradeNo(value);
break;
case "merchantOrderNo":
bill.setMerchantOrderNo(value);
break;
case "tradeType":
bill.setTradeType(value);
break;
case "tradeStatus":
bill.setTradeStatus(value);
break;
case "amount":
bill.setAmount(new BigDecimal(value));
break;
case "fee":
bill.setFee(new BigDecimal(value));
break;
case "tradeTime":
bill.setTradeTime(LocalDateTime.parse(value,
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
break;
}
}
private List<ChannelBillDTO> parseExcel(InputStream inputStream,
Map<String, String> fieldMapping) {
// Excel 解析逻辑,类似 CSV
return new ArrayList<>();
}
}
4.3 对账引擎核心
java
/**
* 对账引擎接口
*/
public interface ReconEngine {
/**
* 执行对账
*/
ReconResult reconcile(ChannelBillDTO channelBill, LocalPaymentRecord localRecord);
/**
* 批量对账
*/
List<ReconResult> batchReconcile(List<ChannelBillDTO> channelBills,
List<LocalPaymentRecord> localRecords);
}
/**
* 对账引擎实现
*/
@Slf4j
@Service
public class DefaultReconEngine implements ReconEngine {
// 金额容差(分)
private static final BigDecimal AMOUNT_TOLERANCE = new BigDecimal("0.01");
@Override
public ReconResult reconcile(ChannelBillDTO channelBill, LocalPaymentRecord localRecord) {
ReconResult result = new ReconResult();
result.setChannelTradeNo(channelBill.getChannelTradeNo());
result.setChannelAmount(channelBill.getAmount());
result.setChannelStatus(channelBill.getTradeStatus());
if (localRecord != null) {
result.setOrderNo(localRecord.getOrderNo());
result.setLocalAmount(localRecord.getAmount());
result.setLocalStatus(String.valueOf(localRecord.getPayStatus()));
// 比对金额
BigDecimal diff = channelBill.getAmount()
.subtract(localRecord.getAmount()).abs();
if (diff.compareTo(AMOUNT_TOLERANCE) > 0) {
// 金额不一致
result.setResultType(ReconResultType.AMOUNT_MISMATCH.getCode());
result.setDiffAmount(diff);
} else if (!isStatusMatched(channelBill.getTradeStatus(),
localRecord.getPayStatus())) {
// 状态不一致
result.setResultType(ReconResultType.STATUS_MISMATCH.getCode());
} else {
// 匹配成功
result.setResultType(ReconResultType.MATCHED.getCode());
}
} else {
// 渠道有,本地无(短款)
result.setResultType(ReconResultType.CHANNEL_MORE.getCode());
}
return result;
}
@Override
public List<ReconResult> batchReconcile(List<ChannelBillDTO> channelBills,
List<LocalPaymentRecord> localRecords) {
List<ReconResult> results = new ArrayList<>();
// 构建本地记录索引(按商户订单号)
Map<String, LocalPaymentRecord> localRecordMap = localRecords.stream()
.collect(Collectors.toMap(
LocalPaymentRecord::getOrderNo,
r -> r,
(r1, r2) -> r1
));
// 构建渠道记录索引
Map<String, ChannelBillDTO> channelBillMap = channelBills.stream()
.collect(Collectors.toMap(
ChannelBillDTO::getMerchantOrderNo,
b -> b,
(b1, b2) -> b1
));
Set<String> matchedOrderNos = new HashSet<>();
// 遍历渠道账单进行匹配
for (ChannelBillDTO channelBill : channelBills) {
String orderNo = channelBill.getMerchantOrderNo();
LocalPaymentRecord localRecord = localRecordMap.get(orderNo);
ReconResult result = reconcile(channelBill, localRecord);
results.add(result);
if (localRecord != null) {
matchedOrderNos.add(orderNo);
}
}
// 检查本地多的记录(长款)
for (LocalPaymentRecord localRecord : localRecords) {
if (!matchedOrderNos.contains(localRecord.getOrderNo())) {
ReconResult result = new ReconResult();
result.setOrderNo(localRecord.getOrderNo());
result.setLocalAmount(localRecord.getAmount());
result.setLocalStatus(String.valueOf(localRecord.getPayStatus()));
result.setResultType(ReconResultType.LOCAL_MORE.getCode());
results.add(result);
}
}
return results;
}
/**
* 状态匹配判断
*/
private boolean isStatusMatched(String channelStatus, Integer localStatus) {
// 渠道状态映射到本地状态
// 支付宝:TRADE_SUCCESS -> 1
// 微信:SUCCESS -> 1
if ("TRADE_SUCCESS".equals(channelStatus) || "SUCCESS".equals(channelStatus)) {
return localStatus == 1; // 支付成功
}
if ("TRADE_CLOSED".equals(channelStatus) || "CLOSED".equals(channelStatus)) {
return localStatus == 2; // 支付失败/关闭
}
if ("REFUND".equals(channelStatus)) {
return localStatus == 3; // 已退款
}
return false;
}
}
4.4 对账服务
java
/**
* 对账任务服务
*/
@Slf4j
@Service
public class ReconTaskService {
@Autowired
private ReconTaskMapper taskMapper;
@Autowired
private ReconResultMapper resultMapper;
@Autowired
private LocalPaymentRecordMapper localRecordMapper;
@Autowired
private List<BillParser> billParsers;
@Autowired
private DefaultReconEngine reconEngine;
@Autowired
private ChannelBillService channelBillService;
/**
* 创建对账任务
*/
@Transactional
public ReconTask createTask(String channelType, LocalDate reconDate, String filePath) {
ReconTask task = new ReconTask();
task.setTaskNo(generateTaskNo());
task.setChannelType(channelType);
task.setReconDate(reconDate);
task.setFilePath(filePath);
task.setStatus(TaskStatus.PENDING.getCode());
taskMapper.insert(task);
return task;
}
/**
* 执行对账任务
*/
@Async
@Transactional
public void executeTask(Long taskId) {
ReconTask task = taskMapper.selectById(taskId);
if (task == null) {
log.error("对账任务不存在: {}", taskId);
return;
}
try {
// 更新任务状态
task.setStatus(TaskStatus.PROCESSING.getCode());
task.setStartTime(LocalDateTime.now());
taskMapper.updateById(task);
// 1. 解析渠道账单
List<ChannelBillDTO> channelBills = parseBillFile(task);
task.setTotalCount(channelBills.size());
// 2. 保存渠道账单
channelBillService.saveBatch(task.getId(), task.getChannelType(), channelBills);
// 3. 查询本地支付记录
List<LocalPaymentRecord> localRecords = queryLocalRecords(
task.getChannelType(), task.getReconDate());
// 4. 执行对账
List<ReconResult> results = reconEngine.batchReconcile(channelBills, localRecords);
// 5. 保存对账结果
saveReconResults(task.getId(), results);
// 6. 更新任务统计
updateTaskStatistics(task, results);
task.setStatus(TaskStatus.COMPLETED.getCode());
task.setEndTime(LocalDateTime.now());
} catch (Exception e) {
log.error("对账任务执行失败: {}", taskId, e);
task.setStatus(TaskStatus.FAILED.getCode());
task.setErrorMsg(e.getMessage());
task.setEndTime(LocalDateTime.now());
}
taskMapper.updateById(task);
}
/**
* 解析账单文件
*/
private List<ChannelBillDTO> parseBillFile(ReconTask task) throws Exception {
// 获取文件输入流
InputStream inputStream = getFileInputStream(task.getFilePath());
// 查找匹配的解析器
BillParser parser = billParsers.stream()
.filter(p -> p.getChannelType().getCode().equals(task.getChannelType()))
.findFirst()
.orElseThrow(() -> new RuntimeException("不支持的渠道类型: " + task.getChannelType()));
return parser.parse(inputStream, task.getFilePath());
}
/**
* 查询本地支付记录
*/
private List<LocalPaymentRecord> queryLocalRecords(String channelType, LocalDate reconDate) {
LocalDateTime startTime = reconDate.atStartOfDay();
LocalDateTime endTime = reconDate.plusDays(1).atStartOfDay();
return localRecordMapper.selectByChannelAndTime(channelType, startTime, endTime);
}
/**
* 保存对账结果
*/
@Transactional
public void saveReconResults(Long taskId, List<ReconResult> results) {
// 批量插入
List<List<ReconResult>> batches = Lists.partition(results, 500);
for (List<ReconResult> batch : batches) {
batch.forEach(r -> r.setTaskId(taskId));
resultMapper.insertBatch(batch);
}
}
/**
* 更新任务统计
*/
private void updateTaskStatistics(ReconTask task, List<ReconResult> results) {
int successCount = 0;
int diffCount = 0;
for (ReconResult result : results) {
if (result.getResultType() == ReconResultType.MATCHED.getCode()) {
successCount++;
} else {
diffCount++;
}
}
task.setSuccessCount(successCount);
task.setDiffCount(diffCount);
task.setFailCount(0);
}
private String generateTaskNo() {
return "RECON" + System.currentTimeMillis() +
String.format("%04d", new Random().nextInt(10000));
}
private InputStream getFileInputStream(String filePath) throws Exception {
// 支持本地文件和 OSS 文件
if (filePath.startsWith("http")) {
return new URL(filePath).openStream();
}
return new FileInputStream(filePath);
}
}
4.5 定时对账任务
java
/**
* 对账定时任务
*/
@Slf4j
@Component
public class ReconScheduleTask {
@Autowired
private ReconTaskService taskService;
@Autowired
private BillDownloadService billDownloadService;
/**
* 每天凌晨 1 点执行前一天的对账
*/
@Scheduled(cron = "0 0 1 * * ?")
public void dailyReconciliation() {
LocalDate reconDate = LocalDate.now().minusDays(1);
log.info("开始执行 {} 的对账任务", reconDate);
// 支付宝对账
executeChannelRecon(ChannelType.ALIPAY, reconDate);
// 微信对账
executeChannelRecon(ChannelType.WECHAT, reconDate);
// 银行对账
executeChannelRecon(ChannelType.BANK, reconDate);
log.info("{} 的对账任务创建完成", reconDate);
}
private void executeChannelRecon(ChannelType channelType, LocalDate reconDate) {
try {
// 1. 下载账单文件
String filePath = billDownloadService.downloadBill(channelType, reconDate);
// 2. 创建对账任务
ReconTask task = taskService.createTask(
channelType.getCode(), reconDate, filePath);
// 3. 异步执行对账
taskService.executeTask(task.getId());
} catch (Exception e) {
log.error("{}渠道对账任务创建失败: {}", channelType.getDesc(), e.getMessage(), e);
}
}
}
/**
* 账单下载服务
*/
@Slf4j
@Service
public class BillDownloadService {
@Autowired
private AlipayClient alipayClient;
@Autowired
private WxPayService wxPayService;
/**
* 下载渠道账单
*/
public String downloadBill(ChannelType channelType, LocalDate billDate) throws Exception {
switch (channelType) {
case ALIPAY:
return downloadAlipayBill(billDate);
case WECHAT:
return downloadWechatBill(billDate);
case BANK:
return downloadBankBill(billDate);
default:
throw new RuntimeException("不支持的渠道类型: " + channelType);
}
}
/**
* 下载支付宝账单
*/
private String downloadAlipayBill(LocalDate billDate) throws Exception {
AlipayDataDataserviceBillDownloadurlQueryRequest request =
new AlipayDataDataserviceBillDownloadurlQueryRequest();
Map<String, String> params = new HashMap<>();
params.put("bill_type", "trade");
params.put("bill_date", billDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
request.setBizContent(JSON.toJSONString(params));
AlipayDataDataserviceBillDownloadurlQueryResponse response =
alipayClient.execute(request);
if (response.isSuccess()) {
String downloadUrl = response.getBillDownloadUrl();
return downloadAndSaveFile(downloadUrl, "alipay", billDate);
} else {
throw new RuntimeException("获取支付宝账单下载地址失败: " + response.getMsg());
}
}
/**
* 下载微信账单
*/
private String downloadWechatBill(LocalDate billDate) throws Exception {
WxPayDownloadBillRequest request = new WxPayDownloadBillRequest();
request.setBillDate(billDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")));
request.setBillType("ALL");
WxPayDownloadBillResult result = wxPayService.downloadBill(request);
// 保存账单内容到文件
String fileName = String.format("wechat_bill_%s.csv",
billDate.format(DateTimeFormatter.BASIC_ISO_DATE));
String filePath = "/data/bills/" + fileName;
Files.write(Paths.get(filePath), result.getBillContent().getBytes(StandardCharsets.UTF_8));
return filePath;
}
/**
* 下载银行账单(通常是 SFTP 方式)
*/
private String downloadBankBill(LocalDate billDate) throws Exception {
// 银行账单通常通过 SFTP 获取
// 这里简化处理
String fileName = String.format("bank_bill_%s.xlsx",
billDate.format(DateTimeFormatter.BASIC_ISO_DATE));
return "/data/bills/" + fileName;
}
private String downloadAndSaveFile(String url, String prefix, LocalDate billDate)
throws Exception {
String fileName = String.format("%s_bill_%s.csv",
prefix, billDate.format(DateTimeFormatter.BASIC_ISO_DATE));
String filePath = "/data/bills/" + fileName;
// 下载文件
try (InputStream in = new URL(url).openStream()) {
Files.copy(in, Paths.get(filePath), StandardCopyOption.REPLACE_EXISTING);
}
return filePath;
}
}
4.6 差异处理服务
java
/**
* 差异处理服务
*/
@Slf4j
@Service
public class DiffHandleService {
@Autowired
private ReconResultMapper resultMapper;
@Autowired
private LocalPaymentRecordMapper localRecordMapper;
/**
* 查询待处理差异
*/
public List<ReconResult> queryPendingDiffs(Long taskId) {
return resultMapper.selectByTaskAndStatus(taskId, 0);
}
/**
* 处理差异:标记为已处理
*/
@Transactional
public void handleDiff(Long resultId, String handleRemark, String handler) {
ReconResult result = resultMapper.selectById(resultId);
if (result == null) {
throw new RuntimeException("对账结果不存在");
}
result.setHandleStatus(1); // 已处理
result.setHandleRemark(handleRemark);
result.setHandler(handler);
result.setHandleTime(LocalDateTime.now());
resultMapper.updateById(result);
// 更新本地记录的对账状态
if (result.getOrderNo() != null) {
LocalPaymentRecord localRecord = localRecordMapper.selectByOrderNo(result.getOrderNo());
if (localRecord != null) {
localRecord.setReconStatus(1); // 已对账
localRecord.setReconTime(LocalDateTime.now());
localRecordMapper.updateById(localRecord);
}
}
}
/**
* 忽略差异
*/
@Transactional
public void ignoreDiff(Long resultId, String reason, String handler) {
ReconResult result = resultMapper.selectById(resultId);
if (result == null) {
throw new RuntimeException("对账结果不存在");
}
result.setHandleStatus(2); // 忽略
result.setHandleRemark("忽略原因: " + reason);
result.setHandler(handler);
result.setHandleTime(LocalDateTime.now());
resultMapper.updateById(result);
}
/**
* 批量处理短款(渠道有本地无)
* 自动创建本地补单记录
*/
@Transactional
public void batchHandleChannelMore(List<Long> resultIds, String handler) {
for (Long resultId : resultIds) {
ReconResult result = resultMapper.selectById(resultId);
if (result == null || result.getResultType() != ReconResultType.CHANNEL_MORE.getCode()) {
continue;
}
// 创建补单记录
LocalPaymentRecord record = new LocalPaymentRecord();
record.setOrderNo(result.getChannelTradeNo()); // 使用渠道交易号作为订单号
record.setChannelTradeNo(result.getChannelTradeNo());
record.setAmount(result.getChannelAmount());
record.setPayStatus(1); // 支付成功
record.setReconStatus(1); // 已对账
record.setReconTime(LocalDateTime.now());
localRecordMapper.insert(record);
// 更新差异处理状态
result.setHandleStatus(1);
result.setHandleRemark("自动补单处理");
result.setHandler(handler);
result.setHandleTime(LocalDateTime.now());
resultMapper.updateById(result);
log.info("短款自动补单: channelTradeNo={}", result.getChannelTradeNo());
}
}
/**
* 自动处理规则
*/
@Transactional
public void autoHandleDiffs(Long taskId) {
List<ReconResult> diffs = queryPendingDiffs(taskId);
for (ReconResult diff : diffs) {
// 规则1:金额差异小于1分,自动忽略
if (diff.getResultType() == ReconResultType.AMOUNT_MISMATCH.getCode()) {
if (diff.getDiffAmount() != null &&
diff.getDiffAmount().compareTo(new BigDecimal("0.01")) <= 0) {
ignoreDiff(diff.getId(), "金额差异小于1分,系统自动忽略", "SYSTEM");
}
}
// 规则2:时间跨天差异,自动忽略(需要额外逻辑判断)
// ...
}
}
}
4.7 对账报告生成
java
/**
* 对账报告服务
*/
@Slf4j
@Service
public class ReconReportService {
@Autowired
private ReconTaskMapper taskMapper;
@Autowired
private ReconResultMapper resultMapper;
/**
* 生成对账报告
*/
public ReconReport generateReport(Long taskId) {
ReconTask task = taskMapper.selectById(taskId);
if (task == null) {
throw new RuntimeException("对账任务不存在");
}
ReconReport report = new ReconReport();
report.setTaskNo(task.getTaskNo());
report.setChannelType(task.getChannelType());
report.setReconDate(task.getReconDate());
// 统计数据
report.setTotalCount(task.getTotalCount());
report.setSuccessCount(task.getSuccessCount());
report.setDiffCount(task.getDiffCount());
// 计算对账率
if (task.getTotalCount() > 0) {
double successRate = (double) task.getSuccessCount() / task.getTotalCount() * 100;
report.setSuccessRate(String.format("%.2f%%", successRate));
}
// 差异分类统计
Map<Integer, Long> diffTypeCount = resultMapper.countByResultType(taskId);
report.setLocalMoreCount(diffTypeCount.getOrDefault(ReconResultType.LOCAL_MORE.getCode(), 0L));
report.setChannelMoreCount(diffTypeCount.getOrDefault(ReconResultType.CHANNEL_MORE.getCode(), 0L));
report.setAmountMismatchCount(diffTypeCount.getOrDefault(ReconResultType.AMOUNT_MISMATCH.getCode(), 0L));
report.setStatusMismatchCount(diffTypeCount.getOrDefault(ReconResultType.STATUS_MISMATCH.getCode(), 0L));
// 差异金额统计
BigDecimal totalDiffAmount = resultMapper.sumDiffAmount(taskId);
report.setTotalDiffAmount(totalDiffAmount);
// 生成时间
report.setGenerateTime(LocalDateTime.now());
return report;
}
/**
* 导出对账报告 Excel
*/
public void exportReportExcel(Long taskId, OutputStream outputStream) throws Exception {
ReconReport report = generateReport(taskId);
List<ReconResult> diffs = resultMapper.selectDiffsByTaskId(taskId);
// 使用 EasyExcel 导出
ExcelWriter excelWriter = EasyExcel.write(outputStream).build();
// Sheet1: 报告摘要
WriteSheet summarySheet = EasyExcel.writerSheet(0, "对账摘要")
.head(ReportSummary.class).build();
excelWriter.write(Collections.singletonList(convertToSummary(report)), summarySheet);
// Sheet2: 差异明细
WriteSheet diffSheet = EasyExcel.writerSheet(1, "差异明细")
.head(DiffDetail.class).build();
excelWriter.write(convertToDiffDetails(diffs), diffSheet);
excelWriter.finish();
}
@Data
public static class ReconReport {
private String taskNo;
private String channelType;
private LocalDate reconDate;
private Integer totalCount;
private Integer successCount;
private Integer diffCount;
private String successRate;
private Long localMoreCount;
private Long channelMoreCount;
private Long amountMismatchCount;
private Long statusMismatchCount;
private BigDecimal totalDiffAmount;
private LocalDateTime generateTime;
}
@Data
public static class ReportSummary {
@ExcelProperty("任务编号")
private String taskNo;
@ExcelProperty("渠道类型")
private String channelType;
@ExcelProperty("对账日期")
private String reconDate;
@ExcelProperty("总记录数")
private Integer totalCount;
@ExcelProperty("成功数")
private Integer successCount;
@ExcelProperty("差异数")
private Integer diffCount;
@ExcelProperty("对账成功率")
private String successRate;
}
@Data
public static class DiffDetail {
@ExcelProperty("商户订单号")
private String orderNo;
@ExcelProperty("渠道交易号")
private String channelTradeNo;
@ExcelProperty("差异类型")
private String resultType;
@ExcelProperty("本地金额")
private BigDecimal localAmount;
@ExcelProperty("渠道金额")
private BigDecimal channelAmount;
@ExcelProperty("差异金额")
private BigDecimal diffAmount;
@ExcelProperty("处理状态")
private String handleStatus;
}
private ReportSummary convertToSummary(ReconReport report) {
ReportSummary summary = new ReportSummary();
summary.setTaskNo(report.getTaskNo());
summary.setChannelType(report.getChannelType());
summary.setReconDate(report.getReconDate().toString());
summary.setTotalCount(report.getTotalCount());
summary.setSuccessCount(report.getSuccessCount());
summary.setDiffCount(report.getDiffCount());
summary.setSuccessRate(report.getSuccessRate());
return summary;
}
private List<DiffDetail> convertToDiffDetails(List<ReconResult> diffs) {
return diffs.stream().map(d -> {
DiffDetail detail = new DiffDetail();
detail.setOrderNo(d.getOrderNo());
detail.setChannelTradeNo(d.getChannelTradeNo());
detail.setResultType(ReconResultType.values()[d.getResultType() - 1].getDesc());
detail.setLocalAmount(d.getLocalAmount());
detail.setChannelAmount(d.getChannelAmount());
detail.setDiffAmount(d.getDiffAmount());
detail.setHandleStatus(d.getHandleStatus() == 0 ? "待处理" :
d.getHandleStatus() == 1 ? "已处理" : "已忽略");
return detail;
}).collect(Collectors.toList());
}
}
五、自定义渠道导入
5.1 自定义渠道配置
java
/**
* 渠道配置实体
*/
@Data
@TableName("recon_channel_config")
public class ReconChannelConfig {
@TableId(type = IdType.AUTO)
private Long id;
private String channelCode;
private String channelName;
private String fileType;
private String fileEncoding;
private String fieldMapping; // JSON 格式
private String parserClass;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
/**
* 渠道配置服务
*/
@Slf4j
@Service
public class ChannelConfigService {
@Autowired
private ReconChannelConfigMapper configMapper;
/**
* 创建自定义渠道配置
*/
public void createChannelConfig(ChannelConfigDTO dto) {
// 验证字段映射
validateFieldMapping(dto.getFieldMapping());
ReconChannelConfig config = new ReconChannelConfig();
config.setChannelCode(dto.getChannelCode());
config.setChannelName(dto.getChannelName());
config.setFileType(dto.getFileType());
config.setFileEncoding(dto.getFileEncoding());
config.setFieldMapping(JSON.toJSONString(dto.getFieldMapping()));
config.setStatus(1);
configMapper.insert(config);
}
/**
* 验证字段映射配置
*/
private void validateFieldMapping(Map<String, String> fieldMapping) {
// 必须包含的字段
List<String> requiredFields = Arrays.asList(
"channelTradeNo", "merchantOrderNo", "amount", "tradeStatus"
);
for (String field : requiredFields) {
if (!fieldMapping.containsKey(field)) {
throw new RuntimeException("缺少必要字段映射: " + field);
}
}
}
@Data
public static class ChannelConfigDTO {
private String channelCode;
private String channelName;
private String fileType; // CSV / EXCEL
private String fileEncoding;
private Map<String, String> fieldMapping;
}
}
5.2 文件上传与导入
java
/**
* 文件上传控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/recon")
public class ReconController {
@Autowired
private ReconTaskService taskService;
@Autowired
private CustomBillParserFactory parserFactory;
@Autowired
private ChannelBillService channelBillService;
/**
* 上传自定义渠道账单
*/
@PostMapping("/upload/custom")
public Result<?> uploadCustomBill(
@RequestParam("file") MultipartFile file,
@RequestParam("channelCode") String channelCode,
@RequestParam("reconDate") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate reconDate) {
try {
// 1. 保存文件
String filePath = saveUploadFile(file);
// 2. 创建对账任务
ReconTask task = taskService.createTask("CUSTOM", reconDate, filePath);
// 3. 解析账单
List<ChannelBillDTO> bills = parserFactory.parse(channelCode, file.getInputStream());
// 4. 保存账单数据
channelBillService.saveCustomBills(task.getId(), channelCode, bills);
// 5. 异步执行对账
taskService.executeTask(task.getId());
return Result.success(task.getTaskNo());
} catch (Exception e) {
log.error("上传自定义渠道账单失败", e);
return Result.fail("上传失败: " + e.getMessage());
}
}
/**
* 上传支付宝/微信账单
*/
@PostMapping("/upload/{channelType}")
public Result<?> uploadBill(
@PathVariable String channelType,
@RequestParam("file") MultipartFile file,
@RequestParam("reconDate") @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate reconDate) {
try {
// 保存文件
String filePath = saveUploadFile(file);
// 创建并执行对账任务
ReconTask task = taskService.createTask(channelType.toUpperCase(), reconDate, filePath);
taskService.executeTask(task.getId());
return Result.success(task.getTaskNo());
} catch (Exception e) {
log.error("上传账单失败", e);
return Result.fail("上传失败: " + e.getMessage());
}
}
/**
* 查询对账任务状态
*/
@GetMapping("/task/{taskNo}")
public Result<ReconTask> getTaskStatus(@PathVariable String taskNo) {
ReconTask task = taskService.getByTaskNo(taskNo);
return Result.success(task);
}
/**
* 查询对账差异列表
*/
@GetMapping("/diff/list")
public Result<PageInfo<ReconResult>> getDiffList(
@RequestParam Long taskId,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "20") int pageSize) {
PageHelper.startPage(pageNum, pageSize);
List<ReconResult> diffs = taskService.getDiffList(taskId);
return Result.success(new PageInfo<>(diffs));
}
/**
* 导出对账报告
*/
@GetMapping("/report/export")
public void exportReport(@RequestParam Long taskId, HttpServletResponse response)
throws Exception {
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setHeader("Content-Disposition",
"attachment;filename=recon_report_" + taskId + ".xlsx");
taskService.exportReport(taskId, response.getOutputStream());
}
private String saveUploadFile(MultipartFile file) throws Exception {
String originalFilename = file.getOriginalFilename();
String fileName = System.currentTimeMillis() + "_" + originalFilename;
String filePath = "/data/bills/upload/" + fileName;
File destFile = new File(filePath);
if (!destFile.getParentFile().exists()) {
destFile.getParentFile().mkdirs();
}
file.transferTo(destFile);
return filePath;
}
}
六、监控告警
6.1 对账监控指标
java
/**
* 对账监控服务
*/
@Slf4j
@Service
public class ReconMonitorService {
@Autowired
private ReconTaskMapper taskMapper;
@Autowired
private ReconResultMapper resultMapper;
@Autowired
private AlertService alertService;
/**
* 监控对账任务执行情况
*/
@Scheduled(cron = "0 0/30 * * * ?")
public void monitorTaskExecution() {
// 检查超时任务(处理中超过1小时)
List<ReconTask> timeoutTasks = taskMapper.selectTimeoutTasks(
TaskStatus.PROCESSING.getCode(), 60);
for (ReconTask task : timeoutTasks) {
log.warn("对账任务执行超时: taskNo={}", task.getTaskNo());
alertService.sendAlert(AlertLevel.WARNING,
"对账任务执行超时", "任务编号: " + task.getTaskNo());
}
// 检查失败任务
List<ReconTask> failedTasks = taskMapper.selectTodayFailedTasks();
if (!failedTasks.isEmpty()) {
log.error("今日存在失败的对账任务: {}", failedTasks.size());
alertService.sendAlert(AlertLevel.ERROR,
"对账任务执行失败", "失败任务数: " + failedTasks.size());
}
}
/**
* 监控差异率
*/
@Scheduled(cron = "0 0 9 * * ?")
public void monitorDiffRate() {
LocalDate yesterday = LocalDate.now().minusDays(1);
List<ReconTask> tasks = taskMapper.selectByReconDate(yesterday);
for (ReconTask task : tasks) {
if (task.getTotalCount() == null || task.getTotalCount() == 0) {
continue;
}
// 计算差异率
double diffRate = (double) task.getDiffCount() / task.getTotalCount() * 100;
// 差异率超过阈值告警
if (diffRate > 1.0) { // 差异率超过1%
log.warn("对账差异率异常: channel={}, date={}, diffRate={}%",
task.getChannelType(), task.getReconDate(), diffRate);
alertService.sendAlert(AlertLevel.WARNING,
"对账差异率异常",
String.format("渠道: %s, 日期: %s, 差异率: %.2f%%",
task.getChannelType(), task.getReconDate(), diffRate));
}
}
}
/**
* 获取对账看板数据
*/
public DashboardData getDashboardData(LocalDate startDate, LocalDate endDate) {
DashboardData data = new DashboardData();
// 任务统计
data.setTotalTasks(taskMapper.countByDateRange(startDate, endDate));
data.setSuccessTasks(taskMapper.countByStatusAndDateRange(
TaskStatus.COMPLETED.getCode(), startDate, endDate));
data.setFailedTasks(taskMapper.countByStatusAndDateRange(
TaskStatus.FAILED.getCode(), startDate, endDate));
// 差异统计
data.setTotalDiffs(resultMapper.countDiffsByDateRange(startDate, endDate));
data.setHandledDiffs(resultMapper.countHandledDiffsByDateRange(startDate, endDate));
data.setPendingDiffs(data.getTotalDiffs() - data.getHandledDiffs());
// 差异金额
data.setTotalDiffAmount(resultMapper.sumDiffAmountByDateRange(startDate, endDate));
// 渠道统计
data.setChannelStats(taskMapper.groupByChannel(startDate, endDate));
// 趋势数据
data.setTrendData(taskMapper.getDailyTrend(startDate, endDate));
return data;
}
@Data
public static class DashboardData {
private Long totalTasks;
private Long successTasks;
private Long failedTasks;
private Long totalDiffs;
private Long handledDiffs;
private Long pendingDiffs;
private BigDecimal totalDiffAmount;
private List<ChannelStat> channelStats;
private List<TrendData> trendData;
}
@Data
public static class ChannelStat {
private String channelType;
private Long taskCount;
private Long diffCount;
private Double diffRate;
}
@Data
public static class TrendData {
private LocalDate date;
private Long totalCount;
private Long successCount;
private Long diffCount;
}
}
6.2 告警服务
java
/**
* 告警级别
*/
public enum AlertLevel {
INFO, WARNING, ERROR, CRITICAL
}
/**
* 告警服务
*/
@Slf4j
@Service
public class AlertService {
@Value("${alert.dingtalk.webhook}")
private String dingTalkWebhook;
@Value("${alert.email.to}")
private String emailTo;
@Autowired
private RestTemplate restTemplate;
@Autowired
private JavaMailSender mailSender;
/**
* 发送告警
*/
public void sendAlert(AlertLevel level, String title, String content) {
log.info("发送告警: level={}, title={}", level, title);
// 根据告警级别选择通知方式
switch (level) {
case INFO:
// 仅记录日志
break;
case WARNING:
sendDingTalkAlert(title, content);
break;
case ERROR:
case CRITICAL:
sendDingTalkAlert(title, content);
sendEmailAlert(title, content);
break;
}
}
/**
* 发送钉钉告警
*/
private void sendDingTalkAlert(String title, String content) {
try {
Map<String, Object> message = new HashMap<>();
message.put("msgtype", "markdown");
Map<String, String> markdown = new HashMap<>();
markdown.put("title", title);
markdown.put("text", String.format("### %s\n\n%s\n\n> 时间: %s",
title, content, LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))));
message.put("markdown", markdown);
restTemplate.postForObject(dingTalkWebhook, message, String.class);
} catch (Exception e) {
log.error("发送钉钉告警失败", e);
}
}
/**
* 发送邮件告警
*/
private void sendEmailAlert(String title, String content) {
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(emailTo.split(","));
message.setSubject("[对账系统告警] " + title);
message.setText(content + "\n\n时间: " + LocalDateTime.now());
mailSender.send(message);
} catch (Exception e) {
log.error("发送邮件告警失败", e);
}
}
}
七、系统优化
7.1 大文件处理优化
java
/**
* 分片解析大文件
*/
@Slf4j
@Component
public class LargeFileBillParser {
private static final int BATCH_SIZE = 10000;
@Autowired
private ChannelBillService channelBillService;
/**
* 流式解析大文件
*/
public void parseAndSave(Long taskId, String channelType,
InputStream inputStream) throws Exception {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
List<ChannelBillDTO> batch = new ArrayList<>(BATCH_SIZE);
int totalCount = 0;
// 跳过表头
reader.readLine();
while ((line = reader.readLine()) != null) {
if (line.trim().isEmpty()) continue;
ChannelBillDTO bill = parseLine(line);
if (bill != null) {
batch.add(bill);
totalCount++;
}
// 批量保存
if (batch.size() >= BATCH_SIZE) {
channelBillService.saveBatch(taskId, channelType, batch);
log.info("已处理 {} 条记录", totalCount);
batch.clear();
}
}
// 保存剩余数据
if (!batch.isEmpty()) {
channelBillService.saveBatch(taskId, channelType, batch);
}
log.info("文件解析完成,共 {} 条记录", totalCount);
}
}
private ChannelBillDTO parseLine(String line) {
// 解析逻辑
return null;
}
}
7.2 并行对账优化
java
/**
* 并行对账引擎
*/
@Slf4j
@Service
public class ParallelReconEngine {
@Autowired
private DefaultReconEngine reconEngine;
private final ExecutorService executor = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors());
/**
* 并行批量对账
*/
public List<ReconResult> parallelReconcile(List<ChannelBillDTO> channelBills,
List<LocalPaymentRecord> localRecords) {
// 构建本地记录索引
Map<String, LocalPaymentRecord> localRecordMap = localRecords.stream()
.collect(Collectors.toMap(
LocalPaymentRecord::getOrderNo,
r -> r,
(r1, r2) -> r1
));
// 分片并行处理
int partitionSize = 1000;
List<List<ChannelBillDTO>> partitions = Lists.partition(channelBills, partitionSize);
List<CompletableFuture<List<ReconResult>>> futures = partitions.stream()
.map(partition -> CompletableFuture.supplyAsync(() ->
processPartition(partition, localRecordMap), executor))
.collect(Collectors.toList());
// 合并结果
return futures.stream()
.map(CompletableFuture::join)
.flatMap(List::stream)
.collect(Collectors.toList());
}
private List<ReconResult> processPartition(List<ChannelBillDTO> partition,
Map<String, LocalPaymentRecord> localRecordMap) {
List<ReconResult> results = new ArrayList<>();
for (ChannelBillDTO channelBill : partition) {
LocalPaymentRecord localRecord = localRecordMap.get(channelBill.getMerchantOrderNo());
ReconResult result = reconEngine.reconcile(channelBill, localRecord);
results.add(result);
}
return results;
}
}
7.3 缓存优化
java
/**
* 对账缓存服务
*/
@Slf4j
@Service
public class ReconCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String TASK_KEY_PREFIX = "recon:task:";
private static final String RESULT_KEY_PREFIX = "recon:result:";
/**
* 缓存任务信息
*/
public void cacheTask(ReconTask task) {
String key = TASK_KEY_PREFIX + task.getTaskNo();
redisTemplate.opsForValue().set(key, task, Duration.ofHours(24));
}
/**
* 获取缓存的任务
*/
public ReconTask getTaskFromCache(String taskNo) {
String key = TASK_KEY_PREFIX + taskNo;
return (ReconTask) redisTemplate.opsForValue().get(key);
}
/**
* 缓存对账进度
*/
public void updateProgress(String taskNo, int processedCount, int totalCount) {
String key = TASK_KEY_PREFIX + taskNo + ":progress";
Map<String, Integer> progress = new HashMap<>();
progress.put("processed", processedCount);
progress.put("total", totalCount);
redisTemplate.opsForValue().set(key, progress, Duration.ofHours(2));
}
/**
* 获取对账进度
*/
@SuppressWarnings("unchecked")
public Map<String, Integer> getProgress(String taskNo) {
String key = TASK_KEY_PREFIX + taskNo + ":progress";
return (Map<String, Integer>) redisTemplate.opsForValue().get(key);
}
}
八、最佳实践与总结
8.1 对账系统设计要点
diff
+------------------------------------------------------------------+
| 对账系统设计要点 |
+------------------------------------------------------------------+
| |
| 1. 可扩展性 |
| - 支持多渠道接入 |
| - 解析器插件化 |
| - 字段映射可配置 |
| |
| 2. 可靠性 |
| - 任务失败重试 |
| - 断点续传 |
| - 数据校验 |
| |
| 3. 性能 |
| - 大文件分片处理 |
| - 批量数据库操作 |
| - 并行对账 |
| |
| 4. 可观测性 |
| - 任务状态监控 |
| - 差异率告警 |
| - 对账报告 |
| |
+------------------------------------------------------------------+
8.2 常见问题处理
| 问题类型 | 原因分析 | 解决方案 |
|---|---|---|
| 时间差异 | 跨天交易、时区问题 | 扩大时间范围查询,统一时区 |
| 金额差异 | 手续费计算方式不同 | 明确金额口径,分开比对 |
| 订单号不匹配 | 格式不一致、前缀差异 | 标准化订单号格式 |
| 重复对账 | 同一笔交易多次出现 | 去重处理,幂等设计 |
| 账单延迟 | 渠道账单生成延迟 | 延后对账时间,增加容错 |
8.3 运维建议
- 对账时间选择:建议凌晨执行,避免影响业务
- 账单保留策略:至少保留 3 个月,便于追溯
- 差异处理 SLA:制定差异处理时效要求
- 定期巡检:每周检查对账任务执行情况
- 容灾备份:对账数据定期备份