fix: 兼容支付宝新版交易明细导入并修复外部交易号超长截断

支付宝新版「交易明细」导出格式的列名与旧版「交易记录明细查询」完全不同,导致导入直接报「未找到支付宝账单表头」;此外部分长订单号超过 t_transaction.external_no 的 VARCHAR(64) 长度,导入时触发数据库截断报错。本次让解析器同时兼容新旧两种格式,将 external_no 加宽至 VARCHAR(255)(含已存在库的自动迁移),并补充导入失败日志便于定位。

变更内容:
- AlipayBillParser 兼容新版「交易明细」:支持「交易时间」开头表头、新版列名映射(交易订单号/商品说明/金额等)、斜杠时间格式(yyyy/M/d H:mm[:ss])、交易号去空白、尾部逗号空行跳过与 UTF-8 BOM 处理。
- SchemaMigrator 新增 external_no 加宽迁移:MySQL 检测当前长度不足时执行 ALTER 至 VARCHAR(255),幂等可重复执行,SQLite 不强制长度直接跳过。
- BillImportServiceImpl 补充日志:逐行校验失败、批量插入回退、单条插入失败(含 externalNo 与根因)、重复跳过分别记录 warn/debug,便于定位失败记录。
- init.sql 与 schema-sqlite.sql 将 external_no 由 VARCHAR(64) 调整为 VARCHAR(255),保持两库结构一致。

