初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 9m3s

This commit is contained in:
Lee 2026-06-08 15:04:18 +08:00
commit c5dee7e444
51 changed files with 4107 additions and 0 deletions

View File

@ -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

39
.gitignore vendored Normal file
View File

@ -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

10
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,10 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# 已忽略包含查询文件的默认文件夹
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

13
.idea/dataSources.xml generated Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="tracecd@192.168.2.5" uuid="911f82d8-654e-4293-bc95-e53e9a4e6769">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<imported>true</imported>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://192.168.2.5:3306/tracecd?useUnicode=true&amp;characterEncoding=utf-8&amp;serverTimezone=Asia/Shanghai&amp;createDatabaseIfNotExist=true</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/data_source_mapping.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/src/main/resources/schema.sql" value="911f82d8-654e-4293-bc95-e53e9a4e6769" />
</component>
</project>

7
.idea/encodings.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

View File

@ -0,0 +1,7 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0" is_locked="false">
<option name="myName" value="Project Default" />
<inspection_tool class="VulnerableLibrariesGlobal" enabled="false" level="WARNING" enabled_by_default="false" />
<inspection_tool class="VulnerableLibrariesLocal" enabled="false" level="WARNING" enabled_by_default="false" />
</profile>
</component>

14
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="openjdk-26" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

39
CLAUDE.md Normal file
View File

@ -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`

28
Dockerfile Normal file
View File

@ -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 '<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"><mirrors><mirror><id>aliyunmaven</id><mirrorOf>*</mirrorOf><name>阿里云公共仓库</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /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"]

208
README.md Normal file
View File

@ -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 + 原生 JSWAV 录音) |
| **LLM** | DeepSeek APIdeepseek-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 # 清理
```

87
pom.xml Normal file
View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.14</version>
<relativePath/>
</parent>
<groupId>com.l</groupId>
<artifactId>tracecd</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<java.version>26</java.version>
<mybatis-plus.version>3.5.12</mybatis-plus.version>
</properties>
<dependencies>
<!-- Spring Boot Starters -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- MySQL Connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Spring Session (Cookie-based, no Redis) -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-core</artifactId>
</dependency>
<!-- BCrypt for password hashing -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<release>26</release>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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;
}
}
}

View File

@ -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");
}
}

View File

@ -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
}

View File

@ -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<MapSession> sessionRepository() {
MapSessionRepository repository = new MapSessionRepository(new ConcurrentHashMap<>());
repository.setDefaultMaxInactiveInterval(Duration.ofSeconds(Constants.SESSION_TIMEOUT_SECONDS));
return repository;
}
}

View File

@ -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";
}

View File

@ -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";
}
}

View File

@ -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<String, Object> query(@RequestBody BrowseQuery query) {
log.info("浏览查询: persons={}, time={}~{}, locations={}, categories={}",
query.getPersons(), query.getStartTime(), query.getEndTime(),
query.getLocations(), query.getCategories());
List<DailyRecord> records = recordService.browseQuery(query);
BigDecimal totalAmount = recordService.sumAmount(records);
Map<String, Object> result = new HashMap<>();
result.put("records", records);
result.put("totalAmount", totalAmount);
result.put("count", records.size());
log.info("查询结果: {} 条记录,总金额: {}", records.size(), totalAmount);
return result;
}
}

View File

@ -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";
}
}

View File

@ -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<FilterOption> 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))
);
}
}

View File

