feat: 支持自定义分类与标签规则并优化 AI 查询交互

- 新增 t_rule、t_rule_tag 表(MySQL/SQLite 双脚本)与规则引擎,支持关键词(备注/交易对方/商品名称)、收支类型、金额区间组合匹配
- 新增 /api/rule 增删改查与启用停用接口,侧边栏新增「规则管理」页,支持排序与启停
- 账单导入解析阶段批量匹配,返回命中规则与建议分类/标签,预览表格新增「匹配规则/建议」列
- 确认导入时按行重新匹配:规则建议分类优先于面板默认分类,规则标签与默认标签合并去重
- 删除标签时同步清理规则标签关联
- AI 自然语言查询解析后直接应用筛选并搜索,移除二次确认弹窗,成功提示展示识别意图
- 修正登录日志筛选 success 参数类型为 'true'/'false'
This commit is contained in:
2026-09-18 17:42:59 +08:00
parent e82e0a4ef9
commit c69e76ed4c
26 changed files with 1622 additions and 78 deletions
@@ -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;
}
+27
View File
@@ -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索引'
);
+33
View File
@@ -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;;