commit c5dee7e444e02679954504a9a4f89bbfaa056915 Author: Lee <1633292@qq.com> Date: Mon Jun 8 15:04:18 2026 +0800 初始化项目 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>() {}); + String sql = (String) args.get("sql"); + + // 校验并执行 SQL + String validatedSql = sqlValidationService.validateSelect(sql); + String queryResultJson = executeSelectSql(validatedSql); + + // 构建对话历史发送回 LLM + List messages = buildToolResultMessages(originalText, response, toolCall, queryResultJson); + DeepSeekResponse.ResponseMessage finalResponse = llmService.continueWithToolResult(messages); + + String replyContent = finalResponse.getContent(); + if (replyContent == null || replyContent.isBlank()) { + replyContent = "查询完成,但未能生成回复"; + } + return VoiceResponse.queryResult(replyContent); + } + + /** + * 执行 SELECT SQL 并返回 JSON 字符串 + */ + private String executeSelectSql(String sql) throws Exception { + log.debug("执行函数查询 SQL: {}", sql); + try (var conn = dataSource.getConnection(); + var stmt = conn.createStatement(); + var rs = stmt.executeQuery(sql)) { + + var meta = rs.getMetaData(); + int colCount = meta.getColumnCount(); + + List> rows = new ArrayList<>(); + while (rs.next()) { + java.util.LinkedHashMap row = new java.util.LinkedHashMap<>(); + for (int i = 1; i <= colCount; i++) { + row.put(meta.getColumnLabel(i), rs.getObject(i)); + } + rows.add(row); + } + + String json = objectMapper.writeValueAsString(rows); + log.info("查询返回 {} 条记录", rows.size()); + return json; + } + } + + /** + * 构建 function call 的完整对话历史 + */ + private List buildToolResultMessages( + String originalText, DeepSeekResponse.ResponseMessage assistantMsg, + DeepSeekRequest.ToolCall toolCall, String queryResultJson) { + + List messages = new ArrayList<>(); + + // system + messages.add(new DeepSeekRequest.Message("system", + "你是一个日常记账助手。请根据查询结果友好地回答用户的问题。多条数据请用 markdown 表格展示,表格上方展示金额汇总。")); + + // user original + messages.add(new DeepSeekRequest.Message("user", originalText)); + + // assistant with tool_call + DeepSeekRequest.Message assistantMessage = new DeepSeekRequest.Message("assistant", null); + assistantMessage.setToolCalls(List.of(toolCall)); + messages.add(assistantMessage); + + // tool result + DeepSeekRequest.Message toolMessage = new DeepSeekRequest.Message("tool", queryResultJson); + toolMessage.setToolCallId(toolCall.getId()); + toolMessage.setName(Constants.FUNCTION_QUERY_RECORDS); + messages.add(toolMessage); + + return messages; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e168461 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,78 @@ +server: + port: 8080 + servlet: + session: + timeout: 30d # 长期保持登录 + +spring: + application: + name: tracecd + main: + banner-mode: off # 关闭 Spring Boot banner + + # Jackson 日期格式 + jackson: + date-format: yyyy-MM-dd HH:mm:ss + time-zone: Asia/Shanghai + serialization: + write-dates-as-timestamps: false + + # Thymeleaf + thymeleaf: + cache: false + mode: HTML + + # MySQL 数据源 + datasource: + url: jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true + username: root + password: root + driver-class-name: com.mysql.cj.jdbc.Driver + + # Session 存储方式 + session: + jdbc: + initialize-schema: never + timeout: 30d + + # 文件上传限制(音频文件) + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + +# MyBatis-Plus 配置 +mybatis-plus: + global-config: + banner: false # 关闭 MyBatis-Plus banner + configuration: + log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl # 不输出 SQL + mapper-locations: classpath*:/mapper/**/*.xml + +# 日志 +logging: + level: + root: WARN + com.l.tracecd: DEBUG + com.baomidou.mybatisplus: WARN + org.apache.ibatis: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n" + +# DeepSeek API +deepseek: + api-key: ${DEEPSEEK_API_KEY:sk-732e5f5f1f07454492022401f0a2bf40} + base-url: https://api.deepseek.com/chat/completions + model: deepseek-v4-pro + +# MIMO ASR API +mimo: + api-key: ${MIMO_API_KEY:sk-cqttr117tzllfolonxztu4qa5208liota2llfut458ixx2m1} + base-url: https://api.xiaomimimo.com/v1/chat/completions + model: mimo-v2.5-asr + +# 应用配置 +app: + sql-max-retries: 5 # SQL 生成最大重试次数 + default-username: admin # 默认用户名 + default-password: admin123 # 默认密码(首次启动自动创建) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..e4aae11 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,31 @@ +-- 用户表 +CREATE TABLE IF NOT EXISTS t_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- 日常事项表 +CREATE TABLE IF NOT EXISTS t_daily_record ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + person VARCHAR(50) NOT NULL COMMENT '人物', + record_time DATETIME NOT NULL COMMENT '事项发生时间', + location VARCHAR(200) DEFAULT '' COMMENT '地点', + content VARCHAR(500) NOT NULL COMMENT '事项内容', + category VARCHAR(20) NOT NULL COMMENT '分类(2-4字)', + amount DECIMAL(12,2) DEFAULT 0 COMMENT '金额', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_person (person), + INDEX idx_record_time (record_time), + INDEX idx_location (location), + INDEX idx_category (category) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日常事项记录表'; + +-- 去重筛选项表 +CREATE TABLE IF NOT EXISTS t_distinct_value ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + field_name VARCHAR(30) NOT NULL COMMENT '字段名: person/location/category', + field_value VARCHAR(200) NOT NULL COMMENT '去重值', + UNIQUE KEY uk_field (field_name, field_value) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='筛选项去重表'; diff --git a/src/main/resources/templates/browse.html b/src/main/resources/templates/browse.html new file mode 100644 index 0000000..728b14b --- /dev/null +++ b/src/main/resources/templates/browse.html @@ -0,0 +1,410 @@ + + + + + + 浏览事项 - TraceCD + + + + + + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ 合计: + +
+
+ + +
+
+
+

查询中...

+
+
+ + + + + + + + + + + + + +
+
点击"查询"按钮查看事项数据
+
+
+ + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..4a7c2c8 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,583 @@ + + + + + + TraceCD - 日常记录 + + + + + + + + + + +
+ + +
+
+
+ + +
+
+ +

按住右下角麦克风按钮开始语音录入或查询

+
+ + +
+ + +
+
+ + +
+ +
按住说话,松开发送
+
+ + + + diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 0000000..b489254 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,49 @@ + + + + + + 登录 - TraceCD + + + + + + +