@ -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";
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,64 @@
package com.l.tracecd.dto;
import java.util.List;
/**
* 浏览页查询参数 DTO
*/
public class BrowseQuery {
/** 人物列表(多选) */
private List<String> persons;
/** 时间段起始 */
private String startTime;
/** 时间段结束 */
private String endTime;
/** 地点列表(多选) */
private List<String> locations;
/** 分类列表(多选) */
private List<String> categories;
public List<String> getPersons() {
return persons;
}
public void setPersons(List<String> 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<String> getLocations() {
return locations;
}
public void setLocations(List<String> locations) {
this.locations = locations;
}
public List<String> getCategories() {
return categories;
}
public void setCategories(List<String> categories) {
this.categories = categories;
}
}

View File

@ -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<Message> messages;
private List<Tool> 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<Message> getMessages() {
return messages;
}
public void setMessages(List<Message> messages) {
this.messages = messages;
}
public List<Tool> getTools() {
return tools;
}
public void setTools(List<Tool> 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<ToolCall> 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<ToolCall> getToolCalls() {
return toolCalls;
}
public void setToolCalls(List<ToolCall> 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<String, Object> properties;
private List<String> required;
public Parameters() {}
public Parameters(java.util.Map<String, Object> properties, List<String> required) {
this.properties = properties;
this.required = required;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public java.util.Map<String, Object> getProperties() {
return properties;
}
public void setProperties(java.util.Map<String, Object> properties) {
this.properties = properties;
}
public List<String> getRequired() {
return required;
}
public void setRequired(List<String> 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;
}
}
}

View File

@ -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<Choice> choices;
public List<Choice> getChoices() {
return choices;
}
public void setChoices(List<Choice> 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<DeepSeekRequest.ToolCall> 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<DeepSeekRequest.ToolCall> getToolCalls() {
return toolCalls;
}
public void setToolCalls(List<DeepSeekRequest.ToolCall> toolCalls) {
this.toolCalls = toolCalls;
}
}
}

View File

@ -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<String> values;
public FilterOption() {
}
public FilterOption(String fieldName, List<String> values) {
this.fieldName = fieldName;
this.values = values;
}
public String getFieldName() {
return fieldName;
}
public void setFieldName(String fieldName) {
this.fieldName = fieldName;
}
public List<String> getValues() {
return values;
}
public void setValues(List<String> values) {
this.values = values;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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<DailyRecord> {
}

View File

@ -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<DistinctValue> {
}

View File

@ -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<User> {
}

View File

@ -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<String, Object> 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);
}
}

View File

@ -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<User>().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);
}
}
}

View File

@ -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<String> getValues(String fieldName) {
List<DistinctValue> list = distinctValueMapper.selectList(
new LambdaQueryWrapper<DistinctValue>()
.eq(DistinctValue::getFieldName, fieldName)
.orderByAsc(DistinctValue::getFieldValue)
);
return list.stream()
.map(DistinctValue::getFieldValue)
.collect(Collectors.toList());
}
}

View File

@ -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<DeepSeekRequest.Message> 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<DeepSeekRequest.Message> 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<DeepSeekRequest.Tool> buildTools() {
Map<String, Object> 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();
}
}

View File

@ -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<DailyRecord> browseQuery(BrowseQuery query) {
LambdaQueryWrapper<DailyRecord> 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<DailyRecord> 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<String> 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]);
}
}

View File

@ -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);
}
}
}

View File

@ -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<String, Object> args = objectMapper.readValue(argumentsJson,
new TypeReference<Map<String, Object>>() {});
String sql = (String) args.get("sql");
// 校验并执行 SQL
String validatedSql = sqlValidationService.validateSelect(sql);
String queryResultJson = executeSelectSql(validatedSql);
// 构建对话历史发送回 LLM
List<DeepSeekRequest.Message> 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<Map<String, Object>> rows = new ArrayList<>();
while (rs.next()) {
java.util.LinkedHashMap<String, Object> 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<DeepSeekRequest.Message> buildToolResultMessages(
String originalText, DeepSeekResponse.ResponseMessage assistantMsg,
DeepSeekRequest.ToolCall toolCall, String queryResultJson) {
List<DeepSeekRequest.Message> 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;
}
}

View File

@ -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 # 默认密码(首次启动自动创建)

View File

@ -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='筛选项去重表';

View File

