feat: 扩展账单导入(zip/美团/招行/交行)并新增批量删除与动态支付方式
- 账单导入支持 zip 压缩包,加密包自动检测并弹窗输入解压密码(引入 zip4j) - 新增美团、招商银行、交通银行账单解析器及对应账单类型 - 支付方式列表改为后端动态获取,前端筛选与新增/编辑表单动态渲染 - 支付方式筛选从「更多筛选」移至常用筛选栏,移除「银行卡」选项 - 收支记录支持多选批量删除 - 补充相关三语文案并透传业务错误码
This commit is contained in:
@@ -43,21 +43,23 @@ public class BillImportController {
|
||||
* 解析账单文件(不落库)
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param file 账单文件
|
||||
* @param file 账单文件(支持 csv/xlsx 或 zip 压缩包)
|
||||
* @param billType 账单类型,如 ALIPAY
|
||||
* @param password 压缩包解压密码,未加密时可不传
|
||||
* @return 解析预览结果
|
||||
* @throws IOException 文件读取失败
|
||||
*/
|
||||
@PostMapping("/parse")
|
||||
@Operation(summary = "解析账单文件", description = "解析账单文件并返回预览数据,不写入数据库")
|
||||
@Operation(summary = "解析账单文件", description = "解析账单文件(支持 zip 压缩包)并返回预览数据,不写入数据库")
|
||||
public Result<BillParseResultVO> parse(HttpServletRequest request,
|
||||
@Parameter(description = "账单文件") @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "账单类型,如 ALIPAY") @RequestParam("billType") String billType) throws IOException {
|
||||
@Parameter(description = "账单类型,如 ALIPAY") @RequestParam("billType") String billType,
|
||||
@Parameter(description = "压缩包解压密码") @RequestParam(value = "password", required = false) String password) throws IOException {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请选择账单文件");
|
||||
}
|
||||
return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes()));
|
||||
return Result.success(billImportService.parse(userId, resolveBillType(billType), file.getBytes(), password));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.alibaba.excel.EasyExcel;
|
||||
import cn.rosercode.finance.common.PageResult;
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.BatchCategoryRequest;
|
||||
import cn.rosercode.finance.dto.BatchDeleteRequest;
|
||||
import cn.rosercode.finance.dto.BatchTagRequest;
|
||||
import cn.rosercode.finance.dto.TransactionQuery;
|
||||
import cn.rosercode.finance.dto.TransactionRequest;
|
||||
@@ -49,6 +50,17 @@ public class TransactionController {
|
||||
this.transactionService = transactionService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统支持的支付方式列表
|
||||
*
|
||||
* @return 支付方式编码列表
|
||||
*/
|
||||
@GetMapping("/payment-methods")
|
||||
@Operation(summary = "获取支付方式列表", description = "返回系统支持的支付方式编码,含账单导入来源与人工支付方式")
|
||||
public Result<List<String>> paymentMethods() {
|
||||
return Result.success(transactionService.listPaymentMethods());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询交易记录
|
||||
*
|
||||
@@ -113,6 +125,21 @@ public class TransactionController {
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除交易记录
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param body 批量删除请求(记录ID列表)
|
||||
* @return 实际删除的记录数
|
||||
*/
|
||||
@DeleteMapping("/batch")
|
||||
@Operation(summary = "批量删除交易记录", description = "根据记录ID列表批量删除当前用户的交易记录")
|
||||
public Result<Integer> batchDelete(HttpServletRequest request,
|
||||
@RequestBody @Valid BatchDeleteRequest body) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(transactionService.batchDelete(userId, body.getIds()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换交易记录的"计入统计"标记
|
||||
*
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量删除交易记录请求DTO,用于一次性删除多条收支记录。
|
||||
*
|
||||
* @date 2026-09-19 02:32:21
|
||||
*/
|
||||
@Data
|
||||
public class BatchDeleteRequest {
|
||||
|
||||
/** 待删除的交易记录ID列表 */
|
||||
@NotEmpty(message = "请至少选择一条记录")
|
||||
private List<Long> ids;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package cn.rosercode.finance.enums;
|
||||
/**
|
||||
* 账单类型枚举,标识支持的第三方账单来源。
|
||||
*
|
||||
* <p>当前支持支付宝与微信支付账单,后续可扩展银行等账单类型。</p>
|
||||
* <p>当前支持支付宝、微信支付、美团、招商银行与交通银行账单,后续可扩展其他银行等账单类型。</p>
|
||||
*
|
||||
* @date 2026-09-17 09:49:43
|
||||
*/
|
||||
@@ -13,7 +13,16 @@ public enum BillType {
|
||||
ALIPAY("支付宝"),
|
||||
|
||||
/** 微信支付账单 */
|
||||
WECHAT("微信");
|
||||
WECHAT("微信"),
|
||||
|
||||
/** 美团账单 */
|
||||
MEITUAN("美团"),
|
||||
|
||||
/** 招商银行账单 */
|
||||
CMB("招商银行"),
|
||||
|
||||
/** 交通银行账单 */
|
||||
BOCOM("交通银行");
|
||||
|
||||
/** 账单类型的中文名称 */
|
||||
private final String label;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package cn.rosercode.finance.importer;
|
||||
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import net.lingala.zip4j.ZipFile;
|
||||
import net.lingala.zip4j.exception.ZipException;
|
||||
import net.lingala.zip4j.model.FileHeader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账单压缩包解压工具。
|
||||
*
|
||||
* <p>支付宝、微信导出的账单常以 zip 压缩包形式提供,本工具负责从压缩包中提取
|
||||
* 待解析的账单文件字节:按期望扩展名匹配第一个文件,未匹配到时回退首个文件。
|
||||
* 压缩包若已加密,先返回「需要密码」的错误码,待前端输入密码后再行解密。</p>
|
||||
*
|
||||
* @date 2026-09-18 23:26:58
|
||||
*/
|
||||
@Component
|
||||
public class BillArchiveExtractor {
|
||||
|
||||
/** 压缩包已加密、需要用户输入密码的业务错误码 */
|
||||
public static final int CODE_PASSWORD_REQUIRED = 4001;
|
||||
|
||||
/** 解压密码错误的业务错误码 */
|
||||
public static final int CODE_PASSWORD_WRONG = 4002;
|
||||
|
||||
/** 允许解压的最大条目数,防止压缩包内文件过多 */
|
||||
private static final int MAX_ENTRIES = 100;
|
||||
|
||||
/** 允许解压的最大字节数(50MB),防止 zip bomb */
|
||||
private static final long MAX_TOTAL_SIZE = 50L * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 判断字节内容是否为 zip 压缩包(按本地文件头魔数 PK\x03\x04)。
|
||||
*
|
||||
* @param content 文件字节内容
|
||||
* @return 是 zip 压缩包返回 true
|
||||
*/
|
||||
public boolean isZip(byte[] content) {
|
||||
return content != null
|
||||
&& content.length >= 4
|
||||
&& (content[0] & 0xFF) == 0x50
|
||||
&& (content[1] & 0xFF) == 0x4B
|
||||
&& (content[2] & 0xFF) == 0x03
|
||||
&& (content[3] & 0xFF) == 0x04;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断压缩包内是否存在指定扩展名的文件。
|
||||
*
|
||||
* <p>xlsx 等 Office 文件本身也是 zip 结构,需借此区分「账单压缩包」与「原始账单文件」:
|
||||
* 仅当压缩包内确实包含目标扩展名的账单文件时才按压缩包处理。</p>
|
||||
*
|
||||
* @param content 文件字节内容
|
||||
* @param extension 目标文件扩展名(小写,含点,如 .csv、.xlsx)
|
||||
* @return 存在匹配文件返回 true
|
||||
*/
|
||||
public boolean containsEntry(byte[] content, String extension) {
|
||||
if (!isZip(content)) {
|
||||
return false;
|
||||
}
|
||||
Path temp = null;
|
||||
try {
|
||||
temp = writeTemp(content);
|
||||
List<FileHeader> headers = new ZipFile(temp.toFile()).getFileHeaders();
|
||||
for (FileHeader header : headers) {
|
||||
if (!header.isDirectory() && header.getFileName().toLowerCase().endsWith(extension)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// 读取失败按非账单压缩包处理,交由原解析器给出更准确的错误
|
||||
return false;
|
||||
} finally {
|
||||
deleteQuietly(temp);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从压缩包中提取账单文件内容。
|
||||
*
|
||||
* <p>优先返回文件名以期望扩展名结尾的第一个文件;若均不匹配,则返回第一个非目录文件。
|
||||
* 当目标文件已加密且未提供密码时抛出「需要密码」错误,密码错误时抛出对应错误。</p>
|
||||
*
|
||||
* @param content 压缩包字节内容
|
||||
* @param expectedExtension 期望的文件扩展名(小写,含点,如 .csv、.xlsx)
|
||||
* @param password 解压密码,未加密时可为空
|
||||
* @return 提取出的账单文件字节内容
|
||||
*/
|
||||
public byte[] extract(byte[] content, String expectedExtension, String password) {
|
||||
char[] pwd = (password == null || password.isBlank()) ? null : password.toCharArray();
|
||||
Path temp = null;
|
||||
try {
|
||||
temp = writeTemp(content);
|
||||
ZipFile zipFile = pwd == null ? new ZipFile(temp.toFile()) : new ZipFile(temp.toFile(), pwd);
|
||||
FileHeader target = selectEntry(zipFile, expectedExtension);
|
||||
if (target == null) {
|
||||
throw new BusinessException("压缩包为空或未找到可解析的文件");
|
||||
}
|
||||
// 目标文件加密但未提供密码时,提示前端输入密码
|
||||
if (target.isEncrypted() && pwd == null) {
|
||||
throw new BusinessException(CODE_PASSWORD_REQUIRED, "压缩包已加密,请输入解压密码");
|
||||
}
|
||||
if (target.getUncompressedSize() > MAX_TOTAL_SIZE) {
|
||||
throw new BusinessException("压缩包内文件过大,已拒绝解析");
|
||||
}
|
||||
try (InputStream in = zipFile.getInputStream(target)) {
|
||||
return readLimited(in);
|
||||
}
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (ZipException e) {
|
||||
// 已提供密码仍解压失败,按密码错误处理
|
||||
if (pwd != null) {
|
||||
throw new BusinessException(CODE_PASSWORD_WRONG, "解压密码错误");
|
||||
}
|
||||
throw new BusinessException("压缩包解析失败:" + e.getMessage());
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException("压缩包解析失败:" + e.getMessage());
|
||||
} finally {
|
||||
deleteQuietly(temp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字节内容写入临时文件,供 zip4j 按文件随机访问(其不支持直接从内存读取)。
|
||||
*
|
||||
* @param content 字节内容
|
||||
* @return 临时文件路径
|
||||
* @throws IOException 写入失败
|
||||
*/
|
||||
private Path writeTemp(byte[] content) throws IOException {
|
||||
Path temp = Files.createTempFile("bill-archive-", ".zip");
|
||||
Files.write(temp, content);
|
||||
return temp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默删除临时文件。
|
||||
*
|
||||
* @param temp 临时文件路径,可为 null
|
||||
*/
|
||||
private void deleteQuietly(Path temp) {
|
||||
if (temp == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
} catch (IOException ignored) {
|
||||
// 删除失败不影响解析结果
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择待提取的条目:优先匹配期望扩展名,否则回退第一个非目录文件。
|
||||
*
|
||||
* @param zipFile 已打开的压缩包
|
||||
* @param expectedExtension 期望的文件扩展名(小写,含点)
|
||||
* @return 选中的条目,压缩包为空时返回 null
|
||||
* @throws ZipException 读取条目列表失败
|
||||
*/
|
||||
private FileHeader selectEntry(ZipFile zipFile, String expectedExtension) throws ZipException {
|
||||
FileHeader matched = null;
|
||||
FileHeader fallback = null;
|
||||
int count = 0;
|
||||
for (FileHeader header : zipFile.getFileHeaders()) {
|
||||
if (header.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (++count > MAX_ENTRIES) {
|
||||
throw new BusinessException("压缩包内文件过多,已拒绝解析");
|
||||
}
|
||||
if (fallback == null) {
|
||||
fallback = header;
|
||||
}
|
||||
if (matched == null && header.getFileName().toLowerCase().endsWith(expectedExtension)) {
|
||||
matched = header;
|
||||
}
|
||||
}
|
||||
return matched != null ? matched : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取输入流并限制最大字节数,防止解压内容过大。
|
||||
*
|
||||
* @param in 输入流
|
||||
* @return 读取到的字节内容
|
||||
* @throws IOException 读取失败
|
||||
*/
|
||||
private byte[] readLimited(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
total += read;
|
||||
if (total > MAX_TOTAL_SIZE) {
|
||||
throw new BusinessException("压缩包内文件过大,已拒绝解析");
|
||||
}
|
||||
out.write(buffer, 0, read);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package cn.rosercode.finance.importer;
|
||||
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellType;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.Workbook;
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 交通银行账单解析器。
|
||||
*
|
||||
* <p>解析交通银行「银行卡交易明细查询表」导出的 xls 文件。文件首行为标题,
|
||||
* 第二行为卡号、姓名与查询区间等元信息,第三行为表头,之后为数据行。
|
||||
* 金额分为支出、收入两列且以「--」表示空值,无交易单号,需按行生成去重标识。</p>
|
||||
*
|
||||
* @date 2026-09-19 02:18:33
|
||||
*/
|
||||
@Component
|
||||
public class BocomBillParser implements BillParser {
|
||||
|
||||
/** 表头列名 */
|
||||
private static final String COL_DATE = "记账日期";
|
||||
private static final String COL_TIME = "交易时间";
|
||||
private static final String COL_LOCATION = "交易地点";
|
||||
private static final String COL_METHOD = "交易方式";
|
||||
private static final String COL_EXPENSE = "支出金额";
|
||||
private static final String COL_INCOME = "收入金额";
|
||||
private static final String COL_BALANCE = "余额";
|
||||
private static final String COL_COUNTERPARTY = "对方户名";
|
||||
private static final String COL_ACCOUNT = "对方账户";
|
||||
private static final String COL_BANK = "对方开户行";
|
||||
private static final String COL_SUMMARY = "摘要";
|
||||
|
||||
/** 表头首列名称,用于定位表头行 */
|
||||
private static final String HEADER_FIRST_COLUMN = COL_DATE;
|
||||
|
||||
/** 交行账单中表示「无内容」的占位符 */
|
||||
private static final String PLACEHOLDER = "--";
|
||||
|
||||
/** 交易时间格式(如 2026-09-16 08:29:32) */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/** 记账日期格式(如 2026-09-16) */
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/** 生成去重标识时使用的时间格式 */
|
||||
private static final DateTimeFormatter ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmmss");
|
||||
|
||||
/** 生成去重标识时使用的日期格式 */
|
||||
private static final DateTimeFormatter ID_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
/** JSON 序列化器,用于生成原始完整数据 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public BocomBillParser(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BillType getBillType() {
|
||||
return BillType.BOCOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 定位表头后逐行读取数据,跳过缺少有效时间的空行。
|
||||
*/
|
||||
@Override
|
||||
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
||||
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(content))) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
int headerIndex = locateHeader(sheet);
|
||||
if (headerIndex < 0) {
|
||||
throw new IOException("未找到交通银行账单表头,请确认文件是否为交通银行导出的交易明细");
|
||||
}
|
||||
List<String> header = readHeader(sheet.getRow(headerIndex));
|
||||
|
||||
List<ParsedBillRow> rows = new ArrayList<>();
|
||||
int rowNo = 0;
|
||||
for (int i = headerIndex + 1; i <= sheet.getLastRowNum(); i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null || isEmptyRow(row, header.size())) {
|
||||
continue;
|
||||
}
|
||||
rowNo++;
|
||||
rows.add(toRow(rowNo, header, row));
|
||||
}
|
||||
return rows;
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IOException("交通银行账单解析失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定位表头行:首个单元格以「记账日期」开头的行
|
||||
*
|
||||
* @param sheet 工作表
|
||||
* @return 表头行索引,未找到时返回 -1
|
||||
*/
|
||||
private int locateHeader(Sheet sheet) {
|
||||
for (int i = sheet.getFirstRowNum(); i <= sheet.getLastRowNum(); i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
String first = readString(row.getCell(0));
|
||||
if (first != null && first.startsWith(HEADER_FIRST_COLUMN)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取表头行的各列名称
|
||||
*
|
||||
* @param row 表头行
|
||||
* @return 列名列表
|
||||
*/
|
||||
private List<String> readHeader(Row row) {
|
||||
List<String> header = new ArrayList<>();
|
||||
for (int c = 0; c < row.getLastCellNum(); c++) {
|
||||
String value = readString(row.getCell(c));
|
||||
header.add(value == null ? "" : value);
|
||||
}
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断整行是否为空(所有单元格均无内容)
|
||||
*
|
||||
* @param row 数据行
|
||||
* @param colCount 列数
|
||||
* @return 整行为空返回 true
|
||||
*/
|
||||
private boolean isEmptyRow(Row row, int colCount) {
|
||||
for (int c = 0; c < colCount; c++) {
|
||||
if (StringUtils.hasText(readString(row.getCell(c)))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
|
||||
*
|
||||
* @param rowNo 行号
|
||||
* @param header 表头字段列表
|
||||
* @param row 数据行
|
||||
* @return 解析后的账单行
|
||||
*/
|
||||
private ParsedBillRow toRow(int rowNo, List<String> header, Row row) {
|
||||
ParsedBillRow parsed = new ParsedBillRow();
|
||||
parsed.setRowNo(rowNo);
|
||||
parsed.setRawData(buildRawData(header, row));
|
||||
// 对方户名作为交易对方,摘要作为商品名称
|
||||
parsed.setCounterparty(cleanText(get(header, row, COL_COUNTERPARTY)));
|
||||
parsed.setProduct(cleanText(get(header, row, COL_SUMMARY)));
|
||||
|
||||
// 交易时间列含完整时间,缺失时回退记账日期
|
||||
String timeStr = cleanText(get(header, row, COL_TIME));
|
||||
String dateStr = cleanText(get(header, row, COL_DATE));
|
||||
parsed.setTransactionTime(parseTime(timeStr, dateStr));
|
||||
if (parsed.getTransactionTime() == null) {
|
||||
parsed.setError("交易时间为空或格式无法解析");
|
||||
}
|
||||
|
||||
// 支出、收入两列互斥,非「--」的一列决定类型与金额
|
||||
BigDecimal expense = readAmount(get(header, row, COL_EXPENSE));
|
||||
BigDecimal income = readAmount(get(header, row, COL_INCOME));
|
||||
String type;
|
||||
BigDecimal amount;
|
||||
if (expense != null) {
|
||||
type = "EXPENSE";
|
||||
amount = expense;
|
||||
} else if (income != null) {
|
||||
type = "INCOME";
|
||||
amount = income;
|
||||
} else {
|
||||
type = null;
|
||||
amount = null;
|
||||
}
|
||||
parsed.setType(type);
|
||||
parsed.setAmount(amount);
|
||||
if (type == null && parsed.getError() == null) {
|
||||
parsed.setError("收入与支出金额均为空");
|
||||
}
|
||||
|
||||
// 银行流水默认计入统计
|
||||
parsed.setIncludeInStats(true);
|
||||
// 无交易单号,按日期时间、类型与金额生成去重标识
|
||||
parsed.setExternalNo(buildExternalNo(parsed.getTransactionTime(), type, amount));
|
||||
|
||||
boolean importable = parsed.getError() == null
|
||||
&& StringUtils.hasText(parsed.getExternalNo())
|
||||
&& parsed.getTransactionTime() != null
|
||||
&& amount != null
|
||||
&& amount.compareTo(BigDecimal.ZERO) > 0
|
||||
&& type != null;
|
||||
parsed.setImportable(importable);
|
||||
parsed.setSelected(importable);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析交易时间:优先使用完整交易时间,缺失时回退记账日期
|
||||
*
|
||||
* @param timeStr 交易时间文本
|
||||
* @param dateStr 记账日期文本
|
||||
* @return 交易时间,无法解析时返回 null
|
||||
*/
|
||||
private LocalDateTime parseTime(String timeStr, String dateStr) {
|
||||
if (StringUtils.hasText(timeStr)) {
|
||||
try {
|
||||
return LocalDateTime.parse(timeStr, TIME_FORMATTER);
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// 尝试回退记账日期
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(dateStr)) {
|
||||
try {
|
||||
return LocalDate.parse(dateStr, DATE_FORMATTER).atStartOfDay();
|
||||
} catch (DateTimeParseException ignored) {
|
||||
// 无法解析
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成去重标识:银行流水无交易单号,用日期时间、收支类型与金额组合。
|
||||
*
|
||||
* @param time 交易时间
|
||||
* @param type 收支类型
|
||||
* @param amount 金额
|
||||
* @return 去重标识,信息不足时返回 null
|
||||
*/
|
||||
private String buildExternalNo(LocalDateTime time, String type, BigDecimal amount) {
|
||||
if (time == null || type == null || amount == null) {
|
||||
return null;
|
||||
}
|
||||
String date = time.format(ID_DATE_FORMATTER);
|
||||
String clock = time.format(ID_TIME_FORMATTER);
|
||||
String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
return "BOCOM_" + date + "_" + clock + "_" + type + "_" + value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param row 数据行
|
||||
* @return JSON 字符串,序列化失败时返回 null
|
||||
*/
|
||||
private String buildRawData(List<String> header, Row row) {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (int c = 0; c < header.size(); c++) {
|
||||
String key = header.get(c);
|
||||
if (!StringUtils.hasText(key)) {
|
||||
continue;
|
||||
}
|
||||
String value = readString(row.getCell(c));
|
||||
map.put(key, value == null ? "" : value);
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (JsonProcessingException e) {
|
||||
// 序列化失败不影响正常导入,原始数据置空
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表头列名取值
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param row 数据行
|
||||
* @param column 列名
|
||||
* @return 字段值,列不存在时返回 null
|
||||
*/
|
||||
private String get(List<String> header, Row row, String column) {
|
||||
int index = header.indexOf(column);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
return readString(row.getCell(index));
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取金额:去除千分位逗号,「--」或空值返回 null
|
||||
*
|
||||
* @param value 金额文本
|
||||
* @return 金额,无法解析时返回 null
|
||||
*/
|
||||
private BigDecimal readAmount(String value) {
|
||||
String text = cleanText(value);
|
||||
if (text == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text.replace(",", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取单元格的文本值
|
||||
*
|
||||
* @param cell 单元格
|
||||
* @return 文本值,空单元格返回 null
|
||||
*/
|
||||
private String readString(Cell cell) {
|
||||
if (cell == null) {
|
||||
return null;
|
||||
}
|
||||
CellType type = cell.getCellType();
|
||||
if (type == CellType.STRING) {
|
||||
return cell.getStringCellValue().trim();
|
||||
}
|
||||
if (type == CellType.NUMERIC) {
|
||||
return BigDecimal.valueOf(cell.getNumericCellValue()).stripTrailingZeros().toPlainString();
|
||||
}
|
||||
if (type == CellType.BOOLEAN) {
|
||||
return String.valueOf(cell.getBooleanCellValue());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清洗文本:空白或占位符「--」视为无内容
|
||||
*
|
||||
* @param value 原始文本
|
||||
* @return 清洗后的文本,无内容时返回 null
|
||||
*/
|
||||
private String cleanText(String value) {
|
||||
if (!StringUtils.hasText(value) || PLACEHOLDER.equals(value)) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package cn.rosercode.finance.importer;
|
||||
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 招商银行账单解析器。
|
||||
*
|
||||
* <p>解析招商银行「交易流水」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
|
||||
* 前几行以「#」开头的账号、日期范围等元信息,之后为表头与数据行,末尾为收支合计。
|
||||
* 交易日期与交易时间分列,金额分为收入、支出两列,且无交易单号,需按行生成去重标识。</p>
|
||||
*
|
||||
* @date 2026-09-19 02:13:20
|
||||
*/
|
||||
@Component
|
||||
public class CmbBillParser implements BillParser {
|
||||
|
||||
/** 表头列名 */
|
||||
private static final String COL_DATE = "交易日期";
|
||||
private static final String COL_TIME = "交易时间";
|
||||
private static final String COL_INCOME = "收入";
|
||||
private static final String COL_EXPENSE = "支出";
|
||||
private static final String COL_BALANCE = "余额";
|
||||
private static final String COL_TRADE_TYPE = "交易类型";
|
||||
private static final String COL_REMARK = "交易备注";
|
||||
|
||||
/** 表头首列名称,用于定位表头行 */
|
||||
private static final String HEADER_FIRST_COLUMN = COL_DATE;
|
||||
|
||||
/** 元信息与合计行的前缀,遇到即视为数据结束 */
|
||||
private static final String META_PREFIX = "#";
|
||||
|
||||
/** 交易日期格式(如 20260610) */
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
|
||||
|
||||
/** 交易时间格式(如 12:29:45) */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
/** 生成去重标识时使用的时间格式 */
|
||||
private static final DateTimeFormatter ID_TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmmss");
|
||||
|
||||
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
|
||||
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
|
||||
|
||||
/** JSON 序列化器,用于生成原始完整数据 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public CmbBillParser(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BillType getBillType() {
|
||||
return BillType.CMB;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 定位表头后逐行解析数据,遇到元信息或合计行结束。
|
||||
*/
|
||||
@Override
|
||||
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
||||
String text = new String(content, detectCharset(content));
|
||||
String[] lines = text.split("\r?\n", -1);
|
||||
|
||||
// 定位表头行:去除引号后以「交易日期」开头
|
||||
int headerIndex = -1;
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (lines[i].replace("\"", "").trim().startsWith(HEADER_FIRST_COLUMN)) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerIndex < 0) {
|
||||
throw new IOException("未找到招商银行账单表头,请确认文件是否为招商银行导出的交易流水");
|
||||
}
|
||||
|
||||
List<String> header = parseLine(lines[headerIndex]);
|
||||
List<ParsedBillRow> rows = new ArrayList<>();
|
||||
int rowNo = 0;
|
||||
for (int i = headerIndex + 1; i < lines.length; i++) {
|
||||
String trimmed = lines[i].replace("\"", "").trim();
|
||||
// 空行、元信息或合计行表示数据结束
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(META_PREFIX)) {
|
||||
break;
|
||||
}
|
||||
List<String> values = parseLine(lines[i]);
|
||||
if (values.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
rowNo++;
|
||||
rows.add(toRow(rowNo, header, values));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一行 CSV 文本解析为字段列表
|
||||
*
|
||||
* @param line CSV 行文本
|
||||
* @return 字段列表
|
||||
* @throws IOException 解析失败
|
||||
*/
|
||||
private List<String> parseLine(String line) throws IOException {
|
||||
try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
|
||||
for (CSVRecord record : parser) {
|
||||
List<String> values = new ArrayList<>(record.size());
|
||||
for (String value : record) {
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
|
||||
*
|
||||
* @param rowNo 行号
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @return 解析后的账单行
|
||||
*/
|
||||
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
|
||||
ParsedBillRow row = new ParsedBillRow();
|
||||
row.setRowNo(rowNo);
|
||||
row.setRawData(buildRawData(header, values));
|
||||
// 交易备注作为交易对方,交易类型作为商品名称
|
||||
row.setCounterparty(get(header, values, COL_REMARK));
|
||||
row.setProduct(get(header, values, COL_TRADE_TYPE));
|
||||
row.setTradeStatus(get(header, values, COL_TRADE_TYPE));
|
||||
|
||||
// 交易日期与交易时间分列,拼接为完整时间
|
||||
String dateStr = get(header, values, COL_DATE);
|
||||
String timeStr = get(header, values, COL_TIME);
|
||||
if (StringUtils.hasText(dateStr)) {
|
||||
try {
|
||||
LocalDate date = LocalDate.parse(dateStr, DATE_FORMATTER);
|
||||
LocalTime time = StringUtils.hasText(timeStr) ? LocalTime.parse(timeStr, TIME_FORMATTER) : LocalTime.MIDNIGHT;
|
||||
row.setTransactionTime(LocalDateTime.of(date, time));
|
||||
} catch (DateTimeParseException e) {
|
||||
row.setError("交易时间格式无法解析:" + dateStr + " " + timeStr);
|
||||
}
|
||||
} else {
|
||||
row.setError("交易日期为空");
|
||||
}
|
||||
|
||||
// 收入、支出两列互斥,有值的一列决定类型与金额
|
||||
String incomeStr = get(header, values, COL_INCOME);
|
||||
String expenseStr = get(header, values, COL_EXPENSE);
|
||||
String type;
|
||||
String amountStr;
|
||||
if (StringUtils.hasText(expenseStr)) {
|
||||
type = "EXPENSE";
|
||||
amountStr = expenseStr;
|
||||
} else if (StringUtils.hasText(incomeStr)) {
|
||||
type = "INCOME";
|
||||
amountStr = incomeStr;
|
||||
} else {
|
||||
type = null;
|
||||
amountStr = null;
|
||||
}
|
||||
row.setType(type);
|
||||
if (amountStr != null) {
|
||||
try {
|
||||
row.setAmount(new BigDecimal(amountStr.replace(",", "")));
|
||||
} catch (NumberFormatException e) {
|
||||
row.setError("金额格式无法解析:" + amountStr);
|
||||
}
|
||||
} else if (row.getError() == null) {
|
||||
row.setError("收入与支出金额均为空");
|
||||
}
|
||||
if (type == null && row.getError() == null) {
|
||||
row.setError("无法识别收/支类型");
|
||||
}
|
||||
|
||||
// 银行流水默认计入统计
|
||||
row.setIncludeInStats(true);
|
||||
|
||||
// 无交易单号,按日期时间、类型与金额生成去重标识
|
||||
row.setExternalNo(buildExternalNo(row.getTransactionTime(), type, row.getAmount()));
|
||||
|
||||
// 校验必要字段,决定是否可导入
|
||||
boolean importable = row.getError() == null
|
||||
&& StringUtils.hasText(row.getExternalNo())
|
||||
&& row.getTransactionTime() != null
|
||||
&& row.getAmount() != null
|
||||
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
|
||||
&& type != null;
|
||||
row.setImportable(importable);
|
||||
row.setSelected(importable);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成去重标识:银行流水无交易单号,用日期时间、收支类型与金额组合。
|
||||
*
|
||||
* @param time 交易时间
|
||||
* @param type 收支类型
|
||||
* @param amount 金额
|
||||
* @return 去重标识,信息不足时返回 null
|
||||
*/
|
||||
private String buildExternalNo(LocalDateTime time, String type, BigDecimal amount) {
|
||||
if (time == null || type == null || amount == null) {
|
||||
return null;
|
||||
}
|
||||
String date = time.format(DATE_FORMATTER);
|
||||
String clock = time.format(ID_TIME_FORMATTER);
|
||||
String value = amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
|
||||
return "CMB_" + date + "_" + clock + "_" + type + "_" + value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @return JSON 字符串,序列化失败时返回 null
|
||||
*/
|
||||
private String buildRawData(List<String> header, List<String> values) {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i < header.size(); i++) {
|
||||
String key = header.get(i);
|
||||
if (key == null || key.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String value = i < values.size() ? values.get(i) : null;
|
||||
map.put(key, value == null ? "" : value.trim());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (JsonProcessingException e) {
|
||||
// 序列化失败不影响正常导入,原始数据置空
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表头列名取值
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @param column 列名
|
||||
* @return 字段值,列不存在时返回 null
|
||||
*/
|
||||
private String get(List<String> header, List<String> values, String column) {
|
||||
int index = header.indexOf(column);
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return null;
|
||||
}
|
||||
String value = values.get(index);
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
|
||||
*
|
||||
* @param content 文件字节内容
|
||||
* @return 字符集
|
||||
*/
|
||||
private Charset detectCharset(byte[] content) {
|
||||
// UTF-8 BOM
|
||||
if (content.length >= 3
|
||||
&& (content[0] & 0xFF) == 0xEF
|
||||
&& (content[1] & 0xFF) == 0xBB
|
||||
&& (content[2] & 0xFF) == 0xBF) {
|
||||
return StandardCharsets.UTF_8;
|
||||
}
|
||||
// 严格尝试 UTF-8 解码,失败则判定为 GBK
|
||||
try {
|
||||
StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(content));
|
||||
return StandardCharsets.UTF_8;
|
||||
} catch (CharacterCodingException e) {
|
||||
return Charset.forName("GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package cn.rosercode.finance.importer;
|
||||
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
import org.apache.commons.csv.CSVRecord;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CodingErrorAction;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 美团账单解析器。
|
||||
*
|
||||
* <p>解析美团「交易账单明细」导出的 CSV 文件。文件为 UTF-8(含 BOM,兼容 GBK),
|
||||
* 前几行为用户名、起止时间等元信息与统计信息,之后为「【美团交易账单明细列表】」标记、
|
||||
* 表头与数据行。</p>
|
||||
*
|
||||
* @date 2026-09-18 23:34:05
|
||||
*/
|
||||
@Component
|
||||
public class MeituanBillParser implements BillParser {
|
||||
|
||||
/** 表头列名 */
|
||||
private static final String COL_CREATE_TIME = "交易创建时间";
|
||||
private static final String COL_SUCCESS_TIME = "交易成功时间";
|
||||
private static final String COL_TRADE_TYPE = "交易类型";
|
||||
private static final String COL_PRODUCT = "订单标题";
|
||||
private static final String COL_DIRECTION = "收/支";
|
||||
private static final String COL_PAYMENT = "支付方式";
|
||||
private static final String COL_ORDER_AMOUNT = "订单金额";
|
||||
private static final String COL_PAID_AMOUNT = "实付金额";
|
||||
private static final String COL_TRADE_NO = "交易单号";
|
||||
private static final String COL_MERCHANT_NO = "商家单号";
|
||||
private static final String COL_REMARK = "备注";
|
||||
|
||||
/** 表头首列名称,用于定位表头行 */
|
||||
private static final String HEADER_FIRST_COLUMN = COL_CREATE_TIME;
|
||||
|
||||
/** 美团账单中的时间格式 */
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
/** CSV 解析格式:逗号分隔并去除字段首尾空白 */
|
||||
private static final CSVFormat CSV_FORMAT = CSVFormat.DEFAULT.builder().setTrim(true).build();
|
||||
|
||||
/** 金额中需要剔除的货币符号与分隔符 */
|
||||
private static final String AMOUNT_STRIP_PATTERN = "[¥¥,\\s]";
|
||||
|
||||
/** JSON 序列化器,用于生成原始完整数据 */
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public MeituanBillParser(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BillType getBillType() {
|
||||
return BillType.MEITUAN;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 定位表头后逐行解析数据,无法解析出有效字段的行直接跳过。
|
||||
*/
|
||||
@Override
|
||||
public List<ParsedBillRow> parse(byte[] content) throws IOException {
|
||||
String text = new String(content, detectCharset(content));
|
||||
String[] lines = text.split("\r?\n", -1);
|
||||
|
||||
// 定位表头行:以「交易创建时间」开头
|
||||
int headerIndex = -1;
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
if (lines[i].startsWith(HEADER_FIRST_COLUMN)) {
|
||||
headerIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerIndex < 0) {
|
||||
throw new IOException("未找到美团账单表头,请确认文件是否为美团导出的交易账单明细");
|
||||
}
|
||||
|
||||
List<String> header = parseLine(lines[headerIndex]);
|
||||
List<ParsedBillRow> rows = new ArrayList<>();
|
||||
int rowNo = 0;
|
||||
for (int i = headerIndex + 1; i < lines.length; i++) {
|
||||
String trimmed = lines[i].trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
List<String> values = parseLine(lines[i]);
|
||||
if (values.size() < 2) {
|
||||
continue;
|
||||
}
|
||||
rowNo++;
|
||||
rows.add(toRow(rowNo, header, values));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将一行 CSV 文本解析为字段列表
|
||||
*
|
||||
* @param line CSV 行文本
|
||||
* @return 字段列表
|
||||
* @throws IOException 解析失败
|
||||
*/
|
||||
private List<String> parseLine(String line) throws IOException {
|
||||
try (CSVParser parser = CSVParser.parse(line, CSV_FORMAT)) {
|
||||
for (CSVRecord record : parser) {
|
||||
List<String> values = new ArrayList<>(record.size());
|
||||
for (String value : record) {
|
||||
values.add(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单行数据映射为账单行对象,并推断类型、是否计入统计及默认勾选状态
|
||||
*
|
||||
* @param rowNo 行号
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @return 解析后的账单行
|
||||
*/
|
||||
private ParsedBillRow toRow(int rowNo, List<String> header, List<String> values) {
|
||||
ParsedBillRow row = new ParsedBillRow();
|
||||
row.setRowNo(rowNo);
|
||||
row.setRawData(buildRawData(header, values));
|
||||
// 交易单号可能带引号与制表符,去除所有空白字符后作为去重标识
|
||||
row.setExternalNo(compact(get(header, values, COL_TRADE_NO)));
|
||||
row.setProduct(get(header, values, COL_PRODUCT));
|
||||
row.setTradeStatus(get(header, values, COL_TRADE_TYPE));
|
||||
|
||||
String direction = get(header, values, COL_DIRECTION);
|
||||
row.setDirection(direction);
|
||||
|
||||
// 交易时间优先取交易成功时间,为空时回退交易创建时间
|
||||
String successTime = get(header, values, COL_SUCCESS_TIME);
|
||||
String createTime = get(header, values, COL_CREATE_TIME);
|
||||
String timeStr = StringUtils.hasText(successTime) ? successTime : createTime;
|
||||
if (StringUtils.hasText(timeStr)) {
|
||||
try {
|
||||
row.setTransactionTime(LocalDateTime.parse(timeStr, TIME_FORMATTER));
|
||||
} catch (DateTimeParseException e) {
|
||||
row.setError("交易时间格式无法解析:" + timeStr);
|
||||
}
|
||||
}
|
||||
|
||||
// 金额优先取实付金额,为空时回退订单金额
|
||||
String paidAmount = get(header, values, COL_PAID_AMOUNT);
|
||||
String orderAmount = get(header, values, COL_ORDER_AMOUNT);
|
||||
String amountStr = StringUtils.hasText(paidAmount) ? paidAmount : orderAmount;
|
||||
if (StringUtils.hasText(amountStr)) {
|
||||
try {
|
||||
row.setAmount(new BigDecimal(amountStr.replaceAll(AMOUNT_STRIP_PATTERN, "")));
|
||||
} catch (NumberFormatException e) {
|
||||
row.setError("金额格式无法解析:" + amountStr);
|
||||
}
|
||||
}
|
||||
|
||||
// 根据收/支与交易类型推断交易类型
|
||||
boolean noStats = "不计收支".equals(direction);
|
||||
String type = resolveType(direction, row.getTradeStatus());
|
||||
row.setType(type);
|
||||
if (type == null && row.getError() == null) {
|
||||
row.setError("无法识别收/支类型:" + direction);
|
||||
}
|
||||
|
||||
// 不计收支(充值、提现、退款关闭等)默认不计入统计
|
||||
row.setIncludeInStats(!noStats);
|
||||
|
||||
// 校验必要字段,决定是否可导入
|
||||
boolean importable = row.getError() == null
|
||||
&& StringUtils.hasText(row.getExternalNo())
|
||||
&& row.getTransactionTime() != null
|
||||
&& row.getAmount() != null
|
||||
&& row.getAmount().compareTo(BigDecimal.ZERO) > 0
|
||||
&& type != null;
|
||||
row.setImportable(importable);
|
||||
// 不计收支行默认不勾选,用户可手动勾选
|
||||
row.setSelected(importable && !noStats);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将整行数据按表头顺序组装为结构化 JSON,保留全部列(含系统未使用的字段)
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @return JSON 字符串,序列化失败时返回 null
|
||||
*/
|
||||
private String buildRawData(List<String> header, List<String> values) {
|
||||
Map<String, String> map = new LinkedHashMap<>();
|
||||
for (int i = 0; i < header.size(); i++) {
|
||||
String key = header.get(i);
|
||||
if (key == null || key.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String value = i < values.size() ? values.get(i) : null;
|
||||
map.put(key, value == null ? "" : value.trim());
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(map);
|
||||
} catch (JsonProcessingException e) {
|
||||
// 序列化失败不影响正常导入,原始数据置空
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断交易类型
|
||||
*
|
||||
* @param direction 收/支标识
|
||||
* @param tradeType 交易类型(支付、退款等)
|
||||
* @return INCOME、EXPENSE,无法识别时返回 null
|
||||
*/
|
||||
private String resolveType(String direction, String tradeType) {
|
||||
if ("支出".equals(direction)) {
|
||||
return "EXPENSE";
|
||||
}
|
||||
if ("收入".equals(direction)) {
|
||||
return "INCOME";
|
||||
}
|
||||
if ("不计收支".equals(direction)) {
|
||||
// 退款类交易视为收入,其余(如交易关闭)按支出处理
|
||||
if (StringUtils.hasText(tradeType) && tradeType.contains("退款")) {
|
||||
return "INCOME";
|
||||
}
|
||||
return "EXPENSE";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按表头列名取值
|
||||
*
|
||||
* @param header 表头字段列表
|
||||
* @param values 数据字段列表
|
||||
* @param column 列名
|
||||
* @return 字段值,列不存在时返回 null
|
||||
*/
|
||||
private String get(List<String> header, List<String> values, String column) {
|
||||
int index = header.indexOf(column);
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return null;
|
||||
}
|
||||
String value = values.get(index);
|
||||
return value == null ? null : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除字符串中的所有空白字符(含引号字段内的制表符)
|
||||
*
|
||||
* @param value 原字符串
|
||||
* @return 去除空白后的字符串,无内容时返回 null
|
||||
*/
|
||||
private String compact(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
String result = value.replaceAll("\\s", "");
|
||||
return result.isEmpty() ? null : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测账单文件的字符编码:优先 UTF-8(含 BOM),否则回退 GBK
|
||||
*
|
||||
* @param content 文件字节内容
|
||||
* @return 字符集
|
||||
*/
|
||||
private Charset detectCharset(byte[] content) {
|
||||
// UTF-8 BOM
|
||||
if (content.length >= 3
|
||||
&& (content[0] & 0xFF) == 0xEF
|
||||
&& (content[1] & 0xFF) == 0xBB
|
||||
&& (content[2] & 0xFF) == 0xBF) {
|
||||
return StandardCharsets.UTF_8;
|
||||
}
|
||||
// 严格尝试 UTF-8 解码,失败则判定为 GBK
|
||||
try {
|
||||
StandardCharsets.UTF_8.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(content));
|
||||
return StandardCharsets.UTF_8;
|
||||
} catch (CharacterCodingException e) {
|
||||
return Charset.forName("GBK");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,16 @@ public interface BillImportService {
|
||||
/**
|
||||
* 解析账单文件,返回预览数据(不落库),并标记与系统已有记录重复的行。
|
||||
*
|
||||
* <p>当文件为 zip 压缩包时,先解压出账单文件再解析;压缩包已加密时需提供密码。</p>
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param billType 账单类型
|
||||
* @param content 账单文件字节内容
|
||||
* @param password 压缩包解压密码,未加密时可为空
|
||||
* @return 解析结果
|
||||
* @throws IOException 文件解析失败
|
||||
*/
|
||||
BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException;
|
||||
BillParseResultVO parse(Long userId, BillType billType, byte[] content, String password) throws IOException;
|
||||
|
||||
/**
|
||||
* 导入用户勾选的账单行,按外部交易号去重并写入交易记录。
|
||||
|
||||
@@ -49,6 +49,17 @@ public interface TransactionService {
|
||||
*/
|
||||
void delete(Long userId, Long id);
|
||||
|
||||
/**
|
||||
* 批量删除交易记录。
|
||||
*
|
||||
* <p>仅删除属于当前用户的记录,同时清理其标签关联。</p>
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param ids 待删除的交易记录ID列表
|
||||
* @return 实际删除的记录数
|
||||
*/
|
||||
int batchDelete(Long userId, List<Long> ids);
|
||||
|
||||
/**
|
||||
* 导出交易记录
|
||||
*
|
||||
@@ -93,4 +104,14 @@ public interface TransactionService {
|
||||
* @return 涉及的记录数
|
||||
*/
|
||||
int batchTags(Long userId, BatchTagRequest request);
|
||||
|
||||
/**
|
||||
* 获取系统支持的支付方式编码列表。
|
||||
*
|
||||
* <p>由账单来源({@link cn.rosercode.finance.enums.BillType})与人工记账支付方式组成,
|
||||
* 供前端筛选与编辑表单动态渲染。</p>
|
||||
*
|
||||
* @return 支付方式编码列表
|
||||
*/
|
||||
List<String> listPaymentMethods();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.TransactionTag;
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
import cn.rosercode.finance.importer.BillArchiveExtractor;
|
||||
import cn.rosercode.finance.importer.BillParser;
|
||||
import cn.rosercode.finance.importer.ParsedBillRow;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
@@ -56,6 +57,7 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final RuleService ruleService;
|
||||
private final VoConverter voConverter;
|
||||
private final BillArchiveExtractor archiveExtractor;
|
||||
|
||||
public BillImportServiceImpl(List<BillParser> parsers,
|
||||
TransactionMapper transactionMapper,
|
||||
@@ -63,7 +65,8 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
RuleService ruleService,
|
||||
VoConverter voConverter) {
|
||||
VoConverter voConverter,
|
||||
BillArchiveExtractor archiveExtractor) {
|
||||
// 按账单类型注册解析器,便于扩展新账单来源
|
||||
for (BillParser parser : parsers) {
|
||||
parserMap.put(parser.getBillType(), parser);
|
||||
@@ -74,6 +77,7 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.ruleService = ruleService;
|
||||
this.voConverter = voConverter;
|
||||
this.archiveExtractor = archiveExtractor;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,13 +86,19 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
* 解析后批量查询已存在的交易号,标记重复行并取消其默认勾选。
|
||||
*/
|
||||
@Override
|
||||
public BillParseResultVO parse(Long userId, BillType billType, byte[] content) throws IOException {
|
||||
public BillParseResultVO parse(Long userId, BillType billType, byte[] content, String password) throws IOException {
|
||||
BillParser parser = parserMap.get(billType);
|
||||
if (parser == null) {
|
||||
throw new BusinessException("暂不支持的账单类型:" + billType);
|
||||
}
|
||||
|
||||
List<ParsedBillRow> rows = parser.parse(content);
|
||||
// 压缩包先解压出账单文件,再交给对应解析器;xlsx 等本身是 zip,需确认包内含目标文件
|
||||
String extension = expectedExtension(billType);
|
||||
byte[] fileContent = archiveExtractor.containsEntry(content, extension)
|
||||
? archiveExtractor.extract(content, extension, password)
|
||||
: content;
|
||||
|
||||
List<ParsedBillRow> rows = parser.parse(fileContent);
|
||||
|
||||
// 批量查询已存在的交易号,用于标记重复
|
||||
List<String> externalNos = rows.stream()
|
||||
@@ -114,6 +124,22 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账单类型对应的账单文件扩展名,用于从压缩包中匹配文件。
|
||||
*
|
||||
* @param billType 账单类型
|
||||
* @return 扩展名(小写,含点)
|
||||
*/
|
||||
private String expectedExtension(BillType billType) {
|
||||
if (billType == BillType.WECHAT) {
|
||||
return ".xlsx";
|
||||
}
|
||||
if (billType == BillType.BOCOM) {
|
||||
return ".xls";
|
||||
}
|
||||
return ".csv";
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
|
||||
@@ -14,6 +14,7 @@ import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.TransactionTag;
|
||||
import cn.rosercode.finance.enums.BillType;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||
@@ -244,6 +245,34 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
transactionMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 仅删除归属当前用户的记录,先清理标签关联再批量删除,返回实际删除数。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public int batchDelete(Long userId, List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
// 仅保留属于当前用户的记录ID
|
||||
List<Long> ownedIds = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
|
||||
.select(Transaction::getId)
|
||||
.eq(Transaction::getUserId, userId)
|
||||
.in(Transaction::getId, ids))
|
||||
.stream()
|
||||
.map(Transaction::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (ownedIds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
// 先清理标签关联,再批量删除记录
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.in(TransactionTag::getTransactionId, ownedIds));
|
||||
return transactionMapper.deleteByIds(ownedIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
@@ -650,7 +679,27 @@ public class TransactionServiceImpl implements TransactionService {
|
||||
case "ALIPAY" -> "支付宝";
|
||||
case "CASH" -> "现金";
|
||||
case "BANK" -> "银行卡";
|
||||
case "MEITUAN" -> "美团";
|
||||
case "CMB" -> "招商银行";
|
||||
case "BOCOM" -> "交通银行";
|
||||
default -> code;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 由账单来源枚举(支付宝、微信、美团、招商银行、交通银行)与人工记账支付方式
|
||||
* (现金)组成,账单来源在前,人工支付方式在后。
|
||||
*/
|
||||
@Override
|
||||
public List<String> listPaymentMethods() {
|
||||
List<String> methods = new ArrayList<>();
|
||||
for (BillType type : BillType.values()) {
|
||||
methods.add(type.name());
|
||||
}
|
||||
// 人工记账使用的支付方式,非账单来源
|
||||
methods.add("CASH");
|
||||
return methods;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user