说明:
- 旧版「交易记录明细查询」格式的解析行为保持不变。
- 长度迁移在应用启动(DatabaseBootstrap)与切换数据库时自动执行,无需手工执行 SQL。
- 64 到 255 跨越长度前缀边界,MySQL 调整列时会重建该表,数据量小影响可忽略。
This commit is contained in:
2026-09-30 15:54:11 +08:00
parent 801da3e196
commit 15fd1a263d
5 changed files with 207 additions and 27 deletions
@@ -38,6 +38,12 @@ public class SchemaMigrator {
/** AI 配置新增列:图片记账使用的视觉模型 */
private static final String VISION_MODEL_COLUMN = "vision_model";
/** 交易记录表外部交易号列 */
private static final String EXTERNAL_NO_COLUMN = "external_no";
/** 外部交易号列的目标长度(新版支付宝长订单号可达 80+ 字符,原 64 会截断报错) */
private static final int EXTERNAL_NO_LENGTH = 255;
/**
* 执行结构迁移。
*
@@ -61,6 +67,7 @@ public class SchemaMigrator {
try (Connection connection = dataSource.getConnection()) {
renameChannelColumn(connection, type, progress);
addVisionModelColumn(connection, type, progress);
widenExternalNoColumn(connection, type, progress);
} catch (SQLException e) {
throw new IllegalStateException("数据库结构迁移失败:" + e.getMessage(), e);
}
@@ -91,6 +98,41 @@ public class SchemaMigrator {
notifyProgress(progress, "已为 " + AI_CONFIG_TABLE + " 添加列 " + VISION_MODEL_COLUMN);
}
/**
* 扩展 {@code t_transaction.external_no} 列长度。
*
* <p>新版支付宝部分订单号(如转账)可达 80+ 字符,超过原 {@code VARCHAR(64)} 会导致导入时
* 数据库截断报错。仅在当前长度不足目标长度时执行,保证幂等。SQLite 不强制 VARCHAR 长度,
* 无需迁移。</p>
*
* @param connection 数据库连接
* @param type 数据库类型
* @param progress 进度回调,可为空
* @throws SQLException 迁移失败
*/
private void widenExternalNoColumn(Connection connection, DataSourceType type, Consumer<String> progress)
throws SQLException {
if (type == DataSourceType.SQLITE) {
return;
}
Integer currentLength = mysqlColumnLength(connection, TRANSACTION_TABLE, EXTERNAL_NO_COLUMN);
if (currentLength == null) {
log.debug("未找到 {}.{},跳过长度迁移", TRANSACTION_TABLE, EXTERNAL_NO_COLUMN);
return;
}
if (currentLength >= EXTERNAL_NO_LENGTH) {
return;
}
try (Statement statement = connection.createStatement()) {
statement.execute("ALTER TABLE " + TRANSACTION_TABLE + " MODIFY COLUMN " + EXTERNAL_NO_COLUMN
+ " VARCHAR(" + EXTERNAL_NO_LENGTH + ") COMMENT '外部账单交易号,用于账单导入去重'");
}
log.info("已将 {}.{} 长度由 {} 扩展为 VARCHAR({})",
TRANSACTION_TABLE, EXTERNAL_NO_COLUMN, currentLength, EXTERNAL_NO_LENGTH);
notifyProgress(progress, "已将 " + TRANSACTION_TABLE + "." + EXTERNAL_NO_COLUMN
+ " 长度扩展为 VARCHAR(" + EXTERNAL_NO_LENGTH + ")");
}
/**
* 将 {@code t_transaction.payment_method} 重命名为 {@code channel}。
*
@@ -192,4 +234,29 @@ public class SchemaMigrator {
}
}
}
/**
* 查询 MySQL 列的字符最大长度。
*
* @param connection 数据库连接
* @param table 表名
* @param column 列名
* @return 最大长度(字符数),列不存在或无长度定义时返回 null
* @throws SQLException 查询失败
*/
private Integer mysqlColumnLength(Connection connection, String table, String column) throws SQLException {
String sql = "SELECT CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS "
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, table);
statement.setString(2, column);
try (ResultSet resultSet = statement.executeQuery()) {
if (resultSet.next()) {
long length = resultSet.getLong(1);
return resultSet.wasNull() ? null : (int) length;
}
}
}
return null;
}
}
@@ -28,15 +28,15 @@ import cn.rosercode.ledgerly.enums.BillType;
/**
* 支付宝账单解析器。
*
* <p>解析支付宝「交易记录明细查询」导出的 CSV 文件。文件为 GBK 编码(兼容 UTF-8),
* 前几行为账号、日期范围等元信息,之后为表头与数据行,末尾为统计信息。</p>
* <p>解析支付宝导出的 CSV 文件,兼容两种格式:旧版「交易记录明细查询」与新版「交易明细」。
* 文件为 GBK 编码(兼容 UTF-8),前几行为账号、日期范围等元信息,之后为表头与数据行。</p>
*
* @date 2026-09-17 09:50:01
*/
@Component
public class AlipayBillParser implements BillParser {
/** 表头列名 */
/** 旧版「交易记录明细查询」表头列名 */
private static final String COL_TRADE_NO = "交易号";
private static final String COL_CREATE_TIME = "交易创建时间";
private static final String COL_PAY_TIME = "付款时间";
@@ -47,8 +47,17 @@ public class AlipayBillParser implements BillParser {
private static final String COL_STATUS = "交易状态";
private static final String COL_FUND_STATUS = "资金状态";
/** 支付宝账单中的时间格式 */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/** 新版「交易明细」表头列名(收/支、交易对方、交易状态与旧版同名,直接复用上面的常量) */
private static final String COL_NEW_TRADE_NO = "交易订单号";
private static final String COL_NEW_TIME = "交易时间";
private static final String COL_NEW_PRODUCT = "商品说明";
private static final String COL_NEW_AMOUNT = "金额";
/** 支付宝账单中的时间格式,按顺序尝试(旧版为连字符分隔,新版为斜杠分隔且月日不补零) */
private static final List<DateTimeFormatter> TIME_FORMATTERS = List.of(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy/M/d H:mm"),
DateTimeFormatter.ofPattern("yyyy/M/d H:mm:ss"));
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
@@ -73,12 +82,18 @@ public class AlipayBillParser implements BillParser {
@Override
public List<ParsedBillRow> parse(byte[] content) throws IOException {
String text = new String(content, detectCharset(content));
// 去除 UTF-8 BOM,避免影响首行首列解析
if (text.startsWith("\uFEFF")) {
text = text.substring(1);
}
String[] lines = text.split("\r?\n", -1);
// 定位表头行:以「交易号」开头
// 定位表头行:旧版以「交易号」开头,新版以「交易时间」开头
int headerIndex = -1;
for (int i = 0; i < lines.length; i++) {
if (lines[i].startsWith(COL_TRADE_NO)) {
// 去除可能的 BOM 与首部空白,避免影响首列判断
String line = lines[i].replace("\uFEFF", "").stripLeading();
if (line.startsWith(COL_TRADE_NO) || line.startsWith(COL_NEW_TIME)) {
headerIndex = i;
break;
}
@@ -88,6 +103,8 @@ public class AlipayBillParser implements BillParser {
}
List<String> header = parseLine(lines[headerIndex]);
// 新版包含「交易订单号」或「交易时间」列,据此区分新旧格式
boolean newFormat = header.contains(COL_NEW_TRADE_NO) || header.contains(COL_NEW_TIME);
List<ParsedBillRow> rows = new ArrayList<>();
int rowNo = 0;
for (int i = headerIndex + 1; i < lines.length; i++) {
@@ -97,11 +114,15 @@ public class AlipayBillParser implements BillParser {
break;
}
List<String> values = parseLine(lines[i]);
// 仅含逗号的空行跳过(新版账单尾部可能大量出现,不作为数据行)
if (isBlankValues(values)) {
continue;
}
if (values.size() < 2) {
continue;
}
rowNo++;
rows.add(toRow(rowNo, header, values));
rows.add(toRow(rowNo, newFormat, header, values));
}
return rows;
}
@@ -130,37 +151,42 @@ public class AlipayBillParser implements BillParser {
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
*
* @param rowNo 行号
* @param newFormat 是否为新版「交易明细」格式
* @param header 表头字段列表
* @param values 数据字段列表
* @return 解析后的账单行
*/
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
private ParsedBillRow toRow(int rowNo, boolean newFormat, List<String> header, List<String> values) {
ParsedBillRow row = new ParsedBillRow();
row.setRowNo(rowNo);
row.setRawData(buildRawData(header, values));
row.setExternalNo(get(header, values, COL_TRADE_NO));
// 交易号可能带制表符等空白,去除后作为去重标识
row.setExternalNo(cleanTradeNo(get(header, values, newFormat ? COL_NEW_TRADE_NO : COL_TRADE_NO)));
row.setCounterparty(get(header, values, COL_COUNTERPARTY));
row.setProduct(get(header, values, COL_PRODUCT));
row.setProduct(get(header, values, newFormat ? COL_NEW_PRODUCT : COL_PRODUCT));
row.setTradeStatus(get(header, values, COL_STATUS));
String direction = get(header, values, COL_DIRECTION);
String fundStatus = get(header, values, COL_FUND_STATUS);
// 新版无「资金状态」列
String fundStatus = newFormat ? null : get(header, values, COL_FUND_STATUS);
row.setDirection(direction);
// 交易时间优先取付款时间,为空时回退交易创建时间
String payTime = get(header, values, COL_PAY_TIME);
String createTime = get(header, values, COL_CREATE_TIME);
String timeStr = StringUtils.hasText(payTime) ? payTime : createTime;
// 交易时间:旧版优先取付款时间、为空回退交易创建时间;新版仅「交易时间」列
String[] timeColumns = newFormat
? new String[] {COL_NEW_TIME}
: new String[] {COL_PAY_TIME, COL_CREATE_TIME};
String timeStr = firstText(header, values, timeColumns);
if (StringUtils.hasText(timeStr)) {
try {
row.setTransactionTime(LocalDateTime.parse(timeStr, TIME_FORMATTER));
} catch (DateTimeParseException e) {
LocalDateTime transactionTime = parseTime(timeStr);
if (transactionTime == null) {
row.setError("交易时间格式无法解析:" + timeStr);
} else {
row.setTransactionTime(transactionTime);
}
}
// 金额去除千分位逗号后解析
String amountStr = get(header, values, COL_AMOUNT);
String amountStr = get(header, values, newFormat ? COL_NEW_AMOUNT : COL_AMOUNT);
if (StringUtils.hasText(amountStr)) {
try {
row.setAmount(new BigDecimal(amountStr.replace(",", "")));
@@ -246,6 +272,70 @@ public class AlipayBillParser implements BillParser {
return null;
}
/**
* 判断一行解析结果是否全部为空(新版账单尾部为「,,,,,」空行)
*
* @param values 字段列表
* @return 全部为空返回 true
*/
private boolean isBlankValues(List<String> values) {
for (String value : values) {
if (StringUtils.hasText(value)) {
return false;
}
}
return true;
}
/**
* 依次返回各候选列中第一个非空的值
*
* @param header 表头字段列表
* @param values 数据字段列表
* @param columns 候选列名(按优先级)
* @return 第一个非空值,均无时返回 null
*/
private String firstText(List<String> header, List<String> values, String... columns) {
for (String column : columns) {
String value = get(header, values, column);
if (StringUtils.hasText(value)) {
return value;
}
}
return null;
}
/**
* 按多种时间格式解析交易时间
*
* @param text 时间文本
* @return 解析后的时间,均失败时返回 null
*/
private LocalDateTime parseTime(String text) {
for (DateTimeFormatter formatter : TIME_FORMATTERS) {
try {
return LocalDateTime.parse(text.trim(), formatter);
} catch (DateTimeParseException ignored) {
// 继续尝试下一种格式
}
}
return null;
}
/**
* 清理交易号:去除制表符等所有空白字符
*
* @param tradeNo 原始交易号
* @return 清理后的交易号,为空时返回 null
*/
private String cleanTradeNo(String tradeNo) {
if (tradeNo == null) {
return null;
}
String cleaned = tradeNo.replaceAll("\\s+", "");
return cleaned.isEmpty() ? null : cleaned;
}
/**
* 按表头列名取值
*
@@ -209,22 +209,26 @@ public class BillImportServiceImpl implements BillImportService {
if (!TransactionType.isValid(item.getType())) {
failed++;
messages.add(label + ":交易类型无效");
log.warn("账单导入行校验失败: userId={}, externalNo={}, 原因=交易类型无效", userId, item.getExternalNo());
continue;
}
if (item.getTransactionTime() == null) {
failed++;
messages.add(label + ":交易时间为空");
log.warn("账单导入行校验失败: userId={}, externalNo={}, 原因=交易时间为空", userId, item.getExternalNo());
continue;
}
if (item.getAmount() == null || item.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
failed++;
messages.add(label + ":金额无效");
log.warn("账单导入行校验失败: userId={}, externalNo={}, 原因=金额无效", userId, item.getExternalNo());
continue;
}
// 去重:同一用户下交易号唯一
if (StringUtils.hasText(item.getExternalNo()) && seen.contains(item.getExternalNo())) {
skipped++;
messages.add(label + ":已存在,跳过");
log.debug("账单导入跳过重复交易: userId={}, externalNo={}", userId, item.getExternalNo());
continue;
}
@@ -259,7 +263,7 @@ public class BillImportServiceImpl implements BillImportService {
}
// 批量插入交易记录,失败时回退逐条以定位问题行
List<Transaction> inserted = insertTransactions(pending, messages);
List<Transaction> inserted = insertTransactions(userId, pending, messages);
failed += pending.size() - inserted.size();
// 批量插入标签关联(批量插入不回填主键,按交易号回查ID)
@@ -282,11 +286,12 @@ public class BillImportServiceImpl implements BillImportService {
/**
* 分批批量插入交易记录;某一批失败时回退逐条插入以定位并跳过问题行。
*
* @param userId 用户ID
* @param pending 待插入的交易列表
* @param messages 失败信息收集列表
* @return 成功插入的交易列表
*/
private List<Transaction> insertTransactions(List<Transaction> pending, List<String> messages) {
private List<Transaction> insertTransactions(Long userId, List<Transaction> pending, List<String> messages) {
List<Transaction> inserted = new ArrayList<>();
for (int i = 0; i < pending.size(); i += BATCH_SIZE) {
List<Transaction> batch = pending.subList(i, Math.min(i + BATCH_SIZE, pending.size()));
@@ -295,12 +300,16 @@ public class BillImportServiceImpl implements BillImportService {
inserted.addAll(batch);
} catch (RuntimeException e) {
// 批量失败时逐条重试,跳过并记录问题行
log.warn("账单批量插入交易失败,回退逐条插入: userId={}, 批次大小={}, 原因={}",
userId, batch.size(), rootMessage(e));
for (Transaction transaction : batch) {
try {
transactionMapper.insert(transaction);
inserted.add(transaction);
} catch (RuntimeException ex) {
messages.add(transaction.getExternalNo() + ":导入失败," + ex.getMessage());
log.warn("账单交易导入失败: userId={}, externalNo={}, 原因={}",
userId, transaction.getExternalNo(), rootMessage(ex));
}
}
}
@@ -308,6 +317,20 @@ public class BillImportServiceImpl implements BillImportService {
return inserted;
}
/**
* 获取异常链中最底层异常的消息,避免日志被 MyBatis 包装信息淹没。
*
* @param throwable 异常
* @return 根因消息
*/
private String rootMessage(Throwable throwable) {
Throwable cause = throwable;
while (cause.getCause() != null && cause.getCause() != cause) {
cause = cause.getCause();
}
return cause.getMessage() != null ? cause.getMessage() : cause.getClass().getSimpleName();
}
/**
* 批量插入交易与标签的关联关系。
*
+1 -1
View File
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS t_transaction (
channel VARCHAR(30) COMMENT '交易渠道:WECHAT-微信,ALIPAY-支付宝,CASH-现金,BANK-银行卡',
remark VARCHAR(255) COMMENT '备注说明',
remark_hidden TINYINT(1) NOT NULL DEFAULT 0 COMMENT '备注是否隐藏:1-隐藏,0-显示',
external_no VARCHAR(64) COMMENT '外部账单交易号,用于账单导入去重',
external_no VARCHAR(255) COMMENT '外部账单交易号,用于账单导入去重',
transaction_time DATETIME NOT NULL COMMENT '交易发生时间',
include_in_stats TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否计入统计:1-计入,0-不计入(如借款、转账等)',
raw_data JSON COMMENT '账单导入的原始完整数据(结构化JSON),手动记录为空',
+1 -1
View File
@@ -39,7 +39,7 @@ CREATE TABLE IF NOT EXISTS t_transaction (
channel VARCHAR(30),
remark VARCHAR(255),
remark_hidden INTEGER NOT NULL DEFAULT 0,
external_no VARCHAR(64),
external_no VARCHAR(255),
transaction_time DATETIME NOT NULL,
include_in_stats INTEGER NOT NULL DEFAULT 1,
raw_data TEXT