@ -0,0 +1,410 @@
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>浏览事项 - TraceCD</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f8fafc;
min-height: 100vh;
}
.navbar {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar .brand { font-size: 18px; font-weight: 600; color: #1e293b; }
.navbar .nav-links a {
color: #64748b; text-decoration: none; margin-left: 20px;
font-size: 14px; transition: color 0.2s;
}
.navbar .nav-links a:hover { color: #4f46e5; }
.page-container {
max-width: 1200px;
margin: 0 auto;
padding: 30px 20px;
}
/* 筛选栏 */
.filter-bar {
background: white;
border-radius: 16px;
padding: 24px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
margin-bottom: 24px;
}
.filter-bar .row { margin-bottom: 12px; }
.filter-bar label {
font-size: 13px;
font-weight: 600;
color: #64748b;
margin-bottom: 4px;
display: block;
}
.filter-bar .form-select,
.filter-bar .form-control {
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
}
.btn-search {
background: #4f46e5;
color: white;
border: none;
padding: 10px 32px;
border-radius: 8px;
font-weight: 500;
transition: background 0.2s;
}
.btn-search:hover { background: #4338ca; }
.btn-reset {
background: transparent;
color: #64748b;
border: 1px solid #e2e8f0;
padding: 10px 24px;
border-radius: 8px;
margin-left: 8px;
transition: all 0.2s;
}
.btn-reset:hover { background: #f1f5f9; }
/* 汇总 */
.summary-bar {
background: white;
border-radius: 12px;
padding: 16px 24px;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
display: none;
}
.summary-bar.visible { display: flex; }
.summary-amount {
font-size: 22px;
font-weight: 700;
color: #4f46e5;
}
.summary-count { font-size: 14px; color: #64748b; }
/* 表格 */
.table-card {
background: white;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.table-card table {
width: 100%;
margin: 0;
}
.table-card table thead th {
background: #f8fafc;
font-size: 12px;
font-weight: 700;
color: #64748b;
text-transform: uppercase;
padding: 14px 16px;
border-bottom: 2px solid #e2e8f0;
}
.table-card table tbody td {
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #334155;
vertical-align: middle;
}
.table-card table tbody tr:hover { background: #f8fafc; }
.category-badge {
display: inline-block;
background: #eef2ff;
color: #4f46e5;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
}
.amount-cell {
font-weight: 600;
color: #ef4444;
text-align: right;
}
.empty-msg {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
font-size: 15px;
}
/* 加载 */
.loading-bar {
text-align: center;
padding: 40px;
display: none;
}
.loading-bar.show { display: block; }
</style>
</head>
<body>
<nav class="navbar">
<span class="brand">📝 TraceCD</span>
<div class="nav-links">
<a href="/">🎤 语音录入</a>
<a href="/logout">退出</a>
</div>
</nav>
<div class="page-container">
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="row">
<div class="col-md-3 mb-3">
<label>人物</label>
<select class="form-select" id="filterPerson" multiple size="3">
</select>
</div>
<div class="col-md-3 mb-3">
<label>开始时间</label>
<input type="date" class="form-control" id="filterStartTime">
</div>
<div class="col-md-3 mb-3">
<label>结束时间</label>
<input type="date" class="form-control" id="filterEndTime">
</div>
<div class="col-md-3 mb-3">
<label>地点</label>
<select class="form-select" id="filterLocation" multiple size="3">
</select>
</div>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<label>分类</label>
<select class="form-select" id="filterCategory" multiple size="3">
</select>
</div>
<div class="col-md-9 mb-3 d-flex align-items-end">
<button class="btn-search" onclick="doQuery()">
<i class="bi bi-search"></i> 查询
</button>
<button class="btn-reset" onclick="resetFilters()">
<i class="bi bi-arrow-clockwise"></i> 重置
</button>
</div>
</div>
</div>
<!-- 汇总 -->
<div class="summary-bar" id="summaryBar">
<div>
<span class="summary-count" id="summaryCount"></span>
</div>
<div>
<span style="color: #64748b;">合计:</span>
<span class="summary-amount" id="summaryAmount"></span>
</div>
</div>
<!-- 表格 -->
<div class="table-card">
<div class="loading-bar" id="loadingBar">
<div class="spinner-border text-secondary" role="status"></div>
<p class="mt-2 text-muted">查询中...</p>
</div>
<div class="table-responsive">
<table class="table" id="resultTable" style="display:none;">
<thead>
<tr>
<th>人物</th>
<th>时间</th>
<th>地点</th>
<th>内容</th>
<th>分类</th>
<th style="text-align:right;">金额</th>
</tr>
</thead>
<tbody id="resultTbody"></tbody>
</table>
</div>
<div class="empty-msg" id="emptyMsg">点击"查询"按钮查看事项数据</div>
</div>
</div>
<script th:inline="javascript">
// ==================== 初始化 ====================
document.addEventListener('DOMContentLoaded', loadFilterOptions);
async function loadFilterOptions() {
try {
const response = await fetch('/api/filter/options');
if (response.status === 401) {
window.location.href = '/login';
return;
}
const options = await response.json();
options.forEach(opt => {
let selectEl;
switch (opt.fieldName) {
case 'person': selectEl = document.getElementById('filterPerson'); break;
case 'location': selectEl = document.getElementById('filterLocation'); break;
case 'category': selectEl = document.getElementById('filterCategory'); break;
default: return;
}
opt.values.forEach(v => {
const option = document.createElement('option');
option.value = v;
option.textContent = v;
selectEl.appendChild(option);
});
});
} catch (err) {
console.error('加载筛选选项失败:', err);
}
}
// ==================== 查询 ====================
async function doQuery() {
const query = {
persons: getSelectedValues('filterPerson'),
startTime: document.getElementById('filterStartTime').value || null,
endTime: document.getElementById('filterEndTime').value || null,
locations: getSelectedValues('filterLocation'),
categories: getSelectedValues('filterCategory')
};
// 如果 endTime 有值,附加时间
if (query.endTime) {
query.endTime += 'T23:59:59';
}
if (query.startTime) {
query.startTime += 'T00:00:00';
}
const loadingBar = document.getElementById('loadingBar');
const resultTable = document.getElementById('resultTable');
const emptyMsg = document.getElementById('emptyMsg');
const summaryBar = document.getElementById('summaryBar');
loadingBar.classList.add('show');
resultTable.style.display = 'none';
emptyMsg.style.display = 'none';
summaryBar.classList.remove('visible');
try {
const response = await fetch('/api/record/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(query)
});
if (response.status === 401) {
window.location.href = '/login';
return;
}
const data = await response.json();
renderResults(data);
} catch (err) {
console.error('查询失败:', err);
emptyMsg.textContent = '查询失败,请重试';
emptyMsg.style.display = 'block';
} finally {
loadingBar.classList.remove('show');
}
}
function renderResults(data) {
const tbody = document.getElementById('resultTbody');
const resultTable = document.getElementById('resultTable');
const emptyMsg = document.getElementById('emptyMsg');
const summaryBar = document.getElementById('summaryBar');
if (!data.records || data.records.length === 0) {
resultTable.style.display = 'none';
emptyMsg.textContent = '未找到匹配的事项记录';
emptyMsg.style.display = 'block';
summaryBar.classList.remove('visible');
return;
}
// 渲染表格
tbody.innerHTML = data.records.map(r => `
<tr>
<td>${esc(r.person)}</td>
<td>${formatTime(r.recordTime)}</td>
<td>${esc(r.location)}</td>
<td>${esc(r.content)}</td>
<td><span class="category-badge">${esc(r.category)}</span></td>
<td class="amount-cell">¥${formatAmount(r.amount)}</td>
</tr>
`).join('');
resultTable.style.display = 'table';
emptyMsg.style.display = 'none';
// 汇总
document.getElementById('summaryCount').textContent = `共 ${data.count} 条记录`;
document.getElementById('summaryAmount').textContent = `¥${formatAmount(data.totalAmount)}`;
summaryBar.classList.add('visible');
}
function resetFilters() {
document.getElementById('filterPerson').selectedIndex = -1;
document.getElementById('filterStartTime').value = '';
document.getElementById('filterEndTime').value = '';
document.getElementById('filterLocation').selectedIndex = -1;
document.getElementById('filterCategory').selectedIndex = -1;
document.getElementById('resultTable').style.display = 'none';
document.getElementById('emptyMsg').style.display = 'block';
document.getElementById('emptyMsg').textContent = '点击"查询"按钮查看事项数据';
document.getElementById('summaryBar').classList.remove('visible');
}
// ==================== 工具函数 ====================
function getSelectedValues(selectId) {
const select = document.getElementById(selectId);
const values = [];
for (const opt of select.options) {
if (opt.selected) values.push(opt.value);
}
return values.length > 0 ? values : null;
}
function formatTime(timeStr) {
if (!timeStr) return '';
// 格式化 ISO 时间为可读格式
const d = new Date(timeStr);
if (isNaN(d.getTime())) return timeStr;
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatAmount(amount) {
if (amount == null) return '0.00';
return Number(amount).toFixed(2);
}
function esc(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@ -0,0 +1,583 @@
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TraceCD - 日常记录</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<style>
:root {
--mic-size: 80px;
--mic-color: #4f46e5;
--mic-recording: #ef4444;
--mic-shadow: 0 4px 20px rgba(79, 70, 229, 0.4);
}
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f8fafc;
min-height: 100vh;
margin: 0;
padding-bottom: 140px;
}
/* 顶部导航 */
.navbar {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar .brand {
font-size: 18px;
font-weight: 600;
color: #1e293b;
}
.navbar .nav-links a {
color: #64748b;
text-decoration: none;
margin-left: 20px;
font-size: 14px;
transition: color 0.2s;
}
.navbar .nav-links a:hover { color: #4f46e5; }
/* 通知栏 */
.notification {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
background: #10b981;
color: white;
padding: 12px 28px;
border-radius: 999px;
font-size: 15px;
font-weight: 500;
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.notification.show { opacity: 1; }
.notification.error { background: #ef4444; box-shadow: 0 4px 16px rgba(239, 68, 68, 0.3); }
/* 内容区 */
.content-area {
max-width: 800px;
margin: 40px auto;
padding: 0 20px;
}
/* 查询结果 */
.result-card {
background: white;
border-radius: 16px;
padding: 30px;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
display: none;
}
.result-card.visible { display: block; }
.result-card table { width: 100%; }
.result-card table th, .result-card table td {
padding: 10px 14px;
border-bottom: 1px solid #f1f5f9;
}
.result-card table th {
background: #f8fafc;
font-weight: 600;
font-size: 13px;
color: #64748b;
text-transform: uppercase;
}
.amount-summary {
font-size: 18px;
font-weight: 700;
color: #4f46e5;
margin-bottom: 16px;
}
/* 聊天回复 */
.chat-reply {
background: white;
border-radius: 16px;
padding: 24px 30px;
margin-top: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
font-size: 16px;
line-height: 1.7;
color: #334155;
display: none;
}
.chat-reply.visible { display: block; }
/* 加载动画 */
.loading-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(255,255,255,0.7);
z-index: 9998;
display: none;
align-items: center;
justify-content: center;
}
.loading-overlay.show { display: flex; }
.spinner {
width: 48px;
height: 48px;
border: 4px solid #e2e8f0;
border-top-color: #4f46e5;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 麦克风按钮 */
.mic-container {
position: fixed;
bottom: 40px;
right: 40px;
z-index: 1000;
}
.mic-btn {
width: var(--mic-size);
height: var(--mic-size);
border-radius: 50%;
background: var(--mic-color);
border: none;
color: white;
font-size: 32px;
cursor: pointer;
box-shadow: var(--mic-shadow);
transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
.mic-btn:hover {
transform: scale(1.06);
box-shadow: 0 6px 28px rgba(79, 70, 229, 0.5);
}
.mic-btn:active {
transform: scale(0.96);
}
.mic-btn.recording {
background: var(--mic-recording);
box-shadow: 0 4px 24px rgba(239, 68, 68, 0.5);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); }
50% { box-shadow: 0 0 0 20px rgba(239, 68, 68, 0); }
}
.mic-hint {
text-align: center;
margin-top: 12px;
font-size: 12px;
color: #94a3b8;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 20px;
color: #94a3b8;
}
.empty-state i { font-size: 48px; display: block; margin-bottom: 16px; }
/* 响应式 */
@media (max-width: 640px) {
.mic-container {
bottom: 24px;
right: 24px;
}
:root { --mic-size: 68px; }
}
</style>
</head>
<body>
<!-- 顶部导航 -->
<nav class="navbar">
<span class="brand">📝 TraceCD</span>
<div class="nav-links">
<a href="/browse">📋 浏览事项</a>
<a href="/logout">退出</a>
</div>
</nav>
<!-- 通知 -->
<div class="notification" id="notification"></div>
<!-- 加载遮罩 -->
<div class="loading-overlay" id="loading">
<div class="spinner"></div>
</div>
<!-- 内容区 -->
<div class="content-area" id="contentArea">
<div class="empty-state" id="emptyState">
<i class="bi bi-mic-fill"></i>
<p>按住右下角麦克风按钮开始语音录入或查询</p>
</div>
<!-- 查询结果卡片 -->
<div class="result-card" id="resultCard"></div>
<!-- 聊天回复 -->
<div class="chat-reply" id="chatReply"></div>
</div>
<!-- 麦克风按钮 -->
<div class="mic-container">
<button class="mic-btn" id="micBtn" title="按住录音">
<i class="bi bi-mic-fill"></i>
</button>
<div class="mic-hint">按住说话,松开发送</div>
</div>
<script th:inline="javascript">
// ==================== WAV 录音器 ====================
class WavRecorder {
constructor() {
this.audioContext = null;
this.stream = null;
this.processor = null;
this.chunks = [];
this.sampleRate = 16000;
this.recording = false;
}
async start() {
this.stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: this.sampleRate, channelCount: 1, echoCancellation: true }
});
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: this.sampleRate
});
const source = this.audioContext.createMediaStreamSource(this.stream);
// 使用 ScriptProcessorNode 采集 PCM
this.processor = this.audioContext.createScriptProcessor(4096, 1, 1);
this.chunks = [];
this.processor.onaudioprocess = (e) => {
if (this.recording) {
const input = e.inputBuffer.getChannelData(0);
this.chunks.push(new Float32Array(input));
}
};
source.connect(this.processor);
this.processor.connect(this.audioContext.destination);
this.recording = true;
}
stop() {
this.recording = false;
return new Promise((resolve) => {
// 等一小段时间让最后的数据进来
setTimeout(() => {
// 断开连接
if (this.processor) {
this.processor.disconnect();
this.processor = null;
}
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
if (this.stream) {
this.stream.getTracks().forEach(t => t.stop());
this.stream = null;
}
// 合并所有 chunk
const totalLength = this.chunks.reduce((sum, c) => sum + c.length, 0);
const pcm = new Float32Array(totalLength);
let offset = 0;
for (const chunk of this.chunks) {
pcm.set(chunk, offset);
offset += chunk.length;
}
// 编码为 WAV
const wavBlob = this.encodeWAV(pcm, this.sampleRate);
resolve(wavBlob);
}, 100);
});
}
encodeWAV(samples, sampleRate) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
// RIFF header
this.writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true);
this.writeString(view, 8, 'WAVE');
this.writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // PCM
view.setUint16(22, 1, true); // mono
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
this.writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true);
// PCM samples
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
return new Blob([buffer], { type: 'audio/wav' });
}
writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
// ==================== 录音状态管理 ====================
const micBtn = document.getElementById('micBtn');
const loading = document.getElementById('loading');
const resultCard = document.getElementById('resultCard');
const chatReply = document.getElementById('chatReply');
const emptyState = document.getElementById('emptyState');
const notification = document.getElementById('notification');
let recorder = null;
let isPressed = false;
// 按下开始录音
micBtn.addEventListener('mousedown', async (e) => {
e.preventDefault();
if (isPressed) return;
isPressed = true;
try {
recorder = new WavRecorder();
await recorder.start();
micBtn.classList.add('recording');
micBtn.querySelector('i').className = 'bi bi-mic-fill'; // keep icon
} catch (err) {
console.error('无法启动录音:', err);
showNotification('无法访问麦克风,请检查权限', true);
isPressed = false;
}
});
// 松开停止录音并发送
micBtn.addEventListener('mouseup', async (e) => {
e.preventDefault();
if (!isPressed || !recorder) return;
isPressed = false;
micBtn.classList.remove('recording');
micBtn.querySelector('i').className = 'bi bi-mic-fill';
try {
loading.classList.add('show');
const wavBlob = await recorder.stop();
recorder = null;
if (wavBlob.size < 100) {
// 录音太短
loading.classList.remove('show');
showNotification('录音时间太短,请重试', true);
return;
}
await sendAudio(wavBlob);
} catch (err) {
console.error('发送失败:', err);
showNotification('处理失败,请重试', true);
} finally {
loading.classList.remove('show');
}
});
// 触摸事件(移动端)
micBtn.addEventListener('touchstart', async (e) => {
e.preventDefault();
if (isPressed) return;
isPressed = true;
try {
recorder = new WavRecorder();
await recorder.start();
micBtn.classList.add('recording');
} catch (err) {
console.error('无法启动录音:', err);
showNotification('无法访问麦克风,请检查权限', true);
isPressed = false;
}
});
micBtn.addEventListener('touchend', async (e) => {
e.preventDefault();
if (!isPressed || !recorder) return;
isPressed = false;
micBtn.classList.remove('recording');
try {
loading.classList.add('show');
const wavBlob = await recorder.stop();
recorder = null;
if (wavBlob.size < 100) {
loading.classList.remove('show');
showNotification('录音时间太短,请重试', true);
return;
}
await sendAudio(wavBlob);
} catch (err) {
console.error('发送失败:', err);
showNotification('处理失败,请重试', true);
} finally {
loading.classList.remove('show');
}
});
// ==================== 发送音频 ====================
async function sendAudio(audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.wav');
try {
const response = await fetch('/api/voice/process', {
method: 'POST',
body: formData
});
if (response.status === 401) {
window.location.href = '/login';
return;
}
const result = await response.json();
displayResult(result);
} catch (error) {
console.error('请求失败:', error);
showNotification('网络错误,请重试', true);
}
}
// ==================== 展示结果 ====================
function displayResult(result) {
hideAll();
if (!result.success) {
showNotification(result.error || '处理失败', true);
return;
}
switch (result.type) {
case 'RECORD':
// 录入成功:显示通知
showNotification('已录入「' + (result.category || '未知') + '」事项', false);
break;
case 'QUERY':
// 查询结果:渲染 markdown 表格
emptyState.style.display = 'none';
resultCard.classList.add('visible');
resultCard.innerHTML = renderMarkdown(result.message);
// 滚动到结果区
resultCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
break;
case 'CHAT':
// 聊天回复
emptyState.style.display = 'none';
chatReply.classList.add('visible');
chatReply.innerHTML = '<p>' + escapeHtml(result.message) + '</p>';
chatReply.scrollIntoView({ behavior: 'smooth', block: 'start' });
break;
}
}
function hideAll() {
resultCard.classList.remove('visible');
chatReply.classList.remove('visible');
}
// ==================== 简易 Markdown 渲染 ====================
function renderMarkdown(md) {
if (!md) return '';
let html = md;
// 提取金额汇总行
html = html.replace(/^(.*?金额.*?[:]?\s*[\d,.]+.*)$/gm,
'<div class="amount-summary">$1</div>');
// 表格
html = html.replace(/\|(.+)\|/g, (match) => {
const cells = match.split('|').filter(c => c.trim() !== '');
const isHeader = match.includes('---');
if (isHeader) return '';
const tag = match === html.split('\n').find(l => l.includes('|') && !l.includes('---')) ? 'th' : 'td';
return '<tr>' + cells.map(c => '<' + tag + '>' + c.trim() + '</' + tag + '>').join('') + '</tr>';
});
// 包裹表格
html = html.replace(/(<tr>[\s\S]*?<\/tr>)/g, (match) => {
if (!match.includes('<table>')) {
return '<table class="table table-striped">' + match + '</table>';
}
return match;
});
// 金额汇总(如果未匹配到)
html = html.replace(/(?:合计|总计|金额汇总|总金额)[:]?\s*([\d,.]+)\s*元?/g,
'<div class="amount-summary">合计:$1 元</div>');
// 换行转 <br>
html = html.replace(/\n/g, '<br>');
return html;
}
// ==================== 通知 ====================
let notifyTimer = null;
function showNotification(message, isError) {
if (notifyTimer) clearTimeout(notifyTimer);
notification.textContent = message;
notification.className = 'notification' + (isError ? ' error' : '');
// 强制回流
notification.offsetHeight;
notification.classList.add('show');
notifyTimer = setTimeout(() => {
notification.classList.remove('show');
}, 3000);
}
// ==================== 工具函数 ====================
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="zh-CN" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - TraceCD</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-card {
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
width: 100%;
max-width: 400px;
}
.login-card h1 {
font-size: 24px;
text-align: center;
margin-bottom: 30px;
color: #333;
}
</style>
</head>
<body>
<div class="login-card">
<h1>TraceCD 日常记录</h1>
<div th:if="${error}" class="alert alert-danger" th:text="${error}"></div>
<form method="post" action="/login">
<div class="mb-3">
<label for="username" class="form-label">用户名</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">密码</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">登 录</button>
</form>
</div>
</body>
</html>