初始化项目
Some checks failed
Java Maven 3.9.9 & JDK 26 CI/CD Pipeline / build-and-deploy (push) Failing after 9m3s
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:
commit
c5dee7e444
61
.gitea/workflows/deploy.yaml
Normal file
61
.gitea/workflows/deploy.yaml
Normal 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
39
.gitignore
vendored
Normal 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
10
.idea/.gitignore
generated
vendored
Normal 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
13
.idea/dataSources.xml
generated
Normal 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&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal 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
7
.idea/encodings.xml
generated
Normal 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>
|
||||||
7
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
7
.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
14
.idea/misc.xml
generated
Normal 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
6
.idea/vcs.xml
generated
Normal 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
39
CLAUDE.md
Normal 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
28
Dockerfile
Normal 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
208
README.md
Normal 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 + 原生 JS(WAV 录音) |
|
||||||
|
| **LLM** | DeepSeek API(deepseek-v4-pro) |
|
||||||
|
| **ASR** | 小米 MIMO V2.5 ASR |
|
||||||
|
| **安全** | BCrypt 密码加密 + Session 认证 |
|
||||||
|
| **容器化** | Docker(多阶段构建) |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/com/l/tracecd/
|
||||||
|
├── TracecdApplication.java # 应用主入口
|
||||||
|
├── config/
|
||||||
|
│ ├── DatabaseInitializer.java # 启动自动建表
|
||||||
|
│ ├── DeepSeekConfig.java # DeepSeek API 配置
|
||||||
|
│ ├── MimoAsrConfig.java # MIMO ASR API 配置
|
||||||
|
│ ├── MvcConfig.java # 拦截器注册
|
||||||
|
│ ├── MybatisPlusConfig.java # MyBatis-Plus 配置
|
||||||
|
│ └── SessionConfig.java # Session 会话配置
|
||||||
|
├── constant/
|
||||||
|
│ └── Constants.java # 系统常量
|
||||||
|
├── controller/
|
||||||
|
│ ├── AuthController.java # 登录/登出
|
||||||
|
│ ├── BrowseController.java # 条件筛选查询 API
|
||||||
|
│ ├── CommonController.java # 健康检查
|
||||||
|
│ ├── FilterController.java # 筛选选项 API
|
||||||
|
│ ├── PageController.java # 页面路由
|
||||||
|
│ └── VoiceController.java # 语音处理 API
|
||||||
|
├── dto/
|
||||||
|
│ ├── BrowseQuery.java # 浏览查询参数
|
||||||
|
│ ├── DeepSeekRequest.java # DeepSeek 请求体
|
||||||
|
│ ├── DeepSeekResponse.java # DeepSeek 响应体
|
||||||
|
│ ├── FilterOption.java # 筛选选项
|
||||||
|
│ └── VoiceResponse.java # 语音处理响应
|
||||||
|
├── entity/
|
||||||
|
│ ├── DailyRecord.java # 日常事项实体
|
||||||
|
│ ├── DistinctValue.java # 去重值实体
|
||||||
|
│ └── User.java # 用户实体
|
||||||
|
├── interceptor/
|
||||||
|
│ └── AuthInterceptor.java # 登录认证拦截器
|
||||||
|
├── mapper/
|
||||||
|
│ ├── DailyRecordMapper.java # 事项 Mapper
|
||||||
|
│ ├── DistinctValueMapper.java # 去重值 Mapper
|
||||||
|
│ └── UserMapper.java # 用户 Mapper
|
||||||
|
└── service/
|
||||||
|
├── AsrService.java # ASR 语音识别
|
||||||
|
├── AuthService.java # 认证服务
|
||||||
|
├── DistinctValueService.java # 去重值维护
|
||||||
|
├── LlmService.java # DeepSeek 交互
|
||||||
|
├── RecordService.java # 事项 CRUD
|
||||||
|
├── SqlValidationService.java # SQL 安全校验
|
||||||
|
└── VoiceService.java # 语音处理编排(核心)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据库表
|
||||||
|
|
||||||
|
| 表名 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `t_user` | 用户表,BCrypt 加密存储密码 |
|
||||||
|
| `t_daily_record` | 日常事项记录表(person, record_time, location, content, category, amount) |
|
||||||
|
| `t_distinct_value` | 筛选去重值表(field_name + field_value 唯一索引) |
|
||||||
|
|
||||||
|
建表脚本:`src/main/resources/schema.sql`,应用启动时自动执行。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- JDK 26
|
||||||
|
- Maven 3.9+
|
||||||
|
- MySQL 8.x
|
||||||
|
|
||||||
|
### 1. 配置数据库
|
||||||
|
|
||||||
|
创建 MySQL 数据库(应用会自动建表):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE IF NOT EXISTS tracecd DEFAULT CHARSET utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
修改 `src/main/resources/application.yml` 中的数据库连接信息:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://your-host:3306/tracecd?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai&createDatabaseIfNotExist=true
|
||||||
|
username: your-username
|
||||||
|
password: your-password
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置 API Key
|
||||||
|
|
||||||
|
通过环境变量设置(推荐)或直接修改 `application.yml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DeepSeek API Key
|
||||||
|
export DEEPSEEK_API_KEY=sk-your-key
|
||||||
|
|
||||||
|
# MIMO ASR API Key
|
||||||
|
export MIMO_API_KEY=sk-your-key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 启动应用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译并运行
|
||||||
|
mvn spring-boot:run
|
||||||
|
|
||||||
|
# 或先打包再运行
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
java -jar target/tracecd-1.0-SNAPSHOT.jar
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 访问
|
||||||
|
|
||||||
|
打开浏览器访问 `http://localhost:8080`
|
||||||
|
|
||||||
|
- **默认用户名**: `admin`
|
||||||
|
- **默认密码**: `admin123`
|
||||||
|
|
||||||
|
## Docker 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t tracecd:latest .
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -d \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-e DEEPSEEK_API_KEY=sk-your-key \
|
||||||
|
-e MIMO_API_KEY=sk-your-key \
|
||||||
|
-e SPRING_DATASOURCE_URL=jdbc:mysql://your-host:3306/tracecd?... \
|
||||||
|
-e SPRING_DATASOURCE_USERNAME=root \
|
||||||
|
-e SPRING_DATASOURCE_PASSWORD=root \
|
||||||
|
--name tracecd \
|
||||||
|
tracecd:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
| 路径 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/` | GET | 主页(语音录入) |
|
||||||
|
| `/browse` | GET | 浏览筛选页 |
|
||||||
|
| `/login` | GET/POST | 登录页/登录请求 |
|
||||||
|
| `/logout` | GET | 登出 |
|
||||||
|
| `/hello` | GET | 健康检查 |
|
||||||
|
| `/api/voice/process` | POST | 上传音频,返回处理结果 |
|
||||||
|
| `/api/record/query` | POST | 条件筛选查询事项 |
|
||||||
|
| `/api/filter/options` | GET | 获取筛选下拉选项 |
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 配置路径 | 说明 | 默认值 |
|
||||||
|
|----------|------|--------|
|
||||||
|
| `server.port` | 服务端口 | `8080` |
|
||||||
|
| `deepseek.api-key` | DeepSeek API Key | 环境变量 `DEEPSEEK_API_KEY` |
|
||||||
|
| `deepseek.model` | DeepSeek 模型 | `deepseek-v4-pro` |
|
||||||
|
| `mimo.api-key` | MIMO ASR API Key | 环境变量 `MIMO_API_KEY` |
|
||||||
|
| `mimo.model` | ASR 模型 | `mimo-v2.5-asr` |
|
||||||
|
| `app.sql-max-retries` | SQL 生成失败最大重试次数 | `5` |
|
||||||
|
| `app.default-username` | 默认用户名 | `admin` |
|
||||||
|
| `app.default-password` | 默认密码 | `admin123` |
|
||||||
|
|
||||||
|
## 构建命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mvn verify # 编译 + 测试
|
||||||
|
mvn compile # 仅编译
|
||||||
|
mvn test # 运行测试
|
||||||
|
mvn test -Dtest=MyTestClass # 运行单个测试类
|
||||||
|
mvn test -Dtest=MyTestClass#myMethod # 运行单个测试方法
|
||||||
|
mvn package # 打包 JAR
|
||||||
|
mvn clean # 清理
|
||||||
|
```
|
||||||
87
pom.xml
Normal file
87
pom.xml
Normal 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>
|
||||||
16
src/main/java/com/l/tracecd/TracecdApplication.java
Normal file
16
src/main/java/com/l/tracecd/TracecdApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/main/java/com/l/tracecd/config/DatabaseInitializer.java
Normal file
41
src/main/java/com/l/tracecd/config/DatabaseInitializer.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main/java/com/l/tracecd/config/DeepSeekConfig.java
Normal file
58
src/main/java/com/l/tracecd/config/DeepSeekConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main/java/com/l/tracecd/config/MimoAsrConfig.java
Normal file
58
src/main/java/com/l/tracecd/config/MimoAsrConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/main/java/com/l/tracecd/config/MvcConfig.java
Normal file
20
src/main/java/com/l/tracecd/config/MvcConfig.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main/java/com/l/tracecd/config/MybatisPlusConfig.java
Normal file
13
src/main/java/com/l/tracecd/config/MybatisPlusConfig.java
Normal 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
|
||||||
|
}
|
||||||
28
src/main/java/com/l/tracecd/config/SessionConfig.java
Normal file
28
src/main/java/com/l/tracecd/config/SessionConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/main/java/com/l/tracecd/constant/Constants.java
Normal file
43
src/main/java/com/l/tracecd/constant/Constants.java
Normal 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";
|
||||||
|
}
|
||||||
66
src/main/java/com/l/tracecd/controller/AuthController.java
Normal file
66
src/main/java/com/l/tracecd/controller/AuthController.java
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/main/java/com/l/tracecd/controller/BrowseController.java
Normal file
57
src/main/java/com/l/tracecd/controller/BrowseController.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main/java/com/l/tracecd/controller/CommonController.java
Normal file
13
src/main/java/com/l/tracecd/controller/CommonController.java
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/main/java/com/l/tracecd/controller/FilterController.java
Normal file
42
src/main/java/com/l/tracecd/controller/FilterController.java
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main/java/com/l/tracecd/controller/PageController.java
Normal file
28
src/main/java/com/l/tracecd/controller/PageController.java
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/main/java/com/l/tracecd/controller/VoiceController.java
Normal file
80
src/main/java/com/l/tracecd/controller/VoiceController.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/main/java/com/l/tracecd/dto/BrowseQuery.java
Normal file
64
src/main/java/com/l/tracecd/dto/BrowseQuery.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
308
src/main/java/com/l/tracecd/dto/DeepSeekRequest.java
Normal file
308
src/main/java/com/l/tracecd/dto/DeepSeekRequest.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/main/java/com/l/tracecd/dto/DeepSeekResponse.java
Normal file
88
src/main/java/com/l/tracecd/dto/DeepSeekResponse.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/main/java/com/l/tracecd/dto/FilterOption.java
Normal file
39
src/main/java/com/l/tracecd/dto/FilterOption.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/main/java/com/l/tracecd/dto/VoiceResponse.java
Normal file
97
src/main/java/com/l/tracecd/dto/VoiceResponse.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/main/java/com/l/tracecd/entity/DailyRecord.java
Normal file
103
src/main/java/com/l/tracecd/entity/DailyRecord.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/main/java/com/l/tracecd/entity/DistinctValue.java
Normal file
54
src/main/java/com/l/tracecd/entity/DistinctValue.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main/java/com/l/tracecd/entity/User.java
Normal file
58
src/main/java/com/l/tracecd/entity/User.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/main/java/com/l/tracecd/interceptor/AuthInterceptor.java
Normal file
57
src/main/java/com/l/tracecd/interceptor/AuthInterceptor.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main/java/com/l/tracecd/mapper/DailyRecordMapper.java
Normal file
12
src/main/java/com/l/tracecd/mapper/DailyRecordMapper.java
Normal 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> {
|
||||||
|
}
|
||||||
12
src/main/java/com/l/tracecd/mapper/DistinctValueMapper.java
Normal file
12
src/main/java/com/l/tracecd/mapper/DistinctValueMapper.java
Normal 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> {
|
||||||
|
}
|
||||||
12
src/main/java/com/l/tracecd/mapper/UserMapper.java
Normal file
12
src/main/java/com/l/tracecd/mapper/UserMapper.java
Normal 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> {
|
||||||
|
}
|
||||||
94
src/main/java/com/l/tracecd/service/AsrService.java
Normal file
94
src/main/java/com/l/tracecd/service/AsrService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/main/java/com/l/tracecd/service/AuthService.java
Normal file
70
src/main/java/com/l/tracecd/service/AuthService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/main/java/com/l/tracecd/service/LlmService.java
Normal file
227
src/main/java/com/l/tracecd/service/LlmService.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/main/java/com/l/tracecd/service/RecordService.java
Normal file
151
src/main/java/com/l/tracecd/service/RecordService.java
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/main/java/com/l/tracecd/service/SqlValidationService.java
Normal file
125
src/main/java/com/l/tracecd/service/SqlValidationService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/main/java/com/l/tracecd/service/VoiceService.java
Normal file
221
src/main/java/com/l/tracecd/service/VoiceService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/main/resources/application.yml
Normal file
78
src/main/resources/application.yml
Normal 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 # 默认密码(首次启动自动创建)
|
||||||
31
src/main/resources/schema.sql
Normal file
31
src/main/resources/schema.sql
Normal 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='筛选项去重表';
|
||||||
410
src/main/resources/templates/browse.html
Normal file
410
src/main/resources/templates/browse.html
Normal 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>
|
||||||
583
src/main/resources/templates/index.html
Normal file
583
src/main/resources/templates/index.html
Normal 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>
|
||||||
49
src/main/resources/templates/login.html
Normal file
49
src/main/resources/templates/login.html
Normal 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>
|
||||||
Loading…
Reference in New Issue
Block a user