feat: 支持自定义分类与标签规则并优化 AI 查询交互
- 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配 - 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停 - 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列 - 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重 - 删除标签时同步清理规则标签关联 - AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图 - 修正登录日志筛选 success 参数类型为 'true'/'false'
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package cn.rosercode.finance.controller;
|
||||
|
||||
import cn.rosercode.finance.common.Result;
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.service.RuleService;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.Data;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 规则控制器
|
||||
*
|
||||
* <p>提供自定义分类与标签规则的增删改查、启用停用接口</p>
|
||||
*
|
||||
* @date 2026-09-18 17:25:10
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/rule")
|
||||
@Tag(name = "规则管理", description = "自定义分类与标签规则的增删改查相关接口")
|
||||
public class RuleController {
|
||||
|
||||
private final RuleService ruleService;
|
||||
|
||||
public RuleController(RuleService ruleService) {
|
||||
this.ruleService = ruleService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取规则列表
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @return 规则列表
|
||||
*/
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "获取规则列表", description = "获取当前用户的全部规则,按优先级排序")
|
||||
public Result<List<RuleVO>> list(HttpServletRequest request) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
return Result.success(ruleService.list(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建规则
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param ruleRequest 规则请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PostMapping
|
||||
@Operation(summary = "创建规则", description = "为当前用户创建新的分类与标签规则")
|
||||
public Result<Void> create(HttpServletRequest request, @RequestBody @Valid RuleRequest ruleRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
ruleService.create(userId, ruleRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新规则
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 规则ID
|
||||
* @param ruleRequest 规则请求参数
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新规则", description = "根据规则ID更新规则信息")
|
||||
public Result<Void> update(HttpServletRequest request,
|
||||
@Parameter(description = "规则ID") @PathVariable Long id,
|
||||
@RequestBody @Valid RuleRequest ruleRequest) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
ruleService.update(userId, id, ruleRequest);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除规则
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 规则ID
|
||||
* @return 操作结果
|
||||
*/
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除规则", description = "根据规则ID删除指定规则及其标签关联")
|
||||
public Result<Void> delete(HttpServletRequest request,
|
||||
@Parameter(description = "规则ID") @PathVariable Long id) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
ruleService.delete(userId, id);
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用或停用规则
|
||||
*
|
||||
* @param request HTTP请求,从中获取用户ID
|
||||
* @param id 规则ID
|
||||
* @param enabled 是否启用
|
||||
* @return 操作结果
|
||||
*/
|
||||
@PutMapping("/{id}/enabled")
|
||||
@Operation(summary = "启用/停用规则", description = "切换指定规则的启用状态")
|
||||
public Result<Void> toggleEnabled(HttpServletRequest request,
|
||||
@Parameter(description = "规则ID") @PathVariable Long id,
|
||||
@RequestBody RuleEnabledRequest enabled) {
|
||||
Long userId = (Long) request.getAttribute("userId");
|
||||
ruleService.toggleEnabled(userId, id, enabled.getEnabled());
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用状态请求体
|
||||
*/
|
||||
@Data
|
||||
public static class RuleEnabledRequest {
|
||||
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import cn.rosercode.finance.entity.HttpLog;
|
||||
import cn.rosercode.finance.entity.LoginLog;
|
||||
import cn.rosercode.finance.entity.NotificationChannel;
|
||||
import cn.rosercode.finance.entity.NotificationLog;
|
||||
import cn.rosercode.finance.entity.Rule;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.Transaction;
|
||||
import cn.rosercode.finance.entity.User;
|
||||
@@ -15,6 +16,7 @@ import cn.rosercode.finance.vo.HttpLogVO;
|
||||
import cn.rosercode.finance.vo.LoginLogVO;
|
||||
import cn.rosercode.finance.vo.NotificationChannelVO;
|
||||
import cn.rosercode.finance.vo.NotificationLogVO;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
import cn.rosercode.finance.vo.TagVO;
|
||||
import cn.rosercode.finance.vo.TransactionVO;
|
||||
import cn.rosercode.finance.vo.UserVO;
|
||||
@@ -132,4 +134,23 @@ public interface VoConverter {
|
||||
@Mapping(target = "config", ignore = true)
|
||||
@Mapping(target = "hasPassword", ignore = true)
|
||||
NotificationChannelVO toNotificationChannelVO(NotificationChannel channel);
|
||||
|
||||
/**
|
||||
* 规则实体与关联分类转视图对象。
|
||||
*
|
||||
* <p>分类名称/图标取自关联分类,未关联时为空字符串;标签列表由调用方另行填充。</p>
|
||||
*
|
||||
* @param rule 规则实体
|
||||
* @param category 关联分类实体,可为 null
|
||||
* @return 规则视图对象
|
||||
*/
|
||||
@Mapping(target = "id", source = "rule.id")
|
||||
@Mapping(target = "name", source = "rule.name")
|
||||
@Mapping(target = "type", source = "rule.type")
|
||||
@Mapping(target = "sortOrder", source = "rule.sortOrder")
|
||||
@Mapping(target = "createdAt", source = "rule.createdAt")
|
||||
@Mapping(target = "categoryName", source = "category.name", defaultValue = "")
|
||||
@Mapping(target = "categoryIcon", source = "category.icon", defaultValue = "")
|
||||
@Mapping(target = "tags", ignore = true)
|
||||
RuleVO toRuleVO(Rule rule, Category category);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.rosercode.finance.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 规则请求DTO,用于接收创建或更新自定义分类与标签规则时提交的信息。
|
||||
*
|
||||
* @date 2026-09-18 17:23:11
|
||||
*/
|
||||
@Data
|
||||
public class RuleRequest {
|
||||
|
||||
/** 规则名称,不能为空 */
|
||||
@NotBlank(message = "规则名称不能为空")
|
||||
private String name;
|
||||
|
||||
/** 是否启用,可选,默认启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 优先级,数值越小越优先,可选 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 限定收支类型:INCOME/EXPENSE,为空表示不限 */
|
||||
private String type;
|
||||
|
||||
/** 关键词,匹配备注/交易对方/商品名称,为空表示不限 */
|
||||
private String keyword;
|
||||
|
||||
/** 金额下限(含),可选 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 金额上限(含),可选 */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 命中的目标分类ID,可选 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 命中的目标标签ID列表,可选 */
|
||||
private List<Long> tagIds;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 自定义分类与标签规则实体类,对应数据库表 t_rule。
|
||||
*
|
||||
* <p>规则按关键词、收支类型、金额区间等条件匹配交易,命中后自动应用目标分类与标签。</p>
|
||||
*
|
||||
* @date 2026-09-18 17:22:59
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_rule")
|
||||
public class Rule {
|
||||
|
||||
/** 规则ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 规则名称 */
|
||||
private String name;
|
||||
|
||||
/** 是否启用:true-启用,false-停用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 优先级,数值越小越优先 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 限定收支类型:INCOME-收入,EXPENSE-支出,null 表示不限 */
|
||||
private String type;
|
||||
|
||||
/** 关键词,匹配备注/交易对方/商品名称(包含匹配,忽略大小写),null 表示不限 */
|
||||
private String keyword;
|
||||
|
||||
/** 金额下限(含),null 表示不限 */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 金额上限(含),null 表示不限 */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 命中的目标分类ID,null 表示不指定分类 */
|
||||
private Long categoryId;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.rosercode.finance.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 规则标签关联实体类,对应数据库表 t_rule_tag,表示规则与标签的多对多关系。
|
||||
*
|
||||
* @date 2026-09-18 17:23:02
|
||||
*/
|
||||
@Data
|
||||
@TableName("t_rule_tag")
|
||||
public class RuleTag {
|
||||
|
||||
/** 关联ID,主键自增 */
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/** 规则ID */
|
||||
private Long ruleId;
|
||||
|
||||
/** 标签ID */
|
||||
private Long tagId;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package cn.rosercode.finance.importer;
|
||||
|
||||
import cn.rosercode.finance.vo.TagVO;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账单解析结果行,作为「先解析、后导入」流程中解析阶段的中间对象。
|
||||
@@ -54,6 +56,21 @@ public class ParsedBillRow {
|
||||
/** 是否与系统中已有记录重复(同一用户下交易号已存在) */
|
||||
private boolean duplicate;
|
||||
|
||||
/** 命中的规则名称列表,未命中为空 */
|
||||
private List<String> matchedRuleNames;
|
||||
|
||||
/** 命中规则建议的分类ID,未命中或规则未指定分类时为 null */
|
||||
private Long suggestedCategoryId;
|
||||
|
||||
/** 命中规则建议的分类名称 */
|
||||
private String suggestedCategoryName;
|
||||
|
||||
/** 命中规则建议的分类图标 */
|
||||
private String suggestedCategoryIcon;
|
||||
|
||||
/** 命中规则建议的标签列表 */
|
||||
private List<TagVO> suggestedTags;
|
||||
|
||||
/** 原始账单行的完整数据(结构化JSON,保留全部列) */
|
||||
private String rawData;
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.Rule;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 规则Mapper接口,继承MyBatis-Plus的BaseMapper,提供规则表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-18 17:23:05
|
||||
*/
|
||||
@Mapper
|
||||
public interface RuleMapper extends BaseMapper<Rule> {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package cn.rosercode.finance.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import cn.rosercode.finance.entity.RuleTag;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
/**
|
||||
* 规则标签关联Mapper接口,继承MyBatis-Plus的BaseMapper,提供规则标签关联表的基础CRUD操作。
|
||||
*
|
||||
* @date 2026-09-18 17:23:08
|
||||
*/
|
||||
@Mapper
|
||||
public interface RuleTagMapper extends BaseMapper<RuleTag> {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 规则匹配上下文,承载单条交易的匹配所需信息。
|
||||
*
|
||||
* @date 2026-09-18 17:25:25
|
||||
*/
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
public class RuleMatchContext {
|
||||
|
||||
/** 交易类型:INCOME-收入,EXPENSE-支出 */
|
||||
private String type;
|
||||
|
||||
/** 交易金额 */
|
||||
private BigDecimal amount;
|
||||
|
||||
/** 交易对方 */
|
||||
private String counterparty;
|
||||
|
||||
/** 商品名称 */
|
||||
private String product;
|
||||
|
||||
/** 备注 */
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 规则匹配结果,承载命中规则后建议应用的分类与标签。
|
||||
*
|
||||
* @date 2026-09-18 17:24:52
|
||||
*/
|
||||
@Data
|
||||
public class RuleMatchResult {
|
||||
|
||||
/** 命中规则建议的分类ID,未命中或规则未指定分类时为 null */
|
||||
private Long categoryId;
|
||||
|
||||
/** 命中规则建议的标签ID列表(并集去重) */
|
||||
private List<Long> tagIds = new ArrayList<>();
|
||||
|
||||
/** 命中的规则名称列表,按优先级排列 */
|
||||
private List<String> matchedRuleNames = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 是否命中任意规则。
|
||||
*
|
||||
* @return 命中规则名称非空时返回 true
|
||||
*/
|
||||
public boolean isMatched() {
|
||||
return !matchedRuleNames.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package cn.rosercode.finance.service;
|
||||
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 规则服务,负责自定义分类与标签规则的增删改查及匹配。
|
||||
*
|
||||
* @date 2026-09-18 17:23:26
|
||||
*/
|
||||
public interface RuleService {
|
||||
|
||||
/**
|
||||
* 查询当前用户的全部规则,按优先级和ID升序排列。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @return 规则视图对象列表
|
||||
*/
|
||||
List<RuleVO> list(Long userId);
|
||||
|
||||
/**
|
||||
* 创建规则。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param request 规则请求参数
|
||||
*/
|
||||
void create(Long userId, RuleRequest request);
|
||||
|
||||
/**
|
||||
* 更新规则。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param id 规则ID
|
||||
* @param request 规则请求参数
|
||||
*/
|
||||
void update(Long userId, Long id, RuleRequest request);
|
||||
|
||||
/**
|
||||
* 删除规则及其标签关联。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param id 规则ID
|
||||
*/
|
||||
void delete(Long userId, Long id);
|
||||
|
||||
/**
|
||||
* 启用或停用规则。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param id 规则ID
|
||||
* @param enabled 是否启用
|
||||
*/
|
||||
void toggleEnabled(Long userId, Long id, Boolean enabled);
|
||||
|
||||
/**
|
||||
* 批量按交易信息匹配规则。
|
||||
*
|
||||
* <p>一次性加载启用规则后逐条匹配,避免重复查询;分类取优先级最高的命中规则,
|
||||
* 标签取所有命中规则的并集。返回顺序与入参一致。</p>
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param contexts 匹配上下文列表
|
||||
* @return 与入参一一对应的匹配结果列表
|
||||
*/
|
||||
List<RuleMatchResult> matchBatch(Long userId, List<RuleMatchContext> contexts);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.BillImportRequest;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
@@ -15,8 +16,12 @@ import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
||||
import cn.rosercode.finance.service.BillImportService;
|
||||
import cn.rosercode.finance.service.RuleMatchContext;
|
||||
import cn.rosercode.finance.service.RuleMatchResult;
|
||||
import cn.rosercode.finance.service.RuleService;
|
||||
import cn.rosercode.finance.vo.BillImportResultVO;
|
||||
import cn.rosercode.finance.vo.BillParseResultVO;
|
||||
import cn.rosercode.finance.vo.TagVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -26,8 +31,10 @@ import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -47,12 +54,16 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final RuleService ruleService;
|
||||
private final VoConverter voConverter;
|
||||
|
||||
public BillImportServiceImpl(List<BillParser> parsers,
|
||||
TransactionMapper transactionMapper,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
TransactionTagMapper transactionTagMapper) {
|
||||
TransactionTagMapper transactionTagMapper,
|
||||
RuleService ruleService,
|
||||
VoConverter voConverter) {
|
||||
// 按账单类型注册解析器,便于扩展新账单来源
|
||||
for (BillParser parser : parsers) {
|
||||
parserMap.put(parser.getBillType(), parser);
|
||||
@@ -61,6 +72,8 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.ruleService = ruleService;
|
||||
this.voConverter = voConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,6 +104,9 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
}
|
||||
}
|
||||
|
||||
// 按规则引擎为每行生成分类与标签建议
|
||||
applyRuleSuggestions(userId, rows);
|
||||
|
||||
BillParseResultVO vo = new BillParseResultVO();
|
||||
vo.setBillType(billType.name());
|
||||
vo.setTotal(rows.size());
|
||||
@@ -131,12 +147,21 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
.distinct()
|
||||
.collect(Collectors.toList()));
|
||||
|
||||
// 批量运行规则匹配,规则建议优先于默认分类与标签
|
||||
List<RuleMatchContext> ruleContexts = items.stream()
|
||||
.map(item -> new RuleMatchContext(item.getType(), item.getAmount(),
|
||||
item.getCounterparty(), item.getProduct(),
|
||||
buildRemark(item.getCounterparty(), item.getProduct())))
|
||||
.collect(Collectors.toList());
|
||||
List<RuleMatchResult> ruleMatches = ruleService.matchBatch(userId, ruleContexts);
|
||||
|
||||
int imported = 0;
|
||||
int skipped = 0;
|
||||
int failed = 0;
|
||||
List<String> messages = new ArrayList<>();
|
||||
|
||||
for (BillImportRequest.Item item : items) {
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
BillImportRequest.Item item = items.get(i);
|
||||
String label = StringUtils.hasText(item.getExternalNo()) ? item.getExternalNo() : "未知交易号";
|
||||
try {
|
||||
// 基础字段校验
|
||||
@@ -163,10 +188,14 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
}
|
||||
|
||||
Category category = "INCOME".equals(item.getType()) ? incomeCategory : expenseCategory;
|
||||
// 未选择默认分类时置空,表示未分类;规则建议分类优先于默认分类
|
||||
Long categoryId = category != null ? category.getId() : null;
|
||||
if (ruleMatches.get(i).getCategoryId() != null) {
|
||||
categoryId = ruleMatches.get(i).getCategoryId();
|
||||
}
|
||||
Transaction transaction = new Transaction();
|
||||
transaction.setUserId(userId);
|
||||
// 未选择默认分类时置空,表示未分类
|
||||
transaction.setCategoryId(category != null ? category.getId() : null);
|
||||
transaction.setCategoryId(categoryId);
|
||||
transaction.setType(item.getType());
|
||||
transaction.setAmount(item.getAmount());
|
||||
transaction.setPaymentMethod(resolvePaymentMethod(request.getBillType()));
|
||||
@@ -176,8 +205,10 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
transaction.setIncludeInStats(item.getIncludeInStats() == null ? Boolean.TRUE : item.getIncludeInStats());
|
||||
transaction.setRawData(item.getRawData());
|
||||
transactionMapper.insert(transaction);
|
||||
// 关联默认标签
|
||||
for (Long tagId : tagIds) {
|
||||
// 关联标签:规则建议标签与默认标签合并去重
|
||||
Set<Long> itemTagIds = new LinkedHashSet<>(tagIds);
|
||||
itemTagIds.addAll(ruleMatches.get(i).getTagIds());
|
||||
for (Long tagId : itemTagIds) {
|
||||
TransactionTag relation = new TransactionTag();
|
||||
relation.setTransactionId(transaction.getId());
|
||||
relation.setTagId(tagId);
|
||||
@@ -227,6 +258,64 @@ public class BillImportServiceImpl implements BillImportService {
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为解析出的账单行生成规则匹配建议(分类与标签)。
|
||||
*
|
||||
* <p>批量运行规则匹配后,再批量查询建议分类与标签,避免 N+1 查询。</p>
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param rows 解析出的账单行列表
|
||||
*/
|
||||
private void applyRuleSuggestions(Long userId, List<ParsedBillRow> rows) {
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<RuleMatchContext> contexts = rows.stream()
|
||||
.map(row -> new RuleMatchContext(row.getType(), row.getAmount(),
|
||||
row.getCounterparty(), row.getProduct(),
|
||||
buildRemark(row.getCounterparty(), row.getProduct())))
|
||||
.collect(Collectors.toList());
|
||||
List<RuleMatchResult> matches = ruleService.matchBatch(userId, contexts);
|
||||
|
||||
// 批量查询建议分类
|
||||
Set<Long> categoryIds = matches.stream()
|
||||
.map(RuleMatchResult::getCategoryId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, Category> categoryMap = categoryIds.isEmpty() ? Map.of()
|
||||
: categoryMapper.selectBatchIds(categoryIds).stream()
|
||||
.collect(Collectors.toMap(Category::getId, c -> c));
|
||||
|
||||
// 批量查询建议标签
|
||||
Set<Long> tagIds = matches.stream()
|
||||
.flatMap(match -> match.getTagIds().stream())
|
||||
.collect(Collectors.toSet());
|
||||
Map<Long, Tag> tagMap = tagIds.isEmpty() ? Map.of()
|
||||
: tagMapper.selectBatchIds(tagIds).stream()
|
||||
.collect(Collectors.toMap(Tag::getId, t -> t));
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
ParsedBillRow row = rows.get(i);
|
||||
RuleMatchResult match = matches.get(i);
|
||||
row.setMatchedRuleNames(match.getMatchedRuleNames());
|
||||
Long categoryId = match.getCategoryId();
|
||||
row.setSuggestedCategoryId(categoryId);
|
||||
if (categoryId != null) {
|
||||
Category category = categoryMap.get(categoryId);
|
||||
if (category != null) {
|
||||
row.setSuggestedCategoryName(category.getName());
|
||||
row.setSuggestedCategoryIcon(category.getIcon());
|
||||
}
|
||||
}
|
||||
List<TagVO> suggestedTags = match.getTagIds().stream()
|
||||
.map(tagMap::get)
|
||||
.filter(Objects::nonNull)
|
||||
.map(voConverter::toTagVO)
|
||||
.collect(Collectors.toList());
|
||||
row.setSuggestedTags(suggestedTags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分类是否存在、归属当前用户且类型匹配
|
||||
*
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
package cn.rosercode.finance.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.RuleRequest;
|
||||
import cn.rosercode.finance.entity.Category;
|
||||
import cn.rosercode.finance.entity.Rule;
|
||||
import cn.rosercode.finance.entity.RuleTag;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.mapper.CategoryMapper;
|
||||
import cn.rosercode.finance.mapper.RuleMapper;
|
||||
import cn.rosercode.finance.mapper.RuleTagMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.service.RuleMatchContext;
|
||||
import cn.rosercode.finance.service.RuleMatchResult;
|
||||
import cn.rosercode.finance.service.RuleService;
|
||||
import cn.rosercode.finance.vo.RuleVO;
|
||||
import cn.rosercode.finance.vo.TagVO;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 规则服务实现类,处理自定义分类与标签规则的增删改查与匹配业务逻辑。
|
||||
*
|
||||
* <p>匹配时分类取优先级最高的命中规则,标签取所有命中规则的并集;
|
||||
* 关键词在备注、交易对方、商品名称拼接文本中做包含匹配(忽略大小写)。</p>
|
||||
*
|
||||
* @date 2026-09-18 17:24:59
|
||||
*/
|
||||
@Service
|
||||
public class RuleServiceImpl implements RuleService {
|
||||
|
||||
private final RuleMapper ruleMapper;
|
||||
private final RuleTagMapper ruleTagMapper;
|
||||
private final CategoryMapper categoryMapper;
|
||||
private final TagMapper tagMapper;
|
||||
private final VoConverter voConverter;
|
||||
|
||||
public RuleServiceImpl(RuleMapper ruleMapper,
|
||||
RuleTagMapper ruleTagMapper,
|
||||
CategoryMapper categoryMapper,
|
||||
TagMapper tagMapper,
|
||||
VoConverter voConverter) {
|
||||
this.ruleMapper = ruleMapper;
|
||||
this.ruleTagMapper = ruleTagMapper;
|
||||
this.categoryMapper = categoryMapper;
|
||||
this.tagMapper = tagMapper;
|
||||
this.voConverter = voConverter;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 批量查询关联分类与标签,避免 N+1 查询。
|
||||
*/
|
||||
@Override
|
||||
public List<RuleVO> list(Long userId) {
|
||||
List<Rule> rules = ruleMapper.selectList(new LambdaQueryWrapper<Rule>()
|
||||
.eq(Rule::getUserId, userId)
|
||||
.orderByAsc(Rule::getSortOrder)
|
||||
.orderByAsc(Rule::getId));
|
||||
if (rules.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
// 批量查询关联分类
|
||||
List<Long> categoryIds = rules.stream()
|
||||
.map(Rule::getCategoryId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, Category> categoryMap = Map.of();
|
||||
if (!categoryIds.isEmpty()) {
|
||||
categoryMap = categoryMapper.selectBatchIds(categoryIds).stream()
|
||||
.collect(Collectors.toMap(Category::getId, c -> c));
|
||||
}
|
||||
|
||||
Map<Long, Category> finalCategoryMap = categoryMap;
|
||||
List<RuleVO> voList = rules.stream()
|
||||
.map(rule -> voConverter.toRuleVO(rule,
|
||||
rule.getCategoryId() == null ? null : finalCategoryMap.get(rule.getCategoryId())))
|
||||
.collect(Collectors.toList());
|
||||
// 批量填充规则标签
|
||||
fillTags(voList);
|
||||
return voList;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 校验目标分类与标签归属,插入规则后同步标签关联。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void create(Long userId, RuleRequest request) {
|
||||
validate(userId, request);
|
||||
Rule rule = new Rule();
|
||||
rule.setUserId(userId);
|
||||
applyRequest(rule, request);
|
||||
ruleMapper.insert(rule);
|
||||
saveRuleTags(userId, rule.getId(), request.getTagIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 校验规则归属后更新,并全量替换标签关联。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void update(Long userId, Long id, RuleRequest request) {
|
||||
Rule rule = ruleMapper.selectById(id);
|
||||
if (rule == null || !rule.getUserId().equals(userId)) {
|
||||
throw new BusinessException("规则不存在");
|
||||
}
|
||||
validate(userId, request);
|
||||
applyRequest(rule, request);
|
||||
ruleMapper.updateById(rule);
|
||||
saveRuleTags(userId, id, request.getTagIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 删除规则前先清理其标签关联,避免残留脏数据。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void delete(Long userId, Long id) {
|
||||
Rule rule = ruleMapper.selectById(id);
|
||||
if (rule == null || !rule.getUserId().equals(userId)) {
|
||||
throw new BusinessException("规则不存在");
|
||||
}
|
||||
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
|
||||
.eq(RuleTag::getRuleId, id));
|
||||
ruleMapper.deleteById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
public void toggleEnabled(Long userId, Long id, Boolean enabled) {
|
||||
Rule rule = ruleMapper.selectById(id);
|
||||
if (rule == null || !rule.getUserId().equals(userId)) {
|
||||
throw new BusinessException("规则不存在");
|
||||
}
|
||||
rule.setEnabled(enabled == null ? Boolean.TRUE : enabled);
|
||||
ruleMapper.updateById(rule);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 一次性加载启用规则及其标签关联,再对每条上下文按优先级逐条判断,命中即累计。
|
||||
*/
|
||||
@Override
|
||||
public List<RuleMatchResult> matchBatch(Long userId, List<RuleMatchContext> contexts) {
|
||||
List<RuleMatchResult> results = new ArrayList<>();
|
||||
if (contexts == null || contexts.isEmpty()) {
|
||||
return results;
|
||||
}
|
||||
for (int i = 0; i < contexts.size(); i++) {
|
||||
results.add(new RuleMatchResult());
|
||||
}
|
||||
|
||||
List<Rule> rules = ruleMapper.selectList(new LambdaQueryWrapper<Rule>()
|
||||
.eq(Rule::getUserId, userId)
|
||||
.eq(Rule::getEnabled, true)
|
||||
.orderByAsc(Rule::getSortOrder)
|
||||
.orderByAsc(Rule::getId));
|
||||
if (rules.isEmpty()) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 预加载规则标签关联,按规则分组
|
||||
List<Long> ruleIds = rules.stream().map(Rule::getId).collect(Collectors.toList());
|
||||
Map<Long, List<Long>> tagIdsByRule = new HashMap<>();
|
||||
List<RuleTag> relations = ruleTagMapper.selectList(new LambdaQueryWrapper<RuleTag>()
|
||||
.in(RuleTag::getRuleId, ruleIds));
|
||||
for (RuleTag relation : relations) {
|
||||
tagIdsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>())
|
||||
.add(relation.getTagId());
|
||||
}
|
||||
|
||||
// 预加载规则引用的分类,用于校验分类类型与交易类型一致
|
||||
List<Long> categoryIds = rules.stream()
|
||||
.map(Rule::getCategoryId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<Long, Category> categoryMap = categoryIds.isEmpty() ? Map.of()
|
||||
: categoryMapper.selectBatchIds(categoryIds).stream()
|
||||
.collect(Collectors.toMap(Category::getId, c -> c));
|
||||
|
||||
for (int i = 0; i < contexts.size(); i++) {
|
||||
RuleMatchContext context = contexts.get(i);
|
||||
RuleMatchResult result = results.get(i);
|
||||
// 拼接匹配文本:备注 + 交易对方 + 商品名称
|
||||
String text = (safe(context.getCounterparty()) + " " + safe(context.getProduct())
|
||||
+ " " + safe(context.getRemark())).toLowerCase();
|
||||
Set<Long> matchedTagIds = new LinkedHashSet<>();
|
||||
|
||||
for (Rule rule : rules) {
|
||||
// 收支类型限制
|
||||
if (StringUtils.hasText(rule.getType()) && !rule.getType().equals(context.getType())) {
|
||||
continue;
|
||||
}
|
||||
// 关键词包含匹配
|
||||
if (StringUtils.hasText(rule.getKeyword())
|
||||
&& !text.contains(rule.getKeyword().toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
// 金额区间
|
||||
if (rule.getMinAmount() != null
|
||||
&& (context.getAmount() == null
|
||||
|| context.getAmount().compareTo(rule.getMinAmount()) < 0)) {
|
||||
continue;
|
||||
}
|
||||
if (rule.getMaxAmount() != null
|
||||
&& (context.getAmount() == null
|
||||
|| context.getAmount().compareTo(rule.getMaxAmount()) > 0)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.getMatchedRuleNames().add(rule.getName());
|
||||
// 分类取首个命中且类型匹配的规则
|
||||
if (result.getCategoryId() == null && rule.getCategoryId() != null) {
|
||||
Category category = categoryMap.get(rule.getCategoryId());
|
||||
if (category != null
|
||||
&& (context.getType() == null || context.getType().equals(category.getType()))) {
|
||||
result.setCategoryId(rule.getCategoryId());
|
||||
}
|
||||
}
|
||||
matchedTagIds.addAll(tagIdsByRule.getOrDefault(rule.getId(), List.of()));
|
||||
}
|
||||
result.setTagIds(new ArrayList<>(matchedTagIds));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将请求字段写入规则实体,统一处理默认值。
|
||||
*
|
||||
* @param rule 目标规则实体
|
||||
* @param request 规则请求参数
|
||||
*/
|
||||
private void applyRequest(Rule rule, RuleRequest request) {
|
||||
rule.setName(request.getName());
|
||||
rule.setEnabled(request.getEnabled() == null ? Boolean.TRUE : request.getEnabled());
|
||||
rule.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0);
|
||||
rule.setType(StringUtils.hasText(request.getType()) ? request.getType() : null);
|
||||
rule.setKeyword(StringUtils.hasText(request.getKeyword()) ? request.getKeyword().trim() : null);
|
||||
rule.setMinAmount(request.getMinAmount());
|
||||
rule.setMaxAmount(request.getMaxAmount());
|
||||
rule.setCategoryId(request.getCategoryId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求中的分类、标签归属与金额区间合法性。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param request 规则请求参数
|
||||
*/
|
||||
private void validate(Long userId, RuleRequest request) {
|
||||
// 收支类型白名单
|
||||
if (StringUtils.hasText(request.getType())
|
||||
&& !"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
|
||||
throw new BusinessException("收支类型无效");
|
||||
}
|
||||
// 金额区间
|
||||
if (request.getMinAmount() != null && request.getMaxAmount() != null
|
||||
&& request.getMinAmount().compareTo(request.getMaxAmount()) > 0) {
|
||||
throw new BusinessException("金额下限不能大于上限");
|
||||
}
|
||||
// 目标分类归属与类型一致性
|
||||
if (request.getCategoryId() != null) {
|
||||
Category category = categoryMapper.selectById(request.getCategoryId());
|
||||
if (category == null || !category.getUserId().equals(userId)) {
|
||||
throw new BusinessException("分类不存在");
|
||||
}
|
||||
if (StringUtils.hasText(request.getType()) && !request.getType().equals(category.getType())) {
|
||||
throw new BusinessException("规则分类与收支类型不匹配");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 全量替换规则的标签关联。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param ruleId 规则ID
|
||||
* @param tagIds 目标标签ID列表,为空表示清空
|
||||
*/
|
||||
private void saveRuleTags(Long userId, Long ruleId, List<Long> tagIds) {
|
||||
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
|
||||
.eq(RuleTag::getRuleId, ruleId));
|
||||
for (Long tagId : validTagIds(userId, tagIds)) {
|
||||
RuleTag relation = new RuleTag();
|
||||
relation.setRuleId(ruleId);
|
||||
relation.setTagId(tagId);
|
||||
ruleTagMapper.insert(relation);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤出属于当前用户的标签ID。
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param tagIds 待校验的标签ID列表
|
||||
* @return 有效的标签ID列表
|
||||
*/
|
||||
private List<Long> validTagIds(Long userId, List<Long> tagIds) {
|
||||
if (tagIds == null || tagIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return tagMapper.selectBatchIds(tagIds).stream()
|
||||
.filter(tag -> tag.getUserId().equals(userId))
|
||||
.map(Tag::getId)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量填充规则视图对象的标签列表。
|
||||
*
|
||||
* @param voList 规则视图对象列表
|
||||
*/
|
||||
private void fillTags(List<RuleVO> voList) {
|
||||
List<Long> ruleIds = voList.stream().map(RuleVO::getId).collect(Collectors.toList());
|
||||
List<RuleTag> relations = ruleTagMapper.selectList(new LambdaQueryWrapper<RuleTag>()
|
||||
.in(RuleTag::getRuleId, ruleIds));
|
||||
if (relations.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Long> tagIds = relations.stream().map(RuleTag::getTagId).distinct().collect(Collectors.toList());
|
||||
Map<Long, Tag> tagMap = tagMapper.selectBatchIds(tagIds).stream()
|
||||
.collect(Collectors.toMap(Tag::getId, tag -> tag));
|
||||
Map<Long, List<TagVO>> tagsByRule = new HashMap<>();
|
||||
for (RuleTag relation : relations) {
|
||||
Tag tag = tagMap.get(relation.getTagId());
|
||||
if (tag == null) {
|
||||
continue;
|
||||
}
|
||||
tagsByRule.computeIfAbsent(relation.getRuleId(), key -> new ArrayList<>())
|
||||
.add(voConverter.toTagVO(tag));
|
||||
}
|
||||
for (RuleVO vo : voList) {
|
||||
vo.setTags(tagsByRule.getOrDefault(vo.getId(), List.of()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 空值安全的字符串处理。
|
||||
*
|
||||
* @param value 原字符串
|
||||
* @return 非空字符串,null 时返回空串
|
||||
*/
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import cn.rosercode.finance.common.BusinessException;
|
||||
import cn.rosercode.finance.converter.VoConverter;
|
||||
import cn.rosercode.finance.dto.TagRequest;
|
||||
import cn.rosercode.finance.entity.RuleTag;
|
||||
import cn.rosercode.finance.entity.Tag;
|
||||
import cn.rosercode.finance.entity.TransactionTag;
|
||||
import cn.rosercode.finance.mapper.RuleTagMapper;
|
||||
import cn.rosercode.finance.mapper.TagMapper;
|
||||
import cn.rosercode.finance.mapper.TransactionTagMapper;
|
||||
import cn.rosercode.finance.service.TagService;
|
||||
@@ -26,11 +28,14 @@ public class TagServiceImpl implements TagService {
|
||||
|
||||
private final TagMapper tagMapper;
|
||||
private final TransactionTagMapper transactionTagMapper;
|
||||
private final RuleTagMapper ruleTagMapper;
|
||||
private final VoConverter voConverter;
|
||||
|
||||
public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper, VoConverter voConverter) {
|
||||
public TagServiceImpl(TagMapper tagMapper, TransactionTagMapper transactionTagMapper,
|
||||
RuleTagMapper ruleTagMapper, VoConverter voConverter) {
|
||||
this.tagMapper = tagMapper;
|
||||
this.transactionTagMapper = transactionTagMapper;
|
||||
this.ruleTagMapper = ruleTagMapper;
|
||||
this.voConverter = voConverter;
|
||||
}
|
||||
|
||||
@@ -101,7 +106,7 @@ public class TagServiceImpl implements TagService {
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* 删除标签前先清理其与交易记录的关联,避免残留脏数据。
|
||||
* 删除标签前先清理其与交易记录、规则的关联,避免残留脏数据。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -112,6 +117,8 @@ public class TagServiceImpl implements TagService {
|
||||
}
|
||||
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
|
||||
.eq(TransactionTag::getTagId, id));
|
||||
ruleTagMapper.delete(new LambdaQueryWrapper<RuleTag>()
|
||||
.eq(RuleTag::getTagId, id));
|
||||
tagMapper.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package cn.rosercode.finance.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 规则视图对象,用于向前端返回自定义分类与标签规则信息。
|
||||
*
|
||||
* <p>目标分类名称/图标取自关联分类,标签列表由调用方另行填充。</p>
|
||||
*
|
||||
* @date 2026-09-18 17:23:15
|
||||
*/
|
||||
@Data
|
||||
public class RuleVO {
|
||||
|
||||
/** 规则ID */
|
||||
private Long id;
|
||||
|
||||
/** 规则名称 */
|
||||
private String name;
|
||||
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 优先级,数值越小越优先 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/** 限定收支类型:INCOME/EXPENSE,为空表示不限 */
|
||||
private String type;
|
||||
|
||||
/** 关键词 */
|
||||
private String keyword;
|
||||
|
||||
/** 金额下限(含) */
|
||||
private BigDecimal minAmount;
|
||||
|
||||
/** 金额上限(含) */
|
||||
private BigDecimal maxAmount;
|
||||
|
||||
/** 目标分类ID */
|
||||
private Long categoryId;
|
||||
|
||||
/** 目标分类名称 */
|
||||
private String categoryName;
|
||||
|
||||
/** 目标分类图标 */
|
||||
private String categoryIcon;
|
||||
|
||||
/** 目标标签列表 */
|
||||
private List<TagVO> tags;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/** 更新时间 */
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -166,3 +166,30 @@ CREATE TABLE IF NOT EXISTS t_login_log (
|
||||
INDEX idx_login_log_created (created_at) COMMENT '创建时间索引',
|
||||
INDEX idx_login_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
||||
);
|
||||
|
||||
-- 自定义分类与标签规则表:按关键词、收支类型、金额区间自动归类与打标签
|
||||
CREATE TABLE IF NOT EXISTS t_rule (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '规则ID,主键自增',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
name VARCHAR(50) NOT NULL COMMENT '规则名称',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-停用',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '优先级,数值越小越优先',
|
||||
type VARCHAR(10) COMMENT '限定收支类型:INCOME-收入,EXPENSE-支出,为空表示不限',
|
||||
keyword VARCHAR(100) COMMENT '关键词,匹配备注/交易对方/商品名称(包含匹配,忽略大小写)',
|
||||
min_amount DECIMAL(12, 2) COMMENT '金额下限(含),可为空',
|
||||
max_amount DECIMAL(12, 2) COMMENT '金额上限(含),可为空',
|
||||
category_id BIGINT COMMENT '命中的目标分类ID,可为空',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
INDEX idx_rule_user_id (user_id) COMMENT '用户ID索引'
|
||||
);
|
||||
|
||||
-- 规则标签关联表:规则与标签的多对多关系
|
||||
CREATE TABLE IF NOT EXISTS t_rule_tag (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
|
||||
rule_id BIGINT NOT NULL COMMENT '规则ID',
|
||||
tag_id BIGINT NOT NULL COMMENT '标签ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE INDEX uk_rule_tag (rule_id, tag_id) COMMENT '规则+标签唯一索引',
|
||||
INDEX idx_rule_tag_tag_id (tag_id) COMMENT '标签ID索引'
|
||||
);
|
||||
|
||||
@@ -204,3 +204,36 @@ CREATE TABLE IF NOT EXISTS t_login_log (
|
||||
);;
|
||||
CREATE INDEX IF NOT EXISTS idx_login_log_created ON t_login_log (created_at);;
|
||||
CREATE INDEX IF NOT EXISTS idx_login_log_user_created ON t_login_log (user_id, created_at);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_rule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
type VARCHAR(10),
|
||||
keyword VARCHAR(100),
|
||||
min_amount NUMERIC(12, 2),
|
||||
max_amount NUMERIC(12, 2),
|
||||
category_id INTEGER,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
|
||||
);;
|
||||
CREATE INDEX IF NOT EXISTS idx_rule_user_id ON t_rule (user_id);;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS t_rule_tag (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
|
||||
);;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_rule_tag ON t_rule_tag (rule_id, tag_id);;
|
||||
CREATE INDEX IF NOT EXISTS idx_rule_tag_tag_id ON t_rule_tag (tag_id);;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_rule_updated_at
|
||||
AFTER UPDATE ON t_rule
|
||||
FOR EACH ROW
|
||||
WHEN NEW.updated_at = OLD.updated_at
|
||||
BEGIN
|
||||
UPDATE t_rule SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
|
||||
END;;
|
||||
|
||||
Reference in New Issue
Block a user