完整项目地址

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=995499

loginBySms

{
"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("系统异常,请稍后再试");
    }
}
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