feat: 账单关系(父子/同级)与关系图,AI 生成关系名称备注

- 新增 t_transaction_group、t_transaction_group_item 两张表(MySQL 与 SQLite 双脚本)
- 后端新增关系组实体/枚举/Mapper/DTO/VO/Service/Controller:按账单查询、创建、更新、删除;校验同类型唯一、父子角色与成员归属;删除账单时级联清理(组不足或父子组失去父账单则整组移除);分页填充 relationGroupCount
- 前端收支记录新增「关系」列与详情弹窗(基本信息、标签、账单关系、来源图片、原始数据)
- 新增关系图(自定义 SVG 渲染):圆形节点、按收支红/绿着色、父子/同级连线带箭头与「关联」标签、当前账单高亮,成员较多时自动换行
- 关系编辑弹窗支持新建/编辑/删除、分页选择关联账单、AI 生成名称与备注、关系名称历史下拉(按类型过滤、聚焦即显示)、关系类型偏好记忆(localStorage)
- 支持勾选账单批量建立关系,并新增批量选择模式(默认不显示多选列)
- 新增 AI 关系能力:AiCallType.RELATION、AiRelationService 与提示词模板 ai-relation-system(含借贷场景),接口 POST /api/ai/relation
- 新增 /api/transaction/group/* 接口与关系名称历史接口
- 补充中英日三语文案
This commit is contained in:
2026-09-21 22:23:47 +08:00
parent 06fd80d55f
commit f390246528
35 changed files with 2947 additions and 111 deletions
@@ -28,6 +28,7 @@ import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.dto.AiDecorateRequest;
import cn.rosercode.finance.dto.AiQueryRequest;
import cn.rosercode.finance.dto.AiRecordRequest;
import cn.rosercode.finance.dto.AiRelationRequest;
import cn.rosercode.finance.dto.AiRuleApplyRequest;
import cn.rosercode.finance.dto.AiRuleSuggestRequest;
import cn.rosercode.finance.dto.AiSuggestOneRequest;
@@ -44,6 +45,7 @@ import cn.rosercode.finance.service.AiImageRecordStreamService;
import cn.rosercode.finance.service.AiImageStorage;
import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiRelationService;
import cn.rosercode.finance.service.AiRuleService;
import cn.rosercode.finance.service.AiStatisticsService;
import cn.rosercode.finance.service.AiSuggestionService;
@@ -57,6 +59,7 @@ import cn.rosercode.finance.vo.AiConfigVO;
import cn.rosercode.finance.vo.AiQueryResultVO;
import cn.rosercode.finance.vo.AiRecordImageResultVO;
import cn.rosercode.finance.vo.AiRecordResultVO;
import cn.rosercode.finance.vo.AiRelationResultVO;
import cn.rosercode.finance.vo.AiRuleSuggestResultVO;
import cn.rosercode.finance.vo.AiStatisticsResultVO;
import cn.rosercode.finance.vo.AiSuggestionVO;
@@ -91,6 +94,7 @@ public class AiController {
private final AiStatisticsService aiStatisticsService;
private final AiImageRecordService aiImageRecordService;
private final AiImageRecordStreamService aiImageRecordStreamService;
private final AiRelationService aiRelationService;
private final AiImageMapper aiImageMapper;
private final AiImageStorage aiImageStorage;
@@ -102,6 +106,7 @@ public class AiController {
AiRuleService aiRuleService, AiStatisticsService aiStatisticsService,
AiImageRecordService aiImageRecordService,
AiImageRecordStreamService aiImageRecordStreamService,
AiRelationService aiRelationService,
AiImageMapper aiImageMapper, AiImageStorage aiImageStorage) {
this.aiConfigService = aiConfigService;
this.aiSuggestionService = aiSuggestionService;
@@ -116,6 +121,7 @@ public class AiController {
this.aiStatisticsService = aiStatisticsService;
this.aiImageRecordService = aiImageRecordService;
this.aiImageRecordStreamService = aiImageRecordStreamService;
this.aiRelationService = aiRelationService;
this.aiImageMapper = aiImageMapper;
this.aiImageStorage = aiImageStorage;
}
@@ -360,6 +366,19 @@ public class AiController {
return Result.success(aiChannelService.parse(userId, channelRequest.getText()));
}
/**
* 根据关系类型与成员账单特征生成关系名称与备注
*
* @param relationRequest 生成请求,携带关系类型与成员账单特征
* @return 关系名称与备注建议
*/
@PostMapping("/relation")
@Operation(summary = "AI 生成账单关系", description = "根据关系类型与成员账单特征生成关系名称与备注")
public Result<AiRelationResultVO> generateRelation(@RequestBody @Valid AiRelationRequest relationRequest) {
Long userId = UserContext.getUserId();
return Result.success(aiRelationService.parse(userId, relationRequest));
}
/**
* 根据分类名称推荐 Emoji 图标
*
@@ -0,0 +1,108 @@
package cn.rosercode.finance.controller;
import java.util.List;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.TransactionGroupRequest;
import cn.rosercode.finance.service.TransactionGroupService;
import cn.rosercode.finance.util.UserContext;
import cn.rosercode.finance.vo.TransactionGroupVO;
/**
* 账单关系组控制器
*
* <p>提供账单之间父子/同级关系的查询、创建、更新与删除接口</p>
*
* @date 2026-09-21 20:32:41
*/
@RestController
@RequestMapping("/api/transaction/group")
@Tag(name = "账单关系管理", description = "账单之间父子/同级关系的查询与维护接口")
public class TransactionGroupController {
private final TransactionGroupService transactionGroupService;
public TransactionGroupController(TransactionGroupService transactionGroupService) {
this.transactionGroupService = transactionGroupService;
}
/**
* 查询某笔账单所属的全部关系组
*
* @param transactionId 交易记录ID
* @return 关系组列表
*/
@GetMapping("/by-transaction/{transactionId}")
@Operation(summary = "查询账单关系", description = "返回指定账单所属的父子/同级关系组及成员账单")
public Result<List<TransactionGroupVO>> listByTransaction(
@Parameter(description = "交易记录ID") @PathVariable Long transactionId) {
return Result.success(transactionGroupService.listByTransaction(UserContext.getUserId(), transactionId));
}
/**
* 查询当前用户使用过的关系名称历史
*
* @param type 关系类型过滤,可选
* @return 关系名称列表
*/
@GetMapping("/names")
@Operation(summary = "查询关系名称历史", description = "返回当前用户使用过的账单关系名称,按最近使用排序,可按关系类型过滤")
public Result<List<String>> names(
@Parameter(description = "关系类型过滤,可选") @RequestParam(value = "type", required = false) String type) {
return Result.success(transactionGroupService.listNames(UserContext.getUserId(), type));
}
/**
* 创建关系组
*
* @param request 关系组请求参数
* @return 新建关系组ID
*/
@PostMapping
@Operation(summary = "创建账单关系", description = "将多笔账单按父子或同级关系聚合为一个关系组")
public Result<Long> create(@RequestBody @Valid TransactionGroupRequest request) {
return Result.success(transactionGroupService.create(UserContext.getUserId(), request));
}
/**
* 更新关系组
*
* @param id 关系组ID
* @param request 关系组请求参数
* @return 操作结果
*/
@PutMapping("/{id}")
@Operation(summary = "更新账单关系", description = "更新关系组的名称、备注、成员与角色")
public Result<Void> update(@Parameter(description = "关系组ID") @PathVariable Long id,
@RequestBody @Valid TransactionGroupRequest request) {
transactionGroupService.update(UserContext.getUserId(), id, request);
return Result.success();
}
/**
* 删除关系组
*
* @param id 关系组ID
* @return 操作结果
*/
@DeleteMapping("/{id}")
@Operation(summary = "删除账单关系", description = "删除关系组,仅解除账单关联,不删除账单")
public Result<Void> delete(@Parameter(description = "关系组ID") @PathVariable Long id) {
transactionGroupService.delete(UserContext.getUserId(), id);
return Result.success();
}
}
@@ -18,6 +18,8 @@ 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.TransactionGroup;
import cn.rosercode.finance.entity.TransactionGroupItem;
import cn.rosercode.finance.entity.User;
import cn.rosercode.finance.vo.AiUsageVO;
import cn.rosercode.finance.vo.CategoryVO;
@@ -30,6 +32,8 @@ 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.TransactionGroupItemVO;
import cn.rosercode.finance.vo.TransactionGroupVO;
import cn.rosercode.finance.vo.TransactionVO;
import cn.rosercode.finance.vo.UserVO;
@@ -129,6 +133,27 @@ public interface VoConverter {
@Mapping(target = "tags", ignore = true)
TransactionVO toTransactionVO(Transaction transaction, Category category);
/**
* 账单关系组实体转视图对象。
*
* <p>成员列表由调用方另行填充。</p>
*
* @param group 关系组实体
* @return 关系组视图对象
*/
@Mapping(target = "items", ignore = true)
TransactionGroupVO toTransactionGroupVO(TransactionGroup group);
/**
* 账单关系组成员实体转视图对象。
*
* <p>仅映射成员角色与排序,关联账单的展示快照由调用方另行填充。</p>
*
* @param item 关系组成员实体
* @return 关系组成员视图对象
*/
TransactionGroupItemVO toTransactionGroupItemVO(TransactionGroupItem item);
/**
* 通知渠道实体转视图对象。
*
@@ -0,0 +1,26 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* AI 生成账单关系请求的成员账单特征 DTO。
*
* <p>仅承载收支类型、交易渠道、分类名称与备注,禁止携带金额、交易号、原始数据等敏感字段。</p>
*
* @date 2026-09-21 21:08:21
*/
@Data
public class AiRelationMemberRequest {
/** 收支类型:INCOME-收入,EXPENSE-支出 */
private String type;
/** 交易渠道 */
private String channel;
/** 分类名称 */
private String categoryName;
/** 备注 */
private String remark;
}
@@ -0,0 +1,26 @@
package cn.rosercode.finance.dto;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
/**
* AI 生成账单关系名称与备注请求 DTO,携带关系类型与成员账单特征。
*
* @date 2026-09-21 21:08:21
*/
@Data
public class AiRelationRequest {
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
@NotBlank(message = "关系类型不能为空")
private String type;
/** 成员账单特征列表,至少两条 */
@NotEmpty(message = "请先选择关联账单")
@Valid
private List<AiRelationMemberRequest> members;
}
@@ -0,0 +1,23 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 账单关系组成员请求DTO,用于指定关系组中的一笔账单及其角色。
*
* @date 2026-09-21 20:32:41
*/
@Data
public class TransactionGroupItemRequest {
/** 交易记录ID,不能为空 */
@NotNull(message = "关联账单不能为空")
private Long transactionId;
/** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */
private String role;
/** 排序序号,用于同级链路顺序 */
private Integer sortOrder;
}
@@ -0,0 +1,35 @@
package cn.rosercode.finance.dto;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 账单关系组请求DTO,用于创建或更新一个关系组及其成员。
*
* @date 2026-09-21 20:32:41
*/
@Data
public class TransactionGroupRequest {
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
@NotBlank(message = "关系类型不能为空")
private String type;
/** 关系组名称,可选 */
@Size(max = 100, message = "关系组名称不能超过100个字符")
private String name;
/** 备注,可选 */
@Size(max = 255, message = "备注不能超过255个字符")
private String remark;
/** 关系组成员列表,至少两条 */
@NotEmpty(message = "关系组至少包含两条账单")
@Valid
private List<TransactionGroupItemRequest> items;
}
@@ -0,0 +1,40 @@
package cn.rosercode.finance.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 账单关系组实体类,对应数据库表 t_transaction_group,将多笔账单按父子或同级关系聚合。
*
* @date 2026-09-21 20:32:41
*/
@Data
@TableName("t_transaction_group")
public class TransactionGroup {
/** 关系组ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 所属用户ID */
private Long userId;
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
private String type;
/** 关系组名称 */
private String name;
/** 备注 */
private String remark;
}
@@ -0,0 +1,37 @@
package cn.rosercode.finance.entity;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
/**
* 账单关系组成员实体类,对应数据库表 t_transaction_group_item,维护关系组与账单的关联及组内角色。
*
* @date 2026-09-21 20:32:41
*/
@Data
@TableName("t_transaction_group_item")
public class TransactionGroupItem {
/** 关联ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 创建时间 */
private LocalDateTime createdAt;
/** 关系组ID */
private Long groupId;
/** 交易记录ID */
private Long transactionId;
/** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */
private String role;
/** 排序序号,用于同级链路顺序与展示顺序 */
private Integer sortOrder;
}
@@ -38,5 +38,8 @@ public enum AiCallType {
RULE,
/** 自然语言统计查询解析 */
STATISTICS
STATISTICS,
/** 生成账单关系名称与备注 */
RELATION
}
@@ -0,0 +1,57 @@
package cn.rosercode.finance.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 账单关系组内角色枚举,统一维护成员在关系组中的角色编码与中文名称。
*
* @date 2026-09-21 20:32:41
*/
public enum TransactionGroupRole {
/** 父账单:父子关系中作为资金池的一方(如充值) */
PARENT("父账单"),
/** 子账单:父子关系中从资金池消费的一方 */
CHILD("子账单"),
/** 成员:同级关系中地位平等的账单 */
MEMBER("成员");
/** 角色中文名称 */
private final String label;
TransactionGroupRole(String label) {
this.label = label;
}
/**
* 获取角色中文名称。
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
/**
* 获取全部合法角色编码列表,顺序与枚举声明一致。
*
* @return 角色编码列表
*/
public static List<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
}
/**
* 判断是否为合法角色编码。
*
* @param code 角色编码
* @return 合法返回 true
*/
public static boolean isValid(String code) {
return code != null && Arrays.stream(values()).anyMatch(role -> role.name().equals(code));
}
}
@@ -0,0 +1,72 @@
package cn.rosercode.finance.enums;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 账单关系类型枚举,统一维护关系组的类型编码与中文名称。
*
* @date 2026-09-21 20:32:41
*/
public enum TransactionRelationType {
/** 父子关系:一个父账单(如充值)对应多个子账单(如消费) */
PARENT_CHILD("父子关系"),
/** 同级关系:多笔账单地位平等(如支付链路上的多条记录) */
SIBLING("同级关系");
/** 类型中文名称 */
private final String label;
TransactionRelationType(String label) {
this.label = label;
}
/**
* 获取类型中文名称。
*
* @return 中文名称
*/
public String getLabel() {
return label;
}
/**
* 获取全部合法类型编码列表,顺序与枚举声明一致。
*
* @return 类型编码列表
*/
public static List<String> codes() {
return Arrays.stream(values()).map(Enum::name).collect(Collectors.toList());
}
/**
* 判断是否为合法类型编码。
*
* @param code 类型编码
* @return 合法返回 true
*/
public static boolean isValid(String code) {
return code != null && Arrays.stream(values()).anyMatch(type -> type.name().equals(code));
}
/**
* 将类型编码转换为中文名称,未知编码原样返回。
*
* @param code 类型编码
* @return 中文名称
*/
public static String labelOf(String code) {
if (code == null) {
return "";
}
for (TransactionRelationType type : values()) {
if (type.name().equals(code)) {
return type.label;
}
}
return code;
}
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.finance.entity.TransactionGroupItem;
/**
* 账单关系组成员Mapper接口,继承MyBatis-Plus的BaseMapper,提供关系组成员表的基础CRUD操作。
*
* @date 2026-09-21 20:32:41
*/
@Mapper
public interface TransactionGroupItemMapper extends BaseMapper<TransactionGroupItem> {
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import cn.rosercode.finance.entity.TransactionGroup;
/**
* 账单关系组Mapper接口,继承MyBatis-Plus的BaseMapper,提供关系组的基础CRUD操作。
*
* @date 2026-09-21 20:32:41
*/
@Mapper
public interface TransactionGroupMapper extends BaseMapper<TransactionGroup> {
}
@@ -152,6 +152,15 @@ public class PromptTemplateService {
return render("ai-channel-system", null);
}
/**
* 渲染生成账单关系名称与备注的系统提示词(模板 {@code ai-relation-system})。
*
* @return 系统提示词
*/
public String relationSystemPrompt() {
return render("ai-relation-system", null);
}
/**
* 渲染 Emoji 图标建议的系统提示词(模板 {@code ai-decoration-emoji-system})。
*
@@ -0,0 +1,21 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.AiRelationRequest;
import cn.rosercode.finance.vo.AiRelationResultVO;
/**
* AI 生成账单关系服务接口,根据关系类型与成员账单特征生成关系名称与备注。
*
* @date 2026-09-21 21:08:21
*/
public interface AiRelationService {
/**
* 根据关系类型与成员账单特征生成关系名称与备注。
*
* @param userId 用户ID
* @param request 关系类型与成员账单特征
* @return 关系名称与备注建议
*/
AiRelationResultVO parse(Long userId, AiRelationRequest request);
}
@@ -0,0 +1,66 @@
package cn.rosercode.finance.service;
import java.util.List;
import cn.rosercode.finance.dto.TransactionGroupRequest;
import cn.rosercode.finance.vo.TransactionGroupVO;
/**
* 账单关系组服务接口,提供关系组的查询、创建、更新、删除与删除账单时的级联清理。
*
* @date 2026-09-21 20:32:41
*/
public interface TransactionGroupService {
/**
* 查询某笔账单所属的全部关系组。
*
* @param userId 用户ID
* @param transactionId 交易记录ID
* @return 关系组列表(含成员账单快照)
*/
List<TransactionGroupVO> listByTransaction(Long userId, Long transactionId);
/**
* 查询当前用户使用过的关系名称历史。
*
* @param userId 用户ID
* @param type 关系类型过滤,为空时返回全部类型
* @return 关系名称列表(按最近使用排序,去重)
*/
List<String> listNames(Long userId, String type);
/**
* 创建关系组。
*
* @param userId 用户ID
* @param request 关系组请求参数
* @return 新建关系组ID
*/
Long create(Long userId, TransactionGroupRequest request);
/**
* 更新关系组(名称、备注、成员与角色全量替换)。
*
* @param userId 用户ID
* @param id 关系组ID
* @param request 关系组请求参数
*/
void update(Long userId, Long id, TransactionGroupRequest request);
/**
* 删除关系组(仅解除账单关联,不删除账单)。
*
* @param userId 用户ID
* @param id 关系组ID
*/
void delete(Long userId, Long id);
/**
* 删除账单后清理其关系关联:移除成员,组内不足或父子组失去父账单时删除整个关系组。
*
* @param userId 用户ID
* @param transactionIds 被删除的交易记录ID列表
*/
void removeTransactionFromGroups(Long userId, List<Long> transactionIds);
}
@@ -0,0 +1,211 @@
package cn.rosercode.finance.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.dto.AiRelationMemberRequest;
import cn.rosercode.finance.dto.AiRelationRequest;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.enums.TransactionRelationType;
import cn.rosercode.finance.enums.TransactionType;
import cn.rosercode.finance.prompt.PromptTemplateService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiRelationService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiRelationResultVO;
/**
* AI 生成账单关系服务实现类,根据关系类型与成员账单特征生成关系名称与备注。
*
* <p>用户提示词在服务端集中构造,仅包含收支类型、交易渠道、分类名称与截断后的备注,
* 不发送金额、交易号、原始数据等敏感字段。</p>
*
* @date 2026-09-21 21:08:21
*/
@Service
public class AiRelationServiceImpl implements AiRelationService {
/** 关系名称最大长度 */
private static final int NAME_MAX_LENGTH = 50;
/** 关系备注最大长度 */
private static final int REMARK_MAX_LENGTH = 200;
/** 单次最多发送的成员账单数量 */
private static final int MEMBER_LIMIT = 20;
/** 单条备注截断长度 */
private static final int REMARK_TRUNCATE = 30;
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
private final PromptTemplateService promptTemplateService;
public AiRelationServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper,
PromptTemplateService promptTemplateService) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
this.promptTemplateService = promptTemplateService;
}
/**
* {@inheritDoc}
*/
@Override
public AiRelationResultVO parse(Long userId, AiRelationRequest request) {
if (request == null || request.getMembers() == null || request.getMembers().isEmpty()) {
throw new BusinessException("请先选择关联账单");
}
AiConfig config = requireConfig(userId);
String userPrompt = buildUserPrompt(request);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
promptTemplateService.relationSystemPrompt(), userPrompt);
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.RELATION, config.getModel(),
request.getMembers().size(), System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.RELATION, config.getModel(),
request.getMembers().size(), aiResponse, System.currentTimeMillis() - start);
return parseResult(aiResponse.getContent());
}
/**
* 构造用户提示词,仅包含白名单字段。
*
* @param request 请求参数
* @return 用户提示词
*/
private String buildUserPrompt(AiRelationRequest request) {
StringBuilder builder = new StringBuilder();
builder.append("关系类型:").append(TransactionRelationType.labelOf(request.getType())).append('\n');
builder.append("成员账单:").append('\n');
int index = 1;
for (AiRelationMemberRequest member : request.getMembers()) {
if (member == null) {
continue;
}
if (index > MEMBER_LIMIT) {
break;
}
builder.append(index).append(". ")
.append(TransactionType.labelOf(member.getType())).append(" | ")
.append(trim(member.getChannel())).append(" | ")
.append(trim(member.getCategoryName())).append(" | ")
.append(truncate(member.getRemark(), REMARK_TRUNCATE))
.append('\n');
index++;
}
return builder.toString();
}
/**
* 解析模型返回的 JSON 并校验字段合法性。
*
* @param content 模型输出内容
* @return 关系名称与备注建议
*/
private AiRelationResultVO parseResult(String content) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
String name = truncate(root.path("name").asText(""), NAME_MAX_LENGTH);
if (name.isEmpty()) {
throw new BusinessException("AI 未返回有效的关系名称,请重试");
}
AiRelationResultVO vo = new AiRelationResultVO();
vo.setName(name);
vo.setRemark(truncate(root.path("remark").asText(""), REMARK_MAX_LENGTH));
vo.setReason(root.path("reason").asText(""));
return vo;
}
/**
* 获取并校验当前用户的 AI 配置。
*
* @param userId 用户ID
* @return 有效的 AI 配置
*/
private AiConfig requireConfig(Long userId) {
AiConfig config = aiConfigService.getRawConfig(userId);
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
}
if (config.getApiKey() == null || config.getApiKey().isBlank()) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
return config;
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 去除首尾空白。
*
* @param value 原值
* @return 处理后的值,null 转为空字符串
*/
private String trim(String value) {
return value == null ? "" : value.trim();
}
/**
* 截断字符串到指定长度。
*
* @param value 原值
* @param max 最大长度
* @return 截断后的值
*/
private String truncate(String value, int max) {
if (value == null) {
return "";
}
String trimmed = value.trim();
return trimmed.length() <= max ? trimmed : trimmed.substring(0, max);
}
}
@@ -0,0 +1,452 @@
package cn.rosercode.finance.service.impl;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
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;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.converter.VoConverter;
import cn.rosercode.finance.dto.TransactionGroupItemRequest;
import cn.rosercode.finance.dto.TransactionGroupRequest;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionGroup;
import cn.rosercode.finance.entity.TransactionGroupItem;
import cn.rosercode.finance.enums.TransactionGroupRole;
import cn.rosercode.finance.enums.TransactionRelationType;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TransactionGroupItemMapper;
import cn.rosercode.finance.mapper.TransactionGroupMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.service.TransactionGroupService;
import cn.rosercode.finance.vo.TransactionGroupItemVO;
import cn.rosercode.finance.vo.TransactionGroupVO;
/**
* 账单关系组服务实现类,处理关系组的查询、创建、更新、删除与删除账单时的级联清理。
*
* @date 2026-09-21 20:32:41
*/
@Service
@Slf4j
public class TransactionGroupServiceImpl implements TransactionGroupService {
/** 单个关系组成员数量上限 */
private static final int MAX_ITEMS = 50;
/** 关系名称历史返回条数上限 */
private static final int NAME_HISTORY_LIMIT = 50;
private final TransactionGroupMapper transactionGroupMapper;
private final TransactionGroupItemMapper transactionGroupItemMapper;
private final TransactionMapper transactionMapper;
private final CategoryMapper categoryMapper;
private final VoConverter voConverter;
public TransactionGroupServiceImpl(TransactionGroupMapper transactionGroupMapper,
TransactionGroupItemMapper transactionGroupItemMapper,
TransactionMapper transactionMapper,
CategoryMapper categoryMapper,
VoConverter voConverter) {
this.transactionGroupMapper = transactionGroupMapper;
this.transactionGroupItemMapper = transactionGroupItemMapper;
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
this.voConverter = voConverter;
}
/**
* {@inheritDoc}
*/
@Override
public List<TransactionGroupVO> listByTransaction(Long userId, Long transactionId) {
// 校验账单归属当前用户
Transaction transaction = transactionMapper.selectById(transactionId);
if (transaction == null || !transaction.getUserId().equals(userId)) {
throw new BusinessException("记录不存在");
}
List<TransactionGroupItem> memberships = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.eq(TransactionGroupItem::getTransactionId, transactionId));
if (memberships.isEmpty()) {
return List.of();
}
Set<Long> groupIds = memberships.stream()
.map(TransactionGroupItem::getGroupId)
.collect(Collectors.toCollection(LinkedHashSet::new));
List<TransactionGroup> groups = transactionGroupMapper.selectList(new LambdaQueryWrapper<TransactionGroup>()
.eq(TransactionGroup::getUserId, userId)
.in(TransactionGroup::getId, groupIds));
if (groups.isEmpty()) {
return List.of();
}
return toGroupVOs(groups);
}
/**
* {@inheritDoc}
*/
@Override
public List<String> listNames(Long userId, String type) {
LambdaQueryWrapper<TransactionGroup> wrapper = new LambdaQueryWrapper<TransactionGroup>()
.select(TransactionGroup::getName, TransactionGroup::getUpdatedAt)
.eq(TransactionGroup::getUserId, userId)
.isNotNull(TransactionGroup::getName)
.ne(TransactionGroup::getName, "");
// 指定合法类型时仅返回该类型的历史名称
if (TransactionRelationType.isValid(type)) {
wrapper.eq(TransactionGroup::getType, type);
}
List<TransactionGroup> groups = transactionGroupMapper.selectList(wrapper);
// 按更新时间倒序(最近使用优先),去重后截断
return groups.stream()
.sorted(Comparator.comparing(TransactionGroup::getUpdatedAt,
Comparator.nullsLast(Comparator.reverseOrder())))
.map(group -> group.getName().trim())
.filter(name -> !name.isEmpty())
.distinct()
.limit(NAME_HISTORY_LIMIT)
.collect(Collectors.toList());
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public Long create(Long userId, TransactionGroupRequest request) {
String type = requireType(request.getType());
List<TransactionGroupItemRequest> items = normalizeItems(type, request.getItems());
List<Long> transactionIds = transactionIds(items);
validateTransactions(userId, transactionIds);
validateNotInSameTypeGroup(userId, type, transactionIds, null);
TransactionGroup group = new TransactionGroup();
group.setUserId(userId);
group.setType(type);
group.setName(request.getName());
group.setRemark(request.getRemark());
transactionGroupMapper.insert(group);
insertItems(group.getId(), items);
log.info("新增账单关系组: userId={}, groupId={}, type={}, 成员={}",
userId, group.getId(), type, items.size());
return group.getId();
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void update(Long userId, Long id, TransactionGroupRequest request) {
requireGroup(userId, id);
String type = requireType(request.getType());
List<TransactionGroupItemRequest> items = normalizeItems(type, request.getItems());
List<Long> transactionIds = transactionIds(items);
validateTransactions(userId, transactionIds);
// 同类型唯一性校验时排除当前组自身
validateNotInSameTypeGroup(userId, type, transactionIds, id);
TransactionGroup group = new TransactionGroup();
group.setId(id);
group.setType(type);
group.setName(request.getName());
group.setRemark(request.getRemark());
transactionGroupMapper.updateById(group);
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
.eq(TransactionGroupItem::getGroupId, id));
insertItems(id, items);
log.info("更新账单关系组: userId={}, groupId={}, type={}, 成员={}", userId, id, type, items.size());
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void delete(Long userId, Long id) {
requireGroup(userId, id);
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
.eq(TransactionGroupItem::getGroupId, id));
transactionGroupMapper.deleteById(id);
log.info("删除账单关系组: userId={}, groupId={}", userId, id);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void removeTransactionFromGroups(Long userId, List<Long> transactionIds) {
if (transactionIds == null || transactionIds.isEmpty()) {
return;
}
List<TransactionGroupItem> memberships = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getTransactionId, transactionIds));
if (memberships.isEmpty()) {
return;
}
Set<Long> groupIds = memberships.stream()
.map(TransactionGroupItem::getGroupId)
.collect(Collectors.toCollection(LinkedHashSet::new));
// 仅处理属于当前用户的关系组
List<TransactionGroup> groups = transactionGroupMapper.selectList(new LambdaQueryWrapper<TransactionGroup>()
.eq(TransactionGroup::getUserId, userId)
.in(TransactionGroup::getId, groupIds));
if (groups.isEmpty()) {
return;
}
// 移除被删账单在这些组中的成员记录
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getGroupId, groupIds)
.in(TransactionGroupItem::getTransactionId, transactionIds));
// 逐个检查组是否仍有效:不足两条,或父子组失去父账单时整组移除
for (TransactionGroup group : groups) {
List<TransactionGroupItem> remaining = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.eq(TransactionGroupItem::getGroupId, group.getId()));
boolean valid = remaining.size() >= 2;
if (valid && TransactionRelationType.PARENT_CHILD.name().equals(group.getType())) {
valid = remaining.stream()
.anyMatch(item -> TransactionGroupRole.PARENT.name().equals(item.getRole()));
}
if (!valid) {
transactionGroupItemMapper.delete(new LambdaQueryWrapper<TransactionGroupItem>()
.eq(TransactionGroupItem::getGroupId, group.getId()));
transactionGroupMapper.deleteById(group.getId());
log.info("账单删除后关系组失效已移除: userId={}, groupId={}", userId, group.getId());
}
}
log.info("账单删除后清理关系: userId={}, 账单={}, 涉及组={}", userId, transactionIds.size(), groups.size());
}
/**
* 校验并返回合法的关系类型编码。
*
* @param type 关系类型编码
* @return 合法的关系类型编码
*/
private String requireType(String type) {
if (!TransactionRelationType.isValid(type)) {
throw new BusinessException("关系类型不合法");
}
return type;
}
/**
* 校验关系组归属并返回实体。
*
* @param userId 用户ID
* @param id 关系组ID
* @return 关系组实体
*/
private TransactionGroup requireGroup(Long userId, Long id) {
TransactionGroup group = transactionGroupMapper.selectById(id);
if (group == null || !group.getUserId().equals(userId)) {
throw new BusinessException("关系组不存在");
}
return group;
}
/**
* 归一化成员列表:去重、校验数量,并按关系类型规范角色与排序。
*
* @param type 关系类型
* @param items 原始成员请求列表
* @return 归一化后的成员列表
*/
private List<TransactionGroupItemRequest> normalizeItems(String type, List<TransactionGroupItemRequest> items) {
if (items == null || items.isEmpty()) {
throw new BusinessException("关系组至少包含两条账单");
}
// 按交易ID去重并保留输入顺序
Map<Long, TransactionGroupItemRequest> unique = new LinkedHashMap<>();
for (TransactionGroupItemRequest item : items) {
if (item == null || item.getTransactionId() == null) {
throw new BusinessException("关联账单不能为空");
}
unique.putIfAbsent(item.getTransactionId(), item);
}
if (unique.size() < 2) {
throw new BusinessException("关系组至少包含两条账单");
}
if (unique.size() > MAX_ITEMS) {
throw new BusinessException("单个关系组最多包含" + MAX_ITEMS + "条账单");
}
boolean parentChild = TransactionRelationType.PARENT_CHILD.name().equals(type);
if (parentChild) {
long parentCount = unique.values().stream()
.filter(item -> TransactionGroupRole.PARENT.name().equals(item.getRole()))
.count();
if (parentCount != 1) {
throw new BusinessException("父子关系必须且只能指定一条父账单");
}
}
List<TransactionGroupItemRequest> normalized = new ArrayList<>(unique.size());
int index = 0;
for (TransactionGroupItemRequest item : unique.values()) {
if (parentChild) {
item.setRole(TransactionGroupRole.PARENT.name().equals(item.getRole())
? TransactionGroupRole.PARENT.name() : TransactionGroupRole.CHILD.name());
} else {
item.setRole(TransactionGroupRole.MEMBER.name());
}
if (item.getSortOrder() == null) {
item.setSortOrder(index);
}
index++;
normalized.add(item);
}
return normalized;
}
/**
* 提取成员请求中的交易记录ID列表。
*
* @param items 成员请求列表
* @return 交易记录ID列表
*/
private List<Long> transactionIds(List<TransactionGroupItemRequest> items) {
return items.stream().map(TransactionGroupItemRequest::getTransactionId).collect(Collectors.toList());
}
/**
* 校验成员账单均存在且归属当前用户。
*
* @param userId 用户ID
* @param transactionIds 交易记录ID列表
*/
private void validateTransactions(Long userId, List<Long> transactionIds) {
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.select(Transaction::getId)
.eq(Transaction::getUserId, userId)
.in(Transaction::getId, transactionIds));
if (transactions.size() != transactionIds.size()) {
throw new BusinessException("存在无效的关联账单");
}
}
/**
* 校验成员账单未加入同类型的其它关系组。
*
* @param userId 用户ID
* @param type 关系类型
* @param transactionIds 交易记录ID列表
* @param excludeGroupId 需排除的关系组ID(更新场景为当前组),可为 null
*/
private void validateNotInSameTypeGroup(Long userId, String type, List<Long> transactionIds,
Long excludeGroupId) {
List<TransactionGroup> sameTypeGroups = transactionGroupMapper.selectList(
new LambdaQueryWrapper<TransactionGroup>()
.select(TransactionGroup::getId)
.eq(TransactionGroup::getUserId, userId)
.eq(TransactionGroup::getType, type));
if (sameTypeGroups.isEmpty()) {
return;
}
List<Long> groupIds = sameTypeGroups.stream().map(TransactionGroup::getId).collect(Collectors.toList());
LambdaQueryWrapper<TransactionGroupItem> wrapper = new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getGroupId, groupIds)
.in(TransactionGroupItem::getTransactionId, transactionIds);
if (excludeGroupId != null) {
wrapper.ne(TransactionGroupItem::getGroupId, excludeGroupId);
}
if (!transactionGroupItemMapper.selectList(wrapper).isEmpty()) {
throw new BusinessException("存在已加入同类型关系的账单,请先解除原关系");
}
}
/**
* 批量写入关系组成员。
*
* @param groupId 关系组ID
* @param items 归一化后的成员列表
*/
private void insertItems(Long groupId, List<TransactionGroupItemRequest> items) {
for (TransactionGroupItemRequest item : items) {
TransactionGroupItem entity = new TransactionGroupItem();
entity.setGroupId(groupId);
entity.setTransactionId(item.getTransactionId());
entity.setRole(item.getRole());
entity.setSortOrder(item.getSortOrder() == null ? 0 : item.getSortOrder());
transactionGroupItemMapper.insert(entity);
}
}
/**
* 将关系组实体列表转换为视图对象,并批量填充成员账单快照。
*
* @param groups 关系组实体列表
* @return 关系组视图对象列表
*/
private List<TransactionGroupVO> toGroupVOs(List<TransactionGroup> groups) {
List<Long> groupIds = groups.stream().map(TransactionGroup::getId).collect(Collectors.toList());
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getGroupId, groupIds)
.orderByAsc(TransactionGroupItem::getSortOrder)
.orderByAsc(TransactionGroupItem::getId));
// 批量加载成员账单与分类,避免 N+1 查询
List<Long> transactionIds = items.stream()
.map(TransactionGroupItem::getTransactionId)
.distinct()
.collect(Collectors.toList());
Map<Long, Transaction> transactionMap = transactionIds.isEmpty() ? Map.of()
: transactionMapper.selectBatchIds(transactionIds).stream()
.collect(Collectors.toMap(Transaction::getId, transaction -> transaction));
List<Long> categoryIds = transactionMap.values().stream()
.map(Transaction::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, category -> category));
Map<Long, List<TransactionGroupItemVO>> itemsByGroup = new HashMap<>();
for (TransactionGroupItem item : items) {
TransactionGroupItemVO itemVO = voConverter.toTransactionGroupItemVO(item);
Transaction transaction = transactionMap.get(item.getTransactionId());
if (transaction != null) {
itemVO.setType(transaction.getType());
itemVO.setAmount(transaction.getAmount());
itemVO.setChannel(transaction.getChannel());
itemVO.setRemark(transaction.getRemark());
itemVO.setRemarkHidden(Boolean.TRUE.equals(transaction.getRemarkHidden()));
itemVO.setTransactionTime(transaction.getTransactionTime());
Category category = transaction.getCategoryId() == null
? null : categoryMap.get(transaction.getCategoryId());
itemVO.setCategoryName(category == null ? "" : category.getName());
itemVO.setCategoryIcon(category == null ? "" : category.getIcon());
}
itemsByGroup.computeIfAbsent(item.getGroupId(), key -> new ArrayList<>()).add(itemVO);
}
List<TransactionGroupVO> result = new ArrayList<>(groups.size());
for (TransactionGroup group : groups) {
TransactionGroupVO vo = voConverter.toTransactionGroupVO(group);
vo.setItems(itemsByGroup.getOrDefault(group.getId(), List.of()));
result.add(vo);
}
return result;
}
}
@@ -32,6 +32,7 @@ import cn.rosercode.finance.entity.AiImageTransaction;
import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag;
import cn.rosercode.finance.entity.Transaction;
import cn.rosercode.finance.entity.TransactionGroupItem;
import cn.rosercode.finance.entity.TransactionTag;
import cn.rosercode.finance.enums.ChannelType;
import cn.rosercode.finance.enums.TransactionType;
@@ -39,9 +40,11 @@ import cn.rosercode.finance.mapper.AiImageMapper;
import cn.rosercode.finance.mapper.AiImageTransactionMapper;
import cn.rosercode.finance.mapper.CategoryMapper;
import cn.rosercode.finance.mapper.TagMapper;
import cn.rosercode.finance.mapper.TransactionGroupItemMapper;
import cn.rosercode.finance.mapper.TransactionMapper;
import cn.rosercode.finance.mapper.TransactionTagMapper;
import cn.rosercode.finance.service.ChannelService;
import cn.rosercode.finance.service.TransactionGroupService;
import cn.rosercode.finance.service.TransactionService;
import cn.rosercode.finance.util.TransactionDedupUtils;
import cn.rosercode.finance.vo.TagVO;
@@ -64,7 +67,9 @@ public class TransactionServiceImpl implements TransactionService {
private final TransactionTagMapper transactionTagMapper;
private final AiImageMapper aiImageMapper;
private final AiImageTransactionMapper aiImageTransactionMapper;
private final TransactionGroupItemMapper transactionGroupItemMapper;
private final ChannelService channelService;
private final TransactionGroupService transactionGroupService;
private final VoConverter voConverter;
public TransactionServiceImpl(TransactionMapper transactionMapper,
@@ -73,7 +78,9 @@ public class TransactionServiceImpl implements TransactionService {
TransactionTagMapper transactionTagMapper,
AiImageMapper aiImageMapper,
AiImageTransactionMapper aiImageTransactionMapper,
TransactionGroupItemMapper transactionGroupItemMapper,
ChannelService channelService,
TransactionGroupService transactionGroupService,
VoConverter voConverter) {
this.transactionMapper = transactionMapper;
this.categoryMapper = categoryMapper;
@@ -81,7 +88,9 @@ public class TransactionServiceImpl implements TransactionService {
this.transactionTagMapper = transactionTagMapper;
this.aiImageMapper = aiImageMapper;
this.aiImageTransactionMapper = aiImageTransactionMapper;
this.transactionGroupItemMapper = transactionGroupItemMapper;
this.channelService = channelService;
this.transactionGroupService = transactionGroupService;
this.voConverter = voConverter;
}
@@ -194,6 +203,7 @@ public class TransactionServiceImpl implements TransactionService {
// 批量填充标签与来源图片
fillTags(voList);
fillImages(voList);
fillRelationGroupCount(voList);
return new PageResult<>(voList, result.getTotal(), result.getCurrent(), result.getSize());
}
@@ -387,6 +397,7 @@ public class TransactionServiceImpl implements TransactionService {
// 先清理标签关联,再删除记录
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.eq(TransactionTag::getTransactionId, id));
transactionGroupService.removeTransactionFromGroups(userId, List.of(id));
transactionMapper.deleteById(id);
log.info("删除交易记录: userId={}, id={}", userId, id);
}
@@ -416,6 +427,7 @@ public class TransactionServiceImpl implements TransactionService {
// 先清理标签关联,再批量删除记录
transactionTagMapper.delete(new LambdaQueryWrapper<TransactionTag>()
.in(TransactionTag::getTransactionId, ownedIds));
transactionGroupService.removeTransactionFromGroups(userId, ownedIds);
int deleted = transactionMapper.deleteByIds(ownedIds);
log.info("批量删除交易记录: userId={}, 请求={}, 实际={}", userId, ids.size(), deleted);
return deleted;
@@ -680,6 +692,26 @@ public class TransactionServiceImpl implements TransactionService {
}
}
/**
* 批量填充交易视图对象所属的关系组数量
*
* @param voList 交易视图对象列表
*/
private void fillRelationGroupCount(List<TransactionVO> voList) {
if (voList.isEmpty()) {
return;
}
List<Long> transactionIds = voList.stream().map(TransactionVO::getId).collect(Collectors.toList());
List<TransactionGroupItem> items = transactionGroupItemMapper.selectList(
new LambdaQueryWrapper<TransactionGroupItem>()
.in(TransactionGroupItem::getTransactionId, transactionIds));
Map<Long, Long> counts = items.stream()
.collect(Collectors.groupingBy(TransactionGroupItem::getTransactionId, Collectors.counting()));
for (TransactionVO vo : voList) {
vo.setRelationGroupCount(counts.getOrDefault(vo.getId(), 0L).intValue());
}
}
/**
* 应用排序条件:默认按交易时间降序,支持按交易时间或金额升/降序。
*
@@ -0,0 +1,21 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* AI 生成账单关系结果视图对象,字段与关系组表单对应。
*
* @date 2026-09-21 21:08:21
*/
@Data
public class AiRelationResultVO {
/** 建议的关系名称 */
private String name;
/** 建议的关系备注 */
private String remark;
/** 生成理由,用于向用户说明 */
private String reason;
}
@@ -0,0 +1,48 @@
package cn.rosercode.finance.vo;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Data;
/**
* 账单关系组成员视图对象,包含成员角色与所关联账单的展示快照。
*
* @date 2026-09-21 20:32:41
*/
@Data
public class TransactionGroupItemVO {
/** 交易记录ID */
private Long transactionId;
/** 组内角色:PARENT-父,CHILD-子,MEMBER-成员 */
private String role;
/** 排序序号,用于同级链路顺序与展示顺序 */
private Integer sortOrder;
/** 交易类型(收入/支出) */
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 交易渠道 */
private String channel;
/** 备注 */
private String remark;
/** 备注是否隐藏 */
private Boolean remarkHidden;
/** 交易时间 */
private LocalDateTime transactionTime;
/** 分类名称 */
private String categoryName;
/** 分类图标 */
private String categoryIcon;
}
@@ -0,0 +1,36 @@
package cn.rosercode.finance.vo;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Data;
/**
* 账单关系组视图对象,用于向前端返回关系组及其成员账单。
*
* @date 2026-09-21 20:32:41
*/
@Data
public class TransactionGroupVO {
/** 关系组ID */
private Long id;
/** 关系类型:PARENT_CHILD-父子,SIBLING-同级 */
private String type;
/** 关系组名称 */
private String name;
/** 备注 */
private String remark;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
/** 关系组成员列表 */
private List<TransactionGroupItemVO> items;
}
@@ -56,4 +56,7 @@ public class TransactionVO {
/** 来源图片ID列表(图片记账生成时存在,手动记账为空) */
private List<Long> imageIds;
/** 所属关系组数量(父子/同级关系),无关系时为 0 */
private Integer relationGroupCount;
}
@@ -0,0 +1,6 @@
你是个人记账应用的账单关系助手。用户会给出一组账单的关系类型与成员账单特征,请为这组关系生成一个简洁的名称和一句备注。要求:
1. name 为 4-12 个汉字的名称,概括这组账单的关系(如「交通卡充值」「淘宝购物支付链」「借款给朋友」);
2. remark 为一句话说明(不超过 40 个汉字),点明成员账单之间的关联含义(如充值后分次扣减、同一笔消费的多来源记录、借出后对方归还);
3. 借贷场景:当成员为同一交易对方(可结合备注判断)、发生在不同时间,且「支出合计」与「收入合计」大致相等(方向相反,可含多笔)时,应判定为借贷往来(借出—归还);若一笔借出对应多笔还款,说明为分次归还;
4. 不要提及或推算任何金额;
5. 只输出 JSON,不要输出任何多余内容。输出格式:{"name":"交通卡充值","remark":"充值后分次乘车扣减","reason":"简要中文理由"}
+24
View File
@@ -66,6 +66,30 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
INDEX idx_tag_id (tag_id) COMMENT '标签ID索引'
);
-- 账单关系组表:将多笔账单按父子或同级关系聚合为一个关系组
CREATE TABLE IF NOT EXISTS t_transaction_group (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关系组ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
type VARCHAR(20) NOT NULL COMMENT '关系类型:PARENT_CHILD-父子,SIBLING-同级',
name VARCHAR(100) COMMENT '关系组名称',
remark VARCHAR(255) COMMENT '备注',
INDEX idx_user_id (user_id) COMMENT '用户ID索引'
);
-- 账单关系组成员表:关系组与账单的关联及组内角色
CREATE TABLE IF NOT EXISTS t_transaction_group_item (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '关联ID,主键自增',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
group_id BIGINT NOT NULL COMMENT '关系组ID',
transaction_id BIGINT NOT NULL COMMENT '交易记录ID',
role VARCHAR(10) NOT NULL COMMENT '组内角色:PARENT-父,CHILD-子,MEMBER-成员',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序序号,用于同级链路顺序',
UNIQUE INDEX uk_group_transaction (group_id, transaction_id) COMMENT '关系组+交易唯一索引',
INDEX idx_transaction_id (transaction_id) COMMENT '交易记录ID索引'
);
-- AI 配置表:存储每个用户的 AI 智能建议相关配置
CREATE TABLE IF NOT EXISTS t_ai_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
+30
View File
@@ -70,6 +70,28 @@ CREATE TABLE IF NOT EXISTS t_transaction_tag (
CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_tag ON t_transaction_tag (transaction_id, tag_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_tag_tag_id ON t_transaction_tag (tag_id);;
CREATE TABLE IF NOT EXISTS t_transaction_group (
id INTEGER PRIMARY KEY AUTOINCREMENT,
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')),
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
name VARCHAR(100),
remark VARCHAR(255)
);;
CREATE INDEX IF NOT EXISTS idx_transaction_group_user_id ON t_transaction_group (user_id);;
CREATE TABLE IF NOT EXISTS t_transaction_group_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
group_id INTEGER NOT NULL,
transaction_id INTEGER NOT NULL,
role VARCHAR(10) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_transaction_group_item ON t_transaction_group_item (group_id, transaction_id);;
CREATE INDEX IF NOT EXISTS idx_transaction_group_item_transaction_id ON t_transaction_group_item (transaction_id);;
CREATE TABLE IF NOT EXISTS t_ai_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
@@ -143,6 +165,14 @@ BEGIN
UPDATE t_transaction SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_transaction_group_updated_at
AFTER UPDATE ON t_transaction_group
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_transaction_group SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_ai_config_updated_at
AFTER UPDATE ON t_ai_config
FOR EACH ROW