diff --git a/src/main/java/cn/rosercode/ledgerly/config/datasource/SchemaMigrator.java b/src/main/java/cn/rosercode/ledgerly/config/datasource/SchemaMigrator.java
index d75f2be..e7050ce 100644
--- a/src/main/java/cn/rosercode/ledgerly/config/datasource/SchemaMigrator.java
+++ b/src/main/java/cn/rosercode/ledgerly/config/datasource/SchemaMigrator.java
@@ -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} 列长度。
+ *
+ *
新版支付宝部分订单号(如转账)可达 80+ 字符,超过原 {@code VARCHAR(64)} 会导致导入时
+ * 数据库截断报错。仅在当前长度不足目标长度时执行,保证幂等。SQLite 不强制 VARCHAR 长度,
+ * 无需迁移。
+ *
+ * @param connection 数据库连接
+ * @param type 数据库类型
+ * @param progress 进度回调,可为空
+ * @throws SQLException 迁移失败
+ */
+ private void widenExternalNoColumn(Connection connection, DataSourceType type, Consumer 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;
+ }
}
diff --git a/src/main/java/cn/rosercode/ledgerly/importer/AlipayBillParser.java b/src/main/java/cn/rosercode/ledgerly/importer/AlipayBillParser.java
index 3041f95..2122199 100644
--- a/src/main/java/cn/rosercode/ledgerly/importer/AlipayBillParser.java
+++ b/src/main/java/cn/rosercode/ledgerly/importer/AlipayBillParser.java
@@ -28,15 +28,15 @@ import cn.rosercode.ledgerly.enums.BillType;
/**
* 支付宝账单解析器。
*
- * 解析支付宝「交易记录明细查询」导出的 CSV 文件。文件为 GBK 编码(兼容 UTF-8),
- * 前几行为账号、日期范围等元信息,之后为表头与数据行,末尾为统计信息。
+ * 解析支付宝导出的 CSV 文件,兼容两种格式:旧版「交易记录明细查询」与新版「交易明细」。
+ * 文件为 GBK 编码(兼容 UTF-8),前几行为账号、日期范围等元信息,之后为表头与数据行。
*
* @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 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 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 header = parseLine(lines[headerIndex]);
+ // 新版包含「交易订单号」或「交易时间」列,据此区分新旧格式
+ boolean newFormat = header.contains(COL_NEW_TRADE_NO) || header.contains(COL_NEW_TIME);
List 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 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 header, List values) {
+ private ParsedBillRow toRow(int rowNo, boolean newFormat, List header, List 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 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 header, List 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;
+ }
+
/**
* 按表头列名取值
*
diff --git a/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java b/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java
index a5533a1..60a3fff 100644
--- a/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java
+++ b/src/main/java/cn/rosercode/ledgerly/service/impl/BillImportServiceImpl.java
@@ -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 inserted = insertTransactions(pending, messages);
+ List 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 insertTransactions(List pending, List messages) {
+ private List insertTransactions(Long userId, List pending, List messages) {
List inserted = new ArrayList<>();
for (int i = 0; i < pending.size(); i += BATCH_SIZE) {
List 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();
+ }
+
/**
* 批量插入交易与标签的关联关系。
*
diff --git a/src/main/resources/sql/init.sql b/src/main/resources/sql/init.sql
index 0dbabf8..77f46cd 100644
--- a/src/main/resources/sql/init.sql
+++ b/src/main/resources/sql/init.sql
@@ -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),手动记录为空',
diff --git a/src/main/resources/sql/schema-sqlite.sql b/src/main/resources/sql/schema-sqlite.sql
index 7737ec7..2bf94d8 100644
--- a/src/main/resources/sql/schema-sqlite.sql
+++ b/src/main/resources/sql/schema-sqlite.sql
@@ -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