diff --git a/src/main/java/com/accounting/config/AiConfig.java b/src/main/java/com/accounting/config/AiConfig.java
new file mode 100644
index 0000000..adabc18
--- /dev/null
+++ b/src/main/java/com/accounting/config/AiConfig.java
@@ -0,0 +1,128 @@
+package com.accounting.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.net.http.HttpClient;
+import java.time.Duration;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * AI(LLM)接入配置。
+ *
+ *
走 OpenAI 兼容的 chat/completions 协议,**不绑定任何供应商** ——
+ * DeepSeek / 通义(compatible-mode)/ 智谱 / OpenAI 都只需要改 base-url 和 model:
+ *
+ * DeepSeek https://api.deepseek.com/v1
+ * 通义 https://dashscope.aliyuncs.com/compatible-mode/v1
+ * 智谱 https://open.bigmodel.cn/api/paas/v4
+ * OpenAI https://api.openai.com/v1
+ *
+ * 拼接规则是 {@code base-url + "/chat/completions"}。
+ *
+ * api-key 只从环境变量读,application.yml 里不写默认值。key 为空时
+ * {@link #isReady()} 为 false,AI 接口自动降级为「未配置」提示,而不是 500。
+ */
+@Slf4j
+@Configuration
+public class AiConfig {
+
+ @Value("${ai.enabled:true}")
+ private boolean enabled;
+
+ /** 不带 /chat/completions 后缀 */
+ @Value("${ai.base-url:}")
+ private String baseUrl;
+
+ @Value("${ai.api-key:}")
+ private String apiKey;
+
+ @Value("${ai.model:}")
+ private String model;
+
+ /** 工具调用最多循环几轮:模型连续要数据时防止无限循环烧 token */
+ @Value("${ai.max-tool-rounds:3}")
+ private int maxToolRounds;
+
+ /** 单次对话最多带多少条历史消息,太久远的不带 */
+ @Value("${ai.max-history-messages:20}")
+ private int maxHistoryMessages;
+
+ public boolean isReady() {
+ return enabled && !baseUrl.isBlank() && !apiKey.isBlank() && !model.isBlank();
+ }
+
+ /**
+ * 启动时把配置状态打到日志里 —— 配错了不用猜,看 app.log 就知道。
+ * 注意**永远不打印 api-key 的内容**,只报长度。
+ */
+ @PostConstruct
+ public void logConfigStatus() {
+ if (isReady()) {
+ log.info("AI 已启用:base-url={}, model={}, api-key 长度={}",
+ getBaseUrl(), model, apiKey.length());
+ } else {
+ log.warn("AI 未配置,助手功能将降级(status 返回 false,对话推 error 事件):"
+ + "enabled={}, base-url={}, model={}, api-key={}",
+ enabled,
+ baseUrl.isBlank() ? "(空)" : baseUrl,
+ model.isBlank() ? "(空)" : model,
+ apiKey.isBlank() ? "(空)" : "已设置");
+ }
+ }
+
+ /**
+ * 容忍结尾多余的斜杠:否则会拼出 {@code //chat/completions},
+ * 网关直接 404,而且报错信息完全看不出是这个原因。
+ */
+ public String getBaseUrl() {
+ return baseUrl.replaceAll("/+$", "");
+ }
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public int getMaxToolRounds() {
+ return maxToolRounds;
+ }
+
+ public int getMaxHistoryMessages() {
+ return maxHistoryMessages;
+ }
+
+ /**
+ * 出站调 LLM 用的 HttpClient。
+ *
+ * 刻意不复用现有的 RestTemplate —— 那个 5 秒读超时撑不住 LLM 的流式生成。
+ * 用 JDK 自带的 HttpClient 是为了**零新增 Maven 依赖**,流式读响应体靠
+ * {@code BodyHandlers.ofInputStream()}。
+ */
+ @Bean
+ public HttpClient aiHttpClient() {
+ return HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+ }
+
+ /**
+ * SSE 的工作线程池:controller 立刻返回 SseEmitter,
+ * 上游的流式读取和工具循环都在这个池子里跑,不占用 Tomcat 请求线程。
+ */
+ @Bean(destroyMethod = "shutdown")
+ public ExecutorService aiExecutor() {
+ return Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "ai-chat");
+ t.setDaemon(true);
+ return t;
+ });
+ }
+}
diff --git a/src/main/java/com/accounting/controller/AiController.java b/src/main/java/com/accounting/controller/AiController.java
new file mode 100644
index 0000000..d874610
--- /dev/null
+++ b/src/main/java/com/accounting/controller/AiController.java
@@ -0,0 +1,152 @@
+package com.accounting.controller;
+
+import com.accounting.config.AiConfig;
+import com.accounting.dto.ai.ChatMessageItem;
+import com.accounting.dto.ai.ChatSendRequest;
+import com.accounting.dto.ai.SessionItem;
+import com.accounting.entity.ChatMessage;
+import com.accounting.entity.ChatSession;
+import com.accounting.entity.User;
+import com.accounting.mapper.ChatMessageMapper;
+import com.accounting.mapper.ChatSessionMapper;
+import com.accounting.mapper.UserMapper;
+import com.accounting.service.AiChatService;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * AI 助手接口。
+ *
+ * 会话与消息都按 userId 隔离(和 note 等模块同一套纪律)。
+ * /chat 走 SSE 流式,鉴权仍然是 JWT —— 没有在 SecurityConfig 里放行。
+ */
+@Slf4j
+@Tag(name = "AI 助手")
+@RestController
+@RequestMapping("/api/ai")
+public class AiController {
+
+ /** 会话列表最多返回多少条 —— 单用户场景 100 条绰绰有余 */
+ private static final int MAX_SESSIONS = 100;
+
+ @Autowired
+ private AiConfig aiConfig;
+
+ @Autowired
+ private AiChatService aiChatService;
+
+ @Autowired
+ private ChatSessionMapper sessionMapper;
+
+ @Autowired
+ private ChatMessageMapper messageMapper;
+
+ @Autowired
+ private UserMapper userMapper;
+
+ @Operation(summary = "AI 是否已配置(App 据此显示入口或提示)")
+ @GetMapping("/status")
+ public Map status() {
+ return Map.of("enabled", aiConfig.isReady());
+ }
+
+ @Operation(summary = "会话列表(按最近活跃倒序)")
+ @GetMapping("/sessions")
+ public List sessions(Authentication authentication) {
+ Long userId = getUserId(authentication);
+ List rows = sessionMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(ChatSession::getUserId, userId)
+ .orderByDesc(ChatSession::getUpdateTime)
+ .last("LIMIT " + MAX_SESSIONS));
+
+ return rows.stream().map(s -> {
+ SessionItem item = new SessionItem();
+ item.setId(s.getId());
+ item.setTitle(s.getTitle());
+ item.setUpdateTime(s.getUpdateTime());
+ return item;
+ }).toList();
+ }
+
+ @Operation(summary = "某个会话的消息(按时间正序)")
+ @GetMapping("/sessions/{id}/messages")
+ public List messages(@PathVariable Long id, Authentication authentication) {
+ Long userId = getUserId(authentication);
+ requireOwnedSession(userId, id);
+
+ return messageMapper.selectList(new LambdaQueryWrapper()
+ .eq(ChatMessage::getSessionId, id)
+ .orderByAsc(ChatMessage::getId))
+ .stream().map(m -> {
+ ChatMessageItem item = new ChatMessageItem();
+ item.setId(m.getId());
+ item.setRole(m.getRole());
+ item.setContent(m.getContent());
+ item.setCreateTime(m.getCreateTime());
+ return item;
+ }).toList();
+ }
+
+ @Operation(summary = "删除会话(连消息一起逻辑删除)")
+ @DeleteMapping("/sessions/{id}")
+ public Map deleteSession(@PathVariable Long id, Authentication authentication) {
+ Long userId = getUserId(authentication);
+ requireOwnedSession(userId, id);
+
+ sessionMapper.deleteById(id);
+ // 会话没了,消息也一并隐藏 —— 不然跨会话搜索时会捞出孤儿消息
+ messageMapper.update(null, new LambdaUpdateWrapper()
+ .eq(ChatMessage::getSessionId, id)
+ .set(ChatMessage::getDeleted, 1));
+ return Map.of("success", true);
+ }
+
+ @Operation(summary = "发起对话(SSE 流式;sessionId 为空则自动新建会话)")
+ @PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+ public SseEmitter chat(@Valid @RequestBody ChatSendRequest request, Authentication authentication) {
+ Long userId = getUserId(authentication);
+ return aiChatService.chat(userId, request.getSessionId(), request.getContent());
+ }
+
+ // ---------------------------------------------------------------- 内部方法
+
+ private ChatSession requireOwnedSession(Long userId, Long sessionId) {
+ ChatSession session = sessionMapper.selectById(sessionId);
+ if (session == null || !session.getUserId().equals(userId)) {
+ throw new IllegalArgumentException("会话不存在");
+ }
+ return session;
+ }
+
+ private Long getUserId(Authentication authentication) {
+ UserDetails userDetails = (UserDetails) authentication.getPrincipal();
+ String username = userDetails.getUsername();
+ User user = userMapper.selectOne(
+ new LambdaQueryWrapper().eq(User::getUsername, username)
+ );
+ if (user == null) {
+ throw new IllegalArgumentException("用户不存在");
+ }
+ return user.getId();
+ }
+}
diff --git a/src/main/java/com/accounting/dto/ai/ChatMessageItem.java b/src/main/java/com/accounting/dto/ai/ChatMessageItem.java
new file mode 100644
index 0000000..566a465
--- /dev/null
+++ b/src/main/java/com/accounting/dto/ai/ChatMessageItem.java
@@ -0,0 +1,19 @@
+package com.accounting.dto.ai;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/** 会话内的一条消息(只有 user / assistant 文本,工具调用的中间过程不入库) */
+@Data
+public class ChatMessageItem {
+
+ private Long id;
+
+ /** user / assistant */
+ private String role;
+
+ private String content;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/accounting/dto/ai/ChatSendRequest.java b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java
new file mode 100644
index 0000000..3c4d77d
--- /dev/null
+++ b/src/main/java/com/accounting/dto/ai/ChatSendRequest.java
@@ -0,0 +1,23 @@
+package com.accounting.dto.ai;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+/**
+ * 发起对话的请求。
+ *
+ * 只传会话 ID 和这条消息的内容 —— 历史由后端自己从 chat_message 表加载,
+ * App 不用(也不该)把历史搬来搬去。sessionId 传 null 表示新开会话,
+ * 后端会自动建一个、标题取这条消息的前 20 字,并在 done 事件里把新会话 ID 带回来。
+ */
+@Data
+public class ChatSendRequest {
+
+ /** 会话 ID,null = 自动新建 */
+ private Long sessionId;
+
+ @NotBlank(message = "消息内容不能为空")
+ @Size(max = 4000, message = "单条消息最长 4000 字")
+ private String content;
+}
diff --git a/src/main/java/com/accounting/dto/ai/SessionItem.java b/src/main/java/com/accounting/dto/ai/SessionItem.java
new file mode 100644
index 0000000..e911160
--- /dev/null
+++ b/src/main/java/com/accounting/dto/ai/SessionItem.java
@@ -0,0 +1,17 @@
+package com.accounting.dto.ai;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/** 会话列表项(不含消息) */
+@Data
+public class SessionItem {
+
+ private Long id;
+
+ private String title;
+
+ /** 最近活跃时间,列表按它倒序 */
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/accounting/entity/ChatMessage.java b/src/main/java/com/accounting/entity/ChatMessage.java
new file mode 100644
index 0000000..de467f0
--- /dev/null
+++ b/src/main/java/com/accounting/entity/ChatMessage.java
@@ -0,0 +1,35 @@
+package com.accounting.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;
+
+/**
+ * AI 消息
+ *
+ * 消息不可变。工具调用的中间过程(tool_calls / tool 结果)不入库 ——
+ * 历史里只有 user / assistant 文本,App 端因此不需要理解工具消息的格式。
+ */
+@Data
+@TableName("chat_message")
+public class ChatMessage {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long sessionId;
+
+ /** 角色:user / assistant */
+ private String role;
+
+ private String content;
+
+ @TableLogic
+ private Integer deleted;
+
+ private LocalDateTime createTime;
+}
diff --git a/src/main/java/com/accounting/entity/ChatSession.java b/src/main/java/com/accounting/entity/ChatSession.java
new file mode 100644
index 0000000..aa5bca6
--- /dev/null
+++ b/src/main/java/com/accounting/entity/ChatSession.java
@@ -0,0 +1,37 @@
+package com.accounting.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;
+
+/**
+ * AI 会话
+ *
+ * deleted 字段与 application.yml 中的
+ * {@code mybatis-plus.global-config.db-config.logic-delete-field=deleted} 对应,
+ * 查询会自动追加 {@code deleted = 0},删除会自动变成 UPDATE。
+ */
+@Data
+@TableName("chat_session")
+public class ChatSession {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private Long userId;
+
+ /** 标题,首条消息时自动取前 20 字 */
+ private String title;
+
+ @TableLogic
+ private Integer deleted;
+
+ private LocalDateTime createTime;
+
+ /** 最近活跃时间(DB 的 ON UPDATE 维护),会话列表按它倒序 */
+ private LocalDateTime updateTime;
+}
diff --git a/src/main/java/com/accounting/mapper/ChatMessageMapper.java b/src/main/java/com/accounting/mapper/ChatMessageMapper.java
new file mode 100644
index 0000000..14ccbf7
--- /dev/null
+++ b/src/main/java/com/accounting/mapper/ChatMessageMapper.java
@@ -0,0 +1,9 @@
+package com.accounting.mapper;
+
+import com.accounting.entity.ChatMessage;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ChatMessageMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/accounting/mapper/ChatSessionMapper.java b/src/main/java/com/accounting/mapper/ChatSessionMapper.java
new file mode 100644
index 0000000..4b2cb7e
--- /dev/null
+++ b/src/main/java/com/accounting/mapper/ChatSessionMapper.java
@@ -0,0 +1,9 @@
+package com.accounting.mapper;
+
+import com.accounting.entity.ChatSession;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ChatSessionMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/accounting/service/AiChatService.java b/src/main/java/com/accounting/service/AiChatService.java
new file mode 100644
index 0000000..f8ab3a1
--- /dev/null
+++ b/src/main/java/com/accounting/service/AiChatService.java
@@ -0,0 +1,349 @@
+package com.accounting.service;
+
+import com.accounting.config.AiConfig;
+import com.accounting.entity.ChatMessage;
+import com.accounting.entity.ChatSession;
+import com.accounting.mapper.ChatMessageMapper;
+import com.accounting.mapper.ChatSessionMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDate;
+import java.time.YearMonth;
+import java.time.format.TextStyle;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * AI 对话编排:流式调 LLM + 工具调用循环 + 消息落库。
+ *
+ * 链路:
+ *
+ * 入库 user 消息 → 加载历史 → 组装 system prompt
+ * → [流式调 LLM → 有 tool_calls? 执行工具(脱敏) → 回填 → 再调] 最多 N 轮
+ * → 入库 assistant 消息 → SSE done
+ *
+ * 两个刻意的设计:
+ *
+ * - **工具调用的中间过程不入库** —— 历史里只有 user/assistant 文本,
+ * 所以发请求时可以放心地只从 chat_message 表重建上下文,App 端
+ * 不需要理解 tool 消息格式。
+ * - **脱敏只发生在出站方向**(用户消息 + 工具结果),AI 回答里的
+ * [REDACTED_x] 占位符不还原,见 {@link SensitiveDataMasker}。
+ *
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class AiChatService {
+
+ /** SSE 连接的最长寿命。LLM 生成慢 + 工具可能跑好几轮,给足余量 */
+ private static final long EMITTER_TIMEOUT_MS = 180_000;
+
+ private static final String ROLE_USER = "user";
+ private static final String ROLE_ASSISTANT = "assistant";
+
+ private final AiConfig aiConfig;
+ private final HttpClient aiHttpClient;
+ private final ExecutorService aiExecutor;
+ private final ChatSessionMapper sessionMapper;
+ private final ChatMessageMapper messageMapper;
+ private final AiToolService toolService;
+ private final SensitiveDataMasker masker;
+ private final ObjectMapper objectMapper;
+
+ /**
+ * 发起一次对话。立即返回 emitter,整个 LLM 交互在 AI 线程池里进行。
+ *
+ * 统一约定:无论出什么错,客户端收到的都是 SSE 的 error 事件 ——
+ * 这个接口永远不返回 4xx/5xx 的 JSON,App 端只要处理一种错误通道。
+ */
+ public SseEmitter chat(Long userId, Long sessionId, String content) {
+ SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS);
+ emitter.onTimeout(emitter::complete);
+ emitter.onError(e -> log.warn("AI SSE 连接异常断开: {}", e.getMessage()));
+
+ if (!aiConfig.isReady()) {
+ fail(emitter, "AI 功能未配置:请在服务端设置 AI_BASE_URL / AI_API_KEY / AI_MODEL");
+ return emitter;
+ }
+
+ aiExecutor.execute(() -> runChat(userId, sessionId, content, emitter));
+ return emitter;
+ }
+
+ // ---------------------------------------------------------------- 主流程
+
+ private void runChat(Long userId, Long sessionId, String content, SseEmitter emitter) {
+ try {
+ ChatSession session = resolveSession(userId, sessionId, content);
+ saveMessage(session.getId(), ROLE_USER, content);
+
+ List