From 15fd1a263db2a4e7d0851ff25ff0a2c46cba56be Mon Sep 17 00:00:00 2001 From: rosercode Date: Wed, 30 Sep 2026 15:54:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E5=AE=9D=E6=96=B0=E7=89=88=E4=BA=A4=E6=98=93=E6=98=8E=E7=BB=86?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E4=BA=A4=E6=98=93=E5=8F=B7=E8=B6=85=E9=95=BF=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 支付宝新版「交易明细」导出格式的列名与旧版「交易记录明细查询」完全不同,导致导入直接报「未找到支付宝账单表头」;此外部分长订单号超过 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 调整列时会重建该表,数据量小影响可忽略。 --- .../config/datasource/SchemaMigrator.java | 67 +++++++++ .../ledgerly/importer/AlipayBillParser.java | 136 +++++++++++++++--- .../service/impl/BillImportServiceImpl.java | 27 +++- src/main/resources/sql/init.sql | 2 +- src/main/resources/sql/schema-sqlite.sql | 2 +- 5 files changed, 207 insertions(+), 27 deletions(-) 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