From c5dee7e444e02679954504a9a4f89bbfaa056915 Mon Sep 17 00:00:00 2001
From: Lee <1633292@qq.com>
Date: Mon, 8 Jun 2026 15:04:18 +0800
Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9=E7=9B=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yaml | 61 ++
.gitignore | 39 ++
.idea/.gitignore | 10 +
.idea/dataSources.xml | 13 +
.idea/data_source_mapping.xml | 6 +
.idea/encodings.xml | 7 +
.idea/inspectionProfiles/Project_Default.xml | 7 +
.idea/misc.xml | 14 +
.idea/vcs.xml | 6 +
CLAUDE.md | 39 ++
Dockerfile | 28 +
README.md | 208 +++++++
pom.xml | 87 +++
.../com/l/tracecd/TracecdApplication.java | 16 +
.../l/tracecd/config/DatabaseInitializer.java | 41 ++
.../com/l/tracecd/config/DeepSeekConfig.java | 58 ++
.../com/l/tracecd/config/MimoAsrConfig.java | 58 ++
.../java/com/l/tracecd/config/MvcConfig.java | 20 +
.../l/tracecd/config/MybatisPlusConfig.java | 13 +
.../com/l/tracecd/config/SessionConfig.java | 28 +
.../com/l/tracecd/constant/Constants.java | 43 ++
.../l/tracecd/controller/AuthController.java | 66 ++
.../tracecd/controller/BrowseController.java | 57 ++
.../tracecd/controller/CommonController.java | 13 +
.../tracecd/controller/FilterController.java | 42 ++
.../l/tracecd/controller/PageController.java | 28 +
.../l/tracecd/controller/VoiceController.java | 80 +++
.../java/com/l/tracecd/dto/BrowseQuery.java | 64 ++
.../com/l/tracecd/dto/DeepSeekRequest.java | 308 +++++++++
.../com/l/tracecd/dto/DeepSeekResponse.java | 88 +++
.../java/com/l/tracecd/dto/FilterOption.java | 39 ++
.../java/com/l/tracecd/dto/VoiceResponse.java | 97 +++
.../com/l/tracecd/entity/DailyRecord.java | 103 ++++
.../com/l/tracecd/entity/DistinctValue.java | 54 ++
src/main/java/com/l/tracecd/entity/User.java | 58 ++
.../tracecd/interceptor/AuthInterceptor.java | 57 ++
.../l/tracecd/mapper/DailyRecordMapper.java | 12 +
.../l/tracecd/mapper/DistinctValueMapper.java | 12 +
.../java/com/l/tracecd/mapper/UserMapper.java | 12 +
.../com/l/tracecd/service/AsrService.java | 94 +++
.../com/l/tracecd/service/AuthService.java | 70 +++
.../tracecd/service/DistinctValueService.java | 76 +++
.../com/l/tracecd/service/LlmService.java | 227 +++++++
.../com/l/tracecd/service/RecordService.java | 151 +++++
.../tracecd/service/SqlValidationService.java | 125 ++++
.../com/l/tracecd/service/VoiceService.java | 221 +++++++
src/main/resources/application.yml | 78 +++
src/main/resources/schema.sql | 31 +
src/main/resources/templates/browse.html | 410 ++++++++++++
src/main/resources/templates/index.html | 583 ++++++++++++++++++
src/main/resources/templates/login.html | 49 ++
51 files changed, 4107 insertions(+)
create mode 100644 .gitea/workflows/deploy.yaml
create mode 100644 .gitignore
create mode 100644 .idea/.gitignore
create mode 100644 .idea/dataSources.xml
create mode 100644 .idea/data_source_mapping.xml
create mode 100644 .idea/encodings.xml
create mode 100644 .idea/inspectionProfiles/Project_Default.xml
create mode 100644 .idea/misc.xml
create mode 100644 .idea/vcs.xml
create mode 100644 CLAUDE.md
create mode 100644 Dockerfile
create mode 100644 README.md
create mode 100644 pom.xml
create mode 100644 src/main/java/com/l/tracecd/TracecdApplication.java
create mode 100644 src/main/java/com/l/tracecd/config/DatabaseInitializer.java
create mode 100644 src/main/java/com/l/tracecd/config/DeepSeekConfig.java
create mode 100644 src/main/java/com/l/tracecd/config/MimoAsrConfig.java
create mode 100644 src/main/java/com/l/tracecd/config/MvcConfig.java
create mode 100644 src/main/java/com/l/tracecd/config/MybatisPlusConfig.java
create mode 100644 src/main/java/com/l/tracecd/config/SessionConfig.java
create mode 100644 src/main/java/com/l/tracecd/constant/Constants.java
create mode 100644 src/main/java/com/l/tracecd/controller/AuthController.java
create mode 100644 src/main/java/com/l/tracecd/controller/BrowseController.java
create mode 100644 src/main/java/com/l/tracecd/controller/CommonController.java
create mode 100644 src/main/java/com/l/tracecd/controller/FilterController.java
create mode 100644 src/main/java/com/l/tracecd/controller/PageController.java
create mode 100644 src/main/java/com/l/tracecd/controller/VoiceController.java
create mode 100644 src/main/java/com/l/tracecd/dto/BrowseQuery.java
create mode 100644 src/main/java/com/l/tracecd/dto/DeepSeekRequest.java
create mode 100644 src/main/java/com/l/tracecd/dto/DeepSeekResponse.java
create mode 100644 src/main/java/com/l/tracecd/dto/FilterOption.java
create mode 100644 src/main/java/com/l/tracecd/dto/VoiceResponse.java
create mode 100644 src/main/java/com/l/tracecd/entity/DailyRecord.java
create mode 100644 src/main/java/com/l/tracecd/entity/DistinctValue.java
create mode 100644 src/main/java/com/l/tracecd/entity/User.java
create mode 100644 src/main/java/com/l/tracecd/interceptor/AuthInterceptor.java
create mode 100644 src/main/java/com/l/tracecd/mapper/DailyRecordMapper.java
create mode 100644 src/main/java/com/l/tracecd/mapper/DistinctValueMapper.java
create mode 100644 src/main/java/com/l/tracecd/mapper/UserMapper.java
create mode 100644 src/main/java/com/l/tracecd/service/AsrService.java
create mode 100644 src/main/java/com/l/tracecd/service/AuthService.java
create mode 100644 src/main/java/com/l/tracecd/service/DistinctValueService.java
create mode 100644 src/main/java/com/l/tracecd/service/LlmService.java
create mode 100644 src/main/java/com/l/tracecd/service/RecordService.java
create mode 100644 src/main/java/com/l/tracecd/service/SqlValidationService.java
create mode 100644 src/main/java/com/l/tracecd/service/VoiceService.java
create mode 100644 src/main/resources/application.yml
create mode 100644 src/main/resources/schema.sql
create mode 100644 src/main/resources/templates/browse.html
create mode 100644 src/main/resources/templates/index.html
create mode 100644 src/main/resources/templates/login.html
diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml
new file mode 100644
index 0000000..a7a46c1
--- /dev/null
+++ b/.gitea/workflows/deploy.yaml
@@ -0,0 +1,61 @@
+name: Java Maven 3.9.9 & JDK 26 CI/CD Pipeline
+
+on:
+ push:
+ branches:
+ - dev # 只有推送到 main 分支时才触发自动部署
+
+env:
+ IMAGE_NAME: trace-img
+ CONTAINER_NAME: trace-container
+ APP_PORT: 8080
+
+jobs:
+ build-and-deploy:
+ # 调度我们在之前的 runner 中注册的宿主机 Docker 环境
+ runs-on: centos-env
+
+ steps:
+ - name: 1. 拉取最新的仓库代码
+ uses: actions/checkout@v4
+
+ - name: 2. 触发 Docker 容器内编译与镜像打包
+ run: |
+ echo "🚀 开始构建 JDK 26 镜像: ${IMAGE_NAME}:${{ gitea.sha }}"
+ docker build -t ${IMAGE_NAME}:${{ gitea.sha }} .
+ docker tag ${IMAGE_NAME}:${{ gitea.sha }} ${IMAGE_NAME}:latest
+
+ - name: 3. 旧容器清理与新容器平滑重启
+ run: |
+ echo "🧹 正在清理旧版本的容器..."
+ docker stop ${CONTAINER_NAME} || true
+ docker rm ${CONTAINER_NAME} || true
+
+ echo "🔄 正在启动全新 JDK 26 容器环境..."
+ docker run -d \
+ --name ${CONTAINER_NAME} \
+ -p ${APP_PORT}:${APP_PORT} \
+ --restart no \
+ -m 512m \
+ -e JAVA_TOOL_OPTIONS="-Xms128m -Xmx384m -XX:+UseG1GC" \
+ ${IMAGE_NAME}:latest
+
+ - name: 4. 服务健康检查 (HTTP 探活)
+ run: |
+ echo "🔍 开始对端口 ${APP_PORT} 进行健康状态探活..."
+
+ # 循环探活:每隔 5 秒检测一次,最多尝试 15 次(总计 75 秒,给高版本 JVM 充足的预热时间)
+ for i in {1..15}; do
+ # 发送轻量级请求,只要服务响应了(无论是200还是Spring默认的404/无路由),都证明 Tomcat/Netty 已拉起
+ if curl -s -f http://localhost:${APP_PORT}/hello > /dev/null; then
+ echo "✅ [SUCCESS] 探活成功!Java 26 服务已成功拉起,并开始提供服务。"
+ exit 0
+ fi
+ echo "⏳ [WAITING] 服务仍在启动中,等待 5 秒后重试 ($i/15)..."
+ sleep 5
+ done
+
+ echo "❌ [ERROR] 探活失败!服务在 75 秒内未能建立连接,可能发生了启动闪退。"
+ echo "===== 以下为容器崩溃前的实时日志 ====="
+ docker logs ${CONTAINER_NAME}
+ exit 1
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..480bdf5
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,39 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+.kotlin
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..f6906f2
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# 已忽略包含查询文件的默认文件夹
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml
new file mode 100644
index 0000000..77d6e6c
--- /dev/null
+++ b/.idea/dataSources.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ mysql.8
+ true
+ true
+ com.mysql.cj.jdbc.Driver
+ jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
+ $ProjectFileDir$
+
+
+
\ No newline at end of file
diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
new file mode 100644
index 0000000..3f3fe17
--- /dev/null
+++ b/.idea/data_source_mapping.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..aa00ffa
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..04e43cf
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..759361a
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..3ff2447
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,39 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Build System
+
+This is a Maven project targeting Java 26.
+
+```bash
+# Build the project (compile + test)
+mvn verify
+
+# Compile only
+mvn compile
+
+# Run all tests
+mvn test
+
+# Run a single test class
+mvn test -Dtest=MyTestClass
+
+# Run a single test method
+mvn test -Dtest=MyTestClass#myTestMethod
+
+# Package into a JAR
+mvn package
+
+# Clean build artifacts
+mvn clean
+```
+
+## Project Layout
+
+Standard Maven layout:
+- `src/main/java/` — application source
+- `src/main/resources/` — resources bundled with the app
+- `src/test/java/` — test source
+
+Group ID: `com.l` | Artifact ID: `tracecd` | Version: `1.0-SNAPSHOT`
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1b90514
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+# === 第一阶段:使用 Maven 3.9.9 + JDK 26 进行编译打包 ===
+FROM maven:3.9.9-eclipse-temurin-26 AS builder
+WORKDIR /app
+
+# 1. 配置阿里云 Maven 镜像加速下载(可选,国内服务器强烈推荐)
+RUN mkdir -p /root/.m2
+RUN echo 'aliyunmaven*阿里云公共仓库https://maven.aliyun.com/repository/public' > /root/.m2/settings.xml
+
+# 2. 复制源码并编译
+COPY pom.xml .
+COPY src ./src
+
+# 执行打包,跳过单元测试以加快 CI/CD 速度
+RUN mvn clean package -DskipTests
+
+# === 第二阶段:使用轻量级 JDK 26 运行时镜像 ===
+FROM eclipse-temurin:26-jre
+WORKDIR /app
+
+# 从第一阶段的 builder 中,把生成的 jar 包复制过来
+# (请确保你的 pom.xml 最终打包出来的名字能匹配上,或者可以写死特定的 jar 包名)
+COPY --from=builder /app/target/*.jar app.jar
+
+# 暴露端口(根据你的 Spring Boot 实际端口调整)
+EXPOSE 8080
+
+# 针对 JDK 26 优化的启动命令(默认开启一些现代 GC 特性)
+ENTRYPOINT ["java", "-jar", "app.jar"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c624c3f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,208 @@
+# TraceCD — 语音智能记账助手
+
+基于语音识别的智能日常记账应用。按住说话即可完成事项录入与查询,由 DeepSeek 大模型理解意图并自动生成 SQL,实现"说话即记账"的流畅体验。
+
+## 核心功能
+
+| 功能 | 说明 |
+|------|------|
+| 🎤 **语音录入** | 按住麦克风说话,AI 自动提取时间、地点、金额、分类等信息并入库 |
+| 🔍 **语音查询** | 用自然语言查询历史记录,如"我昨天花了多少钱"、"上次吃面是什么时候" |
+| 💬 **智能聊天** | 支持闲聊交互,自动区分记账意图与普通对话 |
+| 📋 **浏览筛选** | 按人物、时间、地点、分类多维度筛选查看所有事项,金额自动汇总 |
+| 🔐 **登录认证** | Session 会话管理,首次启动自动创建默认用户 |
+
+## 处理流程
+
+```
+用户语音 → [按住录音] → WAV 编码
+ → MIMO ASR 语音识别 → 文本
+ → DeepSeek LLM 意图分析
+ ├── 录入意图 → 生成 INSERT SQL → 校验 → 入库
+ ├── 查询意图 → Function Call 生成 SELECT SQL → 校验执行 → LLM 格式化回复
+ └── 聊天意图 → 直接回复
+```
+
+## 技术栈
+
+| 层级 | 技术 |
+|------|------|
+| **框架** | Spring Boot 3.5.14 |
+| **语言** | Java 26 |
+| **ORM** | MyBatis-Plus 3.5.12 |
+| **数据库** | MySQL 8.x |
+| **模板引擎** | Thymeleaf |
+| **前端** | Bootstrap 5.3 + 原生 JS(WAV 录音) |
+| **LLM** | DeepSeek API(deepseek-v4-pro) |
+| **ASR** | 小米 MIMO V2.5 ASR |
+| **安全** | BCrypt 密码加密 + Session 认证 |
+| **容器化** | Docker(多阶段构建) |
+
+## 项目结构
+
+```
+src/main/java/com/l/tracecd/
+├── TracecdApplication.java # 应用主入口
+├── config/
+│ ├── DatabaseInitializer.java # 启动自动建表
+│ ├── DeepSeekConfig.java # DeepSeek API 配置
+│ ├── MimoAsrConfig.java # MIMO ASR API 配置
+│ ├── MvcConfig.java # 拦截器注册
+│ ├── MybatisPlusConfig.java # MyBatis-Plus 配置
+│ └── SessionConfig.java # Session 会话配置
+├── constant/
+│ └── Constants.java # 系统常量
+├── controller/
+│ ├── AuthController.java # 登录/登出
+│ ├── BrowseController.java # 条件筛选查询 API
+│ ├── CommonController.java # 健康检查
+│ ├── FilterController.java # 筛选选项 API
+│ ├── PageController.java # 页面路由
+│ └── VoiceController.java # 语音处理 API
+├── dto/
+│ ├── BrowseQuery.java # 浏览查询参数
+│ ├── DeepSeekRequest.java # DeepSeek 请求体
+│ ├── DeepSeekResponse.java # DeepSeek 响应体
+│ ├── FilterOption.java # 筛选选项
+│ └── VoiceResponse.java # 语音处理响应
+├── entity/
+│ ├── DailyRecord.java # 日常事项实体
+│ ├── DistinctValue.java # 去重值实体
+│ └── User.java # 用户实体
+├── interceptor/
+│ └── AuthInterceptor.java # 登录认证拦截器
+├── mapper/
+│ ├── DailyRecordMapper.java # 事项 Mapper
+│ ├── DistinctValueMapper.java # 去重值 Mapper
+│ └── UserMapper.java # 用户 Mapper
+└── service/
+ ├── AsrService.java # ASR 语音识别
+ ├── AuthService.java # 认证服务
+ ├── DistinctValueService.java # 去重值维护
+ ├── LlmService.java # DeepSeek 交互
+ ├── RecordService.java # 事项 CRUD
+ ├── SqlValidationService.java # SQL 安全校验
+ └── VoiceService.java # 语音处理编排(核心)
+```
+
+## 数据库表
+
+| 表名 | 说明 |
+|------|------|
+| `t_user` | 用户表,BCrypt 加密存储密码 |
+| `t_daily_record` | 日常事项记录表(person, record_time, location, content, category, amount) |
+| `t_distinct_value` | 筛选去重值表(field_name + field_value 唯一索引) |
+
+建表脚本:`src/main/resources/schema.sql`,应用启动时自动执行。
+
+## 快速开始
+
+### 环境要求
+
+- JDK 26
+- Maven 3.9+
+- MySQL 8.x
+
+### 1. 配置数据库
+
+创建 MySQL 数据库(应用会自动建表):
+
+```sql
+CREATE DATABASE IF NOT EXISTS tracecd DEFAULT CHARSET utf8mb4;
+```
+
+修改 `src/main/resources/application.yml` 中的数据库连接信息:
+
+```yaml
+spring:
+ datasource:
+ url: jdbc:mysql://your-host:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
+ username: your-username
+ password: your-password
+```
+
+### 2. 配置 API Key
+
+通过环境变量设置(推荐)或直接修改 `application.yml`:
+
+```bash
+# DeepSeek API Key
+export DEEPSEEK_API_KEY=sk-your-key
+
+# MIMO ASR API Key
+export MIMO_API_KEY=sk-your-key
+```
+
+### 3. 启动应用
+
+```bash
+# 编译并运行
+mvn spring-boot:run
+
+# 或先打包再运行
+mvn clean package -DskipTests
+java -jar target/tracecd-1.0-SNAPSHOT.jar
+```
+
+### 4. 访问
+
+打开浏览器访问 `http://localhost:8080`
+
+- **默认用户名**: `admin`
+- **默认密码**: `admin123`
+
+## Docker 部署
+
+```bash
+# 构建镜像
+docker build -t tracecd:latest .
+
+# 运行容器
+docker run -d \
+ -p 8080:8080 \
+ -e DEEPSEEK_API_KEY=sk-your-key \
+ -e MIMO_API_KEY=sk-your-key \
+ -e SPRING_DATASOURCE_URL=jdbc:mysql://your-host:3306/tracecd?... \
+ -e SPRING_DATASOURCE_USERNAME=root \
+ -e SPRING_DATASOURCE_PASSWORD=root \
+ --name tracecd \
+ tracecd:latest
+```
+
+## API 接口
+
+| 路径 | 方法 | 说明 |
+|------|------|------|
+| `/` | GET | 主页(语音录入) |
+| `/browse` | GET | 浏览筛选页 |
+| `/login` | GET/POST | 登录页/登录请求 |
+| `/logout` | GET | 登出 |
+| `/hello` | GET | 健康检查 |
+| `/api/voice/process` | POST | 上传音频,返回处理结果 |
+| `/api/record/query` | POST | 条件筛选查询事项 |
+| `/api/filter/options` | GET | 获取筛选下拉选项 |
+
+## 配置项
+
+| 配置路径 | 说明 | 默认值 |
+|----------|------|--------|
+| `server.port` | 服务端口 | `8080` |
+| `deepseek.api-key` | DeepSeek API Key | 环境变量 `DEEPSEEK_API_KEY` |
+| `deepseek.model` | DeepSeek 模型 | `deepseek-v4-pro` |
+| `mimo.api-key` | MIMO ASR API Key | 环境变量 `MIMO_API_KEY` |
+| `mimo.model` | ASR 模型 | `mimo-v2.5-asr` |
+| `app.sql-max-retries` | SQL 生成失败最大重试次数 | `5` |
+| `app.default-username` | 默认用户名 | `admin` |
+| `app.default-password` | 默认密码 | `admin123` |
+
+## 构建命令
+
+```bash
+mvn verify # 编译 + 测试
+mvn compile # 仅编译
+mvn test # 运行测试
+mvn test -Dtest=MyTestClass # 运行单个测试类
+mvn test -Dtest=MyTestClass#myMethod # 运行单个测试方法
+mvn package # 打包 JAR
+mvn clean # 清理
+```
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..69a6ebd
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,87 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.5.14
+
+
+
+ com.l
+ tracecd
+ 1.0-SNAPSHOT
+
+
+ 26
+ 3.5.12
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-thymeleaf
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ ${mybatis-plus.version}
+
+
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+
+
+ org.springframework.session
+ spring-session-core
+
+
+
+
+ org.springframework.security
+ spring-security-crypto
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 26
+
+
+
+
+
diff --git a/src/main/java/com/l/tracecd/TracecdApplication.java b/src/main/java/com/l/tracecd/TracecdApplication.java
new file mode 100644
index 0000000..6167aad
--- /dev/null
+++ b/src/main/java/com/l/tracecd/TracecdApplication.java
@@ -0,0 +1,16 @@
+package com.l.tracecd;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 应用主入口
+ */
+@SpringBootApplication
+public class TracecdApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(TracecdApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/l/tracecd/config/DatabaseInitializer.java b/src/main/java/com/l/tracecd/config/DatabaseInitializer.java
new file mode 100644
index 0000000..b47ce3b
--- /dev/null
+++ b/src/main/java/com/l/tracecd/config/DatabaseInitializer.java
@@ -0,0 +1,41 @@
+package com.l.tracecd.config;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
+import org.springframework.stereotype.Component;
+
+import javax.sql.DataSource;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 数据库初始化器,应用启动时自动建表
+ */
+@Component
+public class DatabaseInitializer implements CommandLineRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class);
+
+ private final DataSource dataSource;
+
+ public DatabaseInitializer(DataSource dataSource) {
+ this.dataSource = dataSource;
+ }
+
+ @Override
+ public void run(String... args) {
+ log.info("初始化数据库表结构...");
+ try {
+ ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
+ populator.addScript(new ClassPathResource("schema.sql"));
+ populator.setSqlScriptEncoding(StandardCharsets.UTF_8.name());
+ populator.setContinueOnError(true); // 表已存在时跳过
+ populator.execute(dataSource);
+ log.info("数据库表结构初始化完成");
+ } catch (Exception e) {
+ log.error("数据库表结构初始化失败", e);
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/config/DeepSeekConfig.java b/src/main/java/com/l/tracecd/config/DeepSeekConfig.java
new file mode 100644
index 0000000..299b3c6
--- /dev/null
+++ b/src/main/java/com/l/tracecd/config/DeepSeekConfig.java
@@ -0,0 +1,58 @@
+package com.l.tracecd.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestClient;
+
+/**
+ * DeepSeek API 配置
+ */
+@Configuration
+public class DeepSeekConfig {
+
+ @Bean
+ @ConfigurationProperties(prefix = "deepseek")
+ public DeepSeekProperties deepSeekProperties() {
+ return new DeepSeekProperties();
+ }
+
+ @Bean
+ public RestClient deepSeekRestClient(DeepSeekProperties props) {
+ return RestClient.builder()
+ .baseUrl(props.getBaseUrl())
+ .defaultHeader("Content-Type", "application/json")
+ .defaultHeader("Authorization", "Bearer " + props.getApiKey())
+ .build();
+ }
+
+ public static class DeepSeekProperties {
+ private String apiKey;
+ private String baseUrl;
+ private String model;
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public void setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ }
+
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ public void setBaseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public void setModel(String model) {
+ this.model = model;
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/config/MimoAsrConfig.java b/src/main/java/com/l/tracecd/config/MimoAsrConfig.java
new file mode 100644
index 0000000..2f662e0
--- /dev/null
+++ b/src/main/java/com/l/tracecd/config/MimoAsrConfig.java
@@ -0,0 +1,58 @@
+package com.l.tracecd.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestClient;
+
+/**
+ * MIMO ASR API 配置
+ */
+@Configuration
+public class MimoAsrConfig {
+
+ @Bean
+ @ConfigurationProperties(prefix = "mimo")
+ public MimoProperties mimoProperties() {
+ return new MimoProperties();
+ }
+
+ @Bean
+ public RestClient mimoRestClient(MimoProperties props) {
+ return RestClient.builder()
+ .baseUrl(props.getBaseUrl())
+ .defaultHeader("Content-Type", "application/json")
+ .defaultHeader("api-key", props.getApiKey())
+ .build();
+ }
+
+ public static class MimoProperties {
+ private String apiKey;
+ private String baseUrl;
+ private String model;
+
+ public String getApiKey() {
+ return apiKey;
+ }
+
+ public void setApiKey(String apiKey) {
+ this.apiKey = apiKey;
+ }
+
+ public String getBaseUrl() {
+ return baseUrl;
+ }
+
+ public void setBaseUrl(String baseUrl) {
+ this.baseUrl = baseUrl;
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public void setModel(String model) {
+ this.model = model;
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/config/MvcConfig.java b/src/main/java/com/l/tracecd/config/MvcConfig.java
new file mode 100644
index 0000000..62c89f3
--- /dev/null
+++ b/src/main/java/com/l/tracecd/config/MvcConfig.java
@@ -0,0 +1,20 @@
+package com.l.tracecd.config;
+
+import com.l.tracecd.interceptor.AuthInterceptor;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Spring MVC 配置,注册认证拦截器
+ */
+@Configuration
+public class MvcConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(new AuthInterceptor())
+ .addPathPatterns("/**")
+ .excludePathPatterns("/login", "/css/**", "/js/**", "/error", "/favicon.ico");
+ }
+}
diff --git a/src/main/java/com/l/tracecd/config/MybatisPlusConfig.java b/src/main/java/com/l/tracecd/config/MybatisPlusConfig.java
new file mode 100644
index 0000000..33aaff1
--- /dev/null
+++ b/src/main/java/com/l/tracecd/config/MybatisPlusConfig.java
@@ -0,0 +1,13 @@
+package com.l.tracecd.config;
+
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * MyBatis-Plus 配置
+ * 基础配置通过 application.yml 管理,此处保留作为扩展点
+ */
+@Configuration
+public class MybatisPlusConfig {
+ // 当前无需额外 Bean 配置,MyBatis-Plus 自动配置已覆盖基本需求
+ // 如需分页插件,在此添加 MybatisPlusInterceptor Bean
+}
diff --git a/src/main/java/com/l/tracecd/config/SessionConfig.java b/src/main/java/com/l/tracecd/config/SessionConfig.java
new file mode 100644
index 0000000..0da6786
--- /dev/null
+++ b/src/main/java/com/l/tracecd/config/SessionConfig.java
@@ -0,0 +1,28 @@
+package com.l.tracecd.config;
+
+import com.l.tracecd.constant.Constants;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.session.MapSession;
+import org.springframework.session.MapSessionRepository;
+import org.springframework.session.SessionRepository;
+import org.springframework.session.config.annotation.web.http.EnableSpringHttpSession;
+
+import java.time.Duration;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Spring Session 配置
+ * 基于内存的 Session 存储,30天有效期
+ */
+@Configuration
+@EnableSpringHttpSession
+public class SessionConfig {
+
+ @Bean
+ public SessionRepository sessionRepository() {
+ MapSessionRepository repository = new MapSessionRepository(new ConcurrentHashMap<>());
+ repository.setDefaultMaxInactiveInterval(Duration.ofSeconds(Constants.SESSION_TIMEOUT_SECONDS));
+ return repository;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/constant/Constants.java b/src/main/java/com/l/tracecd/constant/Constants.java
new file mode 100644
index 0000000..94d8d65
--- /dev/null
+++ b/src/main/java/com/l/tracecd/constant/Constants.java
@@ -0,0 +1,43 @@
+package com.l.tracecd.constant;
+
+/**
+ * 系统常量定义,避免魔法值
+ */
+public final class Constants {
+
+ private Constants() {
+ // 工具类禁止实例化
+ }
+
+ /** 语音意图类型 */
+ public static final String INTENT_RECORD = "RECORD";
+ public static final String INTENT_QUERY = "QUERY";
+ public static final String INTENT_CHAT = "CHAT";
+
+ /** 数据库表名 */
+ public static final String TABLE_DAILY_RECORD = "t_daily_record";
+ public static final String TABLE_DISTINCT_VALUE = "t_distinct_value";
+ public static final String TABLE_USER = "t_user";
+
+ /** 去重字段名 */
+ public static final String FIELD_PERSON = "person";
+ public static final String FIELD_LOCATION = "location";
+ public static final String FIELD_CATEGORY = "category";
+
+ /** SQL 校验 */
+ public static final int SQL_MAX_RETRIES = 5;
+
+ /** 会话相关 */
+ public static final String SESSION_USER_KEY = "user";
+ public static final int SESSION_TIMEOUT_SECONDS = 30 * 24 * 60 * 60; // 30天
+
+ /** 默认用户 */
+ public static final String DEFAULT_USERNAME = "admin";
+ public static final String DEFAULT_PASSWORD = "admin123";
+
+ /** 通知显示时长(毫秒) */
+ public static final int NOTIFICATION_DURATION_MS = 3000;
+
+ /** Function Call 名称 */
+ public static final String FUNCTION_QUERY_RECORDS = "query_daily_records";
+}
diff --git a/src/main/java/com/l/tracecd/controller/AuthController.java b/src/main/java/com/l/tracecd/controller/AuthController.java
new file mode 100644
index 0000000..883d87a
--- /dev/null
+++ b/src/main/java/com/l/tracecd/controller/AuthController.java
@@ -0,0 +1,66 @@
+package com.l.tracecd.controller;
+
+import com.l.tracecd.constant.Constants;
+import com.l.tracecd.entity.User;
+import com.l.tracecd.service.AuthService;
+import jakarta.servlet.http.HttpSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Controller;
+import org.springframework.ui.Model;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * 认证控制器
+ * 处理登录/登出和登录页面展示
+ */
+@Controller
+public class AuthController {
+
+ private static final Logger log = LoggerFactory.getLogger(AuthController.class);
+
+ private final AuthService authService;
+
+ public AuthController(AuthService authService) {
+ this.authService = authService;
+ }
+
+ /**
+ * 登录页面
+ */
+ @GetMapping("/login")
+ public String loginPage() {
+ return "login";
+ }
+
+ /**
+ * 处理登录请求
+ */
+ @PostMapping("/login")
+ public String login(@RequestParam String username,
+ @RequestParam String password,
+ HttpSession session,
+ Model model) {
+ User user = authService.authenticate(username, password);
+ if (user == null) {
+ model.addAttribute("error", "用户名或密码错误");
+ return "login";
+ }
+
+ session.setAttribute(Constants.SESSION_USER_KEY, user);
+ session.setMaxInactiveInterval(Constants.SESSION_TIMEOUT_SECONDS);
+ log.info("用户 {} 已登录,session 有效期 {} 秒", username, Constants.SESSION_TIMEOUT_SECONDS);
+ return "redirect:/";
+ }
+
+ /**
+ * 登出
+ */
+ @GetMapping("/logout")
+ public String logout(HttpSession session) {
+ session.invalidate();
+ return "redirect:/login";
+ }
+}
diff --git a/src/main/java/com/l/tracecd/controller/BrowseController.java b/src/main/java/com/l/tracecd/controller/BrowseController.java
new file mode 100644
index 0000000..553a477
--- /dev/null
+++ b/src/main/java/com/l/tracecd/controller/BrowseController.java
@@ -0,0 +1,57 @@
+package com.l.tracecd.controller;
+
+import com.l.tracecd.dto.BrowseQuery;
+import com.l.tracecd.entity.DailyRecord;
+import com.l.tracecd.service.RecordService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+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 java.math.BigDecimal;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 浏览查询控制器
+ * 处理浏览页面的条件筛选查询
+ */
+@RestController
+@RequestMapping("/api/record")
+public class BrowseController {
+
+ private static final Logger log = LoggerFactory.getLogger(BrowseController.class);
+
+ private final RecordService recordService;
+
+ public BrowseController(RecordService recordService) {
+ this.recordService = recordService;
+ }
+
+ /**
+ * 条件筛选查询事项
+ *
+ * @param query 筛选条件(人物、时间段、地点、分类)
+ * @return 查询结果和金额汇总
+ */
+ @PostMapping("/query")
+ public Map query(@RequestBody BrowseQuery query) {
+ log.info("浏览查询: persons={}, time={}~{}, locations={}, categories={}",
+ query.getPersons(), query.getStartTime(), query.getEndTime(),
+ query.getLocations(), query.getCategories());
+
+ List records = recordService.browseQuery(query);
+ BigDecimal totalAmount = recordService.sumAmount(records);
+
+ Map result = new HashMap<>();
+ result.put("records", records);
+ result.put("totalAmount", totalAmount);
+ result.put("count", records.size());
+
+ log.info("查询结果: {} 条记录,总金额: {}", records.size(), totalAmount);
+ return result;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/controller/CommonController.java b/src/main/java/com/l/tracecd/controller/CommonController.java
new file mode 100644
index 0000000..ed628db
--- /dev/null
+++ b/src/main/java/com/l/tracecd/controller/CommonController.java
@@ -0,0 +1,13 @@
+package com.l.tracecd.controller;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+public class CommonController {
+
+ @GetMapping("/hello")
+ public String hello() {
+ return "OK";
+ }
+}
diff --git a/src/main/java/com/l/tracecd/controller/FilterController.java b/src/main/java/com/l/tracecd/controller/FilterController.java
new file mode 100644
index 0000000..8af54da
--- /dev/null
+++ b/src/main/java/com/l/tracecd/controller/FilterController.java
@@ -0,0 +1,42 @@
+package com.l.tracecd.controller;
+
+import com.l.tracecd.constant.Constants;
+import com.l.tracecd.dto.FilterOption;
+import com.l.tracecd.service.DistinctValueService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 筛选选项控制器
+ * 为浏览页面提供 person/location/category 的去重值列表
+ */
+@RestController
+@RequestMapping("/api/filter")
+public class FilterController {
+
+ private final DistinctValueService distinctValueService;
+
+ public FilterController(DistinctValueService distinctValueService) {
+ this.distinctValueService = distinctValueService;
+ }
+
+ /**
+ * 获取所有筛选选项
+ *
+ * @return 包含 person/location/category 三种筛选类型的值列表
+ */
+ @GetMapping("/options")
+ public List getFilterOptions() {
+ return List.of(
+ new FilterOption(Constants.FIELD_PERSON,
+ distinctValueService.getValues(Constants.FIELD_PERSON)),
+ new FilterOption(Constants.FIELD_LOCATION,
+ distinctValueService.getValues(Constants.FIELD_LOCATION)),
+ new FilterOption(Constants.FIELD_CATEGORY,
+ distinctValueService.getValues(Constants.FIELD_CATEGORY))
+ );
+ }
+}
diff --git a/src/main/java/com/l/tracecd/controller/PageController.java b/src/main/java/com/l/tracecd/controller/PageController.java
new file mode 100644
index 0000000..92b8cae
--- /dev/null
+++ b/src/main/java/com/l/tracecd/controller/PageController.java
@@ -0,0 +1,28 @@
+package com.l.tracecd.controller;
+
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+
+/**
+ * 页面控制器
+ * 处理主页和浏览页的页面跳转
+ */
+@Controller
+public class PageController {
+
+ /**
+ * 主页(语音录入/查询入口)
+ */
+ @GetMapping("/")
+ public String index() {
+ return "index";
+ }
+
+ /**
+ * 浏览筛选页面
+ */
+ @GetMapping("/browse")
+ public String browse() {
+ return "browse";
+ }
+}
diff --git a/src/main/java/com/l/tracecd/controller/VoiceController.java b/src/main/java/com/l/tracecd/controller/VoiceController.java
new file mode 100644
index 0000000..4c94fc3
--- /dev/null
+++ b/src/main/java/com/l/tracecd/controller/VoiceController.java
@@ -0,0 +1,80 @@
+package com.l.tracecd.controller;
+
+import com.l.tracecd.dto.VoiceResponse;
+import com.l.tracecd.service.VoiceService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.lang.NonNull;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+/**
+ * 语音处理控制器
+ * 接收前端录音上传,返回处理结果
+ */
+@RestController
+@RequestMapping("/api/voice")
+public class VoiceController {
+
+ private static final Logger log = LoggerFactory.getLogger(VoiceController.class);
+
+ private final VoiceService voiceService;
+
+ public VoiceController(VoiceService voiceService) {
+ this.voiceService = voiceService;
+ }
+
+ /**
+ * 处理语音录音
+ * 接收音频文件,经过 ASR → LLM → SQL 全流程处理,返回结果
+ *
+ * @param audio 音频文件(webm/wav/mp3)
+ * @return 处理结果
+ */
+ @PostMapping("/process")
+ public VoiceResponse processVoice(@RequestParam("audio") MultipartFile audio) {
+ if (audio.isEmpty()) {
+ log.warn("收到空的音频文件");
+ return VoiceResponse.error("音频文件为空");
+ }
+
+ long start = System.currentTimeMillis();
+ try {
+ // 确定 MIME 类型
+ String mimeType = getMimeType(audio);
+
+ byte[] audioBytes = audio.getBytes();
+ log.info("收到语音请求,大小: {} bytes, 类型: {}", audioBytes.length, mimeType);
+
+ VoiceResponse result = voiceService.processVoice(audioBytes, mimeType);
+
+ long elapsed = System.currentTimeMillis() - start;
+ log.info("语音处理总耗时: {}ms, 意图: {}, 成功: {}", elapsed, result.getType(), result.isSuccess());
+ return result;
+
+ } catch (Exception e) {
+ log.error("语音处理异常", e);
+ return VoiceResponse.error("服务器内部错误: " + e.getMessage());
+ }
+ }
+
+ @NonNull
+ private static String getMimeType(MultipartFile audio) {
+ String mimeType = audio.getContentType();
+ if (mimeType == null) {
+ // 根据文件扩展名推测
+ String filename = audio.getOriginalFilename();
+ if (filename != null && filename.endsWith(".wav")) {
+ mimeType = "audio/wav";
+ } else if (filename != null && filename.endsWith(".mp3")) {
+ mimeType = "audio/mp3";
+ } else {
+ mimeType = "audio/webm"; // 默认 webm
+ }
+ }
+ return mimeType;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/dto/BrowseQuery.java b/src/main/java/com/l/tracecd/dto/BrowseQuery.java
new file mode 100644
index 0000000..a2f7972
--- /dev/null
+++ b/src/main/java/com/l/tracecd/dto/BrowseQuery.java
@@ -0,0 +1,64 @@
+package com.l.tracecd.dto;
+
+import java.util.List;
+
+/**
+ * 浏览页查询参数 DTO
+ */
+public class BrowseQuery {
+
+ /** 人物列表(多选) */
+ private List persons;
+
+ /** 时间段起始 */
+ private String startTime;
+
+ /** 时间段结束 */
+ private String endTime;
+
+ /** 地点列表(多选) */
+ private List locations;
+
+ /** 分类列表(多选) */
+ private List categories;
+
+ public List getPersons() {
+ return persons;
+ }
+
+ public void setPersons(List persons) {
+ this.persons = persons;
+ }
+
+ public String getStartTime() {
+ return startTime;
+ }
+
+ public void setStartTime(String startTime) {
+ this.startTime = startTime;
+ }
+
+ public String getEndTime() {
+ return endTime;
+ }
+
+ public void setEndTime(String endTime) {
+ this.endTime = endTime;
+ }
+
+ public List getLocations() {
+ return locations;
+ }
+
+ public void setLocations(List locations) {
+ this.locations = locations;
+ }
+
+ public List getCategories() {
+ return categories;
+ }
+
+ public void setCategories(List categories) {
+ this.categories = categories;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/dto/DeepSeekRequest.java b/src/main/java/com/l/tracecd/dto/DeepSeekRequest.java
new file mode 100644
index 0000000..9cf557d
--- /dev/null
+++ b/src/main/java/com/l/tracecd/dto/DeepSeekRequest.java
@@ -0,0 +1,308 @@
+package com.l.tracecd.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * DeepSeek API 请求 DTO
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class DeepSeekRequest {
+
+ private String model;
+ private List messages;
+ private List tools;
+ private Thinking thinking;
+ @JsonProperty("reasoning_effort")
+ private String reasoningEffort;
+ private boolean stream;
+
+ public DeepSeekRequest() {
+ this.thinking = new Thinking();
+ this.thinking.type = "enabled";
+ this.reasoningEffort = "high";
+ this.stream = false;
+ }
+
+ public String getModel() {
+ return model;
+ }
+
+ public void setModel(String model) {
+ this.model = model;
+ }
+
+ public List getMessages() {
+ return messages;
+ }
+
+ public void setMessages(List messages) {
+ this.messages = messages;
+ }
+
+ public List getTools() {
+ return tools;
+ }
+
+ public void setTools(List tools) {
+ this.tools = tools;
+ }
+
+ public Thinking getThinking() {
+ return thinking;
+ }
+
+ public void setThinking(Thinking thinking) {
+ this.thinking = thinking;
+ }
+
+ public String getReasoningEffort() {
+ return reasoningEffort;
+ }
+
+ public void setReasoningEffort(String reasoningEffort) {
+ this.reasoningEffort = reasoningEffort;
+ }
+
+ public boolean isStream() {
+ return stream;
+ }
+
+ public void setStream(boolean stream) {
+ this.stream = stream;
+ }
+
+ // --- 内嵌类 ---
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Message {
+ private String role;
+ private String content;
+ @JsonProperty("tool_calls")
+ private List toolCalls;
+ @JsonProperty("tool_call_id")
+ private String toolCallId;
+ private String name;
+
+ public Message() {}
+
+ public Message(String role, String content) {
+ this.role = role;
+ this.content = content;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(String role) {
+ this.role = role;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ public List getToolCalls() {
+ return toolCalls;
+ }
+
+ public void setToolCalls(List toolCalls) {
+ this.toolCalls = toolCalls;
+ }
+
+ public String getToolCallId() {
+ return toolCallId;
+ }
+
+ public void setToolCallId(String toolCallId) {
+ this.toolCallId = toolCallId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class ToolCall {
+ private String id;
+ private String type;
+ private Function function;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public Function getFunction() {
+ return function;
+ }
+
+ public void setFunction(Function function) {
+ this.function = function;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Function {
+ private String name;
+ private String arguments;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getArguments() {
+ return arguments;
+ }
+
+ public void setArguments(String arguments) {
+ this.arguments = arguments;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Tool {
+ private String type;
+ private ToolFunction function;
+
+ public Tool() {}
+
+ public Tool(String type, ToolFunction function) {
+ this.type = type;
+ this.function = function;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public ToolFunction getFunction() {
+ return function;
+ }
+
+ public void setFunction(ToolFunction function) {
+ this.function = function;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class ToolFunction {
+ private String name;
+ private String description;
+ private Parameters parameters;
+
+ public ToolFunction() {}
+
+ public ToolFunction(String name, String description, Parameters parameters) {
+ this.name = name;
+ this.description = description;
+ this.parameters = parameters;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public Parameters getParameters() {
+ return parameters;
+ }
+
+ public void setParameters(Parameters parameters) {
+ this.parameters = parameters;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Parameters {
+ private String type = "object";
+ private java.util.Map properties;
+ private List required;
+
+ public Parameters() {}
+
+ public Parameters(java.util.Map properties, List required) {
+ this.properties = properties;
+ this.required = required;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public java.util.Map getProperties() {
+ return properties;
+ }
+
+ public void setProperties(java.util.Map properties) {
+ this.properties = properties;
+ }
+
+ public List getRequired() {
+ return required;
+ }
+
+ public void setRequired(List required) {
+ this.required = required;
+ }
+ }
+
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ public static class Thinking {
+ private String type;
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/dto/DeepSeekResponse.java b/src/main/java/com/l/tracecd/dto/DeepSeekResponse.java
new file mode 100644
index 0000000..caf4681
--- /dev/null
+++ b/src/main/java/com/l/tracecd/dto/DeepSeekResponse.java
@@ -0,0 +1,88 @@
+package com.l.tracecd.dto;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.List;
+
+/**
+ * DeepSeek API 响应 DTO
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DeepSeekResponse {
+
+ private List choices;
+
+ public List getChoices() {
+ return choices;
+ }
+
+ public void setChoices(List choices) {
+ this.choices = choices;
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class Choice {
+ private ResponseMessage message;
+ @JsonProperty("finish_reason")
+ private String finishReason;
+
+ public ResponseMessage getMessage() {
+ return message;
+ }
+
+ public void setMessage(ResponseMessage message) {
+ this.message = message;
+ }
+
+ public String getFinishReason() {
+ return finishReason;
+ }
+
+ public void setFinishReason(String finishReason) {
+ this.finishReason = finishReason;
+ }
+ }
+
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ResponseMessage {
+ private String role;
+ private String content;
+ @JsonProperty("reasoning_content")
+ private String reasoningContent;
+ @JsonProperty("tool_calls")
+ private List toolCalls;
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(String role) {
+ this.role = role;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ public String getReasoningContent() {
+ return reasoningContent;
+ }
+
+ public void setReasoningContent(String reasoningContent) {
+ this.reasoningContent = reasoningContent;
+ }
+
+ public List getToolCalls() {
+ return toolCalls;
+ }
+
+ public void setToolCalls(List toolCalls) {
+ this.toolCalls = toolCalls;
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/dto/FilterOption.java b/src/main/java/com/l/tracecd/dto/FilterOption.java
new file mode 100644
index 0000000..1dcbb6c
--- /dev/null
+++ b/src/main/java/com/l/tracecd/dto/FilterOption.java
@@ -0,0 +1,39 @@
+package com.l.tracecd.dto;
+
+import java.util.List;
+
+/**
+ * 筛选项 DTO
+ */
+public class FilterOption {
+
+ /** 字段名:person / location / category */
+ private String fieldName;
+
+ /** 该字段的去重值列表 */
+ private List values;
+
+ public FilterOption() {
+ }
+
+ public FilterOption(String fieldName, List values) {
+ this.fieldName = fieldName;
+ this.values = values;
+ }
+
+ public String getFieldName() {
+ return fieldName;
+ }
+
+ public void setFieldName(String fieldName) {
+ this.fieldName = fieldName;
+ }
+
+ public List getValues() {
+ return values;
+ }
+
+ public void setValues(List values) {
+ this.values = values;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/dto/VoiceResponse.java b/src/main/java/com/l/tracecd/dto/VoiceResponse.java
new file mode 100644
index 0000000..9beb2f4
--- /dev/null
+++ b/src/main/java/com/l/tracecd/dto/VoiceResponse.java
@@ -0,0 +1,97 @@
+package com.l.tracecd.dto;
+
+/**
+ * 语音处理响应 DTO
+ * 返回给前端的统一响应结构
+ */
+public class VoiceResponse {
+
+ /** 意图类型:RECORD / QUERY / CHAT */
+ private String type;
+
+ /** 展示给用户的消息 */
+ private String message;
+
+ /** 录入成功时的分类名(用于通知展示) */
+ private String category;
+
+ /** 是否成功 */
+ private boolean success;
+
+ /** 错误信息 */
+ private String error;
+
+ public static VoiceResponse recordSuccess(String category) {
+ VoiceResponse r = new VoiceResponse();
+ r.type = "RECORD";
+ r.success = true;
+ r.category = category;
+ r.message = "已录入" + category + "事项";
+ return r;
+ }
+
+ public static VoiceResponse queryResult(String message) {
+ VoiceResponse r = new VoiceResponse();
+ r.type = "QUERY";
+ r.success = true;
+ r.message = message;
+ return r;
+ }
+
+ public static VoiceResponse chatReply(String message) {
+ VoiceResponse r = new VoiceResponse();
+ r.type = "CHAT";
+ r.success = true;
+ r.message = message;
+ return r;
+ }
+
+ public static VoiceResponse error(String errorMsg) {
+ VoiceResponse r = new VoiceResponse();
+ r.type = "CHAT";
+ r.success = false;
+ r.error = errorMsg;
+ r.message = errorMsg;
+ return r;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public void setCategory(String category) {
+ this.category = category;
+ }
+
+ public boolean isSuccess() {
+ return success;
+ }
+
+ public void setSuccess(boolean success) {
+ this.success = success;
+ }
+
+ public String getError() {
+ return error;
+ }
+
+ public void setError(String error) {
+ this.error = error;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/entity/DailyRecord.java b/src/main/java/com/l/tracecd/entity/DailyRecord.java
new file mode 100644
index 0000000..0fdbb6c
--- /dev/null
+++ b/src/main/java/com/l/tracecd/entity/DailyRecord.java
@@ -0,0 +1,103 @@
+package com.l.tracecd.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+/**
+ * 日常事项记录实体
+ */
+@TableName("t_daily_record")
+public class DailyRecord {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** 人物 */
+ private String person;
+
+ /** 事项发生时间 */
+ private LocalDateTime recordTime;
+
+ /** 地点 */
+ private String location;
+
+ /** 事项内容 */
+ private String content;
+
+ /** 分类(2-4字) */
+ private String category;
+
+ /** 金额 */
+ private BigDecimal amount;
+
+ /** 记录创建时间 */
+ private LocalDateTime createdAt;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getPerson() {
+ return person;
+ }
+
+ public void setPerson(String person) {
+ this.person = person;
+ }
+
+ public LocalDateTime getRecordTime() {
+ return recordTime;
+ }
+
+ public void setRecordTime(LocalDateTime recordTime) {
+ this.recordTime = recordTime;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public void setLocation(String location) {
+ this.location = location;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public void setContent(String content) {
+ this.content = content;
+ }
+
+ public String getCategory() {
+ return category;
+ }
+
+ public void setCategory(String category) {
+ this.category = category;
+ }
+
+ public BigDecimal getAmount() {
+ return amount;
+ }
+
+ public void setAmount(BigDecimal amount) {
+ this.amount = amount;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/entity/DistinctValue.java b/src/main/java/com/l/tracecd/entity/DistinctValue.java
new file mode 100644
index 0000000..ba4412f
--- /dev/null
+++ b/src/main/java/com/l/tracecd/entity/DistinctValue.java
@@ -0,0 +1,54 @@
+package com.l.tracecd.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+/**
+ * 去重值实体,用于筛选选项
+ * 通过 field_name + field_value 唯一索引去重
+ */
+@TableName("t_distinct_value")
+public class DistinctValue {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** 字段名:person / location / category */
+ private String fieldName;
+
+ /** 字段值 */
+ private String fieldValue;
+
+ public DistinctValue() {
+ }
+
+ public DistinctValue(String fieldName, String fieldValue) {
+ this.fieldName = fieldName;
+ this.fieldValue = fieldValue;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getFieldName() {
+ return fieldName;
+ }
+
+ public void setFieldName(String fieldName) {
+ this.fieldName = fieldName;
+ }
+
+ public String getFieldValue() {
+ return fieldValue;
+ }
+
+ public void setFieldValue(String fieldValue) {
+ this.fieldValue = fieldValue;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/entity/User.java b/src/main/java/com/l/tracecd/entity/User.java
new file mode 100644
index 0000000..23b5c63
--- /dev/null
+++ b/src/main/java/com/l/tracecd/entity/User.java
@@ -0,0 +1,58 @@
+package com.l.tracecd.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+
+import java.time.LocalDateTime;
+
+/**
+ * 用户实体
+ */
+@TableName("t_user")
+public class User {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ /** 用户名 */
+ private String username;
+
+ /** BCrypt 加密后的密码 */
+ private String password;
+
+ /** 创建时间 */
+ private LocalDateTime createdAt;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getUsername() {
+ return username;
+ }
+
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public LocalDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(LocalDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/interceptor/AuthInterceptor.java b/src/main/java/com/l/tracecd/interceptor/AuthInterceptor.java
new file mode 100644
index 0000000..0e8f1ce
--- /dev/null
+++ b/src/main/java/com/l/tracecd/interceptor/AuthInterceptor.java
@@ -0,0 +1,57 @@
+package com.l.tracecd.interceptor;
+
+import com.l.tracecd.constant.Constants;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.servlet.http.HttpSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * 登录认证拦截器
+ * 拦截 /api/** 和页面请求,未登录则重定向到 /login
+ */
+public class AuthInterceptor implements HandlerInterceptor {
+
+ private static final Logger log = LoggerFactory.getLogger(AuthInterceptor.class);
+
+ /** 不需要认证的路径 */
+ private static final String[] WHITE_LIST = {
+ "/login",
+ "/css/",
+ "/js/",
+ "/error"
+ };
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
+ Object handler) throws Exception {
+ String path = request.getRequestURI();
+
+ // 白名单放行
+ for (String white : WHITE_LIST) {
+ if (path.startsWith(white)) {
+ return true;
+ }
+ }
+
+ // 检查 session 中是否有用户信息
+ HttpSession session = request.getSession(false);
+ if (session != null && session.getAttribute(Constants.SESSION_USER_KEY) != null) {
+ return true;
+ }
+
+ // API 请求返回 401,页面请求重定向到登录页
+ if (path.startsWith("/api/")) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json;charset=UTF-8");
+ response.getWriter().write("{\"error\":\"未登录,请先登录\"}");
+ return false;
+ }
+
+ log.debug("未认证请求: {},重定向到登录页", path);
+ response.sendRedirect("/login");
+ return false;
+ }
+}
diff --git a/src/main/java/com/l/tracecd/mapper/DailyRecordMapper.java b/src/main/java/com/l/tracecd/mapper/DailyRecordMapper.java
new file mode 100644
index 0000000..090475b
--- /dev/null
+++ b/src/main/java/com/l/tracecd/mapper/DailyRecordMapper.java
@@ -0,0 +1,12 @@
+package com.l.tracecd.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.l.tracecd.entity.DailyRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 日常事项记录 Mapper
+ */
+@Mapper
+public interface DailyRecordMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/l/tracecd/mapper/DistinctValueMapper.java b/src/main/java/com/l/tracecd/mapper/DistinctValueMapper.java
new file mode 100644
index 0000000..526df8c
--- /dev/null
+++ b/src/main/java/com/l/tracecd/mapper/DistinctValueMapper.java
@@ -0,0 +1,12 @@
+package com.l.tracecd.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.l.tracecd.entity.DistinctValue;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 去重值 Mapper
+ */
+@Mapper
+public interface DistinctValueMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/l/tracecd/mapper/UserMapper.java b/src/main/java/com/l/tracecd/mapper/UserMapper.java
new file mode 100644
index 0000000..380d9ff
--- /dev/null
+++ b/src/main/java/com/l/tracecd/mapper/UserMapper.java
@@ -0,0 +1,12 @@
+package com.l.tracecd.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.l.tracecd.entity.User;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * 用户 Mapper
+ */
+@Mapper
+public interface UserMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/l/tracecd/service/AsrService.java b/src/main/java/com/l/tracecd/service/AsrService.java
new file mode 100644
index 0000000..9960560
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/AsrService.java
@@ -0,0 +1,94 @@
+package com.l.tracecd.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.l.tracecd.config.MimoAsrConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * ASR 语音识别服务
+ * 调用小米 MIMO V2.5 ASR API 将音频转为文本
+ */
+@Service
+public class AsrService {
+
+ private static final Logger log = LoggerFactory.getLogger(AsrService.class);
+
+ private final RestClient mimoRestClient;
+ private final MimoAsrConfig.MimoProperties mimoProperties;
+ private final ObjectMapper objectMapper;
+
+ public AsrService(RestClient mimoRestClient, MimoAsrConfig.MimoProperties mimoProperties,
+ ObjectMapper objectMapper) {
+ this.mimoRestClient = mimoRestClient;
+ this.mimoProperties = mimoProperties;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * 识别音频文件,返回文本
+ *
+ * @param audioBytes 音频字节数组
+ * @param mimeType 音频 MIME 类型(如 audio/wav)
+ * @return 识别的文本
+ * @throws Exception 识别失败时抛出
+ */
+ public String recognize(byte[] audioBytes, String mimeType) throws Exception {
+ long start = System.currentTimeMillis();
+
+ // 将音频编码为 base64 并构建 data URL
+ String base64Audio = Base64.getEncoder().encodeToString(audioBytes);
+ String dataUrl = "data:" + mimeType + ";base64," + base64Audio;
+
+ // 构建请求体
+ Map requestBody = Map.of(
+ "model", mimoProperties.getModel(),
+ "messages", List.of(
+ Map.of("role", "user", "content", List.of(
+ Map.of("type", "input_audio",
+ "input_audio", Map.of("data", dataUrl))
+ ))
+ ),
+ "asr_options", Map.of("language", "zh")
+ );
+
+ log.debug("调用 MIMO ASR 服务,音频大小: {} bytes", audioBytes.length);
+ String response = mimoRestClient.post()
+ .body(requestBody)
+ .retrieve()
+ .body(String.class);
+
+ long elapsed = System.currentTimeMillis() - start;
+ log.info("ASR 识别完成,耗时: {}ms", elapsed);
+
+ // 解析响应,提取文本内容
+ return extractText(response);
+ }
+
+ /**
+ * 从 MIMO ASR 响应中提取文本
+ */
+ private String extractText(String responseBody) throws Exception {
+ JsonNode root = objectMapper.readTree(responseBody);
+ JsonNode choices = root.get("choices");
+ if (choices != null && choices.isArray() && !choices.isEmpty()) {
+ JsonNode message = choices.get(0).get("message");
+ if (message != null) {
+ JsonNode content = message.get("content");
+ if (content != null && content.isTextual()) {
+ String text = content.asText();
+ log.info("ASR 识别结果: {}", text);
+ return text;
+ }
+ }
+ }
+ throw new RuntimeException("无法解析 ASR 响应: " + responseBody);
+ }
+}
diff --git a/src/main/java/com/l/tracecd/service/AuthService.java b/src/main/java/com/l/tracecd/service/AuthService.java
new file mode 100644
index 0000000..a433cca
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/AuthService.java
@@ -0,0 +1,70 @@
+package com.l.tracecd.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.l.tracecd.constant.Constants;
+import com.l.tracecd.entity.User;
+import com.l.tracecd.mapper.UserMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+
+/**
+ * 认证服务
+ * 负责用户登录验证,首次启动自动创建默认用户
+ */
+@Service
+public class AuthService {
+
+ private static final Logger log = LoggerFactory.getLogger(AuthService.class);
+
+ private final UserMapper userMapper;
+ private final PasswordEncoder passwordEncoder;
+
+ public AuthService(UserMapper userMapper) {
+ this.userMapper = userMapper;
+ this.passwordEncoder = new BCryptPasswordEncoder();
+ initDefaultUser();
+ }
+
+ /**
+ * 验证用户登录
+ *
+ * @param username 用户名
+ * @param rawPassword 明文密码
+ * @return 登录成功返回用户,失败返回 null
+ */
+ public User authenticate(String username, String rawPassword) {
+ User user = userMapper.selectOne(
+ new LambdaQueryWrapper().eq(User::getUsername, username)
+ );
+ if (user == null) {
+ log.warn("用户不存在: {}", username);
+ return null;
+ }
+ if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
+ log.warn("密码错误: {}", username);
+ return null;
+ }
+ log.info("用户登录成功: {}", username);
+ return user;
+ }
+
+ /**
+ * 首次启动时自动创建默认用户
+ */
+ private void initDefaultUser() {
+ long count = userMapper.selectCount(null);
+ if (count == 0) {
+ User user = new User();
+ user.setUsername(Constants.DEFAULT_USERNAME);
+ user.setPassword(passwordEncoder.encode(Constants.DEFAULT_PASSWORD));
+ user.setCreatedAt(LocalDateTime.now());
+ userMapper.insert(user);
+ log.info("已创建默认用户: {}/{}", Constants.DEFAULT_USERNAME, Constants.DEFAULT_PASSWORD);
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/service/DistinctValueService.java b/src/main/java/com/l/tracecd/service/DistinctValueService.java
new file mode 100644
index 0000000..93952ec
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/DistinctValueService.java
@@ -0,0 +1,76 @@
+package com.l.tracecd.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.l.tracecd.constant.Constants;
+import com.l.tracecd.entity.DailyRecord;
+import com.l.tracecd.entity.DistinctValue;
+import com.l.tracecd.mapper.DistinctValueMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 去重值维护服务
+ * 每次入库后更新 person/location/category 的去重集合
+ */
+@Service
+public class DistinctValueService {
+
+ private static final Logger log = LoggerFactory.getLogger(DistinctValueService.class);
+
+ private final DistinctValueMapper distinctValueMapper;
+
+ public DistinctValueService(DistinctValueMapper distinctValueMapper) {
+ this.distinctValueMapper = distinctValueMapper;
+ }
+
+ /**
+ * 入库后更新去重值
+ *
+ * @param record 新入库的事项记录
+ */
+ public void updateDistinctValues(DailyRecord record) {
+ saveIfNotExists(Constants.FIELD_PERSON, record.getPerson());
+ saveIfNotExists(Constants.FIELD_LOCATION, record.getLocation());
+ saveIfNotExists(Constants.FIELD_CATEGORY, record.getCategory());
+ }
+
+ /**
+ * 保存去重值(忽略重复)
+ */
+ private void saveIfNotExists(String fieldName, String fieldValue) {
+ if (fieldValue == null || fieldValue.isBlank()) {
+ return;
+ }
+ try {
+ DistinctValue dv = new DistinctValue(fieldName, fieldValue.trim());
+ distinctValueMapper.insert(dv);
+ } catch (DuplicateKeyException e) {
+ // 唯一索引冲突,忽略
+ log.debug("去重值已存在: {} = {}", fieldName, fieldValue);
+ } catch (Exception e) {
+ log.warn("保存去重值失败: {} = {}, 错误: {}", fieldName, fieldValue, e.getMessage());
+ }
+ }
+
+ /**
+ * 获取某个字段的所有去重值
+ *
+ * @param fieldName 字段名
+ * @return 去重值列表
+ */
+ public List getValues(String fieldName) {
+ List list = distinctValueMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(DistinctValue::getFieldName, fieldName)
+ .orderByAsc(DistinctValue::getFieldValue)
+ );
+ return list.stream()
+ .map(DistinctValue::getFieldValue)
+ .collect(Collectors.toList());
+ }
+}
diff --git a/src/main/java/com/l/tracecd/service/LlmService.java b/src/main/java/com/l/tracecd/service/LlmService.java
new file mode 100644
index 0000000..73fdbdf
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/LlmService.java
@@ -0,0 +1,227 @@
+package com.l.tracecd.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.l.tracecd.config.DeepSeekConfig;
+import com.l.tracecd.constant.Constants;
+import com.l.tracecd.dto.DeepSeekRequest;
+import com.l.tracecd.dto.DeepSeekResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestClient;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * DeepSeek 大模型服务
+ * 负责与 DeepSeek API 交互,包括 function call 处理
+ */
+@Service
+public class LlmService {
+
+ private static final Logger log = LoggerFactory.getLogger(LlmService.class);
+
+ private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final RestClient deepSeekRestClient;
+ private final DeepSeekConfig.DeepSeekProperties deepSeekProperties;
+ private final ObjectMapper objectMapper;
+
+ public LlmService(RestClient deepSeekRestClient, DeepSeekConfig.DeepSeekProperties deepSeekProperties,
+ ObjectMapper objectMapper) {
+ this.deepSeekRestClient = deepSeekRestClient;
+ this.deepSeekProperties = deepSeekProperties;
+ this.objectMapper = objectMapper;
+ }
+
+ /**
+ * 分析用户语音文本,返回 LLM 响应
+ * 可能包含文本回复或 function call 请求
+ *
+ * @param userText ASR 识别后的文本
+ * @return LLM 的响应消息
+ */
+ public DeepSeekResponse.ResponseMessage analyze(String userText) {
+ long start = System.currentTimeMillis();
+
+ DeepSeekRequest request = new DeepSeekRequest();
+ request.setModel(deepSeekProperties.getModel());
+
+ List messages = new ArrayList<>();
+ messages.add(new DeepSeekRequest.Message("system", buildSystemPrompt()));
+ messages.add(new DeepSeekRequest.Message("user", userText));
+ request.setMessages(messages);
+
+ // 设置 function call 工具
+ request.setTools(buildTools());
+
+ log.info("调用 DeepSeek API,用户输入: {}", userText);
+ DeepSeekResponse response = deepSeekRestClient.post()
+ .body(request)
+ .retrieve()
+ .body(DeepSeekResponse.class);
+
+ long elapsed = System.currentTimeMillis() - start;
+ log.info("DeepSeek 响应完成,耗时: {}ms", elapsed);
+
+ if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {
+ throw new RuntimeException("DeepSeek 返回空响应");
+ }
+
+ return response.getChoices().getFirst().getMessage();
+ }
+
+ /**
+ * 将 function call 结果返回给 LLM,获取最终回复
+ *
+ * @param messages 完整对话历史(包含 tool call 和 tool 结果)
+ * @return LLM 的最终响应
+ */
+ public DeepSeekResponse.ResponseMessage continueWithToolResult(
+ List messages) {
+ long start = System.currentTimeMillis();
+
+ DeepSeekRequest request = new DeepSeekRequest();
+ request.setModel(deepSeekProperties.getModel());
+ request.setMessages(messages);
+ request.setTools(buildTools());
+
+ log.debug("继续与 DeepSeek 对话,messages 数量: {}", messages.size());
+ DeepSeekResponse response = deepSeekRestClient.post()
+ .body(request)
+ .retrieve()
+ .body(DeepSeekResponse.class);
+
+ long elapsed = System.currentTimeMillis() - start;
+ log.info("DeepSeek 二次响应完成,耗时: {}ms", elapsed);
+
+ if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {
+ throw new RuntimeException("DeepSeek 返回空响应");
+ }
+
+ return response.getChoices().get(0).getMessage();
+ }
+
+ /**
+ * 构建 System Prompt
+ */
+ private String buildSystemPrompt() {
+ String now = LocalDateTime.now().format(DT_FMT);
+ return """
+ 你是一个日常记账助手。当前时间是 %s。
+
+ 你的任务是分析用户的输入,判断意图并做出相应处理:
+
+ ## 意图判断
+ 1. **录入(record)**: 用户描述了在某个时间、地点、花费金额做了某事。
+ 例如:"我花20块钱在小区门口张三面馆吃了一碗肉丝面"
+ 2. **查询(query)**: 用户想查询已录入的事项。
+ 例如:"我昨天中午12点做了什么"、"我今天一共花了多少钱"、"我上次吃面是什么时候"
+ 3. **聊天(chat)**: 用户输入与记账无关。
+ 例如:"你好"、"今天天气怎么样"
+
+ ## 录入意图处理
+ 当用户意图是录入时,分析内容并提取字段,生成标准 MySQL INSERT 语句。
+ - person: 人物,默认为"我"
+ - record_time: 事项发生时间,如用户未明确时间则用当前时间,格式 yyyy-MM-dd HH:mm:ss
+ - location: 地点
+ - content: 事项内容概述(一句话)
+ - category: 分类,2-4个字,如"吃饭"、"修车"、"旅游"、"加油"、"租房"、"水电"、"话费"、"购物"、"剪头"等
+ - amount: 花费金额(数字)
+
+ 只输出 SQL 语句,不要用```sql```包裹,不要有任何其他内容。
+ 格式举例:INSERT INTO t_daily_record (person, record_time, location, content, category, amount) VALUES ('我', '2025-01-15 12:00:00', '小区门口张三面馆', '吃了一碗肉丝面', '吃饭', 20.00)
+
+ ## 查询意图处理
+ 当用户意图是查询时,**必须调用 query_daily_records 函数**执行 SQL 查询。
+ - 人物、日期、内容、金额相关字段查询
+ - 查询完成后根据返回数据生成回复:
+ * 多条数据用 markdown 表格展示,表格上方展示金额汇总
+ * 一条数据直接描述
+ * 无数据告知用户未找到
+ * 回复要友好自然
+
+ ## 聊天意图处理
+ 当用户输入与记账无关时,直接友好回复。
+
+ ## 重要规则
+ - 只输出 SQL 语句或友好回复,不要输出分析过程
+ - 时间推算要准确:"昨天"推算为具体日期,"中午12点"设为12:00:00
+ - 如果用户没有提及金额,amount 设为 0
+ - 分类必须是2-4个中文字符
+ """.formatted(now);
+ }
+
+ /**
+ * 构建 Function Call 工具定义
+ */
+ private List buildTools() {
+ Map sqlProperty = Map.of(
+ "type", "object",
+ "properties", Map.of(
+ "sql", Map.of(
+ "type", "string",
+ "description", "合法的 MySQL SELECT 语句,仅允许 SELECT,表名限定为 t_daily_record"
+ )
+ ),
+ "required", List.of("sql")
+ );
+
+ DeepSeekRequest.ToolFunction func = new DeepSeekRequest.ToolFunction(
+ Constants.FUNCTION_QUERY_RECORDS,
+ "执行 SQL SELECT 查询获取事项数据。仅支持 SELECT 语句,表名限定为 t_daily_record。查询结果以 JSON 数组返回。",
+ new DeepSeekRequest.Parameters(
+ Map.of("sql", sqlProperty.get("properties")),
+ List.of("sql")
+ )
+ );
+
+ // Fix: properly set the properties type
+ func.getParameters().setProperties(Map.of(
+ "sql", Map.of(
+ "type", "string",
+ "description", "合法的 MySQL SELECT 语句,仅允许 SELECT,表名限定为 t_daily_record"
+ )
+ ));
+
+ return List.of(new DeepSeekRequest.Tool("function", func));
+ }
+
+ /**
+ * 将 LLM 的 INSERT SQL 再次发送给 LLM 要求修正
+ */
+ public DeepSeekResponse.ResponseMessage retryInsert(String userText, String errorMessage) {
+ String prompt = """
+ 之前生成的 SQL 语句不合法。错误信息: %s
+
+ 用户原始输入: %s
+
+ 请重新生成合法的 INSERT INTO t_daily_record 语句。
+ 只输出 SQL 语句,不要有其他内容。
+ """.formatted(errorMessage, userText);
+
+ DeepSeekRequest request = new DeepSeekRequest();
+ request.setModel(deepSeekProperties.getModel());
+ request.setMessages(List.of(
+ new DeepSeekRequest.Message("system", buildSystemPrompt()),
+ new DeepSeekRequest.Message("user", prompt)
+ ));
+ // 重试时不带 tools
+
+ log.info("重试 SQL 生成,错误: {}", errorMessage);
+ DeepSeekResponse response = deepSeekRestClient.post()
+ .body(request)
+ .retrieve()
+ .body(DeepSeekResponse.class);
+
+ if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {
+ throw new RuntimeException("DeepSeek 返回空响应");
+ }
+ return response.getChoices().get(0).getMessage();
+ }
+}
diff --git a/src/main/java/com/l/tracecd/service/RecordService.java b/src/main/java/com/l/tracecd/service/RecordService.java
new file mode 100644
index 0000000..6ee0298
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/RecordService.java
@@ -0,0 +1,151 @@
+package com.l.tracecd.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.l.tracecd.dto.BrowseQuery;
+import com.l.tracecd.entity.DailyRecord;
+import com.l.tracecd.mapper.DailyRecordMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 事项记录服务
+ * 负责日常事项的增删改查
+ */
+@Service
+public class RecordService {
+
+ private static final Logger log = LoggerFactory.getLogger(RecordService.class);
+
+ private final DailyRecordMapper dailyRecordMapper;
+
+ public RecordService(DailyRecordMapper dailyRecordMapper) {
+ this.dailyRecordMapper = dailyRecordMapper;
+ }
+
+ /**
+ * 执行 LLM 生成的 INSERT SQL 入库
+ *
+ * @param sql 校验后的合法 INSERT 语句
+ * @return 入库的记录
+ */
+ @Transactional
+ public DailyRecord insertBySql(String sql) {
+ // 使用 MyBatis-Plus 的原生 SQL 执行或直接操作
+ // 解析 SQL 提取值,构造实体对象入库(更安全)
+ DailyRecord record = parseInsertSql(sql);
+ record.setCreatedAt(LocalDateTime.now());
+ dailyRecordMapper.insert(record);
+ log.info("事项入库成功: person={}, category={}, amount={}, location={}",
+ record.getPerson(), record.getCategory(), record.getAmount(), record.getLocation());
+ return record;
+ }
+
+ /**
+ * 浏览页条件筛选查询
+ */
+ public List browseQuery(BrowseQuery query) {
+ LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>();
+
+ if (query.getPersons() != null && !query.getPersons().isEmpty()) {
+ wrapper.in(DailyRecord::getPerson, query.getPersons());
+ }
+ if (query.getLocations() != null && !query.getLocations().isEmpty()) {
+ wrapper.in(DailyRecord::getLocation, query.getLocations());
+ }
+ if (query.getCategories() != null && !query.getCategories().isEmpty()) {
+ wrapper.in(DailyRecord::getCategory, query.getCategories());
+ }
+ if (query.getStartTime() != null && !query.getStartTime().isBlank()) {
+ wrapper.ge(DailyRecord::getRecordTime, LocalDateTime.parse(query.getStartTime()));
+ }
+ if (query.getEndTime() != null && !query.getEndTime().isBlank()) {
+ wrapper.le(DailyRecord::getRecordTime, LocalDateTime.parse(query.getEndTime()));
+ }
+
+ wrapper.orderByDesc(DailyRecord::getRecordTime);
+ return dailyRecordMapper.selectList(wrapper);
+ }
+
+ /**
+ * 获取查询结果金额汇总
+ */
+ public BigDecimal sumAmount(List records) {
+ return records.stream()
+ .map(DailyRecord::getAmount)
+ .filter(a -> a != null)
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+ }
+
+ /**
+ * 简单解析 INSERT SQL 提取字段值构造实体
+ * 格式: INSERT INTO t_daily_record (cols) VALUES (vals)
+ */
+ private DailyRecord parseInsertSql(String sql) {
+ DailyRecord record = new DailyRecord();
+ try {
+ // 提取列名和值
+ String upper = sql.toUpperCase();
+ int colsStart = upper.indexOf("(") + 1;
+ int colsEnd = upper.indexOf(")");
+ int valsStart = upper.indexOf("VALUES") + 6;
+ // 在原始 SQL 中提取值(保留大小写)
+ int rawValsStart = sql.toUpperCase().indexOf("VALUES") + 6;
+ String rawValues = sql.substring(rawValsStart).trim();
+ // 去掉括号
+ rawValues = rawValues.replaceAll("^\\(|\\);?$", "").trim();
+
+ String cols = sql.substring(colsStart, colsEnd).trim();
+ String[] colNames = cols.split(",");
+ String[] values = splitValues(rawValues);
+
+ for (int i = 0; i < colNames.length && i < values.length; i++) {
+ String col = colNames[i].trim().toLowerCase();
+ String val = values[i].trim();
+ // 去掉引号
+ val = val.replaceAll("^'|'$", "").trim();
+
+ switch (col) {
+ case "person" -> record.setPerson(val);
+ case "record_time" -> record.setRecordTime(LocalDateTime.parse(val,
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+ case "location" -> record.setLocation(val);
+ case "content" -> record.setContent(val);
+ case "category" -> record.setCategory(val);
+ case "amount" -> record.setAmount(new BigDecimal(val));
+ }
+ }
+ } catch (Exception e) {
+ log.error("解析 INSERT SQL 失败: {}", sql, e);
+ throw new RuntimeException("解析 INSERT SQL 失败", e);
+ }
+ return record;
+ }
+
+ /**
+ * 分割 VALUES 中的值(处理引号内的逗号)
+ */
+ private String[] splitValues(String valuesStr) {
+ java.util.List result = new java.util.ArrayList<>();
+ boolean inQuote = false;
+ StringBuilder current = new StringBuilder();
+ for (char c : valuesStr.toCharArray()) {
+ if (c == '\'') {
+ inQuote = !inQuote;
+ }
+ if (c == ',' && !inQuote) {
+ result.add(current.toString());
+ current = new StringBuilder();
+ } else {
+ current.append(c);
+ }
+ }
+ result.add(current.toString());
+ return result.toArray(new String[0]);
+ }
+}
diff --git a/src/main/java/com/l/tracecd/service/SqlValidationService.java b/src/main/java/com/l/tracecd/service/SqlValidationService.java
new file mode 100644
index 0000000..408deca
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/SqlValidationService.java
@@ -0,0 +1,125 @@
+package com.l.tracecd.service;
+
+import com.l.tracecd.constant.Constants;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+/**
+ * SQL 校验服务
+ * 校验 LLM 生成的 SQL 语句合法性,防止注入和危险操作
+ */
+@Service
+public class SqlValidationService {
+
+ private static final Logger log = LoggerFactory.getLogger(SqlValidationService.class);
+
+ /** INSERT 语句禁止的关键字 */
+ private static final String[] INSERT_DANGEROUS = {
+ "DROP", "DELETE", "UPDATE", "ALTER", "TRUNCATE", "CREATE",
+ "EXEC", "EXECUTE", "UNION", "--", "/*", ";", "GRANT", "REVOKE"
+ };
+
+ /** SELECT 语句禁止的关键字 */
+ private static final String[] SELECT_DANGEROUS = {
+ "DROP", "DELETE", "UPDATE", "ALTER", "TRUNCATE", "CREATE",
+ "INSERT", "EXEC", "EXECUTE", "--", "/*", "GRANT", "REVOKE"
+ };
+
+ /**
+ * 校验录入场景的 INSERT 语句
+ *
+ * @param sql 待校验的 SQL
+ * @return 清理后的 SQL
+ * @throws SqlValidationException 校验失败
+ */
+ public String validateInsert(String sql) throws SqlValidationException {
+ if (sql == null || sql.isBlank()) {
+ throw new SqlValidationException("SQL 语句为空");
+ }
+
+ String cleaned = cleanSql(sql);
+ String upper = cleaned.toUpperCase().replaceAll("\\s+", " ").trim();
+
+ // 必须以 INSERT INTO 开头,且目标表为 t_daily_record
+ if (!upper.startsWith("INSERT INTO") || !upper.contains(Constants.TABLE_DAILY_RECORD.toUpperCase())) {
+ throw new SqlValidationException("SQL 必须是 INSERT INTO " + Constants.TABLE_DAILY_RECORD + " 语句");
+ }
+
+ // 检查 VALUES 关键字
+ if (!upper.contains("VALUES")) {
+ throw new SqlValidationException("INSERT 语句必须包含 VALUES");
+ }
+
+ // 检查危险关键字
+ checkDangerous(upper, INSERT_DANGEROUS);
+
+ log.debug("INSERT SQL 校验通过: {}", cleaned);
+ return cleaned;
+ }
+
+ /**
+ * 校验查询场景的 SELECT 语句
+ *
+ * @param sql 待校验的 SQL
+ * @return 清理后的 SQL
+ * @throws SqlValidationException 校验失败
+ */
+ public String validateSelect(String sql) throws SqlValidationException {
+ if (sql == null || sql.isBlank()) {
+ throw new SqlValidationException("SQL 语句为空");
+ }
+
+ String cleaned = cleanSql(sql);
+ String upper = cleaned.toUpperCase().replaceAll("\\s+", " ").trim();
+
+ // 必须以 SELECT 开头
+ if (!upper.startsWith("SELECT")) {
+ throw new SqlValidationException("查询只允许 SELECT 语句");
+ }
+
+ // 必须包含 t_daily_record 表
+ if (!upper.contains(Constants.TABLE_DAILY_RECORD.toUpperCase())) {
+ throw new SqlValidationException("只允许查询 " + Constants.TABLE_DAILY_RECORD + " 表");
+ }
+
+ // 检查危险关键字
+ checkDangerous(upper, SELECT_DANGEROUS);
+
+ log.debug("SELECT SQL 校验通过: {}", cleaned);
+ return cleaned;
+ }
+
+ /**
+ * 清理 SQL:去除首尾空白、末尾分号、markdown 代码块标记
+ */
+ private String cleanSql(String sql) {
+ String cleaned = sql.trim();
+ // 去除 markdown 代码块
+ cleaned = cleaned.replaceAll("^```sql\\s*", "").replaceAll("^```\\s*", "");
+ cleaned = cleaned.replaceAll("```$", "");
+ // 去除末尾分号
+ cleaned = cleaned.replaceAll(";\\s*$", "");
+ return cleaned.trim();
+ }
+
+ /**
+ * 检查是否包含危险关键字
+ */
+ private void checkDangerous(String upperSql, String[] dangerous) throws SqlValidationException {
+ for (String keyword : dangerous) {
+ if (upperSql.contains(keyword)) {
+ throw new SqlValidationException("SQL 包含非法关键字: " + keyword);
+ }
+ }
+ }
+
+ /**
+ * SQL 校验异常
+ */
+ public static class SqlValidationException extends Exception {
+ public SqlValidationException(String message) {
+ super(message);
+ }
+ }
+}
diff --git a/src/main/java/com/l/tracecd/service/VoiceService.java b/src/main/java/com/l/tracecd/service/VoiceService.java
new file mode 100644
index 0000000..e1c5579
--- /dev/null
+++ b/src/main/java/com/l/tracecd/service/VoiceService.java
@@ -0,0 +1,221 @@
+package com.l.tracecd.service;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.l.tracecd.constant.Constants;
+import com.l.tracecd.dto.DeepSeekRequest;
+import com.l.tracecd.dto.DeepSeekResponse;
+import com.l.tracecd.dto.VoiceResponse;
+import com.l.tracecd.entity.DailyRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import javax.sql.DataSource;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 语音处理编排服务
+ * 协调 ASR → LLM → SQL 校验/执行 → 结果返回的完整流程
+ */
+@Service
+public class VoiceService {
+
+ private static final Logger log = LoggerFactory.getLogger(VoiceService.class);
+
+ private final AsrService asrService;
+ private final LlmService llmService;
+ private final SqlValidationService sqlValidationService;
+ private final RecordService recordService;
+ private final DistinctValueService distinctValueService;
+ private final ObjectMapper objectMapper;
+ private final javax.sql.DataSource dataSource;
+
+ public VoiceService(AsrService asrService, LlmService llmService,
+ SqlValidationService sqlValidationService, RecordService recordService,
+ DistinctValueService distinctValueService, ObjectMapper objectMapper,
+ DataSource dataSource) {
+ this.asrService = asrService;
+ this.llmService = llmService;
+ this.sqlValidationService = sqlValidationService;
+ this.recordService = recordService;
+ this.distinctValueService = distinctValueService;
+ this.objectMapper = objectMapper;
+ this.dataSource = dataSource;
+ }
+
+ /**
+ * 处理语音请求的完整流程
+ *
+ * @param audioBytes 音频字节数组
+ * @param mimeType 音频 MIME 类型
+ * @return 处理结果
+ */
+ public VoiceResponse processVoice(byte[] audioBytes, String mimeType) {
+ try {
+ // 1. ASR 识别
+ log.info("开始语音处理流程,音频大小: {} bytes", audioBytes.length);
+ String recognizedText = asrService.recognize(audioBytes, mimeType);
+ if (recognizedText == null || recognizedText.isBlank()) {
+ return VoiceResponse.error("未能识别到语音内容,请重试");
+ }
+
+ // 2. 发送给 LLM 分析
+ DeepSeekResponse.ResponseMessage llmResponse = llmService.analyze(recognizedText);
+
+ // 3. 根据 LLM 响应判断意图并处理
+ return handleLlmResponse(llmResponse, recognizedText);
+
+ } catch (Exception e) {
+ log.error("语音处理失败", e);
+ return VoiceResponse.error("处理失败: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 根据 LLM 响应处理不同意图
+ */
+ private VoiceResponse handleLlmResponse(DeepSeekResponse.ResponseMessage response,
+ String originalText) throws Exception {
+ // 情况1: 有 tool_calls → 查询意图
+ if (response.getToolCalls() != null && !response.getToolCalls().isEmpty()) {
+ log.info("检测到查询意图,处理 function call");
+ return handleQueryIntent(response, originalText);
+ }
+
+ // 情况2: 内容包含 INSERT → 录入意图
+ String content = response.getContent();
+ if (content != null && content.toUpperCase().contains("INSERT INTO")) {
+ log.info("检测到录入意图");
+ return handleRecordIntent(content, originalText);
+ }
+
+ // 情况3: 普通聊天回复
+ log.info("检测到聊天意图");
+ String reply = content != null ? content : "抱歉,我没有理解您的意思";
+ return VoiceResponse.chatReply(reply);
+ }
+
+ /**
+ * 处理录入意图:校验 SQL → 重试 → 入库
+ */
+ private VoiceResponse handleRecordIntent(String sql, String originalText) throws Exception {
+ String category = null;
+
+ for (int i = 0; i < Constants.SQL_MAX_RETRIES; i++) {
+ try {
+ String validatedSql = sqlValidationService.validateInsert(sql);
+ DailyRecord record = recordService.insertBySql(validatedSql);
+ distinctValueService.updateDistinctValues(record);
+ category = record.getCategory();
+ log.info("录入成功: {}", category);
+ return VoiceResponse.recordSuccess(category);
+ } catch (SqlValidationService.SqlValidationException e) {
+ log.warn("SQL 校验失败 (第{}次): {}", i + 1, e.getMessage());
+ if (i < Constants.SQL_MAX_RETRIES - 1) {
+ DeepSeekResponse.ResponseMessage retryResponse =
+ llmService.retryInsert(originalText, e.getMessage());
+ String newContent = retryResponse.getContent();
+ if (newContent != null) {
+ sql = newContent;
+ }
+ }
+ }
+ }
+
+ return VoiceResponse.error("SQL 生成失败,已重试 " + Constants.SQL_MAX_RETRIES + " 次");
+ }
+
+ /**
+ * 处理查询意图:执行 function call → 返回结果给 LLM → 获取格式化回复
+ */
+ private VoiceResponse handleQueryIntent(DeepSeekResponse.ResponseMessage response,
+ String originalText) throws Exception {
+ DeepSeekRequest.ToolCall toolCall = response.getToolCalls().getFirst();
+ String functionName = toolCall.getFunction().getName();
+
+ if (!Constants.FUNCTION_QUERY_RECORDS.equals(functionName)) {
+ log.warn("未知的 function call: {}", functionName);
+ return VoiceResponse.chatReply(response.getContent() != null ? response.getContent() : "抱歉,无法处理该请求");
+ }
+
+ // 解析 SQL 参数
+ String argumentsJson = toolCall.getFunction().getArguments();
+ Map args = objectMapper.readValue(argumentsJson,
+ new TypeReference