提供账单之间父子/同级关系的查询、创建、更新与删除接口
+ * + * @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成员列表由调用方另行填充。
+ * + * @param group 关系组实体 + * @return 关系组视图对象 + */ + @Mapping(target = "items", ignore = true) + TransactionGroupVO toTransactionGroupVO(TransactionGroup group); + + /** + * 账单关系组成员实体转视图对象。 + * + *仅映射成员角色与排序,关联账单的展示快照由调用方另行填充。
+ * + * @param item 关系组成员实体 + * @return 关系组成员视图对象 + */ + TransactionGroupItemVO toTransactionGroupItemVO(TransactionGroupItem item); + /** * 通知渠道实体转视图对象。 * diff --git a/src/main/java/cn/rosercode/finance/dto/AiRelationMemberRequest.java b/src/main/java/cn/rosercode/finance/dto/AiRelationMemberRequest.java new file mode 100644 index 0000000..22e5907 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/AiRelationMemberRequest.java @@ -0,0 +1,26 @@ +package cn.rosercode.finance.dto; + +import lombok.Data; + +/** + * AI 生成账单关系请求的成员账单特征 DTO。 + * + *仅承载收支类型、交易渠道、分类名称与备注,禁止携带金额、交易号、原始数据等敏感字段。
+ * + * @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; +} diff --git a/src/main/java/cn/rosercode/finance/dto/AiRelationRequest.java b/src/main/java/cn/rosercode/finance/dto/AiRelationRequest.java new file mode 100644 index 0000000..b200497 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/dto/AiRelationRequest.java @@ -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用户提示词在服务端集中构造,仅包含收支类型、交易渠道、分类名称与截断后的备注, + * 不发送金额、交易号、原始数据等敏感字段。
+ * + * @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); + } +} diff --git a/src/main/java/cn/rosercode/finance/service/impl/TransactionGroupServiceImpl.java b/src/main/java/cn/rosercode/finance/service/impl/TransactionGroupServiceImpl.java new file mode 100644 index 0000000..312dae3 --- /dev/null +++ b/src/main/java/cn/rosercode/finance/service/impl/TransactionGroupServiceImpl.java @@ -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