【Agent_Bulter】Agent记账管家_开发过程文档(一)
1.结果封装
ResultVO
package com.test.util;
@Data
public class ResultVO <T>{
private int code;
private String msg;
private T data;
}
ResultVOUtil
package com.test.util;
public class ResultVOUtil {
// 1.请求成功
public static ResultVO success(Object data){
ResultVO resultVO = new ResultVO();
resultVO.setCode(200);
resultVO.setMsg("success");
resultVO.setData(data);
return resultVO;
}
// 2.请求失败
public static ResultVO fail(String msg){
ResultVO resultVO = new ResultVO();
resultVO.setCode(-1);
resultVO.setMsg(msg);
return resultVO;
}
}
2.测试接口配置
Knife4jConfig
package com.test.common;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI butlerOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Butler AI 个人记账助手接口文档")
.description("用于测试个人记账、预算管理、AI消费分析等接口")
.version("1.0.0")
.contact(new Contact()
.name("king")))
.servers(List.of(
new Server()
.url("http://localhost:8181")
.description("本地开发环境")
));
}
@Bean
public GroupedOpenApi butlerApi() {
return GroupedOpenApi.builder()
.group("Butler接口")
.packagesToScan("com.test")
.pathsToMatch("/**")
.build();
}
}
3.Controller
3.1.1创建前端给后端的参数
AgentChatVO
package com.test.dto;
import lombok.Data;
@Data
public class AgentChatVO {
private Long userId;
private String sessionId;
private String message;
}
3.1.2创建Agent给前端的参数
AgentChatResponse
package com.test.dto;
import lombok.Data;
import java.util.List;
@Data
public class AgentChatResponse {
private String reply;
private List<AgentAction> actions;
}
AgentAction
package com.test.dto;
import lombok.Data;
@Data
public class AgentAction {
/**
* 动作名称,例如 recordExpense、queryExpense、setBudget
*/
private String name;
/**
* 动作是否成功
*/
private Boolean success;
/**
* 动作说明
*/
private String message;
}
3.1.3创建Service
AgentService
package com.test.service;
import com.test.dto.AgentChatResponse;
public interface AgentService {
AgentChatResponse chat(Long userId,String sessionId,String message);
}
实现chat方法
AgentServiceImpl
package com.test.service.impl;
import com.test.dto.AgentChatResponse;
import com.test.service.AgentService;
import org.springframework.stereotype.Service;
@Service
public class AgentServiceImpl implements AgentService {
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
AgentChatResponse response = new AgentChatResponse();
response.setReply("你好,我已经收到你的消息:" + message);
return response;
}
}
BulterAgentController
package com.test.controller;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentChatVO;
import com.test.service.AgentService;
import com.test.util.ResultVO;
import com.test.util.ResultVOUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/agent")
public class BulterAgentController {
@Autowired
private AgentService agentService;
@PostMapping("/chat")
public ResultVO chat(@RequestBody AgentChatVO chatmessage) {
AgentChatResponse chat = this.agentService.chat(
chatmessage.getUserId(),
chatmessage.getSessionId(),
chatmessage.getMessage()
);
return ResultVOUtil.success(chat);
}
}
测试结果
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 200, "msg": "success", "data": { "reply": "你好,我已经收到你的消息:今天奶茶18和吃饭11", "actions": null } }
3.2.1大模型接入
创建AI接口 AIService
AIService
package com.test.service;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
public interface AIService {
String plan(@MemoryId String sessionId, @UserMessage String message);
}
Ai接口的配置策略
通过配置类中的 @Bean,使用 LangChain4j 根据 AIService 接口创建一个具备大模型调用能力的代理对象,并把它放入 Spring IOC 容器,后续业务类就可以通过注入 AIService 来调用 AI 能力。
AgentServiceFactory
package com.test.config;
import com.test.service.AIService;
import dev.langchain4j.memory.ChatMemory;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.service.AiServices;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AgentServiceFactory {
@Autowired
private ChatModel chatModel;
@Bean
public AIService aiService(ChatModel chatModel) {
// 构建 AICodeService 的 AI 服务代理对象
return AiServices.builder(AIService.class)
// 注入大语言模型,负责生成回答
.chatModel(chatModel)
// 注入对话记忆,使 AI 能够理解上下文
.chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(10))
// 构建服务对象,并注册到 Spring 容器中
.build();
}
}
创建 Agent 计划解析接口
AgentPlanService
package com.test.service;
import com.test.dto.AgentPlan;
public interface AgentPlanService {
AgentPlan parsePlan(String sessionId,String message);
}
解析的json格式
AgentPlan
package com.test.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class AgentPlan {
private String intent;
private String recordType;
private BigDecimal amount;
private String category;
private String description;
private Boolean valid;
private String reason;
}
实现解析功能
AgentPlanServiceImpl
package com.test.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.dto.AgentPlan;
import com.test.service.AIService;
import com.test.service.AgentPlanService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class AgentPlanServiceImpl implements AgentPlanService {
@Autowired
private AIService aiService;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public AgentPlan parsePlan(String sessionId, String message) {
try {
String json = aiService.plan(sessionId,message);
json = cleanJson(json);
return objectMapper.readValue(json, AgentPlan.class);
} catch (Exception e) {
AgentPlan plan = new AgentPlan();
plan.setIntent(null);
plan.setValid(false);
plan.setReason("AI解析失败:" + e.getMessage());
return plan;
}
}
private String cleanJson(String json) {
if (json == null) {
return "{}";
}
json = json.trim();
json = json.replace("```json", "");
json = json.replace("```", "");
json = json.trim();
int start = json.indexOf("{");
int end = json.lastIndexOf("}");
if (start >= 0 && end >= 0 && end > start) {
return json.substring(start, end + 1);
}
return json;
}
}
AgentServiceImpl
实现ai解析接口
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
AgentChatResponse response = new AgentChatResponse();
response.setReply(
"AI识别结果:意图=" + plan.getIntent()
+ ",金额=" + plan.getAmount()
+ ",类别=" + plan.getCategory()
+ ",描述=" + plan.getDescription()
+ ",valid=" + plan.getValid()
+ ", reason=" + plan.getReason()
);
response.setActions(actions);
return response;
}
}
添加提示词
Agent-plan-prompt.txt
你是一个 AI 个人财务管家助手,负责将用户输入的自然语言解析成固定 JSON 格式。
你的任务不是直接回答用户,而是识别用户意图,并提取结构化参数。
你只能返回 JSON。
不要返回解释。
不要返回 Markdown。
不要使用 ```json 代码块。
不要在 JSON 前后添加任何文字。
返回 JSON 必须严格符合以下格式:
{
"intent": "RECORD_EXPENSE",
"recordType": "EXPENSE",
"amount": 18,
"category": "饮品",
"description": "奶茶",
"valid": true,
"reason": ""
}
字段说明:
1. intent 表示用户意图,只能取以下值:
* RECORD_EXPENSE:用户想记录一笔收入或支出
* QUERY_EXPENSE:用户想查询消费金额、收入金额或收支记录
* ANALYZE_EXPENSE:用户想分析消费、收入或收支情况
* SET_BUDGET:用户想设置预算
* BUDGET_RISK:用户想判断预算是否超支或是否够用
* CHAT:普通闲聊
* UNKNOWN:无法判断意图
2. recordType 表示记录类型,只能取以下值:
* EXPENSE:支出,例如吃饭、奶茶、打车、购物、买书、看电影、房租、医疗等
* INCOME:收入,例如工资、兼职、红包、奖金、退款、报销、转账收入等
如果 intent 不是 RECORD_EXPENSE,则 recordType 填写 null。
3. amount 表示金额。
* 如果用户输入中有明确金额,填写数字。
* 如果没有金额,填写 null。
* 金额必须使用阿拉伯数字,不要带单位。
* 例如“18元”“十八块”“18块钱”都返回 18。
4. category 表示类别。
支出类别可选:
餐饮、饮品、交通、购物、学习、娱乐、医疗、住房、其他
收入类别可选:
工资、兼职、红包、奖金、退款、报销、转账、其他
如果无法判断类别,填写 null。
5. description 表示记录描述。
例如:
奶茶、午饭、打车、买书、工资到账、兼职收入、红包、退款。
如果无法提取,填写 null。
6. valid 表示本次解析是否有效。
* 如果用户表达清楚,填写 true。
* 如果缺少关键信息,填写 false。
7. reason 表示原因说明。
* 如果 valid 为 true,reason 填写空字符串 ""。
* 如果 valid 为 false,说明缺少什么信息或为什么无法解析。
判断规则:
1. 如果用户输入包含明确消费内容和金额,判断为 RECORD_EXPENSE,并且 recordType 为 EXPENSE。
例如:
今天奶茶18
午饭28元
打车花了22
买书50
房租1200
2. 如果用户输入包含明确收入来源和金额,判断为 RECORD_EXPENSE,并且 recordType 为 INCOME。
例如:
今天工资到账5000
收到兼职费300
红包收入88
奖金1000
退款20
报销120
3. 如果用户是在问消费、收入或收支情况,判断为 QUERY_EXPENSE。
例如:
我这个月花了多少钱
今天一共消费多少
本月收入多少
我这个月结余多少
查一下本月收支
4. 如果用户要求分析消费、收入或收支情况,判断为 ANALYZE_EXPENSE。
例如:
分析一下我这个月的消费情况
看看我最近消费结构
分析一下我的收入和支出
看看本月收支情况
5. 如果用户想设置预算,判断为 SET_BUDGET。
例如:
帮我把本月预算设置为1800
设置餐饮预算800
这个月预算设成2000
6. 如果用户询问预算是否够用、是否超支,判断为 BUDGET_RISK。
例如:
我这个月会不会超预算
我还能控制在1800以内吗
这个月预算够不够
我是不是快超支了
7. 如果用户只是普通聊天,判断为 CHAT。
例如:
你好
你是谁
我不想上班
今天天气不错
8. 当前阶段暂时只处理单笔记录。
如果用户一次输入多笔记录,例如“今天奶茶18,吃饭28,打车22”,仍然判断为 RECORD_EXPENSE,但只提取第一笔记录,并在 reason 中说明“当前仅提取第一笔记录”。
示例 1:
用户输入:今天奶茶18
返回:
{
"intent": "RECORD_EXPENSE",
"recordType": "EXPENSE",
"amount": 18,
"category": "饮品",
"description": "奶茶",
"valid": true,
"reason": ""
}
示例 2:
用户输入:午饭28元
返回:
{
"intent": "RECORD_EXPENSE",
"recordType": "EXPENSE",
"amount": 28,
"category": "餐饮",
"description": "午饭",
"valid": true,
"reason": ""
}
示例 3:
用户输入:打车花了22
返回:
{
"intent": "RECORD_EXPENSE",
"recordType": "EXPENSE",
"amount": 22,
"category": "交通",
"description": "打车",
"valid": true,
"reason": ""
}
示例 4:
用户输入:今天工资到账5000
返回:
{
"intent": "RECORD_EXPENSE",
"recordType": "INCOME",
"amount": 5000,
"category": "工资",
"description": "工资到账",
"valid": true,
"reason": ""
}
示例 5:
用户输入:收到兼职费300
返回:
{
"intent": "RECORD_EXPENSE",
"recordType": "INCOME",
"amount": 300,
"category": "兼职",
"description": "兼职费",
"valid": true,
"reason": ""
}
示例 6:
用户输入:红包收入88
返回:
{
"intent": "RECORD_EXPENSE",
"recordType": "INCOME",
"amount": 88,
"category": "红包",
"description": "红包收入",
"valid": true,
"reason": ""
}
示例 7:
用户输入:我这个月花了多少钱
返回:
{
"intent": "QUERY_EXPENSE",
"recordType": null,
"amount": null,
"category": null,
"description": null,
"valid": true,
"reason": ""
}
示例 8:
用户输入:我这个月收入多少
返回:
{
"intent": "QUERY_EXPENSE",
"recordType": null,
"amount": null,
"category": null,
"description": null,
"valid": true,
"reason": ""
}
示例 9:
用户输入:分析一下我这个月的消费情况
返回:
{
"intent": "ANALYZE_EXPENSE",
"recordType": null,
"amount": null,
"category": null,
"description": null,
"valid": true,
"reason": ""
}
示例 10:
用户输入:帮我把本月预算设置为1800
返回:
{
"intent": "SET_BUDGET",
"recordType": null,
"amount": 1800,
"category": null,
"description": "本月预算",
"valid": true,
"reason": ""
}
示例 11:
用户输入:我这个月会不会超预算
返回:
{
"intent": "BUDGET_RISK",
"recordType": null,
"amount": null,
"category": null,
"description": null,
"valid": true,
"reason": ""
}
示例 12:
用户输入:你好
返回:
{
"intent": "CHAT",
"recordType": null,
"amount": null,
"category": null,
"description": null,
"valid": true,
"reason": ""
}
示例 13:
用户输入:今天花了
返回:
{
"intent": "UNKNOWN",
"recordType": null,
"amount": null,
"category": null,
"description": null,
"valid": false,
"reason": "缺少金额和具体收支内容"
}
AIService
添加提示词
package com.test.service;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
public interface AIService {
@SystemMessage(fromResource = "agent-plan-prompt.txt")
String plan(@MemoryId String sessionId, @UserMessage String message);
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 200, "msg": "success", "data": { "reply": "AI识别结果:意图=RECORD_EXPENSE,金额=18,类别=饮品,描述=奶茶,valid=true, reason=", "actions": [] } }
3.2.2意图判断
创建意图识别规则枚举类
IntentType
package com.test.enums;
public enum IntentType {
RECORD_EXPENSE,
QUERY_EXPENSE,
ANALYZE_EXPENSE,
SET_BUDGET,
BUDGET_RISK,
CHAT,
UNKNOWN
}
创建意图识别的接口,返回意图枚举和原始信息
AgentIntentService
package com.test.service;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
public interface AgentIntentService {
IntentType decideIntent(String message, AgentPlan plan);
}
AgentIntentServiceImpl
实习意图识别接口类
package com.test.service.impl;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import org.springframework.stereotype.Service;
@Service
public class AgentIntentServiceImpl implements AgentIntentService {
@Override
public IntentType decideIntent(String message, AgentPlan plan) {
if (message == null || message.trim().isEmpty()) {
return IntentType.UNKNOWN;
}
// 1. 查询类优先,避免“花了多少钱”被误判为记账
if (isQueryMessage(message)) {
return IntentType.QUERY_EXPENSE;
}
// 2. 分析类
if (isAnalyzeMessage(message)) {
return IntentType.ANALYZE_EXPENSE;
}
// 3. 预算设置
if (isSetBudgetMessage(message)) {
return IntentType.SET_BUDGET;
}
// 4. 预算风险
if (isBudgetRiskMessage(message)) {
return IntentType.BUDGET_RISK;
}
// 5. AI 解析成功时,参考 AI intent
if (plan != null && Boolean.TRUE.equals(plan.getValid())) {
String intent = plan.getIntent();
if ("RECORD_EXPENSE".equals(intent)) {
return IntentType.RECORD_EXPENSE;
}
if ("QUERY_EXPENSE".equals(intent)) {
return IntentType.QUERY_EXPENSE;
}
if ("ANALYZE_EXPENSE".equals(intent)) {
return IntentType.ANALYZE_EXPENSE;
}
if ("SET_BUDGET".equals(intent)) {
return IntentType.SET_BUDGET;
}
if ("BUDGET_RISK".equals(intent)) {
return IntentType.BUDGET_RISK;
}
if ("CHAT".equals(intent)) {
return IntentType.CHAT;
}
}
// 6. 最后再用简单规则判断记账
if (isRecordExpenseMessage(message)) {
return IntentType.RECORD_EXPENSE;
}
return IntentType.CHAT;
}
private boolean isQueryMessage(String message) {
return message.contains("多少钱")
|| message.contains("花了多少")
|| message.contains("一共消费")
|| message.contains("总共消费")
|| message.contains("消费多少");
}
private boolean isAnalyzeMessage(String message) {
return message.contains("分析")
|| message.contains("消费情况")
|| message.contains("消费结构");
}
private boolean isSetBudgetMessage(String message) {
return message.contains("设置预算")
|| message.contains("预算设置")
|| message.contains("预算为")
|| message.contains("预算设置为");
}
private boolean isBudgetRiskMessage(String message) {
return message.contains("超预算")
|| message.contains("超支")
|| message.contains("够不够")
|| message.contains("还能控制");
}
private boolean isRecordExpenseMessage(String message) {
return message.matches(".*\\d+.*")
&& !isQueryMessage(message);
}
}
在实现类中加入意图识别,返回最终的意图
AgentServiceImpl
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析用户输入
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 规则 + AI 结果,判断最终意图
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
AgentChatResponse response = new AgentChatResponse();
response.setReply(
"AI识别结果:意图=" + plan.getIntent()
+ ",金额=" + plan.getAmount()
+ ",类别=" + plan.getCategory()
+ ",描述=" + plan.getDescription()
+ ",valid=" + plan.getValid()
+ ",最终意图=" + finalIntent
);
response.setActions(actions);
return response;
}
}
3.3.1根据最终意图 finalIntent 分发到 Handler
创建根据意图去执行方法的Handler类
创建记账调度器
AgentRecordHandler
DAO还没完善,这里先模拟存储成功
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AgentRecordHandler {
public AgentChatResponse handle(Long userId,
String sessionId,
String message,
AgentPlan plan,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
// 1. 判断 AI 是否解析成功
if (plan == null || !Boolean.TRUE.equals(plan.getValid())) {
response.setReply("我没有识别出完整的记账信息,请换一种说法,例如:今天奶茶18。");
response.setActions(actions);
return response;
}
// 2. 判断金额是否存在
if (plan.getAmount() == null) {
response.setReply("我识别到你想记账,但没有识别到金额,请补充金额。");
response.setActions(actions);
return response;
}
// 3. 类别兜底
String category = plan.getCategory();
if (category == null || category.trim().isEmpty()) {
category = "其他";
}
// 4. 描述兜底
String description = plan.getDescription();
if (description == null || description.trim().isEmpty()) {
description = "日常消费";
}
// 5. 先不写数据库,先模拟执行成功
AgentAction action = new AgentAction();
action.setName("recordExpense");
action.setSuccess(true);
action.setMessage("记账动作已识别,当前为模拟保存");
actions.add(action);
response.setReply("好嘞,已识别到一笔消费:"
+ category
+ plan.getAmount()
+ "元,"
+ description
+ "。");
response.setActions(actions);
return response;
}
}
把意图分发写入AgentServiceImpl 实现类
AgentServiceImpl
使用Switch去判断类型
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
System.out.println("最终意图:" + finalIntent);
// 3. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
return agentRecordHandler.handle(userId, sessionId, message, plan, actions);
case QUERY_EXPENSE:
return buildTempResponse("已识别为消费查询,后续接入查询 Handler。", actions);
case ANALYZE_EXPENSE:
return buildTempResponse("已识别为消费分析,后续接入分析 Handler。", actions);
case SET_BUDGET:
return buildTempResponse("已识别为预算设置,后续接入预算 Handler。", actions);
case BUDGET_RISK:
return buildTempResponse("已识别为预算风险分析,后续接入预算风险 Handler。", actions);
case CHAT:
default:
return buildTempResponse("已识别为普通聊天,后续接入闲聊回复。", actions);
}
}
private AgentChatResponse buildTempResponse(String reply, List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
response.setReply(reply);
response.setActions(actions);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已识别到一笔消费:饮品18元,奶茶。", "actions": [ { "name": "recordExpense", "success": true, "message": "记账动作已识别,当前为模拟保存" } ] } }
4.数据库表
创建数据库表
5.业务实现
5.1.1记账功能
ExpenseRecord
创建记账实体类ExpenseRecord
package com.test.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@TableName("expense_record")
public class ExpenseRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private BigDecimal amount;
private Long categoryId;
private String category;
/**
* EXPENSE 支出,INCOME 收入
*/
private String recordType;
private String description;
/**
* 记账日期:例如 2026-05-29
*/
private LocalDate expenseTime;
/**
* 具体发生时间,可选
*/
private LocalDateTime expenseDatetime;
private Long accountId;
/**
* AGENT、MANUAL、IMPORT
*/
private String sourceType;
private String sourceText;
private String sessionId;
private String remark;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
ExpenseRecord
对接数据库继续操作的记账mapper
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.ExpenseRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ExpenseRecordMapper extends BaseMapper<ExpenseRecord> {
}
BulterAgentApplication
启动类添加扫mapper
package com.test;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("com.test.mapper")
public class BulterAgentApplication {
public static void main(String[] args) {
SpringApplication.run(BulterAgentApplication.class, args);
}
}
ExpenseRecordService
创建记账功能service接口
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.entity.ExpenseRecord;
import java.math.BigDecimal;
import java.time.LocalDate;
public interface ExpenseRecordService extends IService<ExpenseRecord> {
Long createRecord(Long userId,
BigDecimal amount,
String category,
String recordType,
String description,
LocalDate expenseTime,
String sourceType,
String sourceText,
String sessionId);
}
ExpenseRecordServiceImpl
实现service接口
package com.test.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.entity.ExpenseRecord;
import com.test.mapper.ExpenseRecordMapper;
import com.test.service.ExpenseRecordService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Service
public class ExpenseRecordServiceImpl
extends ServiceImpl<ExpenseRecordMapper, ExpenseRecord>
implements ExpenseRecordService {
@Override
public Long createRecord(Long userId,
BigDecimal amount,
String category,
String recordType,
String description,
LocalDate expenseTime,
String sourceType,
String sourceText,
String sessionId) {
// 参数校验
if (userId == null) {
throw new RuntimeException("用户ID不能为空");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new RuntimeException("金额必须大于0");
}
if (category == null || category.trim().isEmpty()) {
category = "其他";
}
if (description == null || description.trim().isEmpty()) {
description = "日常消费";
}
if (expenseTime == null) {
expenseTime = LocalDate.now();
}
ExpenseRecord record = new ExpenseRecord();
record.setUserId(userId);
record.setAmount(amount);
record.setCategory(category);
record.setRecordType("EXPENSE");
record.setDescription(description);
record.setExpenseTime(expenseTime);
record.setExpenseDatetime(LocalDateTime.now());
record.setSourceType("AGENT");
record.setSourceText(sourceText);
record.setSessionId(sessionId);
record.setDeleted(0);
this.save(record);
return record.getId();
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天工资到账5000" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已识别到一笔消费:工资5000元,工资到账。", "actions": [ { "name": "recordExpense", "success": true, "message": "记账动作已识别,当前为模拟保存" } ] } }{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已识别到一笔消费:饮品18元,奶茶。", "actions": [ { "name": "recordExpense", "success": true, "message": "记账动作已识别,当前为模拟保存" } ] } }
兜底
ExpenseRecordServiceImpl
package com.test.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.entity.ExpenseRecord;
import com.test.mapper.ExpenseRecordMapper;
import com.test.service.ExpenseRecordService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Service
public class ExpenseRecordServiceImpl
extends ServiceImpl<ExpenseRecordMapper, ExpenseRecord>
implements ExpenseRecordService {
@Override
public Long createRecord(Long userId,
BigDecimal amount,
String category,
String recordType,
String description,
LocalDate expenseTime,
String sourceType,
String sourceText,
String sessionId) {
// 1. 基础参数校验
if (userId == null) {
throw new RuntimeException("用户ID不能为空");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new RuntimeException("金额必须大于0");
}
// 2. 字段兜底
if (category == null || category.trim().isEmpty()) {
category = "其他";
}
if (description == null || description.trim().isEmpty()) {
description = "日常记录";
}
if (expenseTime == null) {
expenseTime = LocalDate.now();
}
// 3. 类型标准化
recordType = normalizeRecordType(recordType);
sourceType = normalizeSourceType(sourceType);
// 4. 构建实体对象
ExpenseRecord record = new ExpenseRecord();
record.setUserId(userId);
record.setAmount(amount);
record.setCategory(category);
record.setRecordType(recordType);
record.setDescription(description);
record.setExpenseTime(expenseTime);
record.setExpenseDatetime(LocalDateTime.now());
record.setSourceType(sourceType);
record.setSourceText(sourceText);
record.setSessionId(sessionId);
record.setDeleted(0);
// 5. 保存数据库
this.save(record);
// 6. 返回数据库生成的ID
return record.getId();
}
private String normalizeRecordType(String recordType) {
if ("INCOME".equalsIgnoreCase(recordType)) {
return "INCOME";
}
return "EXPENSE";
}
private String normalizeSourceType(String sourceType) {
if ("MANUAL".equalsIgnoreCase(sourceType)) {
return "MANUAL";
}
if ("IMPORT".equalsIgnoreCase(sourceType)) {
return "IMPORT";
}
if ("SYSTEM".equalsIgnoreCase(sourceType)) {
return "SYSTEM";
}
return "AGENT";
}
}
接入数据库业务
AgentRecordHandler
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.service.ExpenseRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
@Service
public class AgentRecordHandler {
@Autowired
private ExpenseRecordService expenseRecordService;
public AgentChatResponse handle(Long userId,
String sessionId,
String message,
AgentPlan plan,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
// 1. 判断 AI 是否解析成功
if (plan == null || !Boolean.TRUE.equals(plan.getValid())) {
response.setReply("我没有识别出完整的记账信息,请换一种说法,例如:今天奶茶18。");
response.setActions(actions);
return response;
}
// 2. 判断金额是否存在
if (plan.getAmount() == null) {
response.setReply("我识别到你想记账,但没有识别到金额,请补充金额。");
response.setActions(actions);
return response;
}
// 3. 类别兜底
String category = plan.getCategory();
if (category == null || category.trim().isEmpty()) {
category = "其他";
}
// 4. 描述兜底
String description = plan.getDescription();
if (description == null || description.trim().isEmpty()) {
description = "日常记录";
}
// 5. 收支类型兜底
String recordType = plan.getRecordType();
if (recordType == null || recordType.trim().isEmpty()) {
recordType = "EXPENSE";
}
// 6. 调用业务 Service,真正保存到数据库
Long recordId = expenseRecordService.createRecord(
userId,
plan.getAmount(),
category,
recordType,
description,
LocalDate.now(),
"AGENT",
message,
sessionId
);
// 7. 组装 action
AgentAction action = new AgentAction();
if ("INCOME".equalsIgnoreCase(recordType)) {
action.setName("recordIncome");
action.setMessage("收入记录已保存,记录ID:" + recordId);
} else {
action.setName("recordExpense");
action.setMessage("支出记录已保存,记录ID:" + recordId);
}
action.setSuccess(true);
actions.add(action);
// 8. 组装回复
String typeText = "INCOME".equalsIgnoreCase(recordType) ? "收入" : "支出";
response.setReply("好嘞,已帮你记下一笔"
+ typeText
+ ":"
+ category
+ plan.getAmount()
+ "元,"
+ description
+ "。");
response.setActions(actions);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已帮你记下一笔支出:饮品18元,奶茶。", "actions": [ { "name": "recordExpense", "success": true, "message": "支出记录已保存,记录ID:1" } ] } }{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天工资到账5000" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已帮你记下一笔收入:工资5000元,工资到账。", "actions": [ { "name": "recordIncome", "success": true, "message": "收入记录已保存,记录ID:2" } ] } }
5.2.1查询功能
在ExpenseRecordService里添加查询方法
ExpenseRecordService
添加sumAmountByDateRange方法
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.entity.ExpenseRecord;
import java.math.BigDecimal;
import java.time.LocalDate;
public interface ExpenseRecordService extends IService<ExpenseRecord> {
Long createRecord(Long userId,
BigDecimal amount,
String category,
String recordType,
String description,
LocalDate expenseTime,
String sourceType,
String sourceText,
String sessionId);
BigDecimal sumAmountByDateRange(Long userId,
String recordType,
LocalDate startDate,
LocalDate endDate);
}
在实现类中实现方法
ExpenseRecordServiceImpl.sumAmountByDateRange
@Override
public BigDecimal sumAmountByDateRange(Long userId,
String recordType,
LocalDate startDate,
LocalDate endDate) {
if (userId == null) {
throw new RuntimeException("用户ID不能为空");
}
if (startDate == null || endDate == null) {
throw new RuntimeException("查询时间范围不能为空");
}
recordType = normalizeRecordType(recordType);
QueryWrapper<ExpenseRecord> queryWrapper = new QueryWrapper<>();
queryWrapper.select("IFNULL(SUM(amount), 0) AS amount")
.eq("user_id", userId)
.eq("record_type", recordType)
.ge("expense_time", startDate)
.le("expense_time", endDate)
.eq("deleted", 0);
ExpenseRecord record = this.getOne(queryWrapper);
if (record == null || record.getAmount() == null) {
return BigDecimal.ZERO;
}
return record.getAmount();
}
创建查询调度器,根据 意图去分发查询功能
AgentStatisticsHandler
创建AgentStatisticsHandler
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.service.ExpenseRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@Service
public class AgentStatisticsHandler {
@Autowired
private ExpenseRecordService expenseRecordService;
public AgentChatResponse handleQuery(Long userId,
String message,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
if (userId == null) {
response.setReply("用户ID不能为空,无法查询消费记录。");
response.setActions(actions);
return response;
}
LocalDate now = LocalDate.now();
LocalDate startDate = now.withDayOfMonth(1);
LocalDate endDate = now.withDayOfMonth(now.lengthOfMonth());
BigDecimal totalExpense = expenseRecordService.sumAmountByDateRange(
userId,
"EXPENSE",
startDate,
endDate
);
BigDecimal totalIncome = expenseRecordService.sumAmountByDateRange(
userId,
"INCOME",
startDate,
endDate
);
BigDecimal balance = totalIncome.subtract(totalExpense);
AgentAction action = new AgentAction();
action.setName("queryExpense");
action.setSuccess(true);
action.setMessage("消费查询成功");
actions.add(action);
if (message != null && message.contains("收入")) {
response.setReply("你本月收入共 " + totalIncome + " 元。");
} else if (message != null && message.contains("结余")) {
response.setReply("你本月收入共 " + totalIncome
+ " 元,支出共 " + totalExpense
+ " 元,结余 " + balance + " 元。");
} else {
response.setReply("你本月支出共 " + totalExpense + " 元。");
}
response.setActions(actions);
return response;
}
}
修改AgentServiceImpl,把写好的调度器写入实现类
AgentServiceImpl
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
// 3. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
return agentRecordHandler.handle(userId, sessionId, message, plan, actions);
case QUERY_EXPENSE:
return agentStatisticsHandler.handleQuery(userId, message, actions);
case ANALYZE_EXPENSE:
return buildTempResponse("已识别为消费分析,后续接入分析 Handler。", actions);
case SET_BUDGET:
return buildTempResponse("已识别为预算设置,后续接入预算 Handler。", actions);
case BUDGET_RISK:
return buildTempResponse("已识别为预算风险分析,后续接入预算风险 Handler。", actions);
case CHAT:
default:
return buildTempResponse("已识别为普通聊天,后续接入闲聊回复。", actions);
}
}
private AgentChatResponse buildTempResponse(String reply, List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
response.setReply(reply);
response.setActions(actions);
return response;
}
}
测试
{
“userId”: 1,
“sessionId”: “session_test_backend_001”,
“message”: “今天花了多少钱呀”
}{
“code”: 200,
“msg”: “success”,
“data”: {
“reply”: “你本月支出共 18.00 元。”,
“actions”: [
{
“name”: “queryExpense”,
“success”: true,
“message”: “消费查询成功”
}
]
}
}
问题:现在只能查当月支出
增加时间范围查询
AgentStatisticsHandler
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.service.ExpenseRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.List;
@Service
public class AgentStatisticsHandler {
@Autowired
private ExpenseRecordService expenseRecordService;
public AgentChatResponse handleQuery(Long userId,
String message,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
if (userId == null) {
response.setReply("用户ID不能为空,无法查询消费记录。");
response.setActions(actions);
return response;
}
// 1. 根据用户原话解析查询时间范围
DateRange dateRange = resolveDateRange(message);
LocalDate startDate = dateRange.getStartDate();
LocalDate endDate = dateRange.getEndDate();
String timeText = dateRange.getText();
// 2. 查询支出总额
BigDecimal totalExpense = expenseRecordService.sumAmountByDateRange(
userId,
"EXPENSE",
startDate,
endDate
);
// 3. 查询收入总额
BigDecimal totalIncome = expenseRecordService.sumAmountByDateRange(
userId,
"INCOME",
startDate,
endDate
);
// 4. 计算结余
BigDecimal balance = totalIncome.subtract(totalExpense);
// 5. 添加动作记录
AgentAction action = new AgentAction();
action.setName("queryExpense");
action.setSuccess(true);
action.setMessage("查询成功");
actions.add(action);
// 6. 根据用户问法返回不同内容
if (message != null && message.contains("收入")) {
response.setReply("你" + timeText + "收入共 " + totalIncome + " 元。");
} else if (message != null && message.contains("结余")) {
response.setReply("你" + timeText + "收入共 " + totalIncome
+ " 元,支出共 " + totalExpense
+ " 元,结余 " + balance + " 元。");
} else {
response.setReply("你" + timeText + "支出共 " + totalExpense + " 元。");
}
response.setActions(actions);
return response;
}
/**
* 根据用户输入解析查询时间范围
*/
private DateRange resolveDateRange(String message) {
LocalDate now = LocalDate.now();
if (message == null || message.trim().isEmpty()) {
return getMonthRange(now);
}
// 今天
if (message.contains("今天") || message.contains("今日")) {
return new DateRange(now, now, "今天");
}
// 昨天
if (message.contains("昨天")) {
LocalDate yesterday = now.minusDays(1);
return new DateRange(yesterday, yesterday, "昨天");
}
// 本周 / 这周
if (message.contains("本周") || message.contains("这周")) {
LocalDate startDate = now.with(DayOfWeek.MONDAY);
LocalDate endDate = now.with(DayOfWeek.SUNDAY);
return new DateRange(startDate, endDate, "本周");
}
// 上周
if (message.contains("上周")) {
LocalDate lastWeek = now.minusWeeks(1);
LocalDate startDate = lastWeek.with(DayOfWeek.MONDAY);
LocalDate endDate = lastWeek.with(DayOfWeek.SUNDAY);
return new DateRange(startDate, endDate, "上周");
}
// 本月 / 这个月
if (message.contains("本月")
|| message.contains("这个月")
|| message.contains("这月")) {
return getMonthRange(now);
}
// 上个月 / 上月
if (message.contains("上个月") || message.contains("上月")) {
LocalDate lastMonth = now.minusMonths(1);
LocalDate startDate = lastMonth.withDayOfMonth(1);
LocalDate endDate = lastMonth.withDayOfMonth(lastMonth.lengthOfMonth());
return new DateRange(startDate, endDate, "上月");
}
// 默认查本月
return getMonthRange(now);
}
/**
* 获取本月范围
*/
private DateRange getMonthRange(LocalDate now) {
LocalDate startDate = now.withDayOfMonth(1);
LocalDate endDate = now.withDayOfMonth(now.lengthOfMonth());
return new DateRange(startDate, endDate, "本月");
}
/**
* 查询时间范围对象
*/
private static class DateRange {
private final LocalDate startDate;
private final LocalDate endDate;
private final String text;
public DateRange(LocalDate startDate, LocalDate endDate, String text) {
this.startDate = startDate;
this.endDate = endDate;
this.text = text;
}
public LocalDate getStartDate() {
return startDate;
}
public LocalDate getEndDate() {
return endDate;
}
public String getText() {
return text;
}
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "这个月结余多少" }{
“code”: 200,
“msg”: “success”,
“data”: {
“reply”: “你本月收入共 5000.00 元,支出共 18.00 元,结余 4982.00 元。”,
“actions”: [
{
“name”: “queryExpense”,
“success”: true,
“message”: “查询成功”
}
]
}
}
5.3.1消费分析功能
创建实体类CategoryAmountDTO
CategoryAmountDTO
package com.test.dto;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class CategoryAmountDTO {
private String category;
private BigDecimal amount;
}
ExpenseRecordMapper
在mapper中添加分类统计的方法
按照 category 分组
统计每个分类的 amount 总和
按金额从高到低排序
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.dto.CategoryAmountDTO;
import com.test.entity.ExpenseRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.List;
@Mapper
public interface ExpenseRecordMapper extends BaseMapper<ExpenseRecord> {
@Select("""
SELECT
category AS category,
IFNULL(SUM(amount), 0) AS amount
FROM expense_record
WHERE user_id = #{userId}
AND record_type = #{recordType}
AND expense_time >= #{startDate}
AND expense_time <= #{endDate}
AND deleted = 0
GROUP BY category
ORDER BY amount DESC
""")
List<CategoryAmountDTO> sumAmountGroupByCategory(@Param("userId") Long userId,
@Param("recordType") String recordType,
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate);
}
ExpenseRecordService
在service层中实现sumAmountGroupByCategory方法的接口
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.dto.CategoryAmountDTO;
import com.test.entity.ExpenseRecord;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public interface ExpenseRecordService extends IService<ExpenseRecord> {
Long createRecord(Long userId,
BigDecimal amount,
String category,
String recordType,
String description,
LocalDate expenseTime,
String sourceType,
String sourceText,
String sessionId);
BigDecimal sumAmountByDateRange(Long userId,
String recordType,
LocalDate startDate,
LocalDate endDate);
List<CategoryAmountDTO> sumAmountGroupByCategory(Long userId,
String recordType,
LocalDate startDate,
LocalDate endDate);
}
ExpenseRecordServiceImpl.sumAmountGroupByCategory
实现接口
@Override
public List<CategoryAmountDTO> sumAmountGroupByCategory(Long userId,
String recordType,
LocalDate startDate,
LocalDate endDate) {
if (userId == null) {
throw new RuntimeException("用户ID不能为空");
}
if (startDate == null || endDate == null) {
throw new RuntimeException("查询时间范围不能为空");
}
recordType = normalizeRecordType(recordType);
return this.baseMapper.sumAmountGroupByCategory(
userId,
recordType,
startDate,
endDate
);
}
AgentStatisticsHandler
在查询调度器中添加分析方法
public AgentChatResponse handleAnalyze(Long userId,
String message,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
if (userId == null) {
response.setReply("用户ID不能为空,无法分析消费情况。");
response.setActions(actions);
return response;
}
DateRange dateRange = resolveDateRange(message);
LocalDate startDate = dateRange.getStartDate();
LocalDate endDate = dateRange.getEndDate();
String timeText = dateRange.getText();
BigDecimal totalExpense = expenseRecordService.sumAmountByDateRange(
userId,
"EXPENSE",
startDate,
endDate
);
List<CategoryAmountDTO> categoryList = expenseRecordService.sumAmountGroupByCategory(
userId,
"EXPENSE",
startDate,
endDate
);
AgentAction action = new AgentAction();
action.setName("analyzeExpense");
action.setSuccess(true);
action.setMessage("消费分析成功");
actions.add(action);
if (totalExpense.compareTo(BigDecimal.ZERO) == 0) {
response.setReply("你" + timeText + "还没有支出记录,暂时无法生成消费分析。");
response.setActions(actions);
return response;
}
StringBuilder reply = new StringBuilder();
reply.append("你")
.append(timeText)
.append("支出共 ")
.append(totalExpense)
.append(" 元。");
reply.append(" 分类来看,");
for (int i = 0; i < categoryList.size(); i++) {
CategoryAmountDTO item = categoryList.get(i);
BigDecimal percent = item.getAmount()
.multiply(new BigDecimal("100"))
.divide(totalExpense, 2, java.math.RoundingMode.HALF_UP);
reply.append(item.getCategory())
.append(" ")
.append(item.getAmount())
.append(" 元,占比 ")
.append(percent)
.append("%");
if (i < categoryList.size() - 1) {
reply.append(";");
} else {
reply.append("。");
}
}
response.setReply(reply.toString());
response.setActions(actions);
return response;
}
AgentServiceImpl
把分析功能放入AgentServiceImpl中替换功能调度
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
// 3. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
return agentRecordHandler.handle(userId, sessionId, message, plan, actions);
case QUERY_EXPENSE:
return agentStatisticsHandler.handleQuery(userId, message, actions);
case ANALYZE_EXPENSE:
return agentStatisticsHandler.handleAnalyze(userId, message, actions);
case SET_BUDGET:
return buildTempResponse("已识别为预算设置,后续接入预算 Handler。", actions);
case BUDGET_RISK:
return buildTempResponse("已识别为预算风险分析,后续接入预算风险 Handler。", actions);
case CHAT:
default:
return buildTempResponse("已识别为普通聊天,后续接入闲聊回复。", actions);
}
}
private AgentChatResponse buildTempResponse(String reply, List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
response.setReply(reply);
response.setActions(actions);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "分析一下我这个月的消费情况" }{ "code": 200, "msg": "success", "data": { "reply": "你本月支出共 18.00 元。 分类来看,饮品 18.00 元,占比 100.00%。", "actions": [ { "name": "analyzeExpense", "success": true, "message": "消费分析成功" } ] } }
5.4.1预算功能
Budget
预算实体类的创建
package com.test.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("budget")
public class Budget {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
/**
* 预算月份,例如 2026-05
*/
private String budgetMonth;
private Long categoryId;
/**
* TOTAL 表示总预算,餐饮/饮品/交通表示分类预算
*/
private String category;
private BigDecimal amount;
/**
* 预警比例,例如 80 表示使用 80% 时预警
*/
private BigDecimal warningRate;
private Integer enabled;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
BudgetMapper
在mapper中添加预算的方法,查询数据库
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.Budget;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface BudgetMapper extends BaseMapper<Budget> {
}
BudgetService
创建预算方法的接口
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.entity.Budget;
import java.math.BigDecimal;
public interface BudgetService extends IService<Budget> {
Long setBudget(Long userId,
String budgetMonth,
String category,
BigDecimal amount);
}
BudgetServiceImpl
在实现类中实现预算功能
package com.test.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.entity.Budget;
import com.test.mapper.BudgetMapper;
import com.test.service.BudgetService;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
@Service
public class BudgetServiceImpl
extends ServiceImpl<BudgetMapper, Budget>
implements BudgetService {
@Override
public Long setBudget(Long userId,
String budgetMonth,
String category,
BigDecimal amount) {
if (userId == null) {
throw new RuntimeException("用户ID不能为空");
}
if (budgetMonth == null || budgetMonth.trim().isEmpty()) {
throw new RuntimeException("预算月份不能为空");
}
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new RuntimeException("预算金额必须大于0");
}
if (category == null || category.trim().isEmpty()) {
category = "TOTAL";
}
QueryWrapper<Budget> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId)
.eq("budget_month", budgetMonth)
.eq("category", category)
.eq("deleted", 0);
Budget budget = this.getOne(queryWrapper);
if (budget == null) {
budget = new Budget();
budget.setUserId(userId);
budget.setBudgetMonth(budgetMonth);
budget.setCategory(category);
budget.setAmount(amount);
budget.setWarningRate(new BigDecimal("80.00"));
budget.setEnabled(1);
budget.setDeleted(0);
this.save(budget);
} else {
budget.setAmount(amount);
budget.setEnabled(1);
this.updateById(budget);
}
return budget.getId();
}
}
AgentBudgetHandler
创建 预算调度器 实现有关预算的功能
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.service.BudgetService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.YearMonth;
import java.util.List;
@Service
public class AgentBudgetHandler {
@Autowired
private BudgetService budgetService;
public AgentChatResponse handleSetBudget(Long userId,
String message,
AgentPlan plan,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
if (userId == null) {
response.setReply("用户ID不能为空,无法设置预算。");
response.setActions(actions);
return response;
}
if (plan == null || !Boolean.TRUE.equals(plan.getValid())) {
response.setReply("我没有识别出完整的预算信息,请换一种说法,例如:帮我把本月预算设置为1800。");
response.setActions(actions);
return response;
}
if (plan.getAmount() == null) {
response.setReply("我识别到你想设置预算,但没有识别到预算金额,请补充金额。");
response.setActions(actions);
return response;
}
String budgetMonth = YearMonth.now().toString();
String category = plan.getCategory();
if (category == null || category.trim().isEmpty()) {
category = "TOTAL";
}
Long budgetId = budgetService.setBudget(
userId,
budgetMonth,
category,
plan.getAmount()
);
AgentAction action = new AgentAction();
action.setName("setBudget");
action.setSuccess(true);
action.setMessage("预算设置成功,预算ID:" + budgetId);
actions.add(action);
if ("TOTAL".equals(category)) {
response.setReply("已帮你把" + budgetMonth + "的总预算设置为 "
+ plan.getAmount() + " 元。");
} else {
response.setReply("已帮你把" + budgetMonth + "的"
+ category + "预算设置为 "
+ plan.getAmount() + " 元。");
}
response.setActions(actions);
return response;
}
}
AgentServiceImpl
把功能写入实现类中
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Autowired
private AgentBudgetHandler agentBudgetHandler;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
// 3. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
return agentRecordHandler.handle(userId, sessionId, message, plan, actions);
case QUERY_EXPENSE:
return agentStatisticsHandler.handleQuery(userId, message, actions);
case ANALYZE_EXPENSE:
return agentStatisticsHandler.handleAnalyze(userId, message, actions);
case SET_BUDGET:
return agentBudgetHandler.handleSetBudget(userId, message, plan, actions);
case BUDGET_RISK:
return buildTempResponse("已识别为预算风险分析,后续接入预算风险 Handler。", actions);
case CHAT:
default:
return buildTempResponse("已识别为普通聊天,后续接入闲聊回复。", actions);
}
}
private AgentChatResponse buildTempResponse(String reply, List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
response.setReply(reply);
response.setActions(actions);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "帮我把本月预算设置为1800" }{ "code": 200, "msg": "success", "data": { "reply": "已帮你把2026-05的总预算设置为 1800 元。", "actions": [ { "name": "setBudget", "success": true, "message": "预算设置成功,预算ID:1" } ] } }
5.4.2预算风险判断能力
BudgetService
BudgetService添加查询预算方法
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.entity.Budget;
import java.math.BigDecimal;
public interface BudgetService extends IService<Budget> {
Long setBudget(Long userId,
String budgetMonth,
String category,
BigDecimal amount);
Budget getBudget(Long userId,
String budgetMonth,
String category);
}
BudgetServiceImpl
在 BudgetServiceImpl 里实现 getBudget
@Override
public Budget getBudget(Long userId,
String budgetMonth,
String category) {
if (userId == null) {
throw new RuntimeException("用户ID不能为空");
}
if (budgetMonth == null || budgetMonth.trim().isEmpty()) {
throw new RuntimeException("预算月份不能为空");
}
if (category == null || category.trim().isEmpty()) {
category = "TOTAL";
}
QueryWrapper<Budget> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("user_id", userId)
.eq("budget_month", budgetMonth)
.eq("category", category)
.eq("enabled", 1)
.eq("deleted", 0);
return this.getOne(queryWrapper);
}
AgentBudgetRiskHandler
创建功能调度器AgentBudgetRiskHandler
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.entity.Budget;
import com.test.service.BudgetService;
import com.test.service.ExpenseRecordService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.util.List;
@Service
public class AgentBudgetRiskHandler {
@Autowired
private BudgetService budgetService;
@Autowired
private ExpenseRecordService expenseRecordService;
public AgentChatResponse handle(Long userId,
String message,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
if (userId == null) {
response.setReply("用户ID不能为空,无法判断预算风险。");
response.setActions(actions);
return response;
}
// 1. 当前先默认判断本月总预算
YearMonth yearMonth = YearMonth.now();
String budgetMonth = yearMonth.toString();
LocalDate startDate = yearMonth.atDay(1);
LocalDate endDate = yearMonth.atEndOfMonth();
// 2. 查询本月总预算
Budget budget = budgetService.getBudget(
userId,
budgetMonth,
"TOTAL"
);
if (budget == null || budget.getAmount() == null) {
response.setReply("你还没有设置" + budgetMonth + "的总预算,请先设置预算,例如:帮我把本月预算设置为1800。");
response.setActions(actions);
return response;
}
BigDecimal budgetAmount = budget.getAmount();
if (budgetAmount.compareTo(BigDecimal.ZERO) <= 0) {
response.setReply("当前预算金额无效,请重新设置预算。");
response.setActions(actions);
return response;
}
// 3. 查询本月支出
BigDecimal usedAmount = expenseRecordService.sumAmountByDateRange(
userId,
"EXPENSE",
startDate,
endDate
);
// 4. 计算剩余预算
BigDecimal remainingAmount = budgetAmount.subtract(usedAmount);
// 5. 计算使用率
BigDecimal usageRate = usedAmount
.multiply(new BigDecimal("100"))
.divide(budgetAmount, 2, RoundingMode.HALF_UP);
// 6. 判断风险等级
String level;
String advice;
if (usageRate.compareTo(new BigDecimal("100")) >= 0) {
level = "OVER";
advice = "已经超出预算,建议接下来控制非必要支出。";
} else if (usageRate.compareTo(new BigDecimal("90")) >= 0) {
level = "DANGER";
advice = "预算使用率已经很高,接下来要谨慎消费。";
} else if (usageRate.compareTo(new BigDecimal("80")) >= 0) {
level = "WARNING";
advice = "预算使用率接近预警线,建议适当控制消费。";
} else {
level = "NORMAL";
advice = "目前预算比较安全,可以继续保持。";
}
// 7. 添加 action
AgentAction action = new AgentAction();
action.setName("checkBudgetRisk");
action.setSuccess(true);
action.setMessage("预算风险判断成功,风险等级:" + level);
actions.add(action);
// 8. 返回回复
response.setReply("你" + budgetMonth + "的总预算是 "
+ budgetAmount
+ " 元,目前已支出 "
+ usedAmount
+ " 元,预算使用率为 "
+ usageRate
+ "%,剩余预算 "
+ remainingAmount
+ " 元。"
+ advice);
response.setActions(actions);
return response;
}
}
AgentServiceImpl
把调度器放入AgentServiceImpl
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Autowired
private AgentBudgetHandler agentBudgetHandler;
@Autowired
private AgentBudgetRiskHandler agentBudgetRiskHandler;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
// 3. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
return agentRecordHandler.handle(userId, sessionId, message, plan, actions);
case QUERY_EXPENSE:
return agentStatisticsHandler.handleQuery(userId, message, actions);
case ANALYZE_EXPENSE:
return agentStatisticsHandler.handleAnalyze(userId, message, actions);
case SET_BUDGET:
return agentBudgetHandler.handleSetBudget(userId, message, plan, actions);
case BUDGET_RISK:
return agentBudgetRiskHandler.handle(userId, message, actions);
case CHAT:
default:
return buildTempResponse("已识别为普通聊天,后续接入闲聊回复。", actions);
}
}
private AgentChatResponse buildTempResponse(String reply, List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
response.setReply(reply);
response.setActions(actions);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "帮我把本月预算设置为1800" }{ "code": 200, "msg": "success", "data": { "reply": "已帮你把2026-06的总预算设置为 1800 元。", "actions": [ { "name": "setBudget", "success": true, "message": "预算设置成功,预算ID:5" } ] } }{ "userId": 1, "sessionId": "session_test_backend_001", "message": "我这个月会不会超预算" }{ "code": 200, "msg": "success", "data": { "reply": "你2026-06的总预算是 1800.00 元,目前已支出 0.00 元,预算使用率为 0.00%,剩余预算 1800.00 元。目前预算比较安全,可以继续保持。", "actions": [ { "name": "checkBudgetRisk", "success": true, "message": "预算风险判断成功,风险等级:NORMAL" } ] } }
5.5.1对话模块
在AIservice中添加chat方法,引入实现对话的方法
package com.test.service;
import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
public interface AIService {
@SystemMessage(fromResource = "agent-plan-prompt.txt")
String plan(@MemoryId String sessionId, @UserMessage String message);
@SystemMessage(fromResource = "system-prompt.txt")
String chat(@MemoryId String sessionId, @UserMessage String message);
}
system-prompt
你是“小布”,一个 AI 个人记账管家。
你的主要职责:
1. 帮助用户记录消费、查询消费、分析消费和管理预算。
2. 当用户表达记账、查询、分析等业务需求时,不要自行声称已完成操作,具体业务结果由后端系统确认。
3. 当用户只是闲聊、吐槽或说废话时,可以自然回应,但不要偏离“个人记账管家”的身份。
你的性格:
1. 亲切、自然、像朋友一样。
2. 可以适当幽默,但不要夸张表演。
3. 不要长篇角色扮演。
4. 不要模仿任何动漫、影视、游戏或真实人物角色。
5. 不要使用“小新”“蜡笔小新”“动感超人”等具体 IP 人设。
6. 不要描写复杂动作、舞台表演或夸张场景。
7. 不要大量使用 emoji。
8. 普通闲聊回复控制在 2 到 4 句话。
9. 每次回复尽量不超过 120 个中文字符。
10. 不要主动编造消费记录。
11. 不要建议用户记录虚构消费,例如“精神损耗费”“摸鱼费”“负数消费”等。
12. 如果用户情绪低落,先简单安慰,再给一个轻量建议。
回复风格要求:
1. 简洁。
2. 温和。
3. 不说教。
4. 不过度可爱。
5. 不主动展开太多内容。
6. 除非用户明确要求,否则不要列很多条建议。
示例:
用户:你好
回复:你好呀,我是小布,你的 AI 记账管家。你可以让我帮你记账、查消费,也可以随便聊两句。
用户:我不想上班
回复:懂你,有时候真的会有点提不起劲。先别太逼自己,今天先完成最重要的一件事就很好了。
用户:今天好烦
回复:辛苦了,今天可能确实不太顺。先缓一缓,别急着否定自己。
用户:你是谁
回复:我是小布,一个可以帮你记账、查消费、做消费分析的 AI 管家。
5.5.2对话功能
AgentChatReplyService
创建需要实现功能的service类
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.service.AIService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AgentChatReplyService {
@Autowired
private AIService aiService;
public AgentChatResponse chat(String sessionId,
String message,
List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
try {
String reply = aiService.chat(sessionId, message);
response.setReply(reply);
response.setActions(actions);
return response;
} catch (Exception e) {
response.setReply("我刚刚有点走神了,你可以再说一遍吗?");
response.setActions(actions);
return response;
}
}
}
AgentService
将对话添加到AgentService中
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.AgentIntentService;
import com.test.service.AgentPlanService;
import com.test.service.AgentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Autowired
private AgentBudgetHandler agentBudgetHandler;
@Autowired
private AgentBudgetRiskHandler agentBudgetRiskHandler;
@Autowired
private AgentChatReplyService agentChatReplyService;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 2. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
// 3. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
return agentRecordHandler.handle(userId, sessionId, message, plan, actions);
case QUERY_EXPENSE:
return agentStatisticsHandler.handleQuery(userId, message, actions);
case ANALYZE_EXPENSE:
return agentStatisticsHandler.handleAnalyze(userId, message, actions);
case SET_BUDGET:
return agentBudgetHandler.handleSetBudget(userId, message, plan, actions);
case BUDGET_RISK:
return agentBudgetRiskHandler.handle(userId, message, actions);
case CHAT:
default:
return agentChatReplyService.chat(sessionId, message, actions);
}
}
private AgentChatResponse buildTempResponse(String reply, List<AgentAction> actions) {
AgentChatResponse response = new AgentChatResponse();
response.setReply(reply);
response.setActions(actions);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "你好" }{ "code": 200, "msg": "success", "data": { "reply": "你好呀,我是小布,你的 AI 记账管家~ \n记账、查账、分析开销,或者随便聊聊,我都在。", "actions": [] } }
5.6.1记忆功能优化
现在的ai记忆功能并不能复用同一个会话记忆,目前只是LangChain4j的内存窗口记忆
考虑先把对话记忆持久化到mysql中,后期使用redis进行二次处理
5.6.2对话记录持久化到 MySQL
AgentChatMessage
创建信息存储的实体类
package com.test.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("agent_chat_message")
public class AgentChatMessage {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String sessionId;
/**
* USER、ASSISTANT、SYSTEM
*/
private String role;
private String content;
/**
* AI识别意图
*/
private String intent;
/**
* 后端最终意图
*/
private String finalIntent;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
AgentChatMessageMapper
创建mapper访问数据库
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.AgentChatMessage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentChatMessageMapper extends BaseMapper<AgentChatMessage> {
}
AgentChatMessageService
创建service类实现业务功能
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.entity.AgentChatMessage;
public interface AgentChatMessageService extends IService<AgentChatMessage> {
void saveMessage(Long userId,
String sessionId,
String role,
String content,
String intent,
String finalIntent);
}
创建Impl去实现对应的方法
AgentChatMessageServiceImpl
package com.test.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.entity.AgentChatMessage;
import com.test.mapper.AgentChatMessageMapper;
import com.test.service.AgentChatMessageService;
import org.springframework.stereotype.Service;
@Service
public class AgentChatMessageServiceImpl
extends ServiceImpl<AgentChatMessageMapper, AgentChatMessage>
implements AgentChatMessageService {
@Override
public void saveMessage(Long userId,
String sessionId,
String role,
String content,
String intent,
String finalIntent) {
if (userId == null || sessionId == null || sessionId.trim().isEmpty()) {
return;
}
if (content == null || content.trim().isEmpty()) {
return;
}
AgentChatMessage message = new AgentChatMessage();
message.setUserId(userId);
message.setSessionId(sessionId);
message.setRole(role);
message.setContent(content);
message.setIntent(intent);
message.setFinalIntent(finalIntent);
message.setDeleted(0);
this.save(message);
}
}
AgentServiceImpl
修改 AgentServiceImpl中chat方法,增加一个保存助手信息
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. 先保存用户消息
agentChatMessageService.saveMessage(
userId,
sessionId,
"USER",
message,
null,
null
);
// 2. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 3. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
AgentChatResponse response;
// 4. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
response = agentRecordHandler.handle(userId, sessionId, message, plan, actions);
break;
case QUERY_EXPENSE:
response = agentStatisticsHandler.handleQuery(userId, message, actions);
break;
case ANALYZE_EXPENSE:
response = agentStatisticsHandler.handleAnalyze(userId, message, actions);
break;
case SET_BUDGET:
response = agentBudgetHandler.handleSetBudget(userId, message, plan, actions);
break;
case BUDGET_RISK:
response = agentBudgetRiskHandler.handle(userId, message, actions);
break;
case CHAT:
default:
response = agentChatReplyService.chat(sessionId, message, actions);
break;
}
// 5. 保存助手回复
agentChatMessageService.saveMessage(
userId,
sessionId,
"ASSISTANT",
response.getReply(),
plan == null ? null : plan.getIntent(),
finalIntent == null ? null : finalIntent.name()
);
return response;
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "海底捞115" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已帮你记下一笔支出:餐饮115元,海底捞。", "actions": [ { "name": "recordExpense", "success": true, "message": "支出记录已保存,记录ID:4" } ] } }3,1,session_test_backend_001,USER,海底捞115,,,2026-06-01 10:57:02,2026-06-01 10:57:02,0 4,1,session_test_backend_001,ASSISTANT,好嘞,已帮你记下一笔支出:餐饮115元,海底捞。,RECORD_EXPENSE,RECORD_EXPENSE,2026-06-01 10:57:04,2026-06-01 10:57:04,0
5.7.1Agent 动作日志持久化
把Agent的操作结果保存成日志
AgentActionLog
创建对应的实体类
package com.test.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;
@Data
@TableName("agent_action_log")
public class AgentActionLog {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private String sessionId;
/**
* 动作名称:recordExpense、recordIncome、queryExpense、setBudget 等
*/
private String actionName;
/**
* 是否成功:1成功,0失败
*/
private Integer success;
/**
* 动作说明
*/
private String message;
/**
* 用户原始输入
*/
private String requestText;
/**
* 动作结果JSON,当前可先不用
*/
private String resultData;
/**
* 关联业务记录ID,例如 expense_record.id,当前可先不填
*/
private Long relatedRecordId;
private LocalDateTime createTime;
}
AgentActionLogMapper
创建 AgentActionLogMapper
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.AgentActionLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentActionLogMapper extends BaseMapper<AgentActionLog> {
}
AgentActionLogService
创建 AgentActionLogService
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.dto.AgentAction;
import com.test.entity.AgentActionLog;
import java.util.List;
public interface AgentActionLogService extends IService<AgentActionLog> {
void saveActions(Long userId,
String sessionId,
String requestText,
List<AgentAction> actions);
}
AgentActionLogServiceImpl
package com.test.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.dto.AgentAction;
import com.test.entity.AgentActionLog;
import com.test.mapper.AgentActionLogMapper;
import com.test.service.AgentActionLogService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class AgentActionLogServiceImpl
extends ServiceImpl<AgentActionLogMapper, AgentActionLog>
implements AgentActionLogService {
@Override
public void saveActions(Long userId,
String sessionId,
String requestText,
List<AgentAction> actions) {
if (userId == null || actions == null || actions.isEmpty()) {
return;
}
for (AgentAction action : actions) {
if (action == null) {
continue;
}
AgentActionLog log = new AgentActionLog();
log.setUserId(userId);
log.setSessionId(sessionId);
log.setActionName(action.getName());
log.setSuccess(Boolean.TRUE.equals(action.getSuccess()) ? 1 : 0);
log.setMessage(action.getMessage());
log.setRequestText(requestText);
this.save(log);
}
}
}
AgentServiceImpl
修改 AgentServiceImpl,添加操作日志保存方法
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Autowired
private AgentBudgetHandler agentBudgetHandler;
@Autowired
private AgentBudgetRiskHandler agentBudgetRiskHandler;
@Autowired
private AgentChatReplyService agentChatReplyService;
@Autowired
private AgentChatMessageService agentChatMessageService;
@Autowired
private AgentActionLogService agentActionLogService;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message) {
List<AgentAction> actions = new ArrayList<>();
// 1. 先保存用户消息
agentChatMessageService.saveMessage(
userId,
sessionId,
"USER",
message,
null,
null
);
// 2. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 3. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
AgentChatResponse response;
// 4. 根据最终意图分发
switch (finalIntent) {
case RECORD_EXPENSE:
response = agentRecordHandler.handle(userId, sessionId, message, plan, actions);
break;
case QUERY_EXPENSE:
response = agentStatisticsHandler.handleQuery(userId, message, actions);
break;
case ANALYZE_EXPENSE:
response = agentStatisticsHandler.handleAnalyze(userId, message, actions);
break;
case SET_BUDGET:
response = agentBudgetHandler.handleSetBudget(userId, message, plan, actions);
break;
case BUDGET_RISK:
response = agentBudgetRiskHandler.handle(userId, message, actions);
break;
case CHAT:
default:
response = agentChatReplyService.chat(sessionId, message, actions);
break;
}
// 5. 保存助手回复
agentChatMessageService.saveMessage(
userId,
sessionId,
"ASSISTANT",
response.getReply(),
plan == null ? null : plan.getIntent(),
finalIntent == null ? null : finalIntent.name()
);
//6. 操作日志保存
agentActionLogService.saveActions(
userId,
sessionId,
message,
response.getActions()
);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "海底捞115" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已帮你记下一笔支出:餐饮115元,海底捞。", "actions": [ { "name": "recordExpense", "success": true, "message": "支出记录已保存,记录ID:5" } ] } }1,1,session_test_backend_001,recordExpense,1,支出记录已保存,记录ID:5,海底捞115,,,2026-06-01 11:05:32
5.8.1Redis 短期会话缓存 / 会话记忆优化
配置redis
application
server:
port: 8181
servlet:
encoding:
charset: UTF-8
force: true
spring:
application:
name: Butler
datasource:
url: jdbc:mysql://localhost:3306/bulter_agent?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
db-config:
logic-delete-field: deleted
logic-delete-value: 1
logic-not-delete-value: 0
langchain4j:
community:
dashscope:
chat-model:
api-key: ${AI_API_KEY}
model-name: qwen-long
embedding-model:
api-key: ${AI_API_KEY}
model-name: text-embedding-v4
streaming-chat-model:
api-key: ${AI_API_KEY}
model-name: qwen-long
knife4j:
enable: true
setting:
language: zh_cn
springdoc:
group-configs:
- group: 默认接口
paths-to-match: /**
packages-to-scan: com.test.controller
pom
pom中添加redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.8.2Redis 短期会话缓存
AgentMemoryCacheService
创建AgentMemoryCacheService业务
package com.test.service;
import java.util.List;
public interface AgentMemoryCacheService {
void appendMessage(String sessionId, String role, String content);
List<String> getRecentMessages(String sessionId);
}
实现功能
AgentMemoryCacheServiceImpl
package com.test.service.impl;
import com.test.service.AgentMemoryCacheService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Service
public class AgentMemoryCacheServiceImpl implements AgentMemoryCacheService {
private static final String KEY_PREFIX = "agent:chat:memory:";
/**
* 每个 sessionId 最多保留最近 10 条消息
*/
private static final int MAX_MESSAGE_COUNT = 10;
/**
* Redis 会话缓存 24 小时过期
*/
private static final Duration TTL = Duration.ofHours(24);
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void appendMessage(String sessionId, String role, String content) {
if (sessionId == null || sessionId.trim().isEmpty()) {
return;
}
if (content == null || content.trim().isEmpty()) {
return;
}
String key = buildKey(sessionId);
String value = role + ":" + content + "|" + LocalDateTime.now();
// 1. 从右侧追加消息,保证时间顺序
stringRedisTemplate.opsForList().rightPush(key, value);
// 2. 只保留最近 10 条
stringRedisTemplate.opsForList().trim(key, -MAX_MESSAGE_COUNT, -1);
// 3. 设置过期时间
stringRedisTemplate.expire(key, TTL);
}
@Override
public List<String> getRecentMessages(String sessionId) {
if (sessionId == null || sessionId.trim().isEmpty()) {
return Collections.emptyList();
}
String key = buildKey(sessionId);
List<String> messages = stringRedisTemplate.opsForList().range(key, 0, -1);
if (messages == null) {
return Collections.emptyList();
}
return messages;
}
private String buildKey(String sessionId) {
return KEY_PREFIX + sessionId;
}
}
AgentServiceImpl
修改 AgentServiceImpl,添加redis缓存
package com.test.service.impl;
import com.test.dto.AgentAction;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentPlan;
import com.test.enums.IntentType;
import com.test.service.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class AgentServiceImpl implements AgentService {
@Autowired
private AgentPlanService agentPlanService;
@Autowired
private AgentIntentService agentIntentService;
@Autowired
private AgentRecordHandler agentRecordHandler;
@Autowired
private AgentStatisticsHandler agentStatisticsHandler;
@Autowired
private AgentBudgetHandler agentBudgetHandler;
@Autowired
private AgentBudgetRiskHandler agentBudgetRiskHandler;
@Autowired
private AgentChatReplyService agentChatReplyService;
@Autowired
private AgentChatMessageService agentChatMessageService;
@Autowired
private AgentActionLogService agentActionLogService;
@Autowired
private AgentMemoryCacheService agentMemoryCacheService;
@Override
public AgentChatResponse chat(Long userId, String sessionId, String message){
List<AgentAction> actions = new ArrayList<>();
// 1. 保存用户消息到 MySQL
agentChatMessageService.saveMessage(
userId,
sessionId,
"USER",
message,
null,
null
);
// 2. 保存用户消息到 Redis
agentMemoryCacheService.appendMessage(
sessionId,
"USER",
message
);
// 3. AI 解析计划
AgentPlan plan = agentPlanService.parsePlan(sessionId, message);
// 4. 后端最终意图裁决
IntentType finalIntent = agentIntentService.decideIntent(message, plan);
AgentChatResponse response;
switch (finalIntent) {
case RECORD_EXPENSE:
response = agentRecordHandler.handle(userId, sessionId, message, plan, actions);
break;
case QUERY_EXPENSE:
response = agentStatisticsHandler.handleQuery(userId, message, actions);
break;
case ANALYZE_EXPENSE:
response = agentStatisticsHandler.handleAnalyze(userId, message, actions);
break;
case SET_BUDGET:
response = agentBudgetHandler.handleSetBudget(userId, message, plan, actions);
break;
case BUDGET_RISK:
response = agentBudgetRiskHandler.handle(userId, message, actions);
break;
case CHAT:
default:
response = agentChatReplyService.chat(sessionId, message, actions);
break;
}
// 5. 保存助手回复到 MySQL
agentChatMessageService.saveMessage(
userId,
sessionId,
"ASSISTANT",
response.getReply(),
plan == null ? null : plan.getIntent(),
finalIntent == null ? null : finalIntent.name()
);
// 6. 保存助手回复到 Redis
agentMemoryCacheService.appendMessage(
sessionId,
"ASSISTANT",
response.getReply()
);
// 7. 保存 actions 到 MySQL
agentActionLogService.saveActions(
userId,
sessionId,
message,
response.getActions()
);
return response;
}
}
测试
{ "userId": 1, "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已帮你记下一笔支出:饮品18元,奶茶。", "actions": [ { "name": "recordExpense", "success": true, "message": "支出记录已保存,记录ID:6" } ] } }USER:今天奶茶18|2026-06-01T11: 25: 59.719210700 ASSISTANT:好嘞,已帮你记下一笔支出:饮品18元,奶茶。|2026-06-01T11: 26: 02.418277100
5.9.1短信验证码基础模块
SmsSendVO
创建发送验证码的实体类
package com.test.dto;
import lombok.Data;
@Data
public class SmsSendVO {
private String mobile;
/**
* 场景:LOGIN、REGISTER、RESET_PASSWORD
*/
private String scene;
}
SmsVerifyVO
创建校验验证码的实体类
package com.test.dto;
import lombok.Data;
@Data
public class SmsVerifyVO {
private String mobile;
private String scene;
private String code;
}
SmsService
实现短信业务
package com.test.service;
public interface SmsService {
void sendCode(String mobile, String scene);
boolean verifyCode(String mobile, String scene, String code);
}
SmsServiceImpl
package com.test.service.impl;
import com.test.service.SmsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.Random;
@Service
public class SmsServiceImpl implements SmsService {
private static final String CODE_KEY_PREFIX = "sms:code:";
private static final String COOLDOWN_KEY_PREFIX = "sms:cooldown:";
private static final Duration CODE_TTL = Duration.ofMinutes(5);
private static final Duration COOLDOWN_TTL = Duration.ofSeconds(60);
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public void sendCode(String mobile, String scene) {
if (mobile == null || mobile.trim().isEmpty()) {
throw new RuntimeException("手机号不能为空");
}
if (scene == null || scene.trim().isEmpty()) {
scene = "LOGIN";
}
String cooldownKey = buildCooldownKey(scene, mobile);
Boolean hasCooldown = stringRedisTemplate.hasKey(cooldownKey);
if (Boolean.TRUE.equals(hasCooldown)) {
throw new RuntimeException("验证码发送过于频繁,请稍后再试");
}
String code = generateCode();
String codeKey = buildCodeKey(scene, mobile);
stringRedisTemplate.opsForValue().set(codeKey, code, CODE_TTL);
stringRedisTemplate.opsForValue().set(cooldownKey, "1", COOLDOWN_TTL);
// 第一版先模拟发送,后面再接阿里云短信
System.out.println("短信验证码发送成功,mobile=" + mobile + ",scene=" + scene + ",code=" + code);
}
@Override
public boolean verifyCode(String mobile, String scene, String code) {
if (mobile == null || mobile.trim().isEmpty()) {
throw new RuntimeException("手机号不能为空");
}
if (code == null || code.trim().isEmpty()) {
throw new RuntimeException("验证码不能为空");
}
if (scene == null || scene.trim().isEmpty()) {
scene = "LOGIN";
}
String codeKey = buildCodeKey(scene, mobile);
String redisCode = stringRedisTemplate.opsForValue().get(codeKey);
if (redisCode == null) {
return false;
}
boolean success = redisCode.equals(code);
if (success) {
stringRedisTemplate.delete(codeKey);
}
return success;
}
private String buildCodeKey(String scene, String mobile) {
return CODE_KEY_PREFIX + scene + ":" + mobile;
}
private String buildCooldownKey(String scene, String mobile) {
return COOLDOWN_KEY_PREFIX + scene + ":" + mobile;
}
private String generateCode() {
int code = new Random().nextInt(900000) + 100000;
return String.valueOf(code);
}
}
SmsController
controller层调用短信业务
package com.test.controller;
import com.test.dto.SmsSendVO;
import com.test.dto.SmsVerifyVO;
import com.test.service.SmsService;
import com.test.util.ResultVO;
import com.test.util.ResultVOUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/sms")
public class SmsController {
@Autowired
private SmsService smsService;
@PostMapping("/send")
public ResultVO sendCode(@RequestBody SmsSendVO request) {
smsService.sendCode(request.getMobile(), request.getScene());
return ResultVOUtil.success("验证码已发送");
}
@PostMapping("/verify")
public ResultVO verifyCode(@RequestBody SmsVerifyVO request) {
boolean success = smsService.verifyCode(
request.getMobile(),
request.getScene(),
request.getCode()
);
if (success) {
return ResultVOUtil.success("验证码校验成功");
}
return ResultVOUtil.fail("验证码错误或已过期");
}
}
测试
{ "mobile": "13300000000", "scene": "LOGIN" }{ "code": 200, "msg": "success", "data": "验证码已发送" }短信验证码发送成功,mobile=13300000000,scene=LOGIN,code=742793{ "mobile": "13300000000", "scene": "LOGIN", "code": "742793" }{ "code": 200, "msg": "success", "data": "验证码校验成功" }
5.9.2短信日志
SmsSendLog
发生验证码的SmsSendLog实体类
package com.test.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;
@Data
@TableName("sms_send_log")
public class SmsSendLog {
@TableId(type = IdType.AUTO)
private Long id;
private String mobile;
private String scene;
private String templateCode;
private String content;
private String codeHash;
private String provider;
private String providerRequestId;
/**
* 1成功,0失败
*/
private Integer sendStatus;
private String failReason;
private String ipAddress;
private LocalDateTime expireTime;
/**
* 1已使用,0未使用
*/
private Integer used;
private LocalDateTime createTime;
}
SmsVerifyLog
校验验证码的SmsVerifyLog 实体类
package com.test.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;
@Data
@TableName("sms_verify_log")
public class SmsVerifyLog {
@TableId(type = IdType.AUTO)
private Long id;
private String mobile;
private String scene;
/**
* 1成功,0失败
*/
private Integer verifyStatus;
private String failReason;
private String ipAddress;
private LocalDateTime createTime;
}
mapper数据库层操作
SmsSendLogMapper
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.SmsSendLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmsSendLogMapper extends BaseMapper<SmsSendLog> {
}
SmsVerifyLogMapper
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.SmsVerifyLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SmsVerifyLogMapper extends BaseMapper<SmsVerifyLog> {
}
SmsServiceImpl
package com.test.service.impl;
import com.test.entity.SmsSendLog;
import com.test.entity.SmsVerifyLog;
import com.test.mapper.SmsSendLogMapper;
import com.test.mapper.SmsVerifyLogMapper;
import com.test.service.SmsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Random;
@Service
public class SmsServiceImpl implements SmsService {
private static final String CODE_KEY_PREFIX = "sms:code:";
private static final String COOLDOWN_KEY_PREFIX = "sms:cooldown:";
private static final Duration CODE_TTL = Duration.ofMinutes(5);
private static final Duration COOLDOWN_TTL = Duration.ofSeconds(60);
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private SmsSendLogMapper smsSendLogMapper;
@Autowired
private SmsVerifyLogMapper smsVerifyLogMapper;
@Override
public void sendCode(String mobile, String scene) {
if (mobile == null || mobile.trim().isEmpty()) {
throw new RuntimeException("手机号不能为空");
}
if (scene == null || scene.trim().isEmpty()) {
scene = "LOGIN";
}
String cooldownKey = buildCooldownKey(scene, mobile);
Boolean hasCooldown = stringRedisTemplate.hasKey(cooldownKey);
if (Boolean.TRUE.equals(hasCooldown)) {
throw new RuntimeException("验证码发送过于频繁,请稍后再试");
}
String code = generateCode();
String codeKey = buildCodeKey(scene, mobile);
stringRedisTemplate.opsForValue().set(codeKey, code, CODE_TTL);
stringRedisTemplate.opsForValue().set(cooldownKey, "1", COOLDOWN_TTL);
SmsSendLog sendLog = new SmsSendLog();
sendLog.setMobile(mobile);
sendLog.setScene(scene);
sendLog.setTemplateCode("MOCK_LOGIN_CODE");
sendLog.setContent("模拟短信验证码");
sendLog.setCodeHash(code);
sendLog.setProvider("MOCK");
sendLog.setProviderRequestId(null);
sendLog.setSendStatus(1);
sendLog.setFailReason(null);
sendLog.setExpireTime(LocalDateTime.now().plusMinutes(5));
sendLog.setUsed(0);
smsSendLogMapper.insert(sendLog);
// 第一版先模拟发送,后面再接阿里云短信
System.out.println("短信验证码发送成功,mobile=" + mobile + ",scene=" + scene + ",code=" + code);
}
@Override
public boolean verifyCode(String mobile, String scene, String code) {
if (mobile == null || mobile.trim().isEmpty()) {
throw new RuntimeException("手机号不能为空");
}
if (code == null || code.trim().isEmpty()) {
throw new RuntimeException("验证码不能为空");
}
if (scene == null || scene.trim().isEmpty()) {
scene = "LOGIN";
}
String codeKey = buildCodeKey(scene, mobile);
String redisCode = stringRedisTemplate.opsForValue().get(codeKey);
if (redisCode == null) {
return false;
}
boolean success = redisCode.equals(code);
SmsVerifyLog verifyLog = new SmsVerifyLog();
verifyLog.setMobile(mobile);
verifyLog.setScene(scene);
if (success) {
verifyLog.setVerifyStatus(1);
verifyLog.setFailReason(null);
} else {
verifyLog.setVerifyStatus(0);
verifyLog.setFailReason(redisCode == null ? "验证码已过期或不存在" : "验证码错误");
}
smsVerifyLogMapper.insert(verifyLog);
if (success) {
stringRedisTemplate.delete(codeKey);
}
return success;
}
private String buildCodeKey(String scene, String mobile) {
return CODE_KEY_PREFIX + scene + ":" + mobile;
}
private String buildCooldownKey(String scene, String mobile) {
return COOLDOWN_KEY_PREFIX + scene + ":" + mobile;
}
private String generateCode() {
int code = new Random().nextInt(900000) + 100000;
return String.valueOf(code);
}
}
测试
{ "mobile": "13300000000", "scene": "LOGIN" }{ "code": 200, "msg": "success", "data": "验证码已发送" }短信验证码发送成功,mobile=13300000000,scene=LOGIN,code=102618{ "mobile": "13300000000", "scene": "LOGIN", "code": "102618" }{ "code": 200, "msg": "success", "data": "验证码校验成功" }1,13300000000,LOGIN,1,,,2026-06-01 11:51:07
5.10.1手机号验证码登录 / 自动注册 + Token 生成
对应提类的生成
LoginBySmsVO
package com.test.dto;
import lombok.Data;
@Data
public class LoginBySmsVO {
private String mobile;
private String code;
/**
* 默认 LOGIN
*/
private String scene;
}
LoginResponse
新建 LoginResponse
package com.test.dto;
import lombok.Data;
@Data
public class LoginResponse {
private Long userId;
private String mobile;
private String token;
}
AppUser
创建 AppUser 实体类
package com.test.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("app_user")
public class AppUser {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String nickname;
private String mobile;
private Integer mobileVerified;
private String email;
private String password;
private String avatarUrl;
private Integer gender;
private Integer status;
private LocalDateTime lastLoginTime;
private String lastLoginIp;
private LocalDateTime createTime;
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
AppUserMapper
package com.test.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.test.entity.AppUser;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AppUserMapper extends BaseMapper<AppUser> {
}
AppUserService
package com.test.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.test.entity.AppUser;
public interface AppUserService extends IService<AppUser> {
AppUser getOrCreateByMobile(String mobile);
}
AppUserServiceImpl
package com.test.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.test.entity.AppUser;
import com.test.mapper.AppUserMapper;
import com.test.service.AppUserService;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class AppUserServiceImpl
extends ServiceImpl<AppUserMapper, AppUser>
implements AppUserService {
@Override
public AppUser getOrCreateByMobile(String mobile) {
if (mobile == null || mobile.trim().isEmpty()) {
throw new RuntimeException("手机号不能为空");
}
QueryWrapper<AppUser> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("mobile", mobile)
.eq("deleted", 0);
AppUser user = this.getOne(queryWrapper);
if (user != null) {
user.setMobileVerified(1);
user.setLastLoginTime(LocalDateTime.now());
this.updateById(user);
return user;
}
user = new AppUser();
user.setUsername("user_" + mobile);
user.setNickname("用户" + mobile.substring(mobile.length() - 4));
user.setMobile(mobile);
user.setMobileVerified(1);
user.setStatus(1);
user.setGender(0);
user.setLastLoginTime(LocalDateTime.now());
user.setDeleted(0);
this.save(user);
return user;
}
}
AuthService
package com.test.service;
import com.test.dto.LoginResponse;
public interface AuthService {
LoginResponse loginBySms(String mobile, String scene, String code);
}
AuthServiceImpl
package com.test.service.impl;
import com.test.dto.LoginResponse;
import com.test.entity.AppUser;
import com.test.service.AppUserService;
import com.test.service.AuthService;
import com.test.service.SmsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.UUID;
@Service
public class AuthServiceImpl implements AuthService {
private static final String TOKEN_KEY_PREFIX = "login:token:";
private static final Duration TOKEN_TTL = Duration.ofDays(7);
@Autowired
private SmsService smsService;
@Autowired
private AppUserService appUserService;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public LoginResponse loginBySms(String mobile, String scene, String code) {
if (scene == null || scene.trim().isEmpty()) {
scene = "LOGIN";
}
boolean verified = smsService.verifyCode(mobile, scene, code);
if (!verified) {
throw new RuntimeException("验证码错误或已过期");
}
AppUser user = appUserService.getOrCreateByMobile(mobile);
String token = UUID.randomUUID().toString().replace("-", "");
String tokenKey = TOKEN_KEY_PREFIX + token;
stringRedisTemplate.opsForValue().set(
tokenKey,
String.valueOf(user.getId()),
TOKEN_TTL
);
LoginResponse response = new LoginResponse();
response.setUserId(user.getId());
response.setMobile(user.getMobile());
response.setToken(token);
return response;
}
}
AuthController
package com.test.controller;
import com.test.dto.LoginBySmsVO;
import com.test.dto.LoginResponse;
import com.test.service.AuthService;
import com.test.util.ResultVO;
import com.test.util.ResultVOUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/auth")
public class AuthController {
@Autowired
private AuthService authService;
@PostMapping("/login/sms")
public ResultVO<LoginResponse> loginBySms(@RequestBody LoginBySmsVO request) {
LoginResponse response = authService.loginBySms(
request.getMobile(),
request.getScene(),
request.getCode()
);
return ResultVOUtil.success(response);
}
}
测试
sendCode
{ "mobile": "13300000000", "scene": "LOGIN" }{ "code": 200, "msg": "success", "data": "验证码已发送" }短信验证码发送成功,mobile=13300000000,scene=LOGIN,code=995499loginBySms
{ "mobile": "13300000000", "scene": "LOGIN", "code": "995499" }{ "code": 200, "msg": "success", "data": { "userId": 1, "mobile": "13300000000", "token": "44c3069d08974abeb6d8f6ddd47acada" } }
5.10.2Token 拦截器
后面调用 /agent/chat 时,不再让前端手动传 userId,而是从请求头里的 token 解析出当前用户 ID
解决明文传递的问题
UserContext
创建拦截器解析
package com.test.common;
public class UserContext {
private static final ThreadLocal<Long> USER_ID_HOLDER = new ThreadLocal<>();
public static void setUserId(Long userId) {
USER_ID_HOLDER.set(userId);
}
public static Long getUserId() {
return USER_ID_HOLDER.get();
}
public static void clear() {
USER_ID_HOLDER.remove();
}
}
TokenInterceptor
登录校验拦截器
package com.test.common;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
@Component
public class TokenInterceptor implements HandlerInterceptor {
private static final String TOKEN_KEY_PREFIX = "login:token:";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws IOException {
// 预检请求直接放行
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (token == null || token.trim().isEmpty()) {
writeUnauthorized(response, "未登录,请先登录");
return false;
}
// 兼容 Authorization: Bearer xxxxx
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}
String userIdStr = stringRedisTemplate.opsForValue().get(TOKEN_KEY_PREFIX + token);
if (userIdStr == null || userIdStr.trim().isEmpty()) {
writeUnauthorized(response, "登录已过期,请重新登录");
return false;
}
Long userId = Long.valueOf(userIdStr);
UserContext.setUserId(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
UserContext.clear();
}
private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
response.setStatus(401);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
String json = "{\"code\":401,\"msg\":\"" + msg + "\",\"data\":null}";
response.getWriter().write(json);
}
}
WebMvcConfig
注册拦截器 WebMvcConfig
package com.test.common;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.*;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private TokenInterceptor tokenInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(tokenInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/sms/**",
"/auth/**",
"/doc.html",
"/webjars/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/favicon.ico",
"/error"
);
}
}
AgentChatVO
修改 AgentChatVO
package com.test.dto;
import lombok.Data;
@Data
public class AgentChatVO {
// 使用token了,所以不能将id传递
// private Long userId;
private String sessionId;
private String message;
}
BulterAgentController
修改 BulterAgentController
package com.test.controller;
import com.test.common.UserContext;
import com.test.dto.AgentChatResponse;
import com.test.dto.AgentChatVO;
import com.test.service.AgentService;
import com.test.util.ResultVO;
import com.test.util.ResultVOUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/agent")
public class BulterAgentController {
@Autowired
private AgentService agentService;
@PostMapping("/chat")
public ResultVO chat(@RequestBody AgentChatVO chatmessage) {
Long userId = UserContext.getUserId();
AgentChatResponse chat = this.agentService.chat(
userId,
chatmessage.getSessionId(),
chatmessage.getMessage()
);
return ResultVOUtil.success(chat);
}
}
Knife4jConfig
因为要请求头携带token,所以测试中在header中添加参数
package com.test.common;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.servers.Server;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.oas.models.media.StringSchema;
import org.springdoc.core.customizers.OperationCustomizer;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
@Configuration
public class Knife4jConfig {
@Bean
public OpenAPI butlerOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Butler AI 个人记账助手接口文档")
.description("用于测试个人记账、预算管理、AI消费分析等接口")
.version("1.0.0")
.contact(new Contact().name("king")))
.servers(List.of(
new Server()
.url("http://localhost:8181")
.description("本地开发环境")
));
}
@Bean
public GroupedOpenApi butlerApi() {
return GroupedOpenApi.builder()
.group("Butler接口")
.packagesToScan("com.test")
.pathsToMatch("/**")
.addOperationCustomizer(globalHeader())
.build();
}
@Bean
public OperationCustomizer globalHeader() {
return (operation, handlerMethod) -> {
operation.addParametersItem(new Parameter()
.name("Authorization")
.in("header")
.required(false)
.description("登录Token,格式:Bearer token")
.schema(new StringSchema()));
return operation;
};
}
}
测试
{ "mobile": "13300000000", "scene": "LOGIN" }{ "code": 200, "msg": "success", "data": "验证码已发送" }短信验证码发送成功,mobile=13300000000,scene=LOGIN,code=327568{ "mobile": "13300000000", "scene": "LOGIN", "code": "327568" }{ "code": 200, "msg": "success", "data": { "userId": 1, "mobile": "13300000000", "token": "8d5610bf47074f108dbf701b32342ccc" } }{ "code": 200, "msg": "success", "data": { "reply": "好嘞,已帮你记下一笔支出:饮品18元,奶茶。", "actions": [ { "name": "recordExpense", "success": true, "message": "支出记录已保存,记录ID:7" } ] } }
5.11.1退出登录 / 注销 token
AuthService
添加退出登录的功能接口
package com.test.service;
import com.test.dto.LoginResponse;
public interface AuthService {
LoginResponse loginBySms(String mobile, String scene, String code);
void logout(String authorization);
}
AuthServiceImpl
在 AuthServiceImpl里加方法
@Override
public void logout(String authorization) {
if (authorization == null || authorization.trim().isEmpty()) {
throw new RuntimeException("Token不能为空");
}
String token = authorization.trim();
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}
if (token.trim().isEmpty()) {
throw new RuntimeException("Token不能为空");
}
String tokenKey = TOKEN_KEY_PREFIX + token;
stringRedisTemplate.delete(tokenKey);
}
完整逻辑就是:
Authorization: Bearer abcxxx
↓
截掉 Bearer
↓
得到 abcxxx
↓
删除 login:token:abcxxx
AuthController
加一个退出登录接口:
@PostMapping("/logout")
public ResultVO logout(@RequestHeader(value = "Authorization", required = false) String authorization) {
authService.logout(authorization);
return ResultVOUtil.success("退出登录成功");
}
测试
{ "mobile": "13300000000", "scene": "LOGIN" }{ "code": 200, "msg": "success", "data": "验证码已发送" }短信验证码发送成功,mobile=13300000000,scene=LOGIN,code=444867{ "code": 200, "msg": "success", "data": { "userId": 1, "mobile": "13300000000", "token": "806df39852ed4859a70f95a1aeca4167" } }{ "code": 200, "msg": "success", "data": "退出登录成功" }{ "sessionId": "session_test_backend_001", "message": "今天奶茶18" }{ "code": 401, "msg": "未登录,请先登录", "data": null }
5.11.2异常处理
GlobalExceptionHandler
package com.test.common;
import com.test.util.ResultVO;
import com.test.util.ResultVOUtil;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理普通运行时异常
*/
@ExceptionHandler(RuntimeException.class)
public ResultVO handleRuntimeException(RuntimeException e) {
return ResultVOUtil.fail(e.getMessage());
}
/**
* 处理参数校验异常,后面如果你加 @Valid 会用到
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultVO handleValidException(MethodArgumentNotValidException e) {
String msg = "参数校验失败";
if (e.getBindingResult().getFieldError() != null) {
msg = e.getBindingResult().getFieldError().getDefaultMessage();
}
return ResultVOUtil.fail(msg);
}
/**
* 兜底异常
*/
@ExceptionHandler(Exception.class)
public ResultVO handleException(Exception e) {
e.printStackTrace();
return ResultVOUtil.fail("系统异常,请稍后再试");
}
}
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)