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:
@@ -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;
|
||||
}
|
||||
@@ -129,38 +150,43 @@ public class AlipayBillParser implements BillParser {
|
||||
/**
|
||||
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
|
||||
*
|
||||
* @param rowNo 行号
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量插入交易与标签的关联关系。
|
||||
*
|
||||
|
||||
@@ -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),手动记录为空',